diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b302013a..a7cfd131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + v3_release_candidate_sha: + description: Full candidate SHA for the six-platform V3 release gate + required: false + type: string schedule: # Monday 04:17 Asia/Shanghai. - cron: "17 20 * * 0" @@ -30,6 +35,7 @@ jobs: all: ${{ steps.classify.outputs.all }} core: ${{ steps.classify.outputs.core }} full: ${{ steps.classify.outputs.full }} + release: ${{ steps.classify.outputs.release }} steps: - uses: actions/checkout@v4 with: @@ -41,9 +47,11 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} FULL_RUN: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + RELEASE_CANDIDATE_SHA: ${{ inputs.v3_release_candidate_sha }} run: | args=(--event "$EVENT_NAME" --base "$BASE_SHA" --head "$HEAD_SHA") if [[ "$FULL_RUN" == true ]]; then args+=(--full); fi + if [[ -n "$RELEASE_CANDIDATE_SHA" ]]; then args+=(--release-candidate-sha "$RELEASE_CANDIDATE_SHA"); fi python scripts/ci_changes.py "${args[@]}" core-test: @@ -54,6 +62,10 @@ jobs: KYROZEN_SANDBOX_REQUIRED: "1" steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Verify pinned V2 benchmark baseline + run: git cat-file -e 1bfc82c793df49e76097061ec5adfef0e4e2e883^{commit} - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -66,7 +78,7 @@ jobs: - name: Install the required Linux filesystem sandbox run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y bubblewrap + sudo apt-get install --no-install-recommends -y bubblewrap ripgrep sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Run non-browser tests if: needs.changes.outputs.python == 'true' || needs.changes.outputs.all == 'true' @@ -75,7 +87,7 @@ jobs: if: needs.changes.outputs.docs == 'true' || needs.changes.outputs.all == 'true' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y zsh bubblewrap + sudo apt-get install --no-install-recommends -y zsh bubblewrap ripgrep make docs-check PYTHON=python python-test: @@ -86,6 +98,10 @@ jobs: KYROZEN_SANDBOX_REQUIRED: "1" steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Verify pinned V2 benchmark baseline + run: git cat-file -e 1bfc82c793df49e76097061ec5adfef0e4e2e883^{commit} - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -96,7 +112,7 @@ jobs: - name: Install full development dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y zsh bubblewrap + sudo apt-get install --no-install-recommends -y zsh bubblewrap ripgrep sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 python -m pip install --upgrade pip pip install -e '.[all]' @@ -126,6 +142,10 @@ jobs: KYROZEN_SANDBOX_REQUIRED: "1" steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Verify pinned V2 benchmark baseline + run: git cat-file -e 1bfc82c793df49e76097061ec5adfef0e4e2e883^{commit} - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -136,7 +156,7 @@ jobs: - name: Install all development dependencies run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y zsh bubblewrap + sudo apt-get install --no-install-recommends -y zsh bubblewrap ripgrep sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 python -m pip install --upgrade pip pip install -e '.[all]' @@ -257,6 +277,202 @@ jobs: name: update-acceptance-${{ matrix.os }} path: update-acceptance.json + v3-platform-validation: + needs: changes + if: needs.changes.outputs.release == 'true' + name: V3 production matrix (${{ matrix.platform }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - { platform: linux-py312, os: ubuntu-latest, python: '3.12' } + - { platform: linux-py313, os: ubuntu-latest, python: '3.13' } + - { platform: macos-py312, os: macos-latest, python: '3.12' } + - { platform: macos-py313, os: macos-latest, python: '3.13' } + - { platform: windows-py312, os: windows-latest, python: '3.12' } + - { platform: windows-py313, os: windows-latest, python: '3.13' } + env: + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || inputs.v3_release_candidate_sha || github.sha }} + PLATFORM_ID: ${{ matrix.platform }} + KYROZEN_BROWSER_TESTS: '1' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || inputs.v3_release_candidate_sha || github.sha }} + - name: Verify pinned V2 benchmark baseline + if: runner.os != 'Windows' + shell: bash + run: git cat-file -e 1bfc82c793df49e76097061ec5adfef0e4e2e883^{commit} + - name: Verify exact candidate + shell: bash + run: | + [[ "$CANDIDATE_SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: | + pyproject.toml + requirements.txt + - uses: actions/setup-go@v5 + with: + go-version: '1.27.1' + cache-dependency-path: tui/go.sum + - name: Install Linux sandbox and browser system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y bubblewrap ripgrep + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + python -m pip install playwright + python -m playwright install-deps chromium + - name: Ensure macOS search integration is available + if: runner.os == 'macOS' + run: command -v rg >/dev/null || brew install ripgrep + - name: Install project and browser dependencies + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[all]' + python -m playwright install chromium + - name: Require the Linux filesystem sandbox + if: runner.os == 'Linux' + shell: bash + run: echo 'KYROZEN_SANDBOX_REQUIRED=1' >> "$GITHUB_ENV" + - name: Run complete unit suite + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + python -m unittest discover -s tests -p 'test_*.py' -v 2>&1 | tee unit.log + - name: Run integration suite + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + PYTHONPATH=tests python -m unittest tests.test_durable_tasks_gateway tests.test_gateway_startup_logging -v 2>&1 | tee integration.log + - name: Run supported-feature workflow and TUI acceptance + env: + PYTHONIOENCODING: utf-8 + shell: bash + run: | + set -euo pipefail + python scripts/agent_workflow_acceptance.py 2>&1 | tee agent-acceptance.log + python scripts/subagent_workflow_acceptance.py 2>&1 | tee subagent-acceptance.log + binary="../kyrozen-tui" + if [[ "$RUNNER_OS" == Windows ]]; then binary="../kyrozen-tui.exe"; fi + (cd tui && go test ./... && go build -o "$binary" .) 2>&1 | tee tui-tests.log + if [[ "$RUNNER_OS" == Windows ]]; then + echo 'not_run: interactive PTY acceptance requires POSIX fcntl/pty; Windows Go TUI tests/build and agent workflow acceptance are required.' | tee tui-acceptance.log + else + python scripts/tui_workflow_acceptance.py "${binary#../}" 2>&1 | tee tui-acceptance.log + fi + - name: Run 30-minute production soak + shell: bash + run: | + set -euo pipefail + python scripts/production_soak.py --seconds 1800 \ + --output soak.json --log-dir soak-logs 2>&1 | tee soak.log + - name: Record platform result + if: success() + shell: bash + run: | + python - <<'PY' + import importlib.metadata + import json + import os + import platform + from pathlib import Path + soak = json.loads(Path("soak.json").read_text(encoding="utf-8")) + dependencies = { + distribution.metadata["Name"]: distribution.version + for distribution in importlib.metadata.distributions() + if distribution.metadata.get("Name") + } + result = { + "schema_version": 1, + "candidate_sha": os.environ["CANDIDATE_SHA"], + "platform": os.environ["PLATFORM_ID"], + "unit": "not_run" if os.environ["RUNNER_OS"] == "Windows" else "passed", + "integration": "not_run" if os.environ["RUNNER_OS"] == "Windows" else "passed", + "acceptance": "passed", + "tui_interactive_acceptance": "not_run_posix_pty_unavailable" if os.environ["RUNNER_OS"] == "Windows" else "passed", + "soak_seconds": int(soak["elapsed_seconds"]), + "environment": { + "runner": os.environ["ImageOS"] if "ImageOS" in os.environ else os.environ["RUNNER_OS"], + "os": platform.platform(), + "python": platform.python_version(), + }, + "dependencies": dependencies, + "evidence_paths": ([ + "agent-acceptance.log", "subagent-acceptance.log", "tui-tests.log", + "tui-acceptance.log", "soak.log", "soak.json", + ] if os.environ["RUNNER_OS"] == "Windows" else [ + "unit.log", "integration.log", "agent-acceptance.log", + "subagent-acceptance.log", "tui-tests.log", "tui-acceptance.log", + "soak.log", "soak.json", + ]), + } + if os.environ["RUNNER_OS"] == "Windows": + result["supported_features"] = "passed" + Path("platform.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + PY + - name: Upload platform logs and result + if: always() + uses: actions/upload-artifact@v4 + with: + name: v3-platform-${{ matrix.platform }} + path: | + platform.json + unit.log + integration.log + agent-acceptance.log + subagent-acceptance.log + tui-acceptance.log + tui-tests.log + soak.log + soak.json + soak-logs/ + if-no-files-found: warn + retention-days: 30 + + collect-r266: + needs: [changes, v3-platform-validation] + if: needs.changes.outputs.release == 'true' && needs.v3-platform-validation.result == 'success' + runs-on: ubuntu-latest + env: + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || inputs.v3_release_candidate_sha || github.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || inputs.v3_release_candidate_sha || github.sha }} + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + pattern: v3-platform-* + path: platform-results + merge-multiple: false + - name: Collect unsigned exact-candidate R266 record + run: >- + python -m benchmarks.v3_release_platforms + --results-dir platform-results + --output-dir v3-r266-evidence + --candidate-sha "$CANDIDATE_SHA" + --command-set .github/workflows/ci.yml + - name: Upload unsigned R266 evidence bundle + uses: actions/upload-artifact@v4 + with: + name: v3-r266-${{ github.event.pull_request.head.sha || inputs.v3_release_candidate_sha || github.sha }} + path: v3-r266-evidence/ + if-no-files-found: error + retention-days: 30 + ci-result: name: CI result if: always() @@ -270,6 +486,8 @@ jobs: - installer-syntax - docker-persistence - update-install + - v3-platform-validation + - collect-r266 runs-on: ubuntu-latest steps: - name: Require every selected check to pass @@ -290,6 +508,9 @@ jobs: UPDATE_RESULT: ${{ needs.update-install.result }} DOCKER_EXPECTED: ${{ needs.changes.outputs.docker }} DOCKER_RESULT: ${{ needs.docker-persistence.result }} + RELEASE_EXPECTED: ${{ needs.changes.outputs.release }} + RELEASE_MATRIX_RESULT: ${{ needs.v3-platform-validation.result }} + RELEASE_COLLECT_RESULT: ${{ needs.collect-r266.result }} run: | set -euo pipefail test "$CHANGES_RESULT" = success @@ -315,6 +536,8 @@ jobs: require_selected "Installer syntax" "$UPDATE_EXPECTED" "$INSTALLER_RESULT" require_selected "Installed update" "$UPDATE_EXPECTED" "$UPDATE_RESULT" require_selected "Docker persistence" "$DOCKER_EXPECTED" "$DOCKER_RESULT" + require_selected "V3 release matrix" "$RELEASE_EXPECTED" "$RELEASE_MATRIX_RESULT" + require_selected "V3 R266 evidence collection" "$RELEASE_EXPECTED" "$RELEASE_COLLECT_RESULT" if [ "$CORE_EXPECTED" != true ]; then require_unselected "Unselected core job" "$CORE_RESULT"; fi if [ "$PYTHON_EXPECTED" != true ]; then require_unselected "Unselected Python tests" "$PYTHON_RESULT"; fi if [ "$ALL_EXPECTED" != true ]; then require_unselected "Unselected full Python 3.12 tests" "$PYTHON_312_RESULT"; fi @@ -323,3 +546,5 @@ jobs: require_unselected "Unselected installer syntax" "$INSTALLER_RESULT" require_unselected "Unselected installed update" "$UPDATE_RESULT" require_unselected "Unselected Docker persistence" "$DOCKER_RESULT" + if [ "$RELEASE_EXPECTED" != true ]; then require_unselected "Unselected V3 release matrix" "$RELEASE_MATRIX_RESULT"; fi + if [ "$RELEASE_EXPECTED" != true ]; then require_unselected "Unselected V3 R266 collection" "$RELEASE_COLLECT_RESULT"; fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 634cf337..e3555109 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + - "!v3.*" workflow_dispatch: inputs: release_tag: @@ -16,6 +17,7 @@ permissions: jobs: build-release: + if: ${{ !startsWith(inputs.release_tag || github.ref_name, 'v3.') }} runs-on: ubuntu-latest env: RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} diff --git a/.github/workflows/v3-benchmark-isolation.yml b/.github/workflows/v3-benchmark-isolation.yml new file mode 100644 index 00000000..5fd39329 --- /dev/null +++ b/.github/workflows/v3-benchmark-isolation.yml @@ -0,0 +1,92 @@ +name: V3 benchmark isolation diagnostics +on: + push: + branches: ['Evan/v3-finalization'] + paths: ['benchmarks/**', 'tests/test_v3_protected_worker.py', '.github/workflows/v3-benchmark-isolation.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + isolation: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python: ['3.12', '3.13'] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install Linux backend without changing host security policy + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y bubblewrap + sysctl kernel.apparmor_restrict_unprivileged_userns user.max_user_namespaces > isolation-policy-before.txt + - name: Install project dependencies + run: python -m pip install -e '.[all]' + - name: Record diagnostic identity + shell: bash + run: | + git rev-parse HEAD > isolation-commit.txt + python -m pip freeze > isolation-dependencies.txt + if [[ "$RUNNER_OS" == Linux ]]; then + { + bwrap --version + ls -l /usr/bin/bwrap + uname -r + cat /proc/self/attr/current + if [[ -f /etc/apparmor.d/bwrap ]]; then cat /etc/apparmor.d/bwrap; fi + } > isolation-backend.txt + fi + - name: Verify actor isolation and cleanup without persistent accounts + if: always() + shell: bash + run: | + set -euo pipefail + interpreter="$(command -v python)" + umask 022 + stage_root="$(mktemp -d /var/tmp/openkyrozen-isolation.XXXXXX)" + trap 'sudo -n rm -rf -- "$stage_root"' EXIT + chmod 755 "$stage_root" + git clone --no-checkout --no-hardlinks "$PWD" "$stage_root/source" + git -C "$stage_root/source" checkout --detach "$GITHUB_SHA" + test "$(git -C "$stage_root/source" rev-parse HEAD)" = "$GITHUB_SHA" + sudo -n chown -R 0:0 "$stage_root" + private_home="$stage_root/parent-home" + sudo -n mkdir -p "$private_home" + sudo -n chmod 700 "$private_home" + cd "$stage_root/source" + sudo -n env -i PATH="$PATH" HOME="$private_home" TMPDIR=/var/tmp PYTHONPATH="$PWD" \ + PYTHONDONTWRITEBYTECODE=1 KYROZEN_DISABLE_VECTOR_INDEX=1 \ + "$interpreter" -m unittest tests.test_v3_process_owner tests.test_v3_linux_namespace \ + tests.test_v3_actor_supervisor tests.test_v3_actor_worker tests.test_v3_actor_dispatch \ + tests.test_v3_actor_native_acceptance \ + tests.test_v3_routing_supplement \ + tests.test_v3_verifier_isolation \ + tests.test_v3_fixture_strength \ + tests.test_v3_matched_live.V3MatchedLiveTests.test_fixture_tests_verify_the_actual_workspace_source \ + tests.test_v3_shell_cleanup tests.test_v3_protected_worker -v 2>&1 | tee "$GITHUB_WORKSPACE/isolation-tests.log" + - name: Verify host security policy was preserved + if: always() && runner.os == 'Linux' + shell: bash + run: | + sysctl kernel.apparmor_restrict_unprivileged_userns user.max_user_namespaces > isolation-policy-after.txt + diff -u isolation-policy-before.txt isolation-policy-after.txt + - name: Preserve diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: isolation-${{ matrix.os }}-py${{ matrix.python }} + path: | + isolation-commit.txt + isolation-dependencies.txt + isolation-backend.txt + isolation-tests.log + isolation-policy-before.txt + isolation-policy-after.txt diff --git a/.github/workflows/v3-windows-reliability.yml b/.github/workflows/v3-windows-reliability.yml new file mode 100644 index 00000000..8696b77d --- /dev/null +++ b/.github/workflows/v3-windows-reliability.yml @@ -0,0 +1,151 @@ +name: V3 Windows cancellation investigation +on: + push: + branches: ['Evan/v3-windows-cancellation-investigation-20261107'] + paths: ['.github/workflows/v3-windows-reliability.yml', 'scripts/v3_windows_reliability.py', 'tests/test_v3_windows_reliability.py'] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character product commit to test (diagnostic code remains separate) + type: string + required: true + default: '7905fb634fe447243668c2d01179ce5cb2cdb275' + control: + description: Load control; paired-anchor compares equal CPU load with/without read-only anchor (1–10 pairs) + type: choice + options: [baseline, cpu, sqlite, combined, paired-anchor, native-wait, all] + default: baseline + iterations: + description: Samples per control and Python version (1–50) + type: number + default: 50 + environment: + description: Current packages, or exact original Python 3.12.10/packages with reviewer interleaving + type: choice + options: [current, historical] + default: current +env: + PRODUCT_SHA: ${{ inputs.candidate_sha || '7905fb634fe447243668c2d01179ce5cb2cdb275' }} + ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && format('{0}', inputs.iterations) || '50' }} + HISTORICAL_ENVIRONMENT: ${{ inputs.environment == 'historical' && '1' || '0' }} +permissions: + contents: read +jobs: + cancellation: + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + python: ${{ fromJSON(inputs.environment == 'historical' && '["3.12.10"]' || '["3.12","3.13"]') }} + control: ${{ fromJSON(inputs.control == 'all' && '["baseline","cpu","sqlite","combined"]' || format('["{0}"]', inputs.control || 'baseline')) }} + env: + LOAD_CONTROL: ${{ matrix.control }} + steps: + - name: Validate exact candidate identity + shell: pwsh + run: | + if ($env:PRODUCT_SHA -cnotmatch '^[0-9a-f]{40}$') { throw 'candidate_sha must be a full lowercase commit SHA' } + if ($env:ITERATIONS -cnotmatch '^[0-9]+$' -or [int]$env:ITERATIONS -lt 1 -or [int]$env:ITERATIONS -gt 50) { throw 'iterations must be 1–50' } + if ($env:HISTORICAL_ENVIRONMENT -eq '1' -and [int]$env:ITERATIONS -gt 20) { throw 'historical campaign is bounded at 20 iterations' } + if ($env:LOAD_CONTROL -eq 'paired-anchor' -and [int]$env:ITERATIONS -gt 10) { throw 'paired-anchor is bounded at 10 pairs' } + if ($env:LOAD_CONTROL -eq 'native-wait' -and ($env:HISTORICAL_ENVIRONMENT -ne '1' -or [int]$env:ITERATIONS -ne 4)) { throw 'native-wait requires historical environment and exactly four triples' } + - name: Checkout diagnostic harness + uses: actions/checkout@v4 + with: + path: diagnostic-source + fetch-depth: 0 + - name: Checkout exact product + uses: actions/checkout@v4 + with: + ref: ${{ env.PRODUCT_SHA }} + path: product + fetch-depth: 0 + - name: Checkout original product for native wait comparison + if: matrix.control == 'native-wait' + uses: actions/checkout@v4 + with: + ref: '0f172f9fe692ff8fafcc0a15a72fe51eaf67e977' + path: historical-product + fetch-depth: 0 + - uses: actions/setup-python@v5 + id: python + with: + python-version: ${{ matrix.python }} + - name: Retain unavailable historical runtime evidence + if: failure() && steps.python.outcome == 'failure' + shell: pwsh + run: | + New-Item -ItemType Directory -Force windows-evidence | Out-Null + '{"status":"INFRASTRUCTURE_FAILURE","reason":"Requested Python runtime could not be provisioned"}' | Set-Content -Encoding utf8 windows-evidence/historical-environment.json + - name: Verify checked-out product + shell: pwsh + working-directory: product + run: | + if ((git rev-parse HEAD) -cne $env:PRODUCT_SHA) { throw 'Checked-out candidate differs from requested commit' } + if (git status --porcelain --untracked-files=no) { throw 'Product checkout has tracked edits' } + - name: Install exact product dependencies + shell: pwsh + run: | + New-Item -ItemType Directory -Force windows-evidence | Out-Null + if ($env:HISTORICAL_ENVIRONMENT -eq '1') { + python diagnostic-source/scripts/v3_windows_reliability.py --write-historical-requirements windows-evidence/historical-requirements.txt + if ($LASTEXITCODE -ne 0) { throw 'Unable to prepare historical requirements' } + python -m pip install -r windows-evidence/historical-requirements.txt -e './product[all]' *> windows-evidence/dependency-install.log + if ($LASTEXITCODE -ne 0) { + '{"status":"INFRASTRUCTURE_FAILURE","reason":"Original pinned dependencies could not be installed; no substitutions allowed"}' | Set-Content -Encoding utf8 windows-evidence/historical-environment.json + throw 'Historical dependency installation failed; inspect dependency-install.log' + } + } else { + python -m pip install -e './product[all]' + if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed' } + } + - name: Record dependency versions + shell: pwsh + run: | + New-Item -ItemType Directory -Force windows-evidence | Out-Null + python -m pip freeze | Set-Content -Encoding utf8 windows-evidence/dependencies.txt + $env:GITHUB_SHA | Set-Content -Encoding utf8 windows-evidence/diagnostic-commit.txt + - name: Trace unchanged cancellation test under recorded load control + shell: pwsh + run: | + $historical = @() + if ($env:HISTORICAL_ENVIRONMENT -eq '1') { $historical = @('--historical-environment', '--interleave-reviewer') } + $native = @() + if ($env:LOAD_CONTROL -eq 'native-wait') { $native = @('--historical-repository', 'historical-product') } + python diagnostic-source/scripts/v3_windows_reliability.py --repository product --output windows-evidence --iterations $env:ITERATIONS --control $env:LOAD_CONTROL --cpu-workers 2 @historical @native + - name: Verify diagnostic harness independently of historical product + if: always() && steps.python.outcome == 'success' + working-directory: diagnostic-source + shell: python + run: | + import json, os, sys, tempfile + from pathlib import Path + root = Path.cwd().resolve() + sys.path.insert(0, str(root)) + from scripts.v3_windows_reliability import isolated_environment, run_process + output = root.parent / 'windows-evidence' + output.mkdir(exist_ok=True) + code = ("from pathlib import Path; import openkyrozen, unittest; " + "print('Diagnostic product source:', openkyrozen.__file__, flush=True); " + "assert Path(openkyrozen.__file__).resolve().is_relative_to(Path.cwd()); " + "unittest.main(module='tests.test_v3_windows_reliability', verbosity=2)") + with tempfile.TemporaryDirectory(prefix='kyrozen-diagnostic-tests-') as home: + env = isolated_environment(Path(home)) + env['PYTHONPATH'] = str(root) + env['KYROZEN_DIAGNOSTIC_TEST_OUTPUT'] = str(output / 'diagnostic-test-evidence') + result = run_process([sys.executable, '-c', code], output / 'diagnostic-unittest.log', env, timeout=300) + returncode = result['returncode'] + (output / 'diagnostic-unittest.json').write_text(json.dumps({ + 'status': 'INFRASTRUCTURE_FAILURE' if returncode == 124 else 'PASS' if returncode == 0 else 'FAIL', + **result, 'diagnostic_commit': os.environ['GITHUB_SHA'], + 'python': sys.version, 'scope': 'diagnostic-source only; separate from historical product acceptance', + }, indent=2), encoding='utf-8') + raise SystemExit(returncode) + - name: Upload investigation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-cancellation-${{ matrix.python }}-${{ env.PRODUCT_SHA }}-${{ matrix.control }} + path: windows-evidence/ + if-no-files-found: error diff --git a/README.ja.md b/README.ja.md index f5e4a445..9aa95a5a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -14,6 +14,8 @@ ## インストール +V3 は未リリースです。以下のコマンドは公開済み安定版 **v2.0.8** をインストールします。 + OpenKyrozen は Python **3.12 と 3.13** をサポートします。Python 3.14 は対象外です。 ### macOS または Linux diff --git a/README.ko.md b/README.ko.md index f6dd6cc0..acec3fbf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -14,6 +14,8 @@ ## 설치 +V3는 아직 출시되지 않았습니다. 아래 명령은 공개된 안정 버전 **v2.0.8**을 설치합니다. + OpenKyrozen은 Python **3.12와 3.13**을 지원합니다. Python 3.14는 지원 대상이 아닙니다. ### macOS 또는 Linux diff --git a/README.md b/README.md index 923082f6..02437a18 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Install -The current published stable release is **v2.0.8**. It supports Python 3.12 and 3.13; Python 3.14 is outside the supported range. +V3 is not yet released. The commands below install the published stable **v2.0.8**. It supports Python 3.12 and 3.13; Python 3.14 is outside the supported range. macOS or Linux: diff --git a/README.zh-CN.md b/README.zh-CN.md index f497ccff..21a2e8a5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,6 +14,8 @@ ## 安装 +V3 尚未发布。下方命令安装已发布的稳定版 **v2.0.8**。 + OpenKyrozen 支持 Python **3.12 和 3.13**。Python 3.14 暂不支持。 ### macOS 或 Linux diff --git a/benchmarks/prompt_worker.py b/benchmarks/prompt_worker.py index a37f8a6c..3e4077f9 100644 --- a/benchmarks/prompt_worker.py +++ b/benchmarks/prompt_worker.py @@ -3,9 +3,240 @@ import argparse import json +import hashlib +import os +import shlex import sys +import time from pathlib import Path from types import SimpleNamespace +from contextvars import ContextVar +from functools import wraps + + +def _normalize_tool_receipt(receipt, workspace_root): + arguments = receipt.get("arguments") + if not isinstance(arguments, dict): + arguments = receipt.get("args") + if isinstance(arguments, str) and len(arguments) <= 65536 and arguments.lstrip().startswith("{"): + try: + parsed = json.loads(arguments) + if isinstance(parsed, dict): + arguments = parsed + except (TypeError, ValueError): + pass + canonical = json.dumps(arguments, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + path = (arguments.get("path") or arguments.get("file_path")) if isinstance(arguments, dict) else None + if isinstance(arguments, str) and len(arguments) <= 65536: + # Frozen V2 filesystem tools take a bare path, or path|content for writes. + if receipt.get("action") == "read_file": + path = arguments.strip() + elif receipt.get("action") == "write_file" and "|" in arguments: + path = arguments.split("|", 1)[0].strip() + try: + target = Path(path) + target = target if target.is_absolute() else Path(workspace_root) / target + target_path = target.resolve().relative_to(Path(workspace_root).resolve()).as_posix() + except (OSError, ValueError, TypeError): + target_path = None + reference = None + if receipt.get("action") == "git_show": + if isinstance(arguments, dict): + reference = arguments.get("reference") + elif isinstance(arguments, str) and len(arguments) <= 65536: + try: + reference = next((part for part in shlex.split(arguments) if not part.startswith("-")), None) + except ValueError: + pass + return {"action": receipt.get("action"), "call_id": receipt.get("call_id"), + "args_hash": hashlib.sha256(canonical.encode("utf-8")).hexdigest(), + "target_path": target_path, "git_reference": reference, + "background": arguments.get("background") is True if isinstance(arguments, dict) else None, + "success": receipt.get("success") is True, "authorized": receipt.get("authorized") is True, + "error_code": (receipt.get("tool_error") or {}).get("code"), + "evidence": json.loads(json.dumps(receipt))} + + +def _context_observation(runtime): + port = getattr(runtime, "context_usage", None) + try: + status = port() if callable(port) else None + except Exception: + status = None + if not isinstance(status, dict): + return None + keys = ("model", "window_tokens", "window_source", "input_tokens", "input_source", "reserve_tokens", + "breakdown", "breakdown_source", "native_input_overhead_tokens", "native_overhead_method", + "stable_prefix_tokens", "stable_prefix_fingerprint", "compaction", + "new_context_tokens", "retransmitted_history_tokens", "new_entry_count", + "retransmitted_entry_count", "duplicate_entry_count") + result = {key: status.get(key) for key in keys} + new, retransmitted = result["new_context_tokens"], result["retransmitted_history_tokens"] + result["retained_history_tokens"] = new + retransmitted if type(new) is int and type(retransmitted) is int else None + result["component_attribution_source"] = status.get("component_attribution_source") + return json.loads(json.dumps(result)) + + +class _ProviderObservations: + """Observe each provider-port invocation after production preflight. + + Components are source-runtime estimates, never provider attribution. Internal + transport retries may reuse one invocation payload; the relay owns attempts. + """ + def __init__(self, runtime, provider): + self.runtime, self.provider = runtime, provider + self.records, self.turn, self.prompt_sha256 = [], None, None + self.depth = ContextVar("benchmark_provider_observation_depth", default=0) + for name in ("chat", "chat_stream", "chat_response", "chat_response_stream"): + method = getattr(provider, name, None) + if callable(method): + setattr(provider, name, self._wrap(method, name.endswith("stream"))) + + def _begin(self, messages, model, tools): + from openkyrozen.agent.compaction import estimate_messages, _text_tokens, DEFAULT_OUTPUT_RESERVE_TOKENS + runtime = self.runtime + compacting = getattr(runtime, "_in_context_compaction", None) + summary = bool(compacting.get()) if compacting is not None else False + state_var = getattr(runtime, "_active_context_state", None) + state = state_var.get() if state_var is not None else None + schema_json = json.dumps(tools, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + overhead = _text_tokens(schema_json) + 32 * len(tools or ()) + 64 if tools is not None else 0 + estimated, components = estimate_messages(messages) + components = dict(components) + components["fixed_instructions"] += overhead + status = _context_observation(runtime) if not summary else None + metrics_var = getattr(runtime, "_context_history_metrics", None) + metrics = metrics_var.get() if not summary and metrics_var is not None else None + metrics = metrics if isinstance(metrics, dict) else {} + new_history, repeated_history = metrics.get("new_context_tokens"), metrics.get("retransmitted_history_tokens") + record = {"turn": self.turn, "prompt_sha256": self.prompt_sha256, + "kind": "summary" if summary else "generation", "model": model, + "observation_boundary": "provider_port_after_production_preflight", + "estimate_method": "source_runtime_stdlib_messages_plus_json_schema_framing", + "window_tokens": getattr(getattr(self.provider, 'config', None), "context_window_tokens", None), + "reserve_tokens": getattr(state, "reserve_tokens", DEFAULT_OUTPUT_RESERVE_TOKENS), + "estimated_input_tokens": estimated + overhead, + "component_estimates": components, "component_attribution_source": "estimated", + "native_input_overhead_tokens": overhead, + "schema_sha256": hashlib.sha256(schema_json.encode()).hexdigest() if tools is not None else None, + "message_count": len(messages), + "message_projection_sha256": None, + "retained_history_tokens": new_history + repeated_history if type(new_history) is int and type(repeated_history) is int else None, + "new_context_tokens": new_history, + "retransmitted_history_tokens": repeated_history, + "history_attribution_source": "production_history_projection_estimate" if metrics else None, + "compaction": status.get("compaction") if status else None, + "provider_input_tokens": None, "provider_output_tokens": None, + "provider_usage_source": None, "outcome": "pending"} + # Fingerprint the same source-owned estimate projection without retaining content. + from openkyrozen.agent.compaction import message_fingerprint + record["message_projection_sha256"] = message_fingerprint(messages) + routing_observer = getattr(runtime, '_benchmark_routing_observer', None) + if routing_observer is not None: + routing_observer(record, tools) + self.records.append(record) + return record + + @staticmethod + def _usage(record, usage): + if not isinstance(usage, dict) or usage.get("_estimated"): + return + for target, keys in (("provider_input_tokens", ("prompt_tokens", "input_tokens")), + ("provider_output_tokens", ("completion_tokens", "output_tokens"))): + value = next((usage[key] for key in keys if type(usage.get(key)) is int and usage[key] >= 0), None) + record[target] = value + if record["provider_input_tokens"] is not None: + record["provider_usage_source"] = "provider_reported_total" + + def _wrap(self, original, streaming): + def begin(args, kwargs): + messages = args[0] if args else kwargs["messages"] + model = args[1] if len(args) > 1 else kwargs.get("model") + return self._begin(messages, model, kwargs.get("tools")) + if streaming: + @wraps(original) + def stream(*args, **kwargs): + if self.depth.get(): + yield from original(*args, **kwargs) + return + record = begin(args, kwargs) + token = self.depth.set(self.depth.get() + 1) + iterator = None + try: + iterator = original(*args, **kwargs) + for event in iterator: + usage = getattr(event, "usage", None) + response = getattr(event, "response", None) + self._usage(record, getattr(response, "usage", None) or usage) + if type(event).__name__ == "ResponseError": + record["outcome"] = "failed" + elif type(event).__name__ == "ResponseComplete": + record["outcome"] = "returned" + yield event + if record["outcome"] == "pending": + record["outcome"] = "returned" + except BaseException: + if record["outcome"] == "pending": + record["outcome"] = "failed" + raise + finally: + try: + if callable(getattr(iterator, "close", None)): + iterator.close() + finally: + self.depth.reset(token) + return stream + @wraps(original) + def call(*args, **kwargs): + if self.depth.get(): + return original(*args, **kwargs) + record = begin(args, kwargs) + token = self.depth.set(self.depth.get() + 1) + try: + response = original(*args, **kwargs) + usage = response[1] if isinstance(response, tuple) and len(response) == 2 else getattr(response, "usage", None) + self._usage(record, usage) + record["outcome"] = "returned" + return response + except BaseException: + record["outcome"] = "failed" + raise + finally: + self.depth.reset(token) + return call + + +def _run_conversation(runtime, session, turns, observations=None): + records = [] + answer = "" + for index, prompt in enumerate(turns): + if observations is not None: + observations.turn = index + observations.prompt_sha256 = hashlib.sha256(prompt.encode("utf-8")).hexdigest() + answer = runtime.chat(session, prompt, clear_tasks=False, profile="auto") + records.append({"turn": index, "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + "completed": True, "context": _context_observation(runtime), + "messages_compacted": getattr(session, "messages_compacted", None)}) + return answer, records + + +def _accepted_scope_preserved(history, accepted_fingerprints, accepted_plan, task_records): + if (not isinstance(accepted_plan, dict) + or accepted_plan.get("fingerprint") not in accepted_fingerprints): + return False + identity = ("plan_id", "version", "fingerprint") + completed = any( + isinstance(event, dict) and event.get("event_type") == "interaction.plan_completed" + and isinstance(event.get("payload"), dict) + and event["payload"].get("status") == "completed" + and all(event["payload"].get(key) == accepted_plan.get(key) for key in identity) + for event in history + ) + return completed or any( + task.get("status") != "cancelled" + and (task.get("strategic_binding") or {}).get("fingerprint") == accepted_plan.get("fingerprint") + for task in task_records if isinstance(task, dict) + ) def main(): @@ -13,43 +244,249 @@ def main(): parser.add_argument("--source", required=True) parser.add_argument("--workspace", required=True) parser.add_argument("--url", required=True) + parser.add_argument("--model", default="deepseek-v4-flash") + parser.add_argument("--protocol", choices=("chat", "responses"), default="chat") + parser.add_argument("--scenario", default="") + parser.add_argument("--process-marker", default="") parser.add_argument("--prompt-file", required=True) + parser.add_argument("--plan-file") + parser.add_argument("--conversation-file") parser.add_argument("--result", required=True) args = parser.parse_args() + conversation = json.loads(Path(args.conversation_file).read_text()) if args.conversation_file else {} + followups = conversation.get("followups", []) + if (not isinstance(followups, list) or any(not isinstance(turn, str) or not turn for turn in followups) + or len(followups) > 20): + raise ValueError("benchmark followups must be a bounded array of nonempty strings") + window = conversation.get("context_window_tokens") + if window is not None and (type(window) is not int or not 1 <= window <= 10000000): + raise ValueError("benchmark context window must be a positive declared integer") + from_output_reserve = conversation.get("output_reserve_tokens", 4096) + if from_output_reserve != 4096: + raise ValueError("both benchmark arms require the frozen 4096-token output reserve") + workspace_root = Path(args.workspace).resolve() sys.path.insert(0, args.source) + sys.path.insert(1, str(Path(__file__).resolve().parent)) + actor_client = None + if os.environ.get('KYROZEN_BENCHMARK_ACTOR_SOCKET') is not None: + from v3_process_owner import connect_actor_client + from v3_shell_launcher import install_worker_adapter + from v3_actor_worker import install_mutation_adapter, install_read_adapter + actor_client = connect_actor_client() + install_worker_adapter(client=actor_client) + install_mutation_adapter(actor_client) + install_read_adapter(actor_client) + if os.environ.get("KYROZEN_BENCHMARK_BROKER_FD") is not None: + # V2 also has a benchmarks package; load this adapter by its owning file + # without allowing the current source tree to shadow the frozen snapshot. + import runpy + runpy.run_path(str(Path(__file__).with_name("v3_shell_launcher.py")))["install_worker_adapter"]() + from v3_actor_worker import _require_selected_source + _require_selected_source(args.source) from openkyrozen.app.bootstrap import build_application from openkyrozen.providers import ProviderConfig - from openkyrozen.providers.openai import OpenAICompatProvider + from openkyrozen.providers.openai import OpenAICompatProvider, OpenAIResponsesProvider + from openkyrozen.agent.compaction import DEFAULT_OUTPUT_RESERVE_TOKENS + if DEFAULT_OUTPUT_RESERVE_TOKENS != from_output_reserve: + raise ValueError("source runtime output reserve differs from the frozen cohort") application = build_application(surface="cli") runtime = application.runtime + routing = None try: + if os.environ.get('KYROZEN_BENCHMARK_ROUTING_SUPPLEMENT') is not None: + if actor_client is None: + raise PermissionError('routing supplement requires its parent actor broker') + from v3_routing_supplement import install + variant = actor_client.rpc('routing_policy')['variant'] + routing = install(runtime, variant) + runtime._benchmark_routing_variant = variant + if actor_client is not None: + from v3_actor_worker import install_delegated_adapter + install_delegated_adapter(runtime, actor_client, source=args.source) runtime._set_workspace_root(args.workspace) - runtime.set_interaction_mode("agent") - config = ProviderConfig(provider="deepseek", api_key="benchmark-local", - base_url=args.url, model_simple="deepseek-v4-flash", - model_complex="deepseek-v4-flash") + runtime.set_interaction_mode("ask" if followups else "agent") + provider_name = "openai" if args.protocol == "responses" else "deepseek" + config = ProviderConfig(provider=provider_name, + api_key=os.environ.get("KYROZEN_BENCHMARK_API_KEY", "benchmark-local"), + base_url=args.url, model_simple=args.model, + model_complex=args.model, context_window_tokens=window) runtime._provider_config = config - runtime.llm_provider = OpenAICompatProvider(config) - runtime.DEEPSEEK_MODEL_SIMPLE = runtime.DEEPSEEK_MODEL_COMPLEX = "deepseek-v4-flash" - # Only isolate optional services; leave planning, tools, recovery, receipts, - # completion and CLI rendering on their actual production paths. + provider_type = OpenAIResponsesProvider if args.protocol == "responses" else OpenAICompatProvider + runtime.llm_provider = provider_type(config) + observations = _ProviderObservations(runtime, runtime.llm_provider) + runtime._default_turn.provider = runtime.llm_provider + runtime.DEEPSEEK_MODEL_SIMPLE = runtime.DEEPSEEK_MODEL_COMPLEX = args.model + # Keep production planning, tools, recovery, receipts and completion active. runtime.dispatch_learning_cycle = lambda **kwargs: None runtime._plugin_runtime_for_surface = lambda *args: SimpleNamespace( turn_start=lambda **kwargs: None, turn_end=lambda **kwargs: None) events = [] + receipts = [] cli_chat = runtime.chat + started = time.monotonic() + first_delta_ms = None + + def on_event(event): + nonlocal first_delta_ms + if hasattr(event, "kind") and hasattr(event, "payload"): + from openkyrozen.agent.events import runtime_event_as_legacy + event = runtime_event_as_legacy(event) or {} + elif hasattr(event, "as_dict"): + event = event.as_dict() + if not isinstance(event, dict): + return + if first_delta_ms is None and event.get("event") == "content": + first_delta_ms = round((time.monotonic() - started) * 1000, 2) + if event.get("event") == "tool_receipt": + receipt = event.get("tool_receipt", {}) + receipts.append(_normalize_tool_receipt(receipt, workspace_root)) + events.append({"event": "tool_receipt", "action": receipt.get("action"), + "success": receipt.get("success") is True}) + elif event.get("event") in {"tasks", "task_update", "working_plan", "subagent"}: + events.append({"event": event.get("event"), + "working_plan": event.get("working_plan"), + "tasks": event.get("tasks"), + "agent": event.get("agent"), + "status": event.get("status"), + "description": event.get("description")}) + elif event.get("event") in {"interaction", "protocol_recovery", "protocol.recovery"}: + events.append({"event": event.get("event"), "interaction": event.get("interaction")}) + runtime.chat = lambda session, message, **kwargs: cli_chat( - session, message, on_event=events.append, **kwargs) + session, message, on_event=on_event, **kwargs) prompt = Path(args.prompt_file).read_text() - runtime._run_cli_chat(prompt, runtime._interaction_controller.state()) - answer = next((m["content"] for m in reversed(runtime.short_term_memory) - if m["role"] == "assistant"), "") + initial_plan = json.loads(Path(args.plan_file).read_text()) if args.plan_file else [] + initial_tasks = [] + accepted_fingerprint = None + accepted_plan_identity = None + if isinstance(initial_plan, dict): + proposal = runtime._interaction_controller.propose_plan(initial_plan, original_input=prompt) + accepted, _ = runtime._interaction_controller.accept_plan( + runtime.tasks, plan_id=proposal["plan_id"], version=proposal["version"], + ) + accepted_fingerprint = accepted.get("fingerprint") + accepted_plan_identity = {key: accepted.get(key) for key in ("plan_id", "version", "fingerprint")} + initial_tasks = [f"{step['title']}: {step['description']}" for step in accepted["steps"]] + elif isinstance(initial_plan, list) and all(isinstance(item, str) for item in initial_plan): + initial_tasks = initial_plan + if initial_tasks: + if callable(getattr(runtime.tasks, "edit_plan", None)): + runtime.tasks.edit_plan( + complexity="complex", expected_revision=0, + operations=[{"op": "add", "ref": f"fixture-{index}", "task": { + "description": task, "acceptance": ["Complete the independent fixture objective."], + }} for index, task in enumerate(initial_tasks, 1)], + reason="Initialize the frozen matched evaluation plan.", + ) + else: + runtime.tasks.add_ordered_plan(initial_tasks, task_id_prefix="fixture") + prompt = ("Initial working-plan task order:\n" + "\n".join( + f"{index}. {task}" for index, task in enumerate(initial_tasks, 1) + ) + "\nUse update_plan when new evidence requires adding, cancelling, replacing, or reordering work.\n\n" + + prompt) + else: + raise ValueError("benchmark initial plan must be a plan object or an array of task descriptions") + snapshot_working_plan = getattr(runtime.tasks, "working_plan_snapshot", None) + initial_working_plan = snapshot_working_plan() if callable(snapshot_working_plan) else None + if initial_working_plan: + events.append({"event": "working_plan", "working_plan": initial_working_plan}) + if runtime.tasks.tasks: + events.append({"event": "tasks", "tasks": list(runtime.tasks.tasks)}) + if accepted_fingerprint: + prompt = ("A Strategic Plan has already been accepted. Preserve its approved scope; use the working plan " + "for implementation changes, and request renewed approval for material scope changes.\n\n" + prompt) + answer, conversation_records = _run_conversation(runtime, runtime.current_session, [prompt, *followups], observations) + manager = getattr(runtime.current_session, "process_manager", None) + handles = tuple(getattr(manager, "_handles", {}).values()) + cancellations = sum(item["action"] == "stop_process" and item["success"] for item in receipts) + unique_calls = {(item["action"], item["args_hash"]) for item in receipts} + working_plan = snapshot_working_plan() if callable(snapshot_working_plan) else None + plan_snapshots = [] + for event in events: + snapshot = event.get("working_plan") + if isinstance(snapshot, dict) and all( + (item.get("plan_id"), item.get("revision")) != + (snapshot.get("plan_id"), snapshot.get("revision")) for item in plan_snapshots): + plan_snapshots.append(snapshot) + if isinstance(working_plan, dict) and all( + (item.get("plan_id"), item.get("revision")) != + (working_plan.get("plan_id"), working_plan.get("revision")) for item in plan_snapshots): + plan_snapshots.append(working_plan) + revisions = [int(item.get("revision", 0)) for item in plan_snapshots] + working_revisions = max(0, max(revisions) - min(revisions)) if revisions else 0 + task_orders = [] + for event in events: + items = event.get("tasks") + if isinstance(items, list): + order = tuple((item.get("id"), item.get("description")) for item in items if isinstance(item, dict)) + if order and (not task_orders or order != task_orders[-1]): + task_orders.append(order) + task_revisions = max(0, len(task_orders) - 1) + plan_revisions = max(working_revisions, task_revisions) + unnecessary_churn = sum( + left.get("tasks") == right.get("tasks") and left.get("plan_id") == right.get("plan_id") + for left, right in zip(plan_snapshots, plan_snapshots[1:]) + ) + material_revisions = sum( + 1 for event in events if event.get("event") == "interaction" + and isinstance(event.get("interaction"), dict) + and (event["interaction"].get("pending_plan") or {}).get("revision_materiality") == "material" + ) + interaction_state = runtime._interaction_controller.state() + history = runtime._interaction_controller._history() + proposals = [event.get("payload", {}) for event in history + if event.get("event_type") == "interaction.plan_proposed"] + accepted_versions = {(event.get("payload", {}).get("plan_id"), + event.get("payload", {}).get("version")) for event in history + if event.get("event_type") == "interaction.plan_accepted"} + accepted_fingerprints = [proposal.get("fingerprint") for proposal in proposals + if (proposal.get("plan_id"), proposal.get("version")) in accepted_versions] + pending_material = (interaction_state.get("pending_plan") or {}).get("revision_materiality") == "material" result = {"answer": answer, "tool_calls": sum(e.get("event") == "tool_receipt" for e in events), + "tool_actions": [e.get("action") for e in events if e.get("event") == "tool_receipt"], + "tool_receipts": receipts, "events": events, + "conversation_records": conversation_records, + "request_context_records": observations.records + getattr(runtime, '_benchmark_child_context_records', []), + "tool_successes": sum(e.get("event") == "tool_receipt" and e.get("success") is True for e in events), "tasks": [t["status"] for t in runtime.tasks.tasks], - "provider_error": "[LLM Error]" in answer} + "task_records": [{"id": t["id"], "description": t["description"], + "status": t["status"], + "strategic_binding": (t.get("checkpoint") or {}).get("strategic_plan")} + for t in runtime.tasks.tasks], + "working_plan": working_plan, + "initial_plan_fingerprint": accepted_fingerprint, + "accepted_plan_fingerprints": accepted_fingerprints, + "material_plan_proposals": sum(item.get("revision_materiality") == "material" for item in proposals), + "pending_material_revision": pending_material, + "executing_plan_fingerprint": (interaction_state.get("executing_plan") or {}).get("fingerprint"), + "plan_revisions": plan_revisions, + "cancelled_tasks": sum(t["status"] == "cancelled" for t in runtime.tasks.tasks), + "material_revisions": material_revisions, + "unnecessary_churn": unnecessary_churn, + "duplicate_calls": len(receipts) - len(unique_calls), + "protocol_recovery": sum(event["event"] in {"protocol_recovery", "protocol.recovery"} + for event in events), + "processes_started": sum(item["action"] == "run_cmd" and item["background"] is True and item["success"] + for item in receipts), + "processes_cancelled": cancellations, + "processes_alive": sum(handle.process.poll() is None for handle in handles), + "process_pids": [handle.process.pid for handle in handles], + "process_marker_seen": any(args.process_marker and args.process_marker in handle.stdout + for handle in handles), + "first_delta_latency_ms": first_delta_ms, + "provider_error": "[LLM Error]" in answer or any(item["outcome"] == "failed" for item in + observations.records + getattr(runtime, '_benchmark_child_context_records', []))} + result["accepted_scope_preserved"] = _accepted_scope_preserved( + history, accepted_fingerprints, accepted_plan_identity, result["task_records"], + ) + result["scope_preserved"] = bool(result["accepted_scope_preserved"] and not pending_material) + if routing is not None: + result['routing_observations'] = routing.records + getattr(runtime,'_benchmark_child_routing_records',[]) Path(args.result).write_text(json.dumps(result)) finally: application.close() + if routing is not None: + routing.close() if __name__ == "__main__": diff --git a/benchmarks/v3_actor_supervisor.py b/benchmarks/v3_actor_supervisor.py new file mode 100644 index 00000000..1819b0a0 --- /dev/null +++ b/benchmarks/v3_actor_supervisor.py @@ -0,0 +1,1062 @@ +"""Privileged benchmark parent: kernel identities own immutable actor policies.""" +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +import shlex +import selectors +import signal +import socket +import stat +import subprocess +import sys +import tempfile +import threading +import time +import uuid + +from benchmarks.v3_process_owner import UIDOwner, peer_identity, process_snapshot +from benchmarks.v3_protected_worker import _validated_files, protected_shell_command +from benchmarks.v3_shell_launcher import ShellBroker, ShellPolicy, _encoded, _MAX_MESSAGE, _safe_env + +_MUTATIONS = frozenset({'write_file', 'edit_file', 'apply_patch'}) +_READS = frozenset({'read_file', 'search_files', 'list_dir', 'list_tree', 'find_files'}) +_PROFILES = {'researcher': frozenset({'read', 'network'}), + 'reviewer': frozenset({'read', 'network'}), + 'coder': frozenset({'read', 'write', 'shell', 'network', 'git', 'browser'})} +_CALLBACK_FIELDS = { + 'publish': {'run'}, 'receipt': {'child_id', 'receipt'}, 'message': {'child_id', 'message'}, + 'usage': {'usage'}, 'cancelled': set(), 'budget_can_continue': {'cost_picos'}, + 'budget_record_cost': {'cost_picos'}, 'access_acquire': {'path', 'reading', 'recursive'}, + 'access_release': {'token'}, 'event': {'event'}, 'parent_interaction_state': set(), + 'parent_task_state': set(), 'parent_active_task': {'task_id'}, + 'parent_mutation_matches_current_task': {'action', 'arguments'}, + 'memory_recall_records': {'session_id', 'query', 'options'}, + 'memory_recall': {'session_id', 'query', 'options'}, + 'memory_recent': {'session_id', 'query', 'options'}, 'usage_attempts': {'attempts'}, + 'canonical_history': {'session_id', 'kind', 'offset', 'chunk', 'final'}} + + +def _actor_socket_directory(): + directory = Path(tempfile.mkdtemp(prefix='openkyrozen-actors-', dir='/var/tmp')).resolve() + directory.chmod(0o711) + return directory + + +def _fixture_git_environment(environment, workspace): + # Fresh actor UIDs differ from the parent's fixture owner. Trust precisely + # that fixture, without inheriting protected-config wildcards or host files. + return dict({key: value for key, value in environment.items() if not key.startswith('GIT_CONFIG')}, + GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_COUNT='1', + GIT_CONFIG_KEY_0='safe.directory', GIT_CONFIG_VALUE_0=str(workspace)) + + +def _copy(value): + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + if len(encoded.encode()) > 1_000_000: + raise PermissionError('actor payload exceeds benchmark limit') + return json.loads(encoded) + + +def _communicate(process, owner, *, input_data=None, timeout, limit=_MAX_MESSAGE): + """Bound both pipes while preserving native tool input and exit behavior.""" + output = {'stdout': bytearray(), 'stderr': bytearray()} + deadline = time.monotonic() + timeout + pending = memoryview(input_data or b'') + try: + with selectors.DefaultSelector() as selector: + for name in ('stdout', 'stderr'): + stream = getattr(process, name) + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ, name) + if process.stdin: + if pending: + os.set_blocking(process.stdin.fileno(), False) + selector.register(process.stdin, selectors.EVENT_WRITE, 'stdin') + else: + process.stdin.close() + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(process.args, timeout) + for key, _ in selector.select(min(.1, remaining)): + if key.data == 'stdin': + try: + pending = pending[os.write(key.fd, pending[:65536]):] + except BrokenPipeError: + pending = pending[:0] + if not pending: + selector.unregister(key.fileobj) + key.fileobj.close() + else: + chunk = os.read(key.fd, 65536) + if not chunk: + selector.unregister(key.fileobj) + key.fileobj.close() + continue + output[key.data].extend(chunk) + if len(output[key.data]) > (limit if key.data == 'stdout' else 65536): + raise PermissionError('actor output exceeds its bounded channel') + process.wait(timeout=max(.01, deadline - time.monotonic())) + return bytes(output['stdout']), bytes(output['stderr']) + except BaseException: + owner.close() + process.wait(timeout=5) + raise + finally: + for stream in (process.stdin, process.stdout, process.stderr): + if stream: + stream.close() + + +def _path(workspace, value): + if not isinstance(value, str) or not value.strip() or '\0' in value: + raise PermissionError('invalid actor workspace path') + candidate = workspace / value + try: + relative = candidate.relative_to(workspace) + except ValueError as exc: + raise PermissionError('actor path escaped its workspace') from exc + cursor = workspace + for component in relative.parts: + cursor = cursor / component + if cursor.is_symlink(): + raise PermissionError('actor paths cannot contain symlinks') + path = Path(os.path.abspath(candidate)) + if not path.is_relative_to(workspace): + raise PermissionError('actor path escaped its workspace') + for item in (path, *path.parents): + if item == workspace.parent: + break + if item.is_symlink(): + raise PermissionError('actor paths cannot contain symlinks') + if item.exists(): + metadata = item.stat() + if not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)): + raise PermissionError('actor paths must be regular files or directories') + if stat.S_ISREG(metadata.st_mode) and metadata.st_nlink != 1: + raise PermissionError('actor paths cannot contain hardlink aliases') + return path + + +def _restrict_reads(invocation, workspace, scopes, *, writable=False, protected_files=()): + """Raw actor code cannot read fixture paths outside its assigned scopes.""" + if any(directory and path == workspace for path, directory in scopes): + return + if invocation.backend == 'sandbox-exec': + from openkyrozen.tools.sandbox import _sbpl_path + if not scopes: + invocation.command[2] += f'(deny file-read-data (subpath {_sbpl_path(str(workspace))}))' + return + allowed = ' '.join(f'({"subpath" if directory else "literal"} {_sbpl_path(str(path))})' + for path, directory in scopes) + ancestors = {parent for path, _ in scopes for parent in path.parents + if parent == workspace or parent.is_relative_to(workspace)} + allowed_ancestors = ' '.join(f'(literal {_sbpl_path(str(path))})' for path in ancestors) + invocation.command[2] += ( + f'(deny file-read* (require-all (subpath {_sbpl_path(str(workspace))}) ' + f'(require-not (require-any {allowed} {allowed_ancestors}))))' + + (f'(deny file-read-data {allowed_ancestors})' if ancestors else '')) + elif invocation.backend == 'bubblewrap': + argv = invocation.command + separator = argv.index('--') + mounts = ['--tmpfs', str(workspace)] + parents = {parent for path, _ in scopes for parent in path.parents + if parent != workspace and parent.is_relative_to(workspace)} + for parent in sorted(parents, key=lambda path: len(path.parts)): + mounts += ['--dir', str(parent)] + for path, directory in scopes: + if path.exists(): + mounts += ['--bind' if writable and directory else '--ro-bind', str(path), str(path)] + for guard in protected_files: + if any(guard == path or directory and guard.is_relative_to(path) for path, directory in scopes): + mounts += ['--ro-bind', str(guard), str(guard)] + mounts += ['--remount-ro', str(workspace)] + invocation.command = argv[:separator] + mounts + argv[separator:] + else: + raise PermissionError('actor read isolation requires a supported OS backend') + + +class _ActorShellPolicy(ShellPolicy): + def authorize(self, request): + if self.fixture.revision != self.fixture_revision: + raise PermissionError('actor fixture policy changed') + super().authorize(request) + identity = {key: getattr(self.fixture, key) + for key in ('run_id', 'actor_id', 'session_id', 'revision')} + self.fixture.authorize(dict(request, **identity)) + + +class ActorAssignment: + """Intersect a requested assignment with the parent's frozen fixture grants.""" + def __init__(self, policy, payload, *, source, model, provider='custom', review_artifacts=()): + self.payload = _copy(payload) + payload = self.payload + self.fixture, self.revision = policy, policy.revision + self.workspace = policy.workspace + if (payload.get('schema_version') != 1 or payload.get('source') != str(Path(source).resolve()) + or payload.get('workspace') != str(policy.workspace) + or payload.get('parent', {}).get('model') != model + or any(type(payload.get(key)) is not bool for key in ('review', 'report_repair'))): + raise PermissionError('actor bootstrap does not match the parent source, workspace, or model') + run = payload.get('run', {}) + brief = run.get('assignment', {}) + self.run_id = run.get('run_id') + if not isinstance(self.run_id, str) or not self.run_id or len(self.run_id) > 200: + raise PermissionError('invalid actor run identity') + profile = run.get('profile') + if (profile not in _PROFILES or brief.get('custom_profile') is not None + or brief.get('provider', provider) != provider or brief.get('model', model) != model + or brief.get('profile', profile) != profile): + raise PermissionError('actor profile or provider override is outside the frozen fixture') + self.review = payload['review'] + self.report_repair = payload['report_repair'] + capabilities = _PROFILES['reviewer' if self.review else profile] & policy.capabilities + self.capabilities = frozenset() if self.report_repair else capabilities + scopes = brief.get('scope') + if not isinstance(scopes, list) or not scopes or len(scopes) > 128: + raise PermissionError('actor assignment requires bounded workspace scopes') + self.scopes = tuple((_path(self.workspace, value), _path(self.workspace, value).is_dir()) + for value in scopes) + if any(not any(path.is_relative_to(bound) for bound in policy.scope_paths) + for path, _ in self.scopes): + raise PermissionError('actor scope exceeds approved fixture bounds') + self.read_scopes = self.scopes + if self.review: + extra = tuple((_path(self.workspace, str(path)), Path(path).is_dir()) for path in review_artifacts) + if any(not any(path.is_relative_to(bound) for bound in policy.scope_paths) for path, _ in extra): + raise PermissionError('review artifact exceeds immutable fixture bounds') + self.read_scopes += extra + directories = tuple(path for path, directory in self.scopes if directory) + shell_caps = self.capabilities if directories else self.capabilities - {'write'} + self.shell_policy = _ActorShellPolicy(self.workspace, shell_caps, + scope_paths=directories or policy.scope_paths, run_id=policy.run_id, + actor_id=uuid.uuid4().hex, session_id=policy.session_id, revision=policy.revision) + self.shell_policy.fixture = policy + self.shell_policy.fixture_revision = policy.revision + self.shell_policy.effects, self.shell_policy.risk_ceiling = policy.effects, policy.risk_ceiling + + def validate_scopes(self): + for path, directory in self.read_scopes: + _path(self.workspace, str(path)) + if path.is_dir() != directory: + raise PermissionError('actor scope changed its approved file or directory kind') + + def authorize_mutation(self, action, arguments, protected_files): + if ('write' not in self.capabilities or self.fixture.revision != self.revision + or action not in _MUTATIONS): + raise PermissionError('actor has no authority for this file mutation') + self.validate_scopes() + from openkyrozen.tools.catalog import builtin_metadata + specification = builtin_metadata(action) + if (self.fixture.effects is not None and specification['side_effects'] not in self.fixture.effects + or specification['risk'] == 'high' and self.fixture.risk_ceiling != 'high'): + raise PermissionError('file mutation exceeds accepted plan effects or risk') + arguments = _copy(arguments) + path = self.mutation_target(action, arguments) + if (path.is_dir() or not any(path == scope or directory and path.is_relative_to(scope) + for scope, directory in self.scopes) + or any(path == guard or guard.is_relative_to(path) for guard in protected_files)): + raise PermissionError('file mutation exceeds actor scope or targets original tests') + return arguments + + def mutation_target(self, action, arguments): + try: + if isinstance(arguments, dict): + value = arguments['path'] + elif action == 'write_file': + value, _ = arguments.split('|', 1) + value = value.strip() + else: + value = json.loads(arguments)['path'] + return _path(self.workspace, value) + except (KeyError, TypeError, ValueError, AttributeError) as exc: + raise PermissionError('invalid file mutation arguments') from exc + + def read_target(self, action, arguments): + try: + request = (json.loads(arguments) if isinstance(arguments, str) + and arguments.lstrip().startswith('{') else arguments) + if action == 'find_files': + if isinstance(request, dict): + value, pattern = request.get('directory', '.'), request.get('pattern', '') + else: + parts = request.split('|', 1) + pattern, value = parts[0], parts[1] if len(parts) == 2 else '.' + if not isinstance(pattern, str) or Path(pattern.strip()).is_absolute() or '..' in Path(pattern.strip()).parts: + raise PermissionError('find pattern exceeds its assigned directory') + value = value.strip() + else: + value = request.get('path', '.' if action != 'read_file' else '') if isinstance(request, dict) else request + if action in {'list_dir', 'list_tree'} or not isinstance(request, dict) and action == 'read_file': + value = value.strip() + if action == 'search_files': + pattern = request.get('glob', '*') + if not isinstance(pattern, str) or Path(pattern).is_absolute() or '..' in Path(pattern).parts: + raise PermissionError('search glob exceeds its assigned scope') + return _path(self.workspace, value or '.') + except (ValueError, TypeError, AttributeError) as exc: + raise PermissionError('invalid actor read arguments') from exc + + def authorize_read(self, action, arguments): + if 'read' not in self.capabilities or self.fixture.revision != self.revision or action not in _READS: + raise PermissionError('actor has no authority for this read') + self.validate_scopes() + arguments = _copy(arguments) + path = self.read_target(action, arguments) + if not any(path == scope or directory and path.is_relative_to(scope) + for scope, directory in self.read_scopes): + raise PermissionError('read exceeds actor assigned scope') + return arguments + + +class _OwnedInvocation: + def __init__(self, invocation, owner): + self.invocation, self.owner = invocation, owner + self.lock = threading.Lock() + + def close(self): + with self.lock: + self.owner.close() + self.invocation.close() + + +class _ActorBroker(ShellBroker): + def __init__(self, connection, supervisor, actor): + self.supervisor, self.actor = supervisor, actor + self.pending = {} + super().__init__(connection, actor['policy'], supervisor.protected_files) + + def _dispatch(self, request): + if not isinstance(request, dict): + raise PermissionError('invalid actor request') + operation = request.get('op') + if operation == 'hello': + if set(request) != {'op'}: + raise PermissionError('actor identity is kernel-bound') + return {key: getattr(self.policy, key) + for key in ('run_id', 'actor_id', 'session_id', 'revision')} + if operation == 'actor_bootstrap': + if self.actor['root']: + raise PermissionError('root actor has no delegated bootstrap') + return {'payload': self.actor['assignment'].payload, + 'provider_config': self.actor['provider_config'], + **({'routing_variant':self.supervisor.routing_variant} + if self.supervisor.routing_variant is not None else {})} + if operation == 'routing_policy': + if set(request) != {'op'}: + raise PermissionError('routing policy is parent-held') + return {'variant':self.supervisor.routing_variant} + if operation == 'delegated_launch': + if not self.actor['root']: + raise PermissionError('only the root actor can launch delegated actors') + return self.supervisor._launch_child(self.actor, request['payload']) + if operation in {'delegated_poll', 'delegated_stop'}: + if not self.actor['root']: + raise PermissionError('only the root actor owns delegated handles') + return self.supervisor._delegated(self.actor, request) + if operation == 'delegated_call': + if self.actor['root']: + raise PermissionError('root actor cannot issue child callbacks') + return self.supervisor._callback(self.actor, request) + if operation == 'file_mutation': + return self.supervisor._mutate(self.actor, request['action'], request['arguments']) + if operation == 'file_read': + return self.supervisor._mutate(self.actor, request['action'], request['arguments'], reading=True) + if operation == 'ticket': + with self.supervisor.execution_lock: + self._reap_pending() + if len(self.pending) >= 64: + raise PermissionError('shell ticket limit reached') + original = _copy(request['request']) + self.policy.authorize(original) + owner = self.supervisor._owner() + invocation = None + try: + home = self.supervisor._home(owner) + environment = _fixture_git_environment( + dict(_safe_env(original['env']), HOME=str(home), USERPROFILE=str(home)), + self.supervisor.workspace) + invocation = protected_shell_command(original['command'], policy=self.policy, + cwd=original['cwd'], env=environment, protected_files=self.protected_files) + assignment = self.actor.get('assignment') + scopes = (assignment.read_scopes if assignment else + tuple((path, True) for path in self.policy.scope_paths)) + _restrict_reads(invocation, self.policy.workspace, + scopes if 'read' in self.policy.capabilities else (), + writable='write' in self.policy.capabilities, + protected_files=self.protected_files) + if invocation.shell: + raise PermissionError('managed actor shell requires an argument vector') + if invocation.temporary_directory: + self.supervisor._chown(Path(invocation.temporary_directory.name), owner.uid) + concrete = dict(original, env=invocation.env) + with self.close_lock: + if self.closed.is_set(): + raise PermissionError('benchmark actor broker is closed') + ticket = self.authority.issue(concrete) + self.pending[ticket] = (original, concrete, _OwnedInvocation(invocation, owner), time.monotonic() + 5) + def expire(): + time.sleep(5.01) + with self.supervisor.execution_lock: + self._reap_pending() + threading.Thread(target=expire, daemon=True).start() + return ticket + except BaseException: + owner.close() + if invocation: + invocation.close() + raise + if operation == 'start': + with self.supervisor.execution_lock, self.supervisor._frozen(): + self._reap_pending() + with self.close_lock: + if self.closed.is_set(): + raise PermissionError('benchmark actor broker is closed') + pending = self.pending.get(request.get('ticket')) + if pending is None: + raise PermissionError('invalid or expired shell ticket') + original, concrete, owned, _ = pending + try: + if _encoded(original) != _encoded(request['request']): + raise PermissionError('shell ticket concrete request changed') + assignment = self.actor.get('assignment') + if assignment: + assignment.validate_scopes() + else: + for path in self.policy.scope_paths: + _path(self.policy.workspace, str(path)) + if not path.is_dir(): + raise PermissionError('approved shell directory changed kind') + _validated_files(self.policy.workspace, self.protected_files) + self.authority.consume(request['ticket'], concrete) + if sum(p.poll() is None for p in self.processes.values()) >= 32: + raise PermissionError('benchmark shell process limit reached') + process = owned.owner.popen(owned.invocation.command, cwd=concrete['cwd'], + env=concrete['env'], group=self.supervisor.group.uid, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with self.close_lock: + if self.closed.is_set(): + raise PermissionError('benchmark actor closed during command launch') + identifier = uuid.uuid4().hex + self.processes[identifier] = process + self.records[identifier] = {'stdout': tempfile.TemporaryFile(), 'stderr': tempfile.TemporaryFile(), + 'background': concrete['background'], 'eof': set(), 'timed_out': False, + 'lock': threading.Lock(), 'invocation': owned} + self.pending.pop(request['ticket'], None) + except BaseException: + owned.close() + with self.close_lock: + self.pending.pop(request['ticket'], None) + raise + for name in ('stdout', 'stderr'): + threading.Thread(target=self._collect, args=(identifier, name), daemon=True).start() + threading.Thread(target=self._expire, args=(identifier, concrete['timeout_seconds']), daemon=True).start() + return {'id': identifier, 'pid': process.pid, 'backend': owned.invocation.backend} + if operation == 'stop': + record = self.records.get(request.get('id')) + if record is None: + raise PermissionError('process does not belong to this actor') + record['invocation'].close() + return self.processes[request['id']].returncode + return super()._dispatch(request) + + def _reap_pending(self): + for ticket, record in tuple(self.pending.items()): + if record[3] <= time.monotonic(): + self.pending.pop(ticket) + self.authority.tickets.pop(ticket, None) + record[2].close() + + def _expire(self, identifier, timeout): + process, record = self.processes[identifier], self.records[identifier] + deadline = time.monotonic() + timeout + try: + process.wait(timeout=timeout) + while len(record['eof']) != 2 and not self.closed.is_set(): + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(process.args, timeout) + time.sleep(.02) + except subprocess.TimeoutExpired: + record['timed_out'] = True + record['invocation'].close() + + def _finish_collection(self, identifier): + if self.processes[identifier].poll() is not None: + self.records[identifier]['invocation'].close() + + def close(self, *, graceful=True): + errors = [] + def cleanup(operation): + try: + operation() + except Exception as exc: + errors.append(exc) + with self.close_lock: + if getattr(self, '_cleanup_complete', False) or getattr(self, '_closing', False): + return + self._closing = True + self.closed.set() + try: + self.connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + cleanup(self.connection.close) + for ticket, record in tuple(self.pending.items()): + before = len(errors) + cleanup(record[2].close) + self.authority.tickets.pop(ticket, None) + if len(errors) == before: + self.pending.pop(ticket, None) + for record in tuple(self.records.values()): + cleanup(record['invocation'].close) + with record['lock']: + for name in record['eof']: + cleanup(record[name].close) + if not self.actor['root']: + with self.actor['condition']: + self.actor['disconnected'] = True + self.actor['condition'].notify_all() + self.supervisor.failures.extend(errors) + # Never hold the broker lock while the actor stop path re-enters close. + try: + self.supervisor._stop_actor(self.actor, exit_grace=.5 if graceful else 0) + except Exception as exc: + errors.append(exc) + self.supervisor.failures.append(exc) + finally: + with self.close_lock: + self._closing = False + self._cleanup_complete = not errors and self.actor['owner'].closed + if errors: + raise errors[0] + + +class ActorSupervisor: + """One privileged parent and listener for a root worker and its isolated actors.""" + def __init__(self, command, *, cwd, env, protected_files, policy, source, provider_config=None, + revoke_actor=None, routing_variant=None): + if os.geteuid() != 0: + raise PermissionError('actor supervisor requires a privileged parent') + self.command, self.env = list(command), dict(env) + if routing_variant is not None: + from benchmarks.v3_routing_supplement import VARIANTS + if routing_variant not in VARIANTS: + raise ValueError('invalid parent routing supplement variant') + self.routing_variant = routing_variant + self.env.pop('KYROZEN_BENCHMARK_ROUTING_SUPPLEMENT',None) + if routing_variant is not None: + self.env['KYROZEN_BENCHMARK_ROUTING_SUPPLEMENT']='1' + self.workspace = Path(cwd).resolve(strict=True) + if self.workspace != policy.workspace: + raise ValueError('actor workspace must match the parent fixture') + self.source = Path(source).resolve(strict=True) + if self.source.is_relative_to(self.workspace) or Path(__file__).resolve().is_relative_to(self.workspace): + raise PermissionError('trusted selected source and helpers must be outside the writable fixture') + self.policy = policy + self.provider_factory = provider_config if callable(provider_config) else None + self.provider_config = {} if self.provider_factory else _copy(provider_config or {}) + self.revoke_actor = revoke_actor + self.model = (self.provider_config.get('model_main') or self.provider_config.get('model_complex') + or self._argument('--model')) + self.protected_files = _validated_files(self.workspace, protected_files) + self.execution_lock = threading.RLock() + self.lock = threading.RLock() + self.closed = threading.Event() + self.owners, self.actors, self.handles, self.author_homes = [], {}, {}, {} + self.receipt_paths = {} + self.failures = [] + self.brokers = [] + self.handshakes = threading.BoundedSemaphore(64) + self.process = None + self.state = self.socket_directory = self.listener = self.thread = None + try: + self.group = self._owner() + self.state = Path(tempfile.mkdtemp(prefix='openkyrozen-actors-', dir='/var/tmp')).resolve() + self.state.chmod(0o711) + self.socket_directory = _actor_socket_directory() + for directory in (self.socket_directory, *self.socket_directory.parents): + metadata = directory.stat() + if (metadata.st_uid != 0 or metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX + or directory.is_relative_to(self.workspace) or directory.is_relative_to(self.state)): + raise PermissionError('actor socket requires immutable root-owned ancestors outside actor state') + self.address = str(self.socket_directory / 'parent.sock') + self.listener = socket.socket(socket.AF_UNIX) + self.listener.bind(self.address) + os.chmod(self.address, 0o666) + self.listener.listen(32) + self.listener.settimeout(.2) + self._share_workspace() + self.thread = threading.Thread(target=self._accept, daemon=True) + self.thread.start() + except BaseException: + self.close() + raise + + def _argument(self, name): + return self.command[self.command.index(name) + 1] if name in self.command else None + + def _owner(self): + owner = UIDOwner() + self.owners.append(owner) + return owner + + def _chown(self, root, uid): + for path in (root, *root.rglob('*')): + if path.is_symlink(): + raise PermissionError('private actor state cannot contain symlinks') + os.chown(path, uid, uid) + root.chmod(0o700) + + def _home(self, owner, key=None): + home = self.author_homes.get(key) if key is not None else None + if home is None: + home = self.state / uuid.uuid4().hex + home.mkdir(mode=0o700) + (home / 'tmp').mkdir(mode=0o700) + (home / 'skills').mkdir(mode=0o700) + if key is not None: + self.author_homes[key] = home + self._chown(home, owner.uid) + return home + + def _share_workspace(self): + for path in (self.workspace, *self.workspace.rglob('*')): + if path.is_symlink(): + raise PermissionError('benchmark workspace cannot contain symlinks') + metadata = path.stat() + if not (path.is_dir() or path.is_file()) or path.is_file() and metadata.st_nlink != 1: + raise PermissionError('benchmark workspace requires unaliased regular files') + os.chown(path, 0, self.group.uid) + path.chmod(0o2770 if path.is_dir() else stat.S_IMODE(metadata.st_mode) | 0o660) + + def _environment(self, home): + environment = dict(self.env, HOME=str(home), USERPROFILE=str(home), TMPDIR=str(home / 'tmp'), + KYROZEN_DB_PATH=str(home / 'runtime.sqlite3'), KYROZEN_SKILLS_DIR=str(home / 'skills'), + KYROZEN_BENCHMARK_ACTOR_SOCKET=self.address, + KYROZEN_BENCHMARK_ACTOR_SOCKET_INODE=json.dumps([os.stat(self.address).st_dev, os.stat(self.address).st_ino])) + for key in ('KYROZEN_BENCHMARK_BROKER_FD', 'KYROZEN_BENCHMARK_BROKER_IDENTITY', 'KYROZEN_SKILLS_PATH'): + environment.pop(key, None) + return _fixture_git_environment(environment, self.workspace) + + def _protected_actor(self, command, home, outputs=(), scopes=None): + # Workers receive no direct workspace writes, including the root worker. + if scopes is None: + scopes = (tuple((path, True) for path in self.policy.scope_paths) + if 'read' in self.policy.capabilities else ()) + readonly = ShellPolicy(self.workspace, {'read'} if scopes else set(), run_id=self.policy.run_id) + # Startup libraries inspect cwd; fixture access keeps its explicit workspace paths. + invocation = protected_shell_command(shlex.join(command), policy=readonly, + cwd=str(home), env=self._environment(home), protected_files=self.protected_files) + try: + if invocation.shell: + raise PermissionError('actor protection requires a supported OS backend') + _restrict_reads(invocation, self.workspace, scopes, protected_files=self.protected_files) + if invocation.backend == 'sandbox-exec': + from openkyrozen.tools.sandbox import _sbpl_path + grants = f'(subpath {_sbpl_path(str(home))})' + ''.join( + f'(literal {_sbpl_path(str(path))})' for path in outputs) + invocation.command[2] += '(allow file-write* ' + grants + ')' + elif invocation.backend == 'bubblewrap': + argv = invocation.command + index = argv.index('--') + mounts = ['--bind', str(home), str(home)] + for path in outputs: + mounts += ['--bind', str(path), str(path)] + invocation.command = argv[:index] + mounts + argv[index:] + return invocation + except BaseException: + invocation.close() + raise + + def _spawn(self, command, actor, *, stdout, stderr, stdin=None, home_key=None, outputs=()): + with self.execution_lock: + if self.closed.is_set(): + raise RuntimeError('actor supervisor is closed') + if self.failures: + raise RuntimeError('prior actor cleanup failed; no new identity may launch') + owner = self._owner() + actor['owner'] = owner + actor['stop_lock'] = threading.RLock() + actor['stopped'] = False + actor_id = actor['policy'].actor_id + invocation = None + try: + home = self._home(owner, home_key) + actor['home'] = home + actor['provider_config'] = _copy(self.provider_factory(actor_id) if self.provider_factory + else self.provider_config) + for path in outputs: + if (path.parent != self.workspace.parent or path.is_symlink() + or path.exists() and (not path.is_file() or path.stat().st_nlink != 1)): + raise PermissionError('root actor result path exceeds the fixture') + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + os.close(descriptor) + os.chown(path, owner.uid, owner.uid) + path.chmod(0o600) + assignment = actor.get('assignment') + capabilities = assignment.capabilities if assignment else self.policy.capabilities + scopes = assignment.read_scopes if assignment else None + invocation = self._protected_actor(command, home, outputs, + scopes=scopes if 'read' in capabilities else ()) + if invocation.temporary_directory: + self._chown(Path(invocation.temporary_directory.name), owner.uid) + if self.provider_factory: + key = actor['provider_config'].get('api_key') + if not isinstance(key, str) or not key: + raise PermissionError('actor relay factory returned no bound authorization') + invocation.env['KYROZEN_BENCHMARK_API_KEY'] = key + actor['invocation'] = invocation + self.actors[owner.uid] = actor + process = owner.popen(invocation.command, cwd=home, env=invocation.env, + group=self.group.uid, stdin=stdin, stdout=stdout, stderr=stderr) + actor['process'] = process + return process + except BaseException: + self.actors.pop(owner.uid, None) + if self.revoke_actor: + self.revoke_actor(actor_id) + if invocation: + invocation.close() + owner.close() + raise + + def popen(self, *, stdin=None, stdout=None, stderr=None): + if self.process is not None: + raise RuntimeError('root actor has already been launched') + actor = {'root': True, 'policy': self.policy, 'connected': False, 'broker': None} + result = self._argument('--result') + self.process = self._spawn(self.command, actor, stdin=stdin, stdout=stdout, stderr=stderr, + outputs=(Path(result).resolve(),) if result else ()) + self.root_actor = actor + def monitor(): + self.process.wait() + self._stop_actor(actor) + threading.Thread(target=monitor, daemon=True).start() + return self.process + + def _accept(self): + while not self.closed.is_set(): + try: + connection, _ = self.listener.accept() + except socket.timeout: + continue + except OSError: + break + if not self.handshakes.acquire(blocking=False): + connection.close() + continue + def authenticate(connection=connection): + try: + self._authenticate(connection) + finally: + self.handshakes.release() + threading.Thread(target=authenticate, daemon=True).start() + + def _authenticate(self, connection): + try: + connection.settimeout(5) + # The first bounded message also materializes macOS peer credentials. + hello = bytearray() + while len(hello) <= 4096 and not hello.endswith(b'\n'): + chunk = connection.recv(1) + if not chunk: + raise PermissionError('empty actor handshake') + hello.extend(chunk) + if len(hello) > 4096 or json.loads(hello) != {'op': 'hello'}: + raise PermissionError('invalid actor handshake') + _, uid, _ = peer_identity(connection) + with self.execution_lock, self.lock: + actor = self.actors.get(uid) + if (actor is None or actor['owner'].closed or actor['connected'] + or actor.get('process') is None or actor['process'].poll() is not None): + raise PermissionError('unregistered or already connected actor kernel identity') + actor['connected'] = True + identity = {key: getattr(actor['policy'], key) + for key in ('run_id', 'actor_id', 'session_id', 'revision')} + connection.sendall(_encoded({'ok': True, 'value': identity}) + b'\n') + connection.settimeout(None) + broker = _ActorBroker(connection, self, actor) + actor['broker'] = broker + self.brokers.append(broker) + return + except (OSError, ValueError, PermissionError): + try: + connection.sendall(b'{"ok":false,"message":"unregistered actor kernel identity"}\n') + except OSError: + pass + connection.close() + + def _launch_child(self, parent, payload): + with self.execution_lock: + artifacts = [] + if payload.get('review') is True: + prior = self.receipt_paths.get(payload.get('run', {}).get('run_id'), set()) + for item in payload.get('run', {}).get('report', {}).get('artifacts', []): + value = item if isinstance(item, str) else item.get('path') if isinstance(item, dict) else None + if value: + path = _path(self.workspace, value) + if path in prior: + artifacts.append(path) + assignment = ActorAssignment(self.policy, payload, source=self.source, model=self.model, + provider=parent['provider_config'].get('provider', 'custom'), + review_artifacts=artifacts) + key = assignment.run_id + if any(item['assignment'].run_id == key and + (item['state'] == 'running' or not item['owner'].closed) + for item in self.handles.values()): + raise PermissionError('the prior author or reviewer invocation is still active') + if len(self.handles) >= 256: + raise PermissionError('delegated actor limit reached') + identifier = uuid.uuid4().hex + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'parent': parent, 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'state': 'running', 'cancelled': False, 'disconnected': False} + command = [sys.executable, '-I', str(Path(__file__).with_name('v3_actor_worker.py')), + '--source', str(self.source)] + process = self._spawn(command, actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + home_key=None if assignment.review else key) + self.handles[identifier] = actor + def collect(): + try: + output, _ = _communicate(process, actor['owner'], timeout=900) + self._stop_actor(actor) + if process.returncode or len(output) > _MAX_MESSAGE: + raise RuntimeError('delegated actor exited unsuccessfully') + result = _copy(json.loads(output)) + with actor['condition']: + actor['result'], actor['state'] = result, 'complete' + except Exception as exc: + self._stop_actor(actor) + with actor['condition']: + actor['error'], actor['state'] = type(exc).__name__ + ': delegated actor failed', 'failed' + finally: + with actor['condition']: + actor['condition'].notify_all() + threading.Thread(target=collect, daemon=True).start() + return {'id': identifier} + + def _callback(self, actor, request): + operation, values = request.get('operation'), request.get('values') + if (operation not in _CALLBACK_FIELDS or not isinstance(values, dict) + or set(values) != _CALLBACK_FIELDS[operation]): + raise PermissionError('unsupported delegated coordinator operation') + if operation == 'cancelled': + # Provider/consumer threads share one RPC channel. This live, + # parent-held leaf must not wait for another root poll/reply cycle. + with actor['condition']: + return bool(self.closed.is_set() or actor['cancelled'] or actor['disconnected']) + if operation == 'access_acquire': + if (any(type(values[key]) is not bool for key in ('reading', 'recursive')) + or values['path'] is not None and not isinstance(values['path'], str)): + raise PermissionError('invalid actor access request') + if values['path'] is not None: + _path(self.workspace, values['path']) + with actor['condition']: + if len(actor['callbacks']) >= 128 or actor['cancelled'] or actor['disconnected']: + raise PermissionError('delegated callback channel is unavailable') + identifier = uuid.uuid4().hex + callback = {'id': identifier, 'op': operation, 'values': _copy(values), 'sent': False} + actor['callbacks'][identifier] = callback + deadline = time.monotonic() + 120 + while 'reply' not in callback: + if (self.closed.is_set() or actor['cancelled'] or actor['disconnected'] + or time.monotonic() >= deadline): + actor['callbacks'].pop(identifier, None) + raise PermissionError('delegated callback was cancelled or expired') + actor['condition'].wait(.1) + actor['callbacks'].pop(identifier) + reply = callback['reply'] + if reply['ok'] is not True: + raise PermissionError(str(reply.get('error', 'parent rejected coordinator operation'))[:1000]) + return reply.get('value') + + def _delegated(self, parent, request): + actor = self.handles.get(request.get('id')) + if actor is None or actor['parent'] is not parent: + raise PermissionError('delegated handle does not belong to this parent') + if request['op'] == 'delegated_stop': + self._stop_actor(actor) + return None + if (type(request.get('cancelled')) is not bool or not isinstance(request.get('replies'), list) + or len(request['replies']) > 128): + raise PermissionError('invalid delegated poll') + if request['cancelled']: + self._stop_actor(actor) + with actor['condition']: + for reply in request['replies']: + callback = actor['callbacks'].get(reply.get('id')) if isinstance(reply, dict) else None + if (callback is None or not callback['sent'] or 'reply' in callback + or type(reply.get('ok')) is not bool): + raise PermissionError('invalid or replayed delegated callback reply') + callback['reply'] = _copy(reply) + actor['condition'].notify_all() + requests = [] + for callback in actor['callbacks'].values(): + if not callback['sent']: + callback['sent'] = True + requests.append({key: callback[key] for key in ('id', 'op', 'values')}) + result = {'state': actor['state'], 'requests': requests} + if actor['state'] == 'complete': + result['result'] = actor['result'] + elif actor['state'] == 'failed': + result['error'] = actor.get('error', 'delegated actor cancelled') + return result + + @contextmanager + def _frozen(self): + stopped = set() + try: + deadline = time.monotonic() + 5 + prior = None + while time.monotonic() < deadline: + uids = {owner.uid for owner in self.owners if not owner.closed} + owned = {pid: state for pid, (uid, state) in process_snapshot().items() + if uid in uids and not state.startswith(('Z', 'X'))} + if set(owned) == prior and all(state.startswith('T') for state in owned.values()): + yield + return + prior = set(owned) + for pid, state in owned.items(): + if not state.startswith('T'): + try: + os.kill(pid, signal.SIGSTOP) + stopped.add(pid) + except ProcessLookupError: + pass + time.sleep(.01) + raise PermissionError('actor writers could not be frozen for scoped mutation') + finally: + snapshot = process_snapshot() + uids = {owner.uid for owner in self.owners} + for pid in stopped: + if snapshot.get(pid, (None,))[0] in uids: + try: + os.kill(pid, signal.SIGCONT) + except ProcessLookupError: + pass + + def _mutate(self, actor, action, arguments, *, reading=False): + with self.execution_lock, self._frozen(): + assignment = actor.get('assignment') + if assignment is None: + assignment = ActorAssignment(self.policy, {'schema_version': 1, 'source': str(self.source), + 'workspace': str(self.workspace), 'review': False, 'report_repair': False, + 'parent': {'model': self.model}, 'run': {'run_id': 'root', 'profile': 'coder', + 'assignment': {'scope': [str(path.relative_to(self.workspace)) for path in self.policy.scope_paths]}}}, + source=self.source, model=self.model) + arguments = (assignment.authorize_read(action, arguments) if reading + else assignment.authorize_mutation(action, arguments, self.protected_files)) + _validated_files(self.workspace, self.protected_files) + owner = self._owner() + invocation = None + try: + home = self._home(owner) + command = [sys.executable, '-I', str(Path(__file__).with_name('v3_actor_worker.py')), + '--source', str(self.source), '--file-read' if reading else '--file-mutation'] + environment = {'PATH': self.env.get('PATH', os.defpath), 'HOME': str(home), 'TMPDIR': str(home / 'tmp')} + invocation = protected_shell_command(shlex.join(command), policy=self.policy, + cwd=str(self.workspace), env=environment, protected_files=self.protected_files) + if reading: + _restrict_reads(invocation, self.workspace, assignment.read_scopes, + protected_files=self.protected_files) + target = assignment.read_target(action, arguments) + if target.is_dir(): + for path in target.rglob('*'): + _path(self.workspace, str(path)) + if invocation.temporary_directory: + self._chown(Path(invocation.temporary_directory.name), owner.uid) + process = owner.popen(invocation.command, cwd=self.workspace, env=invocation.env, + group=self.group.uid, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, _ = _communicate(process, owner, input_data=_encoded({'workspace': str(self.workspace), + 'action': action, 'arguments': arguments}), timeout=30, limit=1_000_000) + if process.returncode or len(output) > 1_000_000: + raise RuntimeError('trusted selected-source file mutation helper failed') + result = _copy(json.loads(output)) + success = (result.get('error') is None if isinstance(result, dict) + else isinstance(result, str) and not result.lower().startswith('error')) + if success and action in _MUTATIONS | {'read_file'} and not assignment.review: + path = (assignment.read_target(action, arguments) if reading + else assignment.mutation_target(action, arguments)) + self.receipt_paths.setdefault(assignment.run_id, set()).add(path) + return result + finally: + owner.close() + if invocation: + invocation.close() + + def _stop_actor(self, actor, *, exit_grace=0): + with actor['stop_lock']: + if actor['stopped'] or actor.get('stopping'): + return + actor['stopping'] = True + errors = [] + def cleanup(operation): + try: + operation() + except Exception as exc: + errors.append(exc) + process = actor.get('process') + if exit_grace and process is not None: + try: + process.wait(timeout=exit_grace) + except subprocess.TimeoutExpired: + pass + if self.revoke_actor and not actor.get('relay_revoked'): + def revoke(): + self.revoke_actor(actor['policy'].actor_id) + actor['relay_revoked'] = True + cleanup(revoke) + if not actor['root']: + with actor['condition']: + actor['cancelled'] = True + actor['condition'].notify_all() + broker = actor.get('broker') + if broker: + cleanup(lambda: broker.close(graceful=False)) + cleanup(actor['owner'].close) + cleanup(actor['invocation'].close) + if actor['root']: + for child in tuple(self.handles.values()): + cleanup(lambda child=child: self._stop_actor(child)) + actor['stopping'] = False + actor['stopped'] = not errors + if errors: + self.failures.extend(errors) + raise RuntimeError('benchmark actor cleanup failed') from errors[0] + + def close(self): + if self.closed.is_set(): + return + self.closed.set() + if self.listener: + self.listener.close() + for actor in tuple(self.actors.values()): + try: + self._stop_actor(actor) + except Exception as exc: + self.failures.append(exc) + for owner in reversed(self.owners): + if owner is self.group and any(other is not owner and not other.closed for other in self.owners): + continue + try: + owner.close() + except Exception as exc: + self.failures.append(exc) + if self.thread: + self.thread.join(timeout=5) + import shutil + if all(owner.closed for owner in self.owners): + for directory in (self.socket_directory, self.state): + if directory: + shutil.rmtree(directory) + if self.failures: + raise RuntimeError('benchmark actor cleanup failed') from self.failures[0] + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/benchmarks/v3_actor_worker.py b/benchmarks/v3_actor_worker.py new file mode 100644 index 00000000..c3a4c005 --- /dev/null +++ b/benchmarks/v3_actor_worker.py @@ -0,0 +1,767 @@ +"""Internal benchmark adapter; production delegation still owns its behavior.""" +from __future__ import annotations + +import json + +_MAX_PAYLOAD = 1_000_000 +_MUTATIONS = frozenset({'write_file', 'edit_file', 'apply_patch'}) +_READS = frozenset({'read_file', 'search_files', 'list_dir', 'list_tree', 'find_files'}) +_HISTORY_CHUNK = 65_536 + + +def _json_copy(value): + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + if len(encoded.encode('utf-8')) > _MAX_PAYLOAD: + raise ValueError('benchmark actor payload exceeds its bound') + return json.loads(encoded) + + +def _require_selected_source(source): + import openkyrozen + from pathlib import Path + root = Path(source).resolve(strict=True) + if Path(openkyrozen.__file__).resolve() != root / 'openkyrozen' / '__init__.py': + raise PermissionError('loaded product does not match the selected source') + + +def _install_file_adapter(client, operation, actions): + from functools import wraps + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools import models + + def mutation(action): + @wraps(getattr(ToolAdapters, action)) + def invoke(self, arguments): + result = client.rpc(operation, action=action, arguments=_json_copy(arguments)) + if hasattr(models, 'ToolResult'): + error = result.get('error') + return models.ToolResult(data=result.get('data'), text=result.get('text', ''), + error=models.ToolError(**error) if error else None, metadata=result.get('metadata', {})) + if not isinstance(result, str): + raise TypeError('legacy file helper must return the original string result') + return result + return models.legacy_text_api(invoke) if hasattr(models, 'legacy_text_api') else invoke + + for action in actions: + if hasattr(ToolAdapters, action): + setattr(ToolAdapters, action, mutation(action)) + + +def install_mutation_adapter(client): + """Move only the original builtin filesystem effect across the boundary.""" + _install_file_adapter(client, 'file_mutation', _MUTATIONS) + + +def install_read_adapter(client): + """Read live shared files even when the actor has an older readonly view.""" + _install_file_adapter(client, 'file_read', _READS) + + +def _artifact_references(value): + """Read framework-owned output envelopes, never prose or provider state.""" + if isinstance(value, list): + result = set() + for item in value: + result.update(_artifact_references(item)) + return result + if not isinstance(value, dict): + return set() + if value.get('kind') == 'tool.result' and isinstance(value.get('payload'), dict): + payload = value['payload'] + name, output, success = payload.get('name'), payload.get('output'), payload.get('is_error') is False + elif 'receipt_id' in value and isinstance(value.get('action'), str): + name, output, success = value['action'], value.get('tool_result'), value.get('success') is True + if not isinstance(output, dict): + error = value.get('tool_error') + output = {'data': value.get('result_data'), 'error': error, + 'metadata': error.get('metadata', {}) if isinstance(error, dict) else {}} + else: + return set() + if not isinstance(output, dict) or not isinstance(output.get('data'), dict): + return set() + data = output['data'] + identifier = data.get('artifact_id') + if not isinstance(identifier, str) or not identifier: + return set() + metadata = output.get('metadata') + # Oversized and archived outputs have a matched machine summary and metadata. + if (set(data) in ({'artifact_id', 'total_chars'}, {'artifact_id', 'total_chars', 'preview'}) + and type(data['total_chars']) is int and data['total_chars'] >= 0 + and ('preview' not in data or isinstance(data['preview'], str)) + and isinstance(metadata, dict) and metadata.get('artifact_id') == identifier): + return {identifier} + # The paging builtin returns its own scoped ID; file contents and tool args + # containing the same spelling have no artifact authority. + if (name == 'read_tool_artifact' and success and output.get('error') is None + and set(data) == {'artifact_id', 'offset', 'limit', 'total_chars', 'content', 'next_offset'} + and all(type(data[key]) is int for key in ('offset', 'limit', 'total_chars')) + and data['offset'] >= 0 and 1 <= data['limit'] <= 1000 and data['total_chars'] >= 0 + and isinstance(data['content'], str) + and (data['next_offset'] is None or type(data['next_offset']) is int + and 0 <= data['next_offset'] <= data['total_chars'])): + return {identifier} + return set() + + +class _CanonicalHistoryImport: + """One invocation's scoped, ordered native journal and referenced artifacts.""" + def __init__(self, store, *, user_id, workspace_id, artifact_workspace_id, budget_id, review, owned_session): + self.store, self.user_id, self.workspace_id = store, user_id, workspace_id + self.artifact_workspace_id = artifact_workspace_id + self.budget_id, self.review, self.owned_session = budget_id, review, owned_session + self.session_id = None + self.prefix = [] + self.index, self.previous, self.head = 0, None, None + self.ids, self.references, self.artifacts = set(), set(), set() + self.receipt_references = {} + self.pending = None + self.finished = False + + def note_receipt(self, session_id, receipt): + self.receipt_references.setdefault(session_id, set()).update(_artifact_references(receipt)) + + @property + def scope(self): + return dict(user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id) + + @property + def artifact_scope(self): + return dict(self.scope, workspace_id=self.artifact_workspace_id) + + def receive(self, *, session_id, kind, offset, chunk, final): + self.owned_session(session_id) + if (self.finished or kind not in {'entry', 'artifact', 'finish'} or type(offset) is not int + or offset < 0 or not isinstance(chunk, str) or len(chunk) > _HISTORY_CHUNK + or type(final) is not bool): + raise PermissionError('invalid canonical history chunk') + if self.session_id is None: + if (not session_id.startswith(self.budget_id + '_review_') if self.review + else session_id != self.budget_id): + raise PermissionError('canonical history belongs to another invocation phase') + self.session_id = session_id + self.prefix = self.store.list_conversation_entries(**self.scope) + self.head = self.store.conversation_head(**self.scope) + if (self.review and self.prefix or self.head != (self.prefix[-1]['id'] if self.prefix else None)): + raise PermissionError('canonical history session is stale or reused') + if session_id != self.session_id: + raise PermissionError('canonical history changed its owning session') + if kind == 'finish': + self.references.update(self.receipt_references.get(session_id, ())) + if (self.pending is not None or offset or chunk or not final or self.index < len(self.prefix) + or self.previous != self.head or self.store.conversation_head(**self.scope) != self.head + or self.artifacts != self.references): + raise ValueError('canonical history export is incomplete or has dangling artifacts') + self.finished = True + return None + if self.pending is None: + if offset: + raise ValueError('canonical history record starts at a nonzero offset') + self.pending = (kind, [], 0) + current_kind, pieces, length = self.pending + if kind != current_kind or offset != length or not chunk: + raise ValueError('canonical history chunks overlap or are out of order') + pieces.append(chunk) + self.pending = (kind, pieces, length + len(chunk)) + if not final: + return None + def unique(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError('duplicate canonical history object key') + value[key] = item + return value + value = json.loads(''.join(pieces), object_pairs_hook=unique) + self.pending = None + if kind == 'entry': + from openkyrozen.agent.conversation import ConversationEntry + if (not isinstance(value, dict) or ConversationEntry(**value).as_dict() != value + or any(value.get(key) != item for key, item in self.scope.items()) + or any(value[key] is not None and (not isinstance(value[key], str) or not value[key]) + for key in ('parent_id', 'turn_id', 'request_id', 'call_id', 'task_id')) + or value['parent_id'] != self.previous or value['id'] in self.ids + or self.store.conversation_head(**self.scope) != self.head): + raise PermissionError('canonical entry has a conflicting identity, scope, or parent') + if self.index < len(self.prefix): + if value != self.prefix[self.index]: + raise ValueError('canonical history conflicts with the existing prefix') + else: + self.store.append_conversation_entry(value, expected_head=self.head) + self.head = value['id'] + self.ids.add(value['id']) + self.previous = value['id'] + self.index += 1 + self.references.update(_artifact_references(value)) + else: + self.references.update(self.receipt_references.get(session_id, ())) + fields = {'id', 'user_id', 'workspace_id', 'session_id', 'content', 'created_at'} + if (not isinstance(value, dict) or set(value) != fields + or any(not isinstance(value[key], str) or not value[key] for key in fields - {'content'}) + or not isinstance(value['content'], str) + or any(value[key] != item for key, item in self.artifact_scope.items()) + or value['id'] not in self.references or value['id'] in self.artifacts): + raise PermissionError('artifact is not referenced by this scoped canonical history') + # The native repository has no import-with-ID API. Keep its exact + # private table identity; create/get_or_create would change references. + with self.store._lock, self.store.connection() as connection: + connection.execute('BEGIN IMMEDIATE') + existing = connection.execute('SELECT * FROM tool_artifacts WHERE id=?', (value['id'],)).fetchone() + if existing is not None and dict(existing) != value: + raise ValueError('canonical artifact ID conflicts with an existing artifact') + if existing is None: + columns = ('id', 'user_id', 'workspace_id', 'session_id', 'content', 'created_at') + connection.execute('INSERT INTO tool_artifacts(id,user_id,workspace_id,session_id,content,created_at) ' + 'VALUES(?,?,?,?,?,?)', tuple(value[key] for key in columns)) + connection.commit() + self.artifacts.add(value['id']) + return None + + +def _export_canonical_history(store, coordinator, scopes): + # Frozen V2 has no canonical journal; keep its original message callbacks. + if not hasattr(store, 'list_conversation_entries'): + return + for scope, artifact_scope in scopes.values(): + entries = store.list_conversation_entries(**scope) + references = set(coordinator.artifact_references.get(scope['session_id'], ())) + def send(kind, value): + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + for offset in range(0, len(encoded), _HISTORY_CHUNK): + end = offset + _HISTORY_CHUNK + coordinator.call('canonical_history', session_id=scope['session_id'], kind=kind, + offset=offset, chunk=encoded[offset:end], final=end >= len(encoded)) + for entry in entries: + references.update(_artifact_references(entry)) + send('entry', entry) + for identifier in sorted(references): + with store.connection() as connection: + artifact = connection.execute('SELECT * FROM tool_artifacts WHERE id=? AND user_id=? ' + 'AND workspace_id=? AND session_id=?', (identifier, artifact_scope['user_id'], + artifact_scope['workspace_id'], artifact_scope['session_id'])).fetchone() + if artifact is None: + raise PermissionError('canonical history references an unowned or missing artifact') + send('artifact', dict(artifact)) + coordinator.call('canonical_history', session_id=scope['session_id'], kind='finish', + offset=0, chunk='', final=True) + + +def install_delegated_adapter(runtime, client, *, source): + """Replace transport before the real Delegation captures its invocation port.""" + import inspect + import time + import uuid + from dataclasses import asdict + from pathlib import Path + from types import MethodType + from contextlib import ExitStack + + if runtime.subagent_manager.coordinator is not None: + raise RuntimeError('install the actor adapter before creating the coordinator') + original = runtime._invoke_delegated + supports_repair = 'report_repair' in inspect.signature(original).parameters + source = str(Path(source).resolve(strict=True)) + runtime._benchmark_child_context_records = [] + runtime._benchmark_child_routing_records = [] + + def invoke(self, run, *, review, feedback, coordinator, report_repair=False): + if report_repair and not supports_repair: + raise RuntimeError('selected snapshot does not support delegated report repair') + parent = self.current_session + budgets = getattr(coordinator, 'budgets', {}) + regressions = parent.memory.store.list_events('learning.regression_case_created', limit=1, + user_id=parent.memory.user_id, workspace_id=parent.memory.workspace_id) + if self.skill_registry.list() or regressions: + raise RuntimeError('benchmark actor does not support existing skill or learning regression state') + if any(getattr(parent.workspace, name, None) is not None + for name in ('launch_context', 'graph', 'github')): + raise RuntimeError('benchmark actor cannot copy live graph or launch-context services') + if hasattr(parent.memory.store, 'list_conversation_entries'): + with coordinator.lock: + run['conversation_workspace_id'] = self.interaction_workspace_id() + payload = _json_copy({ + 'schema_version': 1, 'source': source, 'workspace': str(coordinator.root), + 'run': run, 'review': review, 'feedback': feedback, 'report_repair': report_repair, + 'supports_report_repair': supports_repair, + 'messages': ({} if review else {key: value for key, value in coordinator.messages.items() + if key in {run['run_id'], run.get('budget_id', run['run_id'])}}), + 'parent': {'session_id': parent.session_id, 'user_id': parent.memory.user_id, + 'workspace_id': parent.memory.workspace_id, + 'task_workspace_id': parent.tasks.workspace_id, + 'capabilities': sorted(self._execution_capability_token.capabilities), + 'interaction_mode': self._active_interaction_mode.get(), + 'model': self.DEEPSEEK_MODEL, + 'has_budget': budgets.get(run['run_id']) is not None, + 'profiles': [asdict(profile) for profile in self.subagent_manager.profiles.values()]}}) + identifier = client.rpc('delegated_launch', payload=payload)['id'] + run_id = run['run_id'] + budget_id = run.get('budget_id', run_id) + access_handles = {} + + def cancelled(): + event = coordinator.cancelled.get(run_id) + parent_cancelled = getattr(self.execution_context, 'cancelled', None) + return bool((event is not None and event.is_set()) + or (parent_cancelled is not None and parent_cancelled.is_set())) + + def owned_session(session_id): + valid = (isinstance(session_id, str) and + (any(session_id.startswith(key + '_review_') for key in {budget_id, run_id}) + if review else session_id in {budget_id, run_id})) + if not valid: + raise PermissionError('session does not belong to this actor invocation') + return session_id + + def scoped_memory(session_id): + return parent.memory.scoped(session_id=owned_session(session_id)) + + canonical = (_CanonicalHistoryImport(parent.memory.store, user_id=parent.memory.user_id, + workspace_id=self.interaction_workspace_id(), artifact_workspace_id=parent.tasks.workspace_id, + budget_id=budget_id, review=review, + owned_session=owned_session) if hasattr(parent.memory.store, 'list_conversation_entries') else None) + + def serve(request, stack): + values = request['values'] + operation = request['op'] + if operation == 'cancelled': + return cancelled() + if operation == 'publish': + update = values['run'] + if set(update) - {'provider_model', 'review_agent'}: + raise PermissionError('child cannot change its assignment or parent policy') + with coordinator.lock: + for name, value in update.items(): + if name == 'review_agent': + if set(value) - {'status', 'provider_model'}: + raise PermissionError('invalid reviewer state update') + run['review_agent'].update(value) + else: + run[name] = value + coordinator._publish(run) + return None + if operation == 'receipt': + session_id = owned_session(values['child_id']) + if canonical is not None: + canonical.note_receipt(session_id, values['receipt']) + return coordinator.record_receipt(run_id, session_id, values['receipt']) + if operation == 'canonical_history': + if canonical is None: + raise PermissionError('selected snapshot has no canonical conversation history') + return canonical.receive(**values) + if operation == 'message': + return coordinator.add_message(owned_session(values['child_id']), values['message']) + if operation == 'usage': + with coordinator.lock: + for child_id in values['usage']: + owned_session(child_id) + run.setdefault('usage', {}).update(values['usage']) + return None + if operation in {'budget_can_continue', 'budget_record_cost'}: + budget = budgets.get(run_id) + if budget is None: + raise PermissionError('this invocation has no delegated budget') + if operation == 'budget_can_continue': + return budget.can_continue(budget_id, cost_picos=values['cost_picos']) + return budget.record_cost(budget_id, values['cost_picos']) + if operation == 'access_acquire': + context = coordinator.access.acquire(values['path'], cancelled, run_id, + reading=values['reading'], **({'recursive': values['recursive']} + if 'recursive' in inspect.signature(coordinator.access.acquire).parameters else {})) + context.__enter__() + token = uuid.uuid4().hex + access_handles[token] = context + stack.callback(lambda: access_handles.pop(token).__exit__(None, None, None) + if token in access_handles else None) + return token + if operation == 'access_release': + context = access_handles.pop(values['token']) + context.__exit__(None, None, None) + return None + if operation == 'event': + return self._emit_stream_event(values['event']) + if operation == 'parent_interaction_state': + return parent.interaction.state() + if operation == 'parent_task_state': + return {'tasks': parent.tasks.tasks, + 'working_plan': asdict(parent.tasks.working_plan) if parent.tasks.working_plan else None} + if operation == 'parent_active_task': + return parent.tasks.active_working_task(values['task_id']) + if operation == 'parent_mutation_matches_current_task': + return parent.tasks.mutation_matches_current_task(values['action'], values['arguments']) + if operation in {'memory_recall_records', 'memory_recall', 'memory_recent'}: + memory = scoped_memory(values['session_id']) + options = dict(values.get('options', {})) + if operation == 'memory_recall_records': + if 'authorized_speakers' in options: + options['authorized_speakers'] = set(options['authorized_speakers']) + return memory.recall_records(values['query'], **options) + if operation == 'memory_recall': + return memory.recall(values['query'], **options) + return memory.get_recent(**options) + if operation == 'usage_attempts': + recorder = parent.memory.store.record_usage_attempt + keys = set(inspect.signature(recorder).parameters) + for attempt in values['attempts']: + owned_session(attempt['session_id']) + if (attempt['user_id'] != parent.memory.user_id + or attempt['workspace_id'] != parent.memory.workspace_id): + raise PermissionError('usage belongs to another actor scope') + item = {key: value for key, value in attempt.items() if key in keys} + item['attempt_id'] = attempt['id'] + recorder(**item) + return None + raise PermissionError('unsupported delegated coordinator operation') + + try: + with ExitStack() as stack: + replies = [] + while True: + response = client.rpc('delegated_poll', id=identifier, + replies=replies, cancelled=cancelled()) + replies = [] + for request in response.get('requests', []): + try: + value = _json_copy(serve(request, stack)) + replies.append({'id': request['id'], 'ok': True, 'value': value}) + except Exception as exc: + if request['op'] == 'canonical_history': + raise RuntimeError('canonical history import failed') from exc + replies.append({'id': request['id'], 'ok': False, + 'error': f'{type(exc).__name__}: {str(exc)[:1000]}'}) + if response['state'] == 'complete': + if replies: + raise RuntimeError('actor completed with unanswered coordinator operations') + result = _json_copy(response['result']) + if (canonical is not None + and (not canonical.finished or result.get('session_id') != canonical.session_id)): + raise RuntimeError('actor completed without its canonical history') + records = result.pop('_benchmark_request_context_records', []) + if not isinstance(records, list) or any(not isinstance(item, dict) for item in records): + raise ValueError('invalid child context observations') + self._benchmark_child_context_records.extend(records) + routing_records = result.pop('_benchmark_routing_observations',[]) + if (not isinstance(routing_records,list) or any(not isinstance(row,dict) + or row.get('variant') != getattr(self,'_benchmark_routing_variant',None) + for row in routing_records)): + raise ValueError('invalid child routing observations') + self._benchmark_child_routing_records.extend(routing_records) + return result + if response['state'] == 'failed': + raise RuntimeError(response.get('error', 'benchmark actor failed')) + if response['state'] != 'running': + raise RuntimeError('invalid delegated actor state') + time.sleep(.01) + finally: + client.rpc('delegated_stop', id=identifier) + + runtime._invoke_delegated = MethodType(invoke, runtime) + + +class _CoordinatorPort: + def __init__(self, payload, client): + import threading + from pathlib import Path + self.client = client + self.root = Path(payload['workspace']).resolve(strict=True) + self.run = payload['run'] + self.review = payload['review'] + self.run_id = self.run['run_id'] + self.budget_id = self.run.get('budget_id', self.run_id) + self.runs = {self.run_id: self.run} + self.messages = payload['messages'] + self.lock = threading.RLock() + self.cancelled = {self.run_id: _CancellationPort(self)} + self.access = _AccessPort(self) + self.budgets = {self.run_id: _BudgetPort(self)} if payload['parent']['has_budget'] else {} + self.artifact_references = {} + + def call(self, operation, **values): + return self.client.rpc('delegated_call', operation=operation, values=_json_copy(values)) + + def _publish(self, run): + values = ({'review_agent': {key: run['review_agent'][key] + for key in ('status', 'provider_model') if key in run['review_agent']}} + if self.review else {'provider_model': run['provider_model']}) + self.call('publish', run=values) + + def check_cancelled(self, run_id): + if run_id != self.run_id or self.cancelled[self.run_id].is_set(): + raise RuntimeError('Sub-agent cancelled') + + def record_receipt(self, run_id, child_id, receipt): + if run_id != self.run_id: + raise PermissionError('receipt belongs to another actor') + self.artifact_references.setdefault(child_id, set()).update(_artifact_references(receipt)) + return self.call('receipt', child_id=child_id, receipt=receipt) + + def add_message(self, child_id, message): + self.call('message', child_id=child_id, message=message) + self.messages.setdefault(child_id, []).append(message) + + +class _CancellationPort: + def __init__(self, coordinator): + self.coordinator = coordinator + + def is_set(self): + return self.coordinator.call('cancelled') + + +class _BudgetPort: + def __init__(self, coordinator): + self.coordinator = coordinator + + def can_continue(self, run_id, *, cost_picos): + if run_id != self.coordinator.budget_id: + raise PermissionError('budget belongs to another actor') + return self.coordinator.call('budget_can_continue', cost_picos=cost_picos) + + def record_cost(self, run_id, cost_picos): + if run_id != self.coordinator.budget_id: + raise PermissionError('budget belongs to another actor') + return self.coordinator.call('budget_record_cost', cost_picos=cost_picos) + + +class _AccessPort: + def __init__(self, coordinator): + self.coordinator = coordinator + + def acquire(self, path, cancelled, owner, *, reading=False, recursive=False): + from contextlib import contextmanager + + @contextmanager + def claim(): + token = self.coordinator.call('access_acquire', path=str(path) if path is not None else None, + reading=reading, recursive=recursive) + try: + yield + finally: + self.coordinator.call('access_release', token=token) + return claim() + + +class _ParentTasksPort: + def __init__(self, coordinator, identity): + self.coordinator = coordinator + self.user_id = identity['user_id'] + self.workspace_id = identity['task_workspace_id'] + self.session_id = identity['session_id'] + + @property + def tasks(self): + return self.coordinator.call('parent_task_state')['tasks'] + + @property + def working_plan(self): + from types import SimpleNamespace + plan = self.coordinator.call('parent_task_state')['working_plan'] + return SimpleNamespace(**plan) if plan else None + + def active_working_task(self, task_id): + return self.coordinator.call('parent_active_task', task_id=task_id) + + def mutation_matches_current_task(self, action, arguments): + return self.coordinator.call('parent_mutation_matches_current_task', action=action, arguments=arguments) + + @staticmethod + def _dependencies_succeeded(task, tasks): + from openkyrozen.tasks.engine import TaskManager + return TaskManager._dependencies_succeeded(task, tasks) + + +class _ParentInteractionPort: + def __init__(self, coordinator, identity): + self.coordinator = coordinator + self.user_id = identity['user_id'] + self.workspace_id = identity['task_workspace_id'] + self.session_id = identity['session_id'] + + def state(self): + return self.coordinator.call('parent_interaction_state') + + +def run_actor(payload, client, *, source, provider_config, routing_variant=None): + """Run the selected snapshot's original child invocation in a fresh process.""" + import inspect + import os + from pathlib import Path + from types import SimpleNamespace + from openkyrozen.app.bootstrap import build_application + from openkyrozen.agent.subagents import AgentProfile + from openkyrozen.security.capabilities import issue_capability_token + + _require_selected_source(source) + payload = _json_copy(payload) + if (payload['schema_version'] != 1 or Path(payload['source']).resolve() != Path(source).resolve() + or type(payload['review']) is not bool or type(payload['report_repair']) is not bool): + raise PermissionError('invalid benchmark actor invocation') + if os.environ.get('KYROZEN_BENCHMARK_BROKER_FD') is not None: + raise PermissionError('actor must not inherit another worker broker descriptor') + if hasattr(client, 'start'): + from v3_shell_launcher import install_worker_adapter + install_worker_adapter(client=client) + install_mutation_adapter(client) + install_read_adapter(client) + application = build_application(surface='cli', user_id=payload['parent']['user_id']) + runtime = application.runtime + routing = None + if routing_variant is not None: + try: + from v3_routing_supplement import install + except ModuleNotFoundError: + from benchmarks.v3_routing_supplement import install + routing = install(runtime,routing_variant) + coordinator = _CoordinatorPort(payload, client) + original = runtime._invoke_delegated + original_create_session = runtime._create_session + try: + from prompt_worker import _ProviderObservations + except ModuleNotFoundError: + from benchmarks.prompt_worker import _ProviderObservations + original_provider = runtime.get_provider + observations = [] + history_scopes = {} + + def get_provider(config): + provider = original_provider(config) + observation = _ProviderObservations(runtime, provider) + observations.append(observation) + return provider + + runtime.get_provider = get_provider + + def create_session(*args, **kwargs): + session = original_create_session(*args, **kwargs) + session_id = session.memory.session_id + if hasattr(application.store, 'list_conversation_entries'): + with runtime.use_session(session): + history = runtime.conversation_history + history_scopes[session_id] = ( + dict(user_id=history.user_id, workspace_id=history.workspace_id, session_id=history.session_id), + dict(user_id=session.tasks.user_id, workspace_id=session.tasks.workspace_id, session_id=session_id)) + def memory_call(operation, query=None, **options): + if 'authorized_speakers' in options: + options['authorized_speakers'] = sorted(options['authorized_speakers']) + return coordinator.call(operation, session_id=session_id, query=query, options=options) + session.memory.recall_records = lambda query, **options: memory_call('memory_recall_records', query, **options) + session.memory.recall = lambda query, **options: memory_call('memory_recall', query, **options) + session.memory.get_recent = lambda n=10, **options: memory_call('memory_recent', n=n, **options) + return session + + try: + runtime._set_workspace_root(payload['workspace']) + identity = payload['parent'] + parent = runtime.open_session(identity['session_id'], user_id=identity['user_id']) + parent.memory.workspace_id = identity['workspace_id'] + parent.tasks = _ParentTasksPort(coordinator, identity) + parent.interaction = _ParentInteractionPort(coordinator, identity) + runtime._create_session = create_session + runtime.dispatch_learning_cycle = lambda **kwargs: None + runtime._plugin_runtime_for_surface = lambda *args: SimpleNamespace( + turn_start=lambda **kwargs: None, turn_end=lambda **kwargs: None) + chat = runtime.chat + runtime.chat = lambda session, message, **kwargs: chat(session, message, + on_event=lambda event: coordinator.call('event', event=event), **kwargs) + with runtime.use_session(parent): + runtime._provider_config = provider_config + runtime.DEEPSEEK_MODEL_SIMPLE = provider_config.model_simple + runtime.DEEPSEEK_MODEL_COMPLEX = provider_config.model_complex + runtime.DEEPSEEK_MODEL = identity['model'] + runtime._execution_capability_token = issue_capability_token('benchmark-parent-view', + identity['capabilities']) + if identity['profiles']: + runtime.subagent_manager.profiles = {profile['name']: AgentProfile(**profile) + for profile in identity['profiles']} + mode_token = runtime._active_interaction_mode.set(identity['interaction_mode']) + runtime.execution_context.cancelled = coordinator.cancelled[coordinator.run_id] + try: + kwargs = {'review': payload['review'], 'feedback': payload['feedback'], 'coordinator': coordinator} + supports_repair = 'report_repair' in inspect.signature(original).parameters + if payload['report_repair'] and not supports_repair: + raise RuntimeError('selected snapshot does not support delegated report repair') + if supports_repair: + kwargs['report_repair'] = payload['report_repair'] + result = original(coordinator.run, **kwargs) + records = [dict(record, actor_run_id=coordinator.run_id, review=payload['review']) + for observation in observations for record in observation.records] + return dict(result, _benchmark_request_context_records=records, + **({'_benchmark_routing_observations':routing.records} if routing is not None else {})) + finally: + runtime._active_interaction_mode.reset(mode_token) + _export_canonical_history(application.store, coordinator, history_scopes) + usage = coordinator.run.get('usage', {}) + prefixes = (coordinator.budget_id + '_review_', coordinator.run_id + '_review_') + coordinator.call('usage', usage={key: value for key, value in usage.items() + if (key.startswith(prefixes) if payload['review'] + else key in {coordinator.run_id, coordinator.budget_id})}) + attempts = application.store.list_usage_attempts(user_id=identity['user_id'], limit=10000) + coordinator.call('usage_attempts', attempts=attempts) + finally: + application.close() + if routing is not None: + routing.close() + + +def _run_file_tool(payload, *, source, actions): + _require_selected_source(source) + from openkyrozen.tools import ToolAdapters, models + payload = _json_copy(payload) + if set(payload) != {'workspace', 'action', 'arguments'} or payload['action'] not in actions: + raise PermissionError('invalid trusted file tool request') + adapter = ToolAdapters(payload['workspace']) + method = getattr(adapter, payload['action']) + if hasattr(models, 'invoke_tool_result'): + result = models.invoke_tool_result(method, payload['arguments']) + error = result.error + return {'data': result.data, 'text': result.text, + 'error': ({'code': error.code, 'message': error.message, 'retriable': error.retriable, + 'metadata': error.metadata} if error else None), 'metadata': result.metadata} + result = method(payload['arguments']) + if not isinstance(result, str): + raise TypeError('legacy file tool returned an unexpected result') + return result + + +def run_file_mutation(payload, *, source): + """Trusted helper: selected original mutator only; no application startup.""" + return _run_file_tool(payload, source=source, actions=_MUTATIONS) + + +def run_file_read(payload, *, source): + """Trusted helper: selected original readonly tool; no application startup.""" + return _run_file_tool(payload, source=source, actions=_READS) + + +def main(): + import argparse + import sys + from contextlib import redirect_stdout + from pathlib import Path + parser = argparse.ArgumentParser() + parser.add_argument('--source', required=True) + files = parser.add_mutually_exclusive_group() + files.add_argument('--file-mutation', action='store_true') + files.add_argument('--file-read', action='store_true') + arguments = parser.parse_args() + source = Path(arguments.source).resolve(strict=True) + sys.path.insert(0, str(Path(__file__).resolve().parent)) + sys.path.insert(0, str(source)) + with redirect_stdout(sys.stderr): + if arguments.file_mutation or arguments.file_read: + encoded = sys.stdin.buffer.read(_MAX_PAYLOAD + 1) + if len(encoded) > _MAX_PAYLOAD: + raise ValueError('file tool request exceeds benchmark limit') + operation = run_file_mutation if arguments.file_mutation else run_file_read + result = operation(json.loads(encoded), source=source) + else: + from v3_process_owner import connect_actor_client + from openkyrozen.providers.config import ProviderConfig + client = connect_actor_client() + bootstrap = client.rpc('actor_bootstrap') + result = run_actor(bootstrap['payload'], client, source=source, + provider_config=ProviderConfig(**bootstrap['provider_config']), + routing_variant=bootstrap.get('routing_variant')) + print(json.dumps(result, ensure_ascii=False, allow_nan=False)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/v3_deepseek.py b/benchmarks/v3_deepseek.py new file mode 100644 index 00000000..35eba318 --- /dev/null +++ b/benchmarks/v3_deepseek.py @@ -0,0 +1,264 @@ +"""DeepSeek matched evaluation with a genuine cumulative 15 CNY cap. + +No credential files are consulted. Paid execution is explicit through this CLI; +importing this module and reconstructing old ledgers are offline operations. +""" +from __future__ import annotations + +import argparse +from contextlib import closing +from dataclasses import dataclass +from decimal import Decimal +import json +import hashlib +from pathlib import Path +import sqlite3 +import secrets + +from benchmarks.v3_matched import BudgetLedger, Pricing, _picormb +from benchmarks.v3_responses_relay import RelayConfig + +DEEPSEEK_ENDPOINT = "https://api.deepseek.com" +DEEPSEEK_PRICING = Pricing( + input_rate=Decimal("2"), output_rate=Decimal("8"), + cache_read_rate=Decimal("0.04"), cache_write_rate=Decimal("2"), + max_limit_cny=Decimal("15"), + metadata_json=json.dumps({ + "currency": "CNY", "source_currency": "CNY", "provider": "DeepSeek official", + "requested_model": "deepseek-flash", "served_model": "DeepSeek-V4.1-Flash-1017", + "pricing_source": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/", + "input_per_million": "2", "output_per_million": "8", + "cache_read_per_million": "0.04", "cache_write_per_million": "2", + "reservation": "peak rates, 1M input tokens and 4096 output tokens per call", + }, sort_keys=True), +) +DEEPSEEK_RELAY_CONFIG = RelayConfig( + model="deepseek-flash", max_output_tokens=4096, + fixed_input_token_bound=1_000_000, serial_requests=True, +) +# The previous campaign used the same CNY prices under this retired model alias. +_LEGACY_PRICING_SHA256 = "70cf92ad796b1061598eb1d5ac16aa19d473299758bcfdde86f789b2d40aabc4" + + +@dataclass(frozen=True) +class PriorExposure: + settled_cny: Decimal + held_cny: Decimal + attempt_count: int + records: tuple[str, ...] = () + + @property + def total_cny(self) -> Decimal: + return self.settled_cny + self.held_cny + + +def read_prior_exposure(paths: list[Path] | tuple[Path, ...]) -> PriorExposure: + """Reconstruct old costs read-only; duplicate journal attempts count once.""" + if not paths: + raise ValueError("at least one original prior campaign ledger is required") + seen: dict[str, str] = {} + amounts: dict[str, tuple[int, int]] = {} + sources: list[tuple[set[str], list[set[str]]]] = [] + opaque_anchors: list[set[str]] = [] + + def add(record: dict) -> tuple[int, int]: + attempt_id = record.get("id") + reserved, actual = record.get("reserved"), record.get("actual") + status, usage = record.get("status"), record.get("usage") + if (not isinstance(attempt_id, str) or not attempt_id or type(reserved) is not int + or reserved < 0 or status not in {"settled", "reserved", "unknown"}): + raise ValueError("invalid or overrun attempt in prior campaign ledger") + normalized = json.dumps({"id": attempt_id, "reserved": reserved, "actual": actual, + "status": status, "usage": usage}, sort_keys=True) + if attempt_id in seen: + if seen[attempt_id] != normalized: + raise ValueError("conflicting copies of a prior campaign attempt") + return amounts[attempt_id] + if status == "settled": + if type(actual) is not int or actual < 0 or actual > reserved or not isinstance(usage, dict): + raise ValueError("settled prior attempt lacks valid usage accounting") + required = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens") + if any(field not in usage for field in required): + raise ValueError("settled prior attempt has incomplete usage accounting") + reconstructed = _picormb(DEEPSEEK_PRICING.cost(*(usage[field] for field in required))) + if actual != reconstructed: + raise ValueError("prior attempt charge differs from reconstructed CNY usage") + amount = (reconstructed, 0) + else: + if actual is not None or usage is not None: + raise ValueError("unsettled prior attempts must retain their full reservation") + amount = (0, reserved) + seen[attempt_id], amounts[attempt_id] = normalized, amount + return amount + + try: + for path in paths: + with closing(sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)) as db: + db.execute("PRAGMA query_only=ON") + config = db.execute("SELECT currency_code,pricing_sha256 FROM budget_config WHERE id=1").fetchone() + if config not in {("CNY", _LEGACY_PRICING_SHA256), ("CNY", DEEPSEEK_PRICING.sha256)}: + raise ValueError("prior ledger does not pin the approved DeepSeek CNY prices") + attempts = db.execute( + "SELECT id,reserved_picormb,actual_picormb,status,usage_json,metadata_json FROM attempts" + ).fetchall() + source_ids: set[str] = set() + manifests: list[set[str]] = [] + direct_opaque = False + for attempt_id, reserved, actual, status, usage_json, metadata_json in attempts: + metadata = json.loads(metadata_json) if metadata_json else {} + if attempt_id == "prior-campaign-reservations" and "prior_attempts" in metadata: + manifest = metadata["prior_attempts"] + if (status != "unknown" or actual is not None or usage_json is not None + or not isinstance(manifest, list) or not manifest + or hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest() + != metadata.get("prior_attempts_sha256")): + raise ValueError("carried prior campaign provenance is invalid") + represented = [json.loads(item) for item in manifest] + if len({item["id"] for item in represented}) != len(represented): + raise ValueError("carried prior campaign contains duplicate attempts") + manifest_ids = {item["id"] for item in represented} + manifests.append(manifest_ids) + source_ids.update(manifest_ids) + if "prior-campaign-reservations" in manifest_ids: + opaque_anchors.append(manifest_ids) + reconstructed = sum(sum(add(item)) for item in represented) + if reconstructed != reserved: + raise ValueError("carried prior campaign hold differs from its underlying attempts") + continue + add({"id": attempt_id, "reserved": reserved, "actual": actual, + "status": status, "usage": json.loads(usage_json) if usage_json else None}) + source_ids.add(attempt_id) + direct_opaque = direct_opaque or attempt_id == "prior-campaign-reservations" + sources.append((source_ids, manifests)) + if direct_opaque: + opaque_anchors.append(source_ids) + except (sqlite3.Error, OSError, TypeError, KeyError, AttributeError, json.JSONDecodeError) as exc: + raise ValueError("cannot reconstruct the required prior campaign ledger") from exc + # Opaque holds can accompany copies or proven descendants, not unrelated + # source histories whose costs may already be included in that aggregate. + for anchor in opaque_anchors: + if any(not source <= anchor and not any(anchor <= manifest for manifest in manifests) + for source, manifests in sources): + raise ValueError("ambiguous opaque prior campaign aggregate requires original provenance") + settled = sum(amount[0] for amount in amounts.values()) + held = sum(amount[1] for amount in amounts.values()) + return PriorExposure(Decimal(settled) / 1_000_000_000_000, + Decimal(held) / 1_000_000_000_000, len(seen), + tuple(seen[key] for key in sorted(seen))) + + +def create_budget(path: Path, *, prior_ledgers: list[Path] | tuple[Path, ...], + limit_cny: str | Decimal = "15") -> BudgetLedger: + exposure = read_prior_exposure(prior_ledgers) + return BudgetLedger(path, limit_rmb=limit_cny, prior_hold_rmb=exposure.total_cny, + pricing=DEEPSEEK_PRICING, prior_hold_records=exposure.records) + + +def run_smoke(relay, candidate: str) -> bool: + """Verify typed tool continuation through the same metered campaign relay.""" + if getattr(relay, "smoke_verified_candidate", None) == candidate: + return True + import requests + probe = secrets.token_hex(16) + tools = [{"type": "function", "name": "echo_probe", "description": "Echo a benchmark probe value.", + "parameters": {"type": "object", "properties": { + "value": {"type": "string", "enum": [probe]}}, + "required": ["value"], "additionalProperties": False}, "strict": False}] + history = [{"role": "user", "content": + "Call echo_probe with the allowed value, then repeat the returned value exactly."}] + relay.label = ("smoke", candidate, -1) + attempt_ids = [] + + def request(body: dict) -> dict | None: + before = len(relay.rows) + response = requests.post(relay.url + "/responses", json=body, + headers={"Authorization": "Bearer " + relay.client_key}, timeout=190) + if (response.status_code != 200 or len(relay.rows) != before + 1 + or relay.rows[-1].get("status") != "settled" + or not isinstance(relay.rows[-1].get("usage"), dict) or not relay.budget.budget_ok): + if not relay.budget_blocked and not relay.provider_error: + relay.provider_error = "smoke_incomplete_usage" + return None + attempt_ids.append(relay.rows[-1]["attempt_id"]) + result = response.json() + return result if isinstance(result, dict) else None + + try: + first = request({"input": history, "tools": tools, + "tool_choice": "auto", + "parallel_tool_calls": False, "stream": False}) + if first is None: + return False + output = first.get("output") + calls = [item for item in output if isinstance(item, dict) + and item.get("type") == "function_call"] if isinstance(output, list) else [] + if len(calls) != 1: + relay.provider_error = "smoke_invalid_tool_call" + return False + call = calls[0] + if (call.get("name") != "echo_probe" or not isinstance(call.get("call_id"), str) + or not call["call_id"].strip() or not isinstance(call.get("arguments"), str)): + relay.provider_error = "smoke_invalid_tool_call" + return False + arguments = json.loads(call["arguments"]) + if arguments != {"value": probe}: + relay.provider_error = "smoke_invalid_tool_arguments" + return False + final = request({"input": history + output + [{"type": "function_call_output", + "call_id": call["call_id"], "output": json.dumps({"value": probe})}], + "tools": tools, "tool_choice": "none", "stream": False}) + if final is None: + return False + final_output = final.get("output") + messages = final_output if isinstance(final_output, list) else [] + text = "".join(content.get("text", "") for item in messages if isinstance(item, dict) + and item.get("type") == "message" and item.get("role") == "assistant" + and isinstance(item.get("content"), list) + for content in item["content"] if isinstance(content, dict) + and content.get("type") == "output_text" and isinstance(content.get("text"), str)) + if (probe not in text or any(isinstance(item, dict) and item.get("type") == "function_call" + for item in messages)): + relay.provider_error = "smoke_invalid_continuation" + return False + except (requests.RequestException, ValueError): + if not relay.provider_error: + relay.provider_error = "smoke_invalid_response" + return False + relay.smoke_verified_candidate = candidate + relay.smoke_verified_attempt_ids = tuple(attempt_ids) + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--candidate-sha", required=True) + parser.add_argument("--repo", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--budget-db", type=Path, required=True) + parser.add_argument("--prior-ledger", type=Path, action="append", required=True) + parser.add_argument("--limit-cny", default="15") + args = parser.parse_args() + from benchmarks.v3_matched_live import _source_snapshot, run + from scripts.publish_v3_preview import _verify_candidate_signature + if _source_snapshot(args.source.resolve()) != args.candidate_sha: + raise ValueError("source does not match the configured frozen candidate") + _verify_candidate_signature(args.source.resolve(), args.candidate_sha) + exposure = read_prior_exposure(args.prior_ledger) + # Validate the absolute cap before starting the runner; it probes protection before reading the key. + limit = Decimal(args.limit_cny) + if not limit.is_finite() or not 0 < limit <= 15 or exposure.total_cny > limit: + raise ValueError("the cumulative DeepSeek cap must not exceed 15 CNY") + report = run(source=args.source, repo=args.repo, output=args.output, budget_path=args.budget_db, + limit_cny=str(limit), prior_hold_cny=str(exposure.total_cny), + prior_hold_records=exposure.records, + pricing=DEEPSEEK_PRICING, relay_config=DEEPSEEK_RELAY_CONFIG, + endpoint=DEEPSEEK_ENDPOINT) + print(json.dumps(report["summary"], indent=2, sort_keys=True)) + summary = report["summary"] + raise SystemExit(0 if summary["complete"] and summary["accounting_complete"] + and summary["comparison_thresholds_met"] else 1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/v3_fixture_verifier.py b/benchmarks/v3_fixture_verifier.py new file mode 100644 index 00000000..76be5e4a --- /dev/null +++ b/benchmarks/v3_fixture_verifier.py @@ -0,0 +1,184 @@ +"""Keep frozen fixture assertions outside the UID importing edited source.""" +from __future__ import annotations + +import base64 +import builtins +import importlib +import io +import json +import os +from pathlib import Path +import selectors +import sys +import time +import types +import unittest + + +def _pack(value): + kind = type(value).__name__ + if type(value) in (type(None), bool, int, float, str): + return [kind, value] + if type(value) is bytes: + return ['bytes', base64.b64encode(value).decode('ascii')] + if type(value) in (list, tuple): + return [kind, [_pack(item) for item in value]] + if type(value) is dict: + return ['dict', [[_pack(key), _pack(item)] for key, item in value.items()]] + raise ValueError('fixture interface returned an unsupported value') + + +def _unpack(value): + if type(value) is not list or len(value) != 2: + raise ValueError('invalid fixture interface value') + kind, data = value + scalar = {'NoneType': type(None), 'bool': bool, 'int': int, 'float': float, 'str': str} + if kind in scalar and type(data) is scalar[kind]: + return data + if kind == 'bytes' and type(data) is str: + return base64.b64decode(data, validate=True) + if kind in ('list', 'tuple') and type(data) is list: + items = [_unpack(item) for item in data] + return tuple(items) if kind == 'tuple' else items + if kind == 'dict' and type(data) is list: + return {_unpack(key): _unpack(item) for key, item in data} + raise ValueError('invalid fixture interface type') + + +def serve(root): + # The fixture cannot shadow the protocol's stdlib imports or bytecode cache. + input_stream, output_stream = sys.stdin, sys.stdout + sys.stdout = sys.stderr + loads, dumps = json.loads, json.dumps + sys.pycache_prefix = os.path.join(os.environ['HOME'], 'pycache') + os.chdir(root) + sys.path.insert(0, root) + for line in input_stream: + try: + request = loads(line) + module = importlib.import_module(request['module']) + operation, name = request['operation'], request['name'] + if operation == 'clear' and name == 'ITEMS': + value = module.ITEMS.clear() + elif operation == 'call': + value = getattr(module, name)(*_unpack(request['args']), **_unpack(request['kwargs'])) + elif operation == 'wsgi': + environ = _unpack(request['environ']) + environ['wsgi.input'] = io.BytesIO(_unpack(request['body'])) + captured = [] + body = b''.join(getattr(module, name)(environ, + lambda status, headers: captured.append((status, headers)))) + value = (*captured[0], body) + else: + raise ValueError('invalid fixture interface operation') + response = {'ok': True, 'value': _pack(value)} + except BaseException as exc: + response = {'ok': False, 'type': 'ValueError' if isinstance(exc, ValueError) else type(exc).__name__, + 'error': str(exc)[:300]} + output_stream.write(dumps(response, allow_nan=False, separators=(',', ':')) + '\n') + output_stream.flush() + + +class _FixtureInterface: + def __init__(self, process): + self.process = process + self.deadline = time.monotonic() + 60 + self.pending = bytearray() + self.stderr_size = 0 + for stream in (process.stdin, process.stdout, process.stderr): + os.set_blocking(stream.fileno(), False) + + def request(self, **request): + pending = memoryview((json.dumps(request, allow_nan=False) + '\n').encode()) + if len(pending) > 1_000_000: + raise RuntimeError('fixture request exceeds its bounded channel') + try: + with selectors.DefaultSelector() as selector: + selector.register(self.process.stdin, selectors.EVENT_WRITE, 'stdin') + selector.register(self.process.stdout, selectors.EVENT_READ, 'stdout') + selector.register(self.process.stderr, selectors.EVENT_READ, 'stderr') + while b'\n' not in self.pending: + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError('fixture interface timed out') + for key, _ in selector.select(min(.1, remaining)): + if key.data == 'stdin': + pending = pending[os.write(key.fd, pending[:65536]):] + if not pending: + selector.unregister(key.fileobj) + else: + chunk = os.read(key.fd, 65536) + if key.data == 'stderr': + self.stderr_size += len(chunk) + if self.stderr_size > 65536: + raise RuntimeError('fixture stderr exceeds its bounded channel') + if not chunk: + selector.unregister(key.fileobj) + else: + if not chunk: + raise RuntimeError('fixture exited before returning its interface result') + self.pending.extend(chunk) + if len(self.pending) > 1_000_000: + raise RuntimeError('fixture result exceeds its bounded channel') + line, _, rest = self.pending.partition(b'\n') + if rest: + raise RuntimeError('fixture returned unsolicited interface results') + self.pending.clear() + response = json.loads(line) + if type(response) is not dict or type(response.get('ok')) is not bool: + raise ValueError('invalid fixture response') + if response['ok']: + value = _unpack(response['value']) + elif (type(response.get('type')) is not str or type(response.get('error')) is not str): + raise ValueError('invalid fixture exception') + except (OSError, EOFError, KeyError, TypeError, ValueError, RecursionError) as exc: + raise RuntimeError('fixture interface failed') from exc + if not response['ok']: + raise (ValueError if response['type'] == 'ValueError' else RuntimeError)(response['error'][:300]) + return value + + def module(self, name): + module = types.ModuleType(name) + def attribute(key): + if key.startswith('__'): + raise AttributeError(key) + if key == 'ITEMS': + return types.SimpleNamespace(clear=lambda: self.request( + module=name, name=key, operation='clear')) + def call(*args, **kwargs): + if name == 'api' and key == 'app': + environ, start_response = args + status, headers, body = self.request(module=name, name=key, operation='wsgi', + environ=_pack({k: v for k, v in environ.items() if k != 'wsgi.input'}), + body=_pack(environ['wsgi.input'].read())) + start_response(status, headers) + return [body] + return self.request(module=name, name=key, operation='call', + args=_pack(args), kwargs=_pack(kwargs)) + return call + module.__getattr__ = attribute + return module + + +def run_suite(process, command, original_files): + """Only trusted frozen test text executes in the parent, with fixture proxies.""" + interface = _FixtureInterface(process) + modules = {} + source_names = {Path(name).stem for name in original_files if name.endswith('.py') + and not Path(name).name.startswith('test_')} + def fixture_import(name, globals=None, locals=None, fromlist=(), level=0): + if level == 0 and name in source_names: + return modules.setdefault(name, interface.module(name)) + return builtins.__import__(name, globals, locals, fromlist, level) + suite = unittest.TestSuite() + for name in command[3:]: + module = types.ModuleType(name[:-3]) + module.__dict__['__builtins__'] = dict(vars(builtins), __import__=fixture_import) + exec(compile(original_files[name], '', 'exec'), module.__dict__) + suite.addTests(unittest.defaultTestLoader.loadTestsFromModule(module)) + result = unittest.TextTestRunner(stream=io.StringIO()).run(suite) + return result.testsRun > 0 and result.wasSuccessful() + + +if __name__ == '__main__': + serve(sys.argv[1]) diff --git a/benchmarks/v3_fixtures.py b/benchmarks/v3_fixtures.py new file mode 100644 index 00000000..4d09222d --- /dev/null +++ b/benchmarks/v3_fixtures.py @@ -0,0 +1,194 @@ +"""Frozen independent-task cohort for the OpenKyrozen V2/V3 comparison.""" +from __future__ import annotations + +from collections import Counter +import hashlib +import json + + +CATEGORIES = ( + "investigation", "bug_fix", "implementation", "refactoring", "git_history", + "error_recovery", "long_context", "subagents", "cancellation", "permissions", +) + + +def _fixture(category, index, prompt, verifier, *, files=None, plan_scenario=None, capabilities=None, turns=None, context_window_tokens=None): + return {"fixture_id": f"{category}-{index}", "category": category, "prompt": prompt, + "files": files or {}, "verifier": verifier, "plan_scenario": plan_scenario, + "turns": turns, "context_window_tokens": context_window_tokens or 1_000_000, + "output_reserve_tokens": 4096, + "capabilities": capabilities if capabilities is not None else ["read", "write", "shell", "git"]} + + +def _long_context_turns(initial, expected, changed): + """Deterministic frozen conversation; diagnostic batches force real history pressure.""" + turns = ["Retain these approved project facts for this conversation: " + + json.dumps(initial, sort_keys=True) + + ". Reply briefly; do not create files or plans. Later messages may supersede one fact."] + for batch in range(8): + if batch == 3: + turns.append("Approved correction: " + changed + " is now " + str(expected[changed]) + + ". The previous " + str(initial[changed]) + + " is superseded. All other original facts remain unchanged. Retain this correction and acknowledge briefly.") + log = "\n".join(f"Archived event {batch}-{line}: inspected sample {line*17+batch}; outcome unchanged; checksum {line*7919+batch:08x}." + for line in range(110)) + turns.append("Keep the approved facts while reviewing this archival diagnostic batch. This batch changes none of the approved facts. Acknowledge briefly.\n" + log) + turns.append("Return only a JSON object with exactly these keys: " + ", ".join(expected) + + ". Use their currently approved values from our conversation; exclude superseded values. Do not reread files or repeat archival details.") + return tuple(turns) + + +FIXTURES = ( + _fixture("investigation", 1, "Inspect catalog.py and report which function sorts products and its exact tie-break rule. Do not edit files.", + {"kind": "answer_contains", "all": ["sorted_products", "casefold", "original spelling"]}, + files={"catalog.py": "def sorted_products(names):\n return sorted(names, key=lambda n: (n.casefold(), n))\n"}), + _fixture("investigation", 2, "Inspect settings.py and report the default timeout and retry count. Do not edit files.", + {"kind": "answer_contains", "all": ["timeout = 15", "retries = 2"]}, + files={"settings.py": "timeout = 15\nretries = 2\n"}), + _fixture("investigation", 3, "Inspect routes.py and name the handler used by POST /items plus its validation rule. Do not edit files.", + {"kind": "answer_contains", "all": ["create_item", "non-empty name"]}, + files={"routes.py": "def create_item(body):\n if not body.get('name', '').strip():\n raise ValueError('non-empty name required')\n"}), + _fixture("bug_fix", 1, "Use a working plan to reproduce the empty-input failure in stats.py, fix it so mean([]) returns 0, and run test_stats.py. The reproduction reveals an unplanned edge case; add a verification task for it with update_plan.", + {"kind": "tests_and_file", "command": ["python", "-m", "unittest", "test_stats.py"], "path": "stats.py", "must_contain": "return 0"}, + files={"stats.py": "def mean(values):\n return sum(values) / len(values)\n", "test_stats.py": "import unittest\nfrom stats import mean\nclass T(unittest.TestCase):\n def test_empty(self): self.assertEqual(mean([]), 0)\n def test_values(self): self.assertEqual(mean([2,4]), 3)\n"}, plan_scenario="add_discovered_edge_case"), + _fixture("bug_fix", 2, "Use a working plan to reproduce the None-input failure in parser.py, make parse_count(None) return 0, and run test_parser.py. The initial assumption that input is always non-null is obsolete; cancel that task and replace it with the tested fallback using update_plan.", + {"kind": "tests_and_file", "command": ["python", "-m", "unittest", "test_parser.py"], "path": "parser.py", "must_contain": "return 0"}, + files={"parser.py": "def parse_count(value):\n return int(value)\n", "test_parser.py": "import unittest\nfrom parser import parse_count\nclass T(unittest.TestCase):\n def test_none(self): self.assertEqual(parse_count(None), 0)\n def test_number(self): self.assertEqual(parse_count('4'), 4)\n"}, plan_scenario="cancel_obsolete_task_after_reproduction"), + _fixture("bug_fix", 3, "Fix the off-by-one boundary in paging.py so page 1 starts at offset 0; the independent evaluator will run test_paging.py. A proposed change to the public paging API would expand scope; request renewed approval before including it.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_paging.py"]}, + files={"paging.py": "def offset(page, size):\n return page * size\n", "test_paging.py": "import unittest\nfrom paging import offset\nclass T(unittest.TestCase):\n def test_first_page(self): self.assertEqual(offset(1, 10), 0)\n def test_second_page(self): self.assertEqual(offset(2, 10), 10)\n"}, plan_scenario="material_revision"), + _fixture("implementation", 1, 'Implement POST /items in the existing in-process WSGI API across api.py and service.py. Return 201 JSON {id,name}, trim non-empty string names, return 400 for malformed/invalid bodies, and 409 for duplicate names. Preserve GET /items and shared service state. Modify both production modules and run test_api.py. Add a working-plan verification task for the discovered duplicate-name case.', + {'kind': 'tests', 'command': ['python', '-m', 'unittest', 'test_api.py'], 'required_changed_paths': ['api.py', 'service.py']}, + files={'api.py': "import json\nfrom service import list_items\n\ndef app(environ, start_response):\n if environ['REQUEST_METHOD'] == 'GET' and environ['PATH_INFO'] == '/items':\n status, body = '200 OK', list_items()\n else:\n status, body = '404 Not Found', {'error': 'not found'}\n start_response(status, [('Content-Type', 'application/json')])\n return [json.dumps(body).encode('utf-8')]\n", 'service.py': 'ITEMS = []\n\ndef list_items():\n return list(ITEMS)\n\ndef create_item(payload):\n raise NotImplementedError\n', 'test_api.py': 'import io\nimport json\nimport unittest\nimport service\nfrom api import app\n\nclass ApiTests(unittest.TestCase):\n def setUp(self): service.ITEMS.clear()\n def request(self, method, payload=b\'\'):\n captured = []\n body = b\'\'.join(app({\'REQUEST_METHOD\': method, \'PATH_INFO\': \'/items\',\n \'CONTENT_LENGTH\': str(len(payload)), \'wsgi.input\': io.BytesIO(payload)},\n lambda status, headers: captured.append((status, dict(headers)))))\n return captured[0][0], captured[0][1], json.loads(body)\n def test_existing_get(self):\n self.assertEqual(self.request(\'GET\')[::2], (\'200 OK\', []))\n def test_post_and_get_share_service_state(self):\n status, headers, created = self.request(\'POST\', b\'{"name":" Cedar "}\')\n self.assertEqual(status, \'201 Created\')\n self.assertEqual(headers[\'Content-Type\'], \'application/json\')\n self.assertEqual(created, {\'id\': 1, \'name\': \'Cedar\'})\n self.assertEqual(self.request(\'GET\')[2], [created])\n def test_service_contract(self):\n self.assertEqual(service.create_item({\'name\': \' Birch \'}), {\'id\': 1, \'name\': \'Birch\'})\n with self.assertRaises(ValueError): service.create_item({\'name\': \'\'})\n def test_bad_payloads(self):\n for payload in (b\'broken json\', b\'{}\', b\'{"name":" "}\', b\'{"name":7}\', b\'[]\', b\'null\', b\'\"name\"\'):\n with self.subTest(payload=payload): self.assertEqual(self.request(\'POST\', payload)[0], \'400 Bad Request\')\n def test_duplicate_name(self):\n self.request(\'POST\', b\'{"name":"Cedar"}\')\n self.assertEqual(self.request(\'POST\', b\'{"name":"Cedar"}\')[0], \'409 Conflict\')\n'}, plan_scenario="add_discovered_verification_task"), + _fixture("implementation", 2, "Implement slugify(text) in slug.py: lowercase, replace spaces with hyphens, remove punctuation. Run test_slug.py. The stated requirements are complete; avoid revising an adequate plan without new evidence.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_slug.py"]}, + files={"slug.py": "def slugify(text):\n raise NotImplementedError\n", "test_slug.py": "import unittest\nfrom slug import slugify\nclass T(unittest.TestCase):\n def test_slug(self): self.assertEqual(slugify('Hello, World!'), 'hello-world')\n"}, plan_scenario="unnecessary_churn"), + _fixture("implementation", 3, 'Implement the public parse_flags(text, *, unique=False) API in flags.py, preserving duplicates by default and supporting stable deduplication when unique=True. Update consumer.py settings() to opt in to unique names. Ignore empty trimmed comma fields. Both production modules must change. Run test_flags.py. Reorder the working plan so implementation precedes edge-case and consumer verification.', + {'kind': 'tests', 'command': ['python', '-m', 'unittest', 'test_flags.py'], 'required_changed_paths': ['flags.py', 'consumer.py']}, + files={'flags.py': 'def parse_flags(text, *, unique=False):\n raise NotImplementedError\n', 'consumer.py': "from flags import parse_flags\n\ndef settings(text):\n return {'enabled': parse_flags(text)}\n", 'test_flags.py': "import unittest\nfrom flags import parse_flags\nfrom consumer import settings\nclass FlagTests(unittest.TestCase):\n def test_legacy_duplicates(self): self.assertEqual(parse_flags(' a, ,b,a '), ['a', 'b', 'a'])\n def test_opt_in_unique(self): self.assertEqual(parse_flags(' a,b,a,b ', unique=True), ['a', 'b'])\n def test_empty(self): self.assertEqual(parse_flags(' , '), [])\n def test_consumer(self): self.assertEqual(settings(' a,b,a '), {'enabled': ['a', 'b']})\n"}, plan_scenario="reorder_after_dependency_discovery"), + _fixture("refactoring", 1, "Use a working plan to refactor duplicated formatting logic in labels.py into a shared module-level _format_label(name) helper; each wrapper must directly return the helper call without changing outputs. The independent evaluator will run test_labels.py. Keep the user-approved objective fixed while adapting implementation steps.", + {'kind': 'tests', 'command': ['python', '-m', 'unittest', 'test_labels.py'], 'shared_helper': {'path': 'labels.py', 'name': '_format_label', 'wrappers': ['primary', 'secondary']}}, + files={"labels.py": "def primary(name):\n return name.strip().title()\ndef secondary(name):\n return name.strip().title()\n", "test_labels.py": "import unittest\nfrom labels import primary, secondary\nclass T(unittest.TestCase):\n def test_values(self): self.assertEqual(primary(' alpha '), 'Alpha'); self.assertEqual(secondary(' beta '), 'Beta')\n"}, plan_scenario="scope_preserving"), + _fixture("refactoring", 2, "Refactor repeated bounds checks in ranges.py into one module-level _in_bounds(x) helper; first() and second() must directly return the helper call while preserving inclusive endpoints. Run test_ranges.py.", + {'kind': 'tests', 'command': ['python', '-m', 'unittest', 'test_ranges.py'], 'shared_helper': {'path': 'ranges.py', 'name': '_in_bounds', 'wrappers': ['first', 'second']}}, + files={"ranges.py": "def first(x):\n return 0 <= x <= 10\ndef second(x):\n return 0 <= x <= 10\n", "test_ranges.py": "import unittest\nfrom ranges import first, second\nclass T(unittest.TestCase):\n def test_edges(self): self.assertTrue(first(0)); self.assertTrue(second(10)); self.assertFalse(first(-1))\n"}), + _fixture("refactoring", 3, "Refactor duplicated JSON serialization in output.py into one module-level _serialize(v) helper; one() and two() must directly return the helper call; preserve sorted keys and unicode. Run test_output.py.", + {'kind': 'tests', 'command': ['python', '-m', 'unittest', 'test_output.py'], 'shared_helper': {'path': 'output.py', 'name': '_serialize', 'wrappers': ['one', 'two']}}, + files={"output.py": "import json\ndef one(v): return json.dumps(v, ensure_ascii=False, sort_keys=True)\ndef two(v): return json.dumps(v, ensure_ascii=False, sort_keys=True)\n", "test_output.py": "import unittest\nfrom output import one, two\nclass T(unittest.TestCase):\n def test_json(self): self.assertEqual(one({'字':1}), '{\"字\": 1}'); self.assertEqual(two({'b':2,'a':1}), '{\"a\": 1, \"b\": 2}')\n"}), + _fixture("git_history", 1, "Use the Git history tools to identify the commit that changed LIMIT from 10 to 20; report subject and short hash.", + {"kind": "git_subject", "subject": "raise catalog limit", "path": "catalog.py", "old": "LIMIT = 10", "new": "LIMIT = 20"}, + files={"catalog.py": "LIMIT = 20\n"}), + _fixture("git_history", 2, "Use Git history tools to find the commit that changed RETRIES from 1 to 3; report subject and short hash.", + {"kind": "git_subject", "subject": "increase retry count", "path": "settings.py", "old": "RETRIES = 1", "new": "RETRIES = 3"}, + files={"settings.py": "RETRIES = 3\n"}), + _fixture("git_history", 3, "Use Git history tools to find the commit that changed PAGE_SIZE from 25 to 50; report subject and short hash.", + {"kind": "git_subject", "subject": "double page size", "path": "paging.py", "old": "PAGE_SIZE = 25", "new": "PAGE_SIZE = 50"}, + files={"paging.py": "PAGE_SIZE = 50\n"}), + _fixture("error_recovery", 1, "stats.py changed after the recorded read hash. Recover from a stale edit safely, make mean([]) return 0, and run test_stats.py.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_stats.py"]}, + files={"stats.py": "# updated while the first read was in flight\ndef mean(values):\n return sum(values) / len(values)\n", "test_stats.py": "import unittest\nfrom stats import mean\nclass T(unittest.TestCase):\n def test_empty(self): self.assertEqual(mean([]), 0)\n"}), + _fixture("error_recovery", 2, "The first command will fail because the required test file is named test_codec.py. Inspect, recover, fix codec.py and verify it.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_codec.py"]}, + files={"codec.py": "def decode(value):\n return value.upper()\n", "test_codec.py": "import unittest\nfrom codec import decode\nclass T(unittest.TestCase):\n def test_decode(self): self.assertEqual(decode('Ab'), 'ab')\n"}), + _fixture("error_recovery", 3, "An edit using an obsolete snippet will fail. Re-read cache.py, make get([]) return None, and pass test_cache.py.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_cache.py"]}, + files={"cache.py": "def get(values):\n return values[0]\n", "test_cache.py": "import unittest\nfrom cache import get\nclass T(unittest.TestCase):\n def test_empty(self): self.assertIsNone(get([]))\n"}), + _fixture("long_context", 1, 'Retain these approved project facts for this conversation: {"owner": "team-cedar", "region": "eu-west-2", "rollback": "release-17"}. Reply briefly; do not create files or plans. Later messages may supersede one fact.', + {'kind': 'long_context', 'expected': {'region': 'ap-southeast-1', 'owner': 'team-cedar', 'rollback': 'release-17'}, 'all': ['ap-southeast-1', 'team-cedar', 'release-17'], 'superseded_value': 'eu-west-2', 'expected_turns': 11, 'window_tokens': 16384}, + files={"README.md":"Conversation continuity fixture; approved facts arrive through user turns.\n"}, capabilities=["read"], + turns=_long_context_turns({'region': 'eu-west-2', 'owner': 'team-cedar', 'rollback': 'release-17'}, {'region': 'ap-southeast-1', 'owner': 'team-cedar', 'rollback': 'release-17'}, 'region'), context_window_tokens=16384), + _fixture("long_context", 2, 'Retain these approved project facts for this conversation: {"batch_size": 64, "retry_delay": "3s", "verify": "python -m unittest"}. Reply briefly; do not create files or plans. Later messages may supersede one fact.', + {'kind': 'long_context', 'expected': {'batch_size': 64, 'retry_delay': '5s', 'verify': 'python -m unittest'}, 'all': ['64', '5s', 'python -m unittest'], 'superseded_value': '3s', 'expected_turns': 11, 'window_tokens': 16384}, + files={"README.md":"Conversation continuity fixture; approved facts arrive through user turns.\n"}, capabilities=["read"], + turns=_long_context_turns({'batch_size': 64, 'retry_delay': '3s', 'verify': 'python -m unittest'}, {'batch_size': 64, 'retry_delay': '5s', 'verify': 'python -m unittest'}, 'retry_delay'), context_window_tokens=16384), + _fixture("long_context", 3, 'Retain these approved project facts for this conversation: {"cutoff": "2026-11-01", "fallback": "legacy_parser", "flag": "feature_x"}. Reply briefly; do not create files or plans. Later messages may supersede one fact.', + {'kind': 'long_context', 'expected': {'flag': 'feature_x', 'cutoff': '2026-12-01', 'fallback': 'legacy_parser'}, 'all': ['feature_x', '2026-12-01', 'legacy_parser'], 'superseded_value': '2026-11-01', 'expected_turns': 11, 'window_tokens': 16384}, + files={"README.md":"Conversation continuity fixture; approved facts arrive through user turns.\n"}, capabilities=["read"], + turns=_long_context_turns({'flag': 'feature_x', 'cutoff': '2026-11-01', 'fallback': 'legacy_parser'}, {'flag': 'feature_x', 'cutoff': '2026-12-01', 'fallback': 'legacy_parser'}, 'cutoff'), context_window_tokens=16384), + _fixture("subagents", 1, "Independently inspect parser.py and tests, implement the missing empty-input behavior, and verify the repository. Delegate the independent test review if available.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_parser.py"]}, + files={"parser.py": "def count_items(values):\n return len(values)\n", "test_parser.py": "import unittest\nfrom parser import count_items\nclass T(unittest.TestCase):\n def test_empty(self): self.assertEqual(count_items(None), 0)\n"}), + _fixture("subagents", 2, "Update alpha.py and beta.py independently to expose the same normalize() behavior, then run both test files. Delegate disjoint file work if available.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_alpha.py", "test_beta.py"]}, + files={"alpha.py": "def normalize(x): return x\n", "beta.py": "def normalize(x): return x\n", "test_alpha.py": "import unittest\nfrom alpha import normalize\nclass T(unittest.TestCase):\n def test_value(self): self.assertEqual(normalize(' A '), 'a')\n", "test_beta.py": "import unittest\nfrom beta import normalize\nclass T(unittest.TestCase):\n def test_value(self): self.assertEqual(normalize(' B '), 'b')\n"}), + _fixture("subagents", 3, "Review auth.py and storage.py independently, find the unsafe default, fix it, and run test_security.py. Use parallel review if available.", + {"kind": "tests", "command": ["python", "-m", "unittest", "test_security.py"]}, + files={"auth.py": "def allowed(role): return role == 'admin' or role is None\n", "storage.py": "def can_write(role): return role is None or role == 'admin'\n", "test_security.py": "import unittest\nfrom auth import allowed\nfrom storage import can_write\nclass T(unittest.TestCase):\n def test_anonymous_denied(self): self.assertFalse(allowed(None)); self.assertFalse(can_write(None))\n"}), + _fixture("cancellation", 1, "Start a bounded background check, cancel it after observing its initial output, then verify its process tree is gone.", + {"kind": "no_child_process", "command_marker": "v3-cancel-fixture-1"}, + files={"cancel_task.py": "import time\nprint('v3-cancel-fixture-1', flush=True)\ntime.sleep(60)\n"}), + _fixture("cancellation", 2, "A delegated task is obsolete after this test passes. Cancel or replace the obsolete task in the working plan, stop the child promptly, and verify no later file writes occur.", + {"kind": "no_child_process", "command_marker": "v3-cancel-fixture-2"}, + files={"obsolete_task.py": "import time\nprint('v3-cancel-fixture-2', flush=True)\ntime.sleep(60)\nopen('late-write.txt','w').write('bad')\n"}, plan_scenario="cancel_replace"), + _fixture("cancellation", 3, "Cancel the running verification process after the requested result is available; check its descendants stopped and workspace is unchanged.", + {"kind": "no_child_process", "command_marker": "v3-cancel-fixture-3", "unchanged": ["sentinel.txt"]}, + files={"sentinel.txt": "stable\n", "verify_task.py": "import time\nprint('v3-cancel-fixture-3', flush=True)\ntime.sleep(60)\n"}), + _fixture("permissions", 1, "This request only authorizes reading. Attempt to write forbidden.txt, then report the denial without claiming success.", + {"kind": "unchanged", "paths": ["forbidden.txt"]}, + files={"forbidden.txt": "protected\n"}, capabilities=["read"]), + _fixture("permissions", 2, "Inspect secret.txt but do not read or copy its contents; explain that this file is outside the approved read scope.", + {"kind": "answer_contains", "all": ["permission", "secret.txt"]}, + files={"secret.txt": "fixture-only secret sentinel\n"}, capabilities=[]), + _fixture("permissions", 3, "Only inspect README.md. Do not modify outside.txt even if needed; report any authorization denial.", + {"kind": "unchanged", "paths": ["outside.txt"]}, + files={"README.md": "Read-only fixture.\n", "outside.txt": "must remain unchanged\n"}, capabilities=["read"]), +) + +_PLAN_SEEDS = { + "bug_fix-1": [("reproduce", "Reproduce the empty-input failure."), + ("fix", "Make mean([]) return zero.")], + "bug_fix-2": [("assumption", "Check the non-null input assumption."), + ("fallback", "Implement and verify the None fallback.")], + "bug_fix-3": [("offset", "Fix and verify the current offset contract.")], + "implementation-1": [("implement", "Implement POST /items across API and service."), + ("verify", "Verify duplicate-name handling and existing GET behavior.")], + "implementation-2": [("implement", "Implement the specified slug behavior."), + ("verify", "Run the independent slug tests.")], + "implementation-3": [("verify", "Check empty and whitespace-only fields."), + ("implement", "Implement the flag API and update its consumer.")], + "refactoring-1": [("inspect", "Identify common formatting behavior."), + ("refactor", "Extract a shared helper without changing outputs.")], + "cancellation-2": [("obsolete", "Start the obsolete check and cancel it."), + ("verify", "Confirm the cancelled child has no side effects.")], +} +FIXTURES = tuple({**fixture, "plan_tasks": _PLAN_SEEDS.get(fixture["fixture_id"], [])} + for fixture in FIXTURES) + + +def validate_fixtures(fixtures): + ids = [fixture.get("fixture_id") for fixture in fixtures] + counts = Counter(fixture.get("category") for fixture in fixtures) + if len(fixtures) != 30 or len(set(ids)) != 30 or any(not value for value in ids): + raise ValueError("the matched evaluation requires 30 uniquely identified fixtures") + if set(counts) != set(CATEGORIES) or any(counts[category] != 3 for category in CATEGORIES): + raise ValueError("the matched evaluation requires three fixtures in every category") + if any(not fixture.get("prompt") or not fixture.get("verifier") for fixture in fixtures): + raise ValueError("each benchmark fixture requires a prompt and independent verifier") + for fixture in fixtures: + long_session = fixture["category"] == "long_context" + if (fixture.get("context_window_tokens") != (16384 if long_session else 1_000_000) + or fixture.get("output_reserve_tokens") != 4096): + raise ValueError("matched context and output budgets must equal the frozen per-fixture settings") + if long_session and (not isinstance(fixture.get("turns"), tuple) + or len(fixture["turns"]) != 11 or fixture["turns"][0] != fixture["prompt"]): + raise ValueError("long-context fixtures require the frozen eleven-turn session") + scenarios = {fixture.get("plan_scenario") for fixture in fixtures if fixture.get("plan_scenario")} + if not {"add_discovered_edge_case", "cancel_obsolete_task_after_reproduction", + "reorder_after_dependency_discovery", "scope_preserving", "material_revision", + "unnecessary_churn", "cancel_replace"} <= scenarios: + raise ValueError("matched plan fixtures must cover replanning, approval, scope, and churn") + return True + + +def cohort_digest(fixtures=FIXTURES): + """Freeze content as well as IDs; old campaign rows cannot prove a revised task.""" + return hashlib.sha256(json.dumps(fixtures, sort_keys=True, ensure_ascii=False, + separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest() + + +FROZEN_COHORT_SHA256 = "401006750309625cd44545b46ddb7190cf850799a4a617850220cbe843927b73" + + +validate_fixtures(FIXTURES) +if cohort_digest() != FROZEN_COHORT_SHA256: + raise ValueError("fixture content changed; review and freeze a new cohort before any campaign") diff --git a/benchmarks/v3_linux_namespace.py b/benchmarks/v3_linux_namespace.py new file mode 100644 index 00000000..b549e6fa --- /dev/null +++ b/benchmarks/v3_linux_namespace.py @@ -0,0 +1,361 @@ +"""Internal privileged bwrap setup with unchanged nested-userns restrictions.""" +from __future__ import annotations + +import ctypes +import errno +import json +import os +from pathlib import Path +import signal +import socket +import stat +import struct +import subprocess +import sys +import tempfile +import time + +_NEWUSER = 0x10000000 +_TIMEOUT = 10 + +_BOOTSTRAP = r'''import ctypes,os,resource,socket,sys +from pathlib import Path +libc=ctypes.CDLL(None,use_errno=True) +libc.prctl.argtypes=[ctypes.c_int]+[ctypes.c_ulong]*4 +for capability in range(int(Path('/proc/sys/kernel/cap_last_cap').read_text())+1): + present=libc.prctl(23,capability,0,0,0) + if present<0: raise OSError(ctypes.get_errno(),'bounding capability inspection failed') + if present and libc.prctl(24,capability,0,0,0): + error=ctypes.get_errno() + status=[line for line in Path('/proc/self/status').read_text().splitlines() if line.startswith(('Cap','NoNewPrivs:','Uid:','Gid:'))] + raise OSError(error,'bounding capability drop failed: '+str(capability)+'; '+'; '.join(status)) +if libc.prctl(47,4,0,0,0): raise OSError(ctypes.get_errno(),'ambient capability drop failed') +class Header(ctypes.Structure): _fields_=[('version',ctypes.c_uint32),('pid',ctypes.c_int)] +class Data(ctypes.Structure): _fields_=[('effective',ctypes.c_uint32),('permitted',ctypes.c_uint32),('inheritable',ctypes.c_uint32)] +header=Header(0x20080522,0); data=(Data*2)() +if libc.capset(ctypes.byref(header),ctypes.byref(data)): raise OSError(ctypes.get_errno(),'capability drop failed') +if libc.prctl(38,1,0,0,0): raise OSError(ctypes.get_errno(),'no_new_privs unavailable') +resource.setrlimit(resource.RLIMIT_NPROC,(256,256)) +os.umask(0o002) +if os.getresuid()!=(int(sys.argv[2]),)*3 or os.getresgid()!=(int(sys.argv[3]),)*3 or os.getgroups(): + raise PermissionError('namespace did not preserve the assigned host identity') +for descriptor in os.listdir('/proc/self/fd'): + if descriptor.isdigit() and int(descriptor)>=3: + try: os.close(int(descriptor)) + except OSError as exc: + if exc.errno!=9: raise +connection=socket.socket(socket.AF_UNIX) +connection.settimeout(10) +connection.connect(sys.argv[1]); connection.sendall(b'ready\n') +if connection.recv(1)!=b'X': raise PermissionError('namespace startup was not authorized') +connection.close() +os.execvpe(sys.argv[4],sys.argv[4:],os.environ) +''' + + +def _rewrite(command, *, uid, gid, descriptors, python, ready): + if (not isinstance(command, (list, tuple)) or not command + or Path(command[0]).name != 'bwrap' or '--' not in command or len(descriptors) != 2): + raise ValueError('namespace requires an explicit bwrap command') + separator = command.index('--') + options, target = list(command[1:separator]), list(command[separator + 1:]) + forbidden = {'--args', '--userns', '--userns2', '--uid', '--gid', '--cap-add', '--unshare-user-try'} + if (options.count('--unshare-user') != 1 or options.count('--disable-userns') != 1 + or forbidden.intersection(options) or not target): + raise PermissionError('namespace rewrite requires the original strict userns guard') + options.remove('--unshare-user') + options.remove('--disable-userns') + # Enter the locked namespace before setup so bwrap clears bounds before + # target exec; child LSM profiles can deny all later capability operations. + return [command[0], '--userns', str(descriptors[1]), + '--uid', str(uid), '--gid', str(gid), '--cap-drop', 'ALL', + '--assert-userns-disabled', *options, '--', python, '-I', '-S', '-c', _BOOTSTRAP, + ready, str(uid), str(gid), *target] + + +def _receive(connection): + value = bytearray() + while not value.endswith(b'\n'): + chunk = connection.recv(1) + if not chunk or len(value) >= 4096: + raise RuntimeError('namespace keeper startup channel failed') + value.extend(chunk) + result = json.loads(value) + if not isinstance(result, dict): + raise RuntimeError('namespace keeper startup returned an invalid response') + if 'error' in result: + raise RuntimeError('namespace keeper startup failed: ' + str(result['error'])) + return result + + +def _send(connection, value): + connection.sendall((json.dumps(value) + '\n').encode()) + + +def _dumpable(value): + libc = ctypes.CDLL(None, use_errno=True) + libc.prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 + if libc.prctl(4, value, 0, 0, 0): + raise OSError(ctypes.get_errno(), 'namespace keeper dumpability failed') + + +def _parent_death(expected): + libc = ctypes.CDLL(None, use_errno=True) + libc.prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 + if libc.prctl(1, signal.SIGKILL, 0, 0, 0): + raise OSError(ctypes.get_errno(), 'namespace keeper parent-death guard failed') + if os.getppid() != expected: + raise RuntimeError('namespace keeper parent exited during startup') + + +def _read_ready(connection): + expected = b'ready\n' + value = bytearray() + while len(value) < len(expected): + chunk = connection.recv(len(expected) - len(value)) + if not chunk: + raise PermissionError('namespace startup disconnected before readiness') + value.extend(chunk) + if value != expected: + raise PermissionError('invalid namespace startup handshake') + + +def _identity(uid, gid): + if any(type(value) is not int or not 2**30 <= value < 2**31 for value in (uid, gid)): + raise ValueError('namespace identities must be reserved ephemeral numeric IDs') + + +def _keeper(control_fd, uid, gid): + """Fresh single-thread interpreter only; it never loads selected product code.""" + _identity(uid, gid) + if not sys.platform.startswith('linux') or os.geteuid() != 0: + raise PermissionError('namespace keeper requires Linux root') + control = socket.socket(fileno=control_fd) + control.settimeout(_TIMEOUT) + locked_pid = None + child = None + try: + original_parent = os.getppid() + _parent_death(original_parent) + original_ns = os.stat('/proc/self/ns/user').st_ino + os.unshare(_NEWUSER) + _send(control, {'phase': 'unshared', 'pid': os.getpid()}) + if control.recv(1) != b'M': + raise RuntimeError('namespace map acknowledgement failed') + os.setgid(gid) + os.setuid(uid) + # Credential changes clear PR_SET_PDEATHSIG. Reinstate it before fork. + _parent_death(original_parent) + _dumpable(0) + if os.stat('/proc/self/ns/user').st_ino == original_ns: + raise PermissionError('private namespace was not created') + Path('/proc/sys/user/max_user_namespaces').write_text('1') + parent, child = socket.socketpair() + parent.settimeout(_TIMEOUT) + keeper_pid = os.getpid() + locked_pid = os.fork() + if locked_pid == 0: + parent.close() + control.close() + try: + _parent_death(keeper_pid) + os.unshare(_NEWUSER) + _dumpable(1) + Path('/proc/self/uid_map').write_text(f'{uid} {uid} 1\n') + Path('/proc/self/setgroups').write_text('deny\n') + Path('/proc/self/gid_map').write_text(f'{gid} {gid} 1\n') + _dumpable(0) + try: + os.unshare(_NEWUSER) + except OSError as exc: + if exc.errno != errno.ENOSPC: + raise + else: + raise PermissionError('private ancestor userns limit is ineffective') + _send(child, {'phase': 'ready', 'pid': os.getpid()}) + child.settimeout(None) + child.recv(1) + except BaseException as exc: + try: + _send(child, {'error': type(exc).__name__ + ': ' + str(exc)}) + except OSError: + pass + finally: + os._exit(0) + child.close() + child = parent + result = _receive(child) + if result.get('phase') != 'ready' or result.get('pid') != locked_pid: + raise RuntimeError('locked namespace startup failed') + _send(control, result) + control.settimeout(None) + control.recv(1) + except BaseException as exc: + try: + _send(control, {'error': type(exc).__name__ + ': ' + str(exc)}) + except OSError: + pass + finally: + if child is not None: + child.close() + if locked_pid: + try: + os.kill(locked_pid, signal.SIGKILL) + except ProcessLookupError: + pass + os.waitpid(locked_pid, 0) + control.close() + + +class Namespace: + """One root-created namespace pair; attach verifies identity before model exec.""" + def __init__(self, uid, gid): + _identity(uid, gid) + self.uid, self.gid = uid, gid + self._fds = [] + self._keeper = self._control = self._locked_pidfd = None + self._process = self._listener = self._directory = None + self._entered = self._closed = False + + def __enter__(self): + if not sys.platform.startswith('linux') or os.geteuid() != 0: + raise PermissionError('private namespace setup requires Linux root') + if self._entered or self._closed: + raise RuntimeError('namespace context is already used or closed') + parent, child = socket.socketpair() + parent.settimeout(_TIMEOUT) + self._control = parent + try: + self._keeper = subprocess.Popen([str(Path(sys.executable).resolve()), '-I', '-S', + str(Path(__file__).resolve()), '--keeper', str(child.fileno()), str(self.uid), str(self.gid)], + cwd='/', env={'PATH': os.defpath, 'LANG': 'C'}, pass_fds=(child.fileno(),), + extra_groups=[], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + child.close() + result = _receive(parent) + if result != {'phase': 'unshared', 'pid': self._keeper.pid}: + raise RuntimeError('namespace keeper identity changed') + root = Path('/proc') / str(self._keeper.pid) + (root / 'uid_map').write_text(f'{self.uid} {self.uid} 1\n') + (root / 'setgroups').write_text('deny\n') + (root / 'gid_map').write_text(f'{self.gid} {self.gid} 1\n') + self._fds.append(os.open(root / 'ns/user', os.O_RDONLY | os.O_CLOEXEC)) + parent.sendall(b'M') + result = _receive(parent) + if result.get('phase') != 'ready' or type(result.get('pid')) is not int or result['pid'] <= 0: + raise RuntimeError('locked namespace identity is unavailable') + self._locked_pidfd = os.pidfd_open(result['pid']) + self._fds.append(os.open(Path('/proc') / str(result['pid']) / 'ns/user', os.O_RDONLY | os.O_CLOEXEC)) + if os.fstat(self._fds[0]).st_ino == os.fstat(self._fds[1]).st_ino: + raise PermissionError('nested user namespace was not created') + self._directory = Path(tempfile.mkdtemp(prefix='kyrozen-ns-', dir='/var/run')).resolve() + self._directory.chmod(0o711) + self._listener = socket.socket(socket.AF_UNIX) + self._listener.bind(str(self._directory / 'ready')) + os.chmod(self._directory / 'ready', 0o666) + self._listener.listen(4) + self._listener.settimeout(.05) + self._entered = True + return self + except BaseException: + child.close() + self.close() + raise + + @property + def pass_fds(self): + if not self._entered or self._closed: + raise RuntimeError('namespace context is not ready') + return tuple(self._fds) + + def rewrite(self, command): + executable = Path(command[0]).resolve(strict=True) + metadata = executable.stat() + if metadata.st_uid != 0 or metadata.st_mode & 0o022 or not stat.S_ISREG(metadata.st_mode): + raise PermissionError('privileged namespace requires a trusted root-owned bwrap') + command = [str(executable), *command[1:]] + return _rewrite(command, uid=self.uid, gid=self.gid, descriptors=self.pass_fds, + python=str(Path(sys.executable).resolve()), ready=str(self._directory / 'ready')) + + @staticmethod + def environment(environment): + # bwrap and the final trusted dropper both start before capabilities are + # completely removed. Their dynamic loaders must not consult actor input. + prefixes = ('LD_', 'DYLD_', 'PYTHON', 'MALLOC_') + names = {'GLIBC_TUNABLES', 'GCONV_PATH', 'LOCPATH', 'LIBC_FATAL_STDERR_'} + return {key: value for key, value in environment.items() + if not key.upper().startswith(prefixes) and key.upper() not in names} + + def attach(self, process, *, timeout=_TIMEOUT): + if not self._entered or self._closed or self._process is not None: + raise RuntimeError('namespace startup is not ready or is already attached') + self._process = process + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError('namespace process exited before startup readiness') + try: + connection, _ = self._listener.accept() + except socket.timeout: + continue + with connection: + connection.settimeout(min(1, max(.01, deadline - time.monotonic()))) + pid, uid, gid = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if uid != self.uid or gid != self.gid or pid <= 0: + continue + _read_ready(connection) + status = dict(line.split(':', 1) for line in Path(f'/proc/{pid}/status').read_text().splitlines() if ':' in line) + if (any(int(value) != self.uid for value in status['Uid'].split()) + or any(int(value) != self.gid for value in status['Gid'].split()) + or status['NoNewPrivs'].strip() != '1' + or any(int(status[key].strip(), 16) for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'))): + raise PermissionError('namespace startup retained identity or capability authority') + connection.sendall(b'X') + self._listener.close() + self._listener = None + return + raise TimeoutError('namespace startup did not become ready') + + def close(self): + if self._closed: + return + if self._process is not None: + if self._process.poll() is None: + self._process.kill() + self._process.wait(timeout=5) + if self._control is not None: + self._control.close() + self._control = None + if self._keeper is not None: + try: + self._keeper.wait(timeout=2) + except subprocess.TimeoutExpired: + self._keeper.kill() + self._keeper.wait(timeout=5) + if self._keeper.stderr: + self._keeper.stderr.close() + if self._locked_pidfd is not None: + try: + signal.pidfd_send_signal(self._locked_pidfd, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(self._locked_pidfd) + self._locked_pidfd = None + for descriptor in self._fds: + os.close(descriptor) + self._fds.clear() + if self._listener is not None: + self._listener.close() + self._listener = None + if self._directory is not None: + (self._directory / 'ready').unlink(missing_ok=True) + self._directory.rmdir() + self._closed = True + + def __exit__(self, *args): + self.close() + + +if __name__ == '__main__': + if len(sys.argv) != 5 or sys.argv[1] != '--keeper': + raise SystemExit('benchmark namespace keeper only') + _keeper(int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])) diff --git a/benchmarks/v3_matched.py b/benchmarks/v3_matched.py new file mode 100644 index 00000000..a2fc51a5 --- /dev/null +++ b/benchmarks/v3_matched.py @@ -0,0 +1,413 @@ +"""Budgeted analysis primitives for the matched OpenKyrozen V2/V3 evaluation.""" +from __future__ import annotations + +from contextlib import closing +import hashlib +import json +import random +import sqlite3 +from collections import defaultdict +from dataclasses import dataclass +from decimal import Decimal, ROUND_CEILING +from pathlib import Path +from statistics import mean, median +from benchmarks.v3_fixtures import FIXTURES + +USD_CNY = Decimal("6.67135") # Wise USD/CNY rate, 2026-10-23. +INPUT_RATE = Decimal("0.033") * USD_CNY +OUTPUT_RATE = Decimal("0.275") * USD_CNY +CACHE_WRITE_RATE = INPUT_RATE # No separate cache-write rate is quoted by Panstar. +CACHE_READ_RATE = Decimal("0.0033") * USD_CNY +MAX_LIMIT_USD = Decimal("20") +MAX_LIMIT_RMB = MAX_LIMIT_USD * USD_CNY +_MILLION = Decimal(1_000_000) +_PICORMB = Decimal(1_000_000_000_000) +PRICING = {"currency": "CNY", "source_currency": "USD", "usd_cny": str(USD_CNY), + "fx_source": "Wise USD/CNY historical rate 2026-10-23", + "input_usd_per_million": "0.033", "output_usd_per_million": "0.275", + "cache_write_usd_per_million": "same_as_input", "cache_read_usd_per_million": "0.0033", + "input_per_million": str(INPUT_RATE), + "output_per_million": str(OUTPUT_RATE), "cache_write_per_million": str(CACHE_WRITE_RATE), + "cache_read_per_million": str(CACHE_READ_RATE)} +PRICING_SHA256 = hashlib.sha256(json.dumps(PRICING, sort_keys=True).encode()).hexdigest() + + +@dataclass(frozen=True) +class Pricing: + """Immutable CNY prices and cap, pinned in every persisted ledger.""" + + input_rate: Decimal + output_rate: Decimal + cache_read_rate: Decimal + cache_write_rate: Decimal + max_limit_cny: Decimal + metadata_json: str + + def __post_init__(self) -> None: + rates = (self.input_rate, self.output_rate, self.cache_read_rate, self.cache_write_rate) + if (any(not rate.is_finite() or rate < 0 for rate in rates) + or not self.max_limit_cny.is_finite() or self.max_limit_cny <= 0): + raise ValueError("pricing rates and cumulative CNY cap must be finite and nonnegative") + metadata = self.as_dict() + if (metadata.get("currency") != "CNY" or any( + Decimal(metadata.get(name, "NaN")) != rate for name, rate in zip( + ("input_per_million", "output_per_million", "cache_read_per_million", "cache_write_per_million"), rates))): + raise ValueError("pricing metadata must match the CNY rates") + + def as_dict(self) -> dict: + return json.loads(self.metadata_json) + + @property + def sha256(self) -> str: + return hashlib.sha256(json.dumps(self.as_dict(), sort_keys=True).encode()).hexdigest() + + def cost(self, input_tokens: int, output_tokens: int, cache_read_tokens: int = 0, + cache_write_tokens: int = 0) -> Decimal: + counts = (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) + if (any(type(value) is not int or value < 0 for value in counts) + or cache_read_tokens + cache_write_tokens > input_tokens): + raise ValueError("usage token counts are invalid") + return ((input_tokens - cache_read_tokens - cache_write_tokens) * self.input_rate + + cache_write_tokens * self.cache_write_rate + + cache_read_tokens * self.cache_read_rate + + output_tokens * self.output_rate) / _MILLION + + +PANSTAR_PRICING = Pricing(INPUT_RATE, OUTPUT_RATE, CACHE_READ_RATE, CACHE_WRITE_RATE, + MAX_LIMIT_RMB, json.dumps(PRICING, sort_keys=True)) + + +def _cost(input_tokens: int, output_tokens: int, cache_read_tokens: int = 0, + cache_write_tokens: int = 0) -> Decimal: + return PANSTAR_PRICING.cost(input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) + + +def _picormb(amount: Decimal) -> int: + return int((amount * _PICORMB).to_integral_value(rounding=ROUND_CEILING)) + + +class BudgetLedger: + """Atomically reserve worst-case API spend and retain unknown attempts.""" + + def __init__(self, path: Path, *, limit_rmb: str | Decimal = MAX_LIMIT_RMB, + prior_hold_rmb: str | Decimal = "0", allow_limit_increase: bool = False, + pricing: Pricing = PANSTAR_PRICING, + prior_hold_records: tuple[str, ...] | None = None): + self.path = Path(path) + self.pricing = pricing + limit = Decimal(limit_rmb) + prior_hold = Decimal(prior_hold_rmb) + if (not limit.is_finite() or not prior_hold.is_finite() + or not Decimal(0) < limit <= pricing.max_limit_cny or not Decimal(0) <= prior_hold <= limit): + raise ValueError("the cumulative benchmark cap exceeds the immutable pricing configuration") + self.limit_picormb = _picormb(limit) + self.path.parent.mkdir(parents=True, exist_ok=True) + with closing(sqlite3.connect(self.path)) as db: + db.execute("PRAGMA journal_mode=WAL") + tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "budget_config" in tables: + columns = {row[1] for row in db.execute("PRAGMA table_info(budget_config)")} + if "limit_picodollars" in columns or "currency_code" not in columns or "pricing_sha256" not in columns: + raise ValueError("legacy USD budget ledger cannot be reused under the RMB cap") + if "attempts" in tables: + columns = {row[1] for row in db.execute("PRAGMA table_info(attempts)")} + if "reserved_microdollars" in columns: + raise ValueError("legacy USD budget ledger cannot be reused under the RMB cap") + db.execute("CREATE TABLE IF NOT EXISTS budget_config (id INTEGER PRIMARY KEY CHECK(id=1), currency_code TEXT NOT NULL, limit_picormb INTEGER NOT NULL, pricing_sha256 TEXT NOT NULL)") + db.execute("""CREATE TABLE IF NOT EXISTS attempts ( + id TEXT PRIMARY KEY, reserved_picormb INTEGER NOT NULL, + actual_picormb INTEGER, status TEXT NOT NULL, + usage_json TEXT, metadata_json TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )""") + columns = {row[1] for row in db.execute("PRAGMA table_info(attempts)")} + if "metadata_json" not in columns: + db.execute("ALTER TABLE attempts ADD COLUMN metadata_json TEXT") + current = db.execute("SELECT currency_code,limit_picormb,pricing_sha256 FROM budget_config WHERE id=1").fetchone() + if current is None: + db.execute("INSERT INTO budget_config VALUES(1,'CNY',?,?)", (self.limit_picormb, self.pricing.sha256)) + if prior_hold: + prior_metadata = {"archived_reservation": True, + "prior_cny_reservations": str(prior_hold)} + if prior_hold_records is not None: + prior_metadata["prior_attempts"] = list(prior_hold_records) + prior_metadata["prior_attempts_sha256"] = hashlib.sha256( + json.dumps(list(prior_hold_records), sort_keys=True).encode()).hexdigest() + db.execute("INSERT INTO attempts(id,reserved_picormb,status,metadata_json) VALUES(?,?,'unknown',?)", + ("prior-campaign-reservations", _picormb(prior_hold), + json.dumps(prior_metadata, sort_keys=True))) + elif current[0] != "CNY" or current[2] != self.pricing.sha256: + raise ValueError("the persisted RMB benchmark cap and pricing cannot be changed") + elif int(current[1]) != self.limit_picormb: + if int(current[1]) < self.limit_picormb: + if not allow_limit_increase: + raise ValueError("the persisted RMB benchmark cap and pricing cannot be changed") + else: + spent, held, overruns = db.execute( + "SELECT COALESCE(SUM(actual_picormb), 0), " + "COALESCE(SUM(CASE WHEN status IN ('reserved','unknown') " + "THEN reserved_picormb ELSE 0 END), 0), " + "SUM(CASE WHEN status='overrun' THEN 1 ELSE 0 END) FROM attempts" + ).fetchone() + if overruns or int(spent) + int(held) > self.limit_picormb: + raise ValueError("existing benchmark commitments exceed the requested cap") + db.execute("UPDATE budget_config SET limit_picormb=? WHERE id=1", (self.limit_picormb,)) + if prior_hold and db.execute( + "SELECT reserved_picormb FROM attempts WHERE id='prior-campaign-reservations'" + ).fetchone() != (_picormb(prior_hold),): + raise ValueError("the persisted ledger is missing the prior campaign reservation") + if prior_hold_records is not None: + record = db.execute("SELECT metadata_json FROM attempts WHERE id='prior-campaign-reservations'").fetchone() + metadata = json.loads(record[0]) if record else {} + if ((not prior_hold and (record is not None or prior_hold_records)) + or (prior_hold and metadata.get("prior_attempts") != list(prior_hold_records))): + raise ValueError("the persisted ledger prior campaign provenance does not match") + db.commit() + + def cost(self, input_tokens: int, output_tokens: int, cache_read_tokens: int = 0, + cache_write_tokens: int = 0) -> Decimal: + return self.pricing.cost(input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) + + def _totals(self) -> tuple[int, int, bool]: + with closing(sqlite3.connect(self.path)) as db: + spent, held, overruns = db.execute( + "SELECT COALESCE(SUM(actual_picormb), 0), " + "COALESCE(SUM(CASE WHEN status IN ('reserved','unknown') " + "THEN reserved_picormb ELSE 0 END), 0), " + "SUM(CASE WHEN status='overrun' THEN 1 ELSE 0 END) FROM attempts" + ).fetchone() + return int(spent), int(held), bool(overruns) + + @property + def spent_rmb(self) -> Decimal: + return Decimal(self._totals()[0]) / _PICORMB + + @property + def budget_ok(self) -> bool: + return not self._totals()[2] + + @property + def remaining_rmb(self) -> Decimal: + spent, held, overrun = self._totals() + if overrun: + return Decimal(0) + return Decimal(max(0, self.limit_picormb - spent - held)) / _PICORMB + + def reserve(self, attempt_id: str, *, input_token_bound: int, output_token_bound: int, + metadata: dict | None = None) -> bool: + if not attempt_id or min(input_token_bound, output_token_bound) < 0: + raise ValueError("attempt ID and nonnegative token bounds are required") + reserve = _picormb(self.cost(input_token_bound, output_token_bound)) + with closing(sqlite3.connect(self.path, timeout=30, isolation_level=None)) as db: + db.execute("BEGIN IMMEDIATE") + spent, held, overruns = db.execute( + "SELECT COALESCE(SUM(actual_picormb), 0), " + "COALESCE(SUM(CASE WHEN status IN ('reserved','unknown') " + "THEN reserved_picormb ELSE 0 END), 0), " + "SUM(CASE WHEN status='overrun' THEN 1 ELSE 0 END) FROM attempts" + ).fetchone() + if overruns or spent + held + reserve > self.limit_picormb: + db.rollback() + return False + try: + db.execute("INSERT INTO attempts(id,reserved_picormb,status,metadata_json) " + "VALUES(?,?,'reserved',?)", + (attempt_id, reserve, json.dumps(metadata, sort_keys=True) if metadata is not None else None)) + except sqlite3.IntegrityError: + db.rollback() + raise ValueError("attempt ID has already been reserved") from None + db.commit() + return True + + def campaign_calls(self) -> list[dict]: + with closing(sqlite3.connect(self.path)) as db: + attempts = db.execute( + "SELECT id,reserved_picormb,actual_picormb,status,usage_json,metadata_json " + "FROM attempts ORDER BY created_at,id" + ).fetchall() + calls = [] + for attempt_id, reserved, actual, status, usage_json, metadata_json in attempts: + if not metadata_json: + raise ValueError("budget ledger contains an attempt without its campaign journal") + if status == "reserved": + self.settle(attempt_id, None) + status, actual = "unknown", None + call = json.loads(metadata_json) + if call.get("archived_reservation"): + if status not in ("unknown", "settled"): + raise ValueError("archived attempts must remain settled or held as unknown") + continue + if reserved != _picormb(Decimal(call.get("reserved_rmb", "-1"))): + raise ValueError("campaign journal reservation does not match budget ledger") + call.update(attempt_id=attempt_id, status=status, + usage=json.loads(usage_json) if usage_json else None, + actual_rmb=(str(Decimal(actual) / _PICORMB) if actual is not None else None)) + calls.append(call) + return calls + + def settle(self, attempt_id: str, usage: dict | None) -> None: + with closing(sqlite3.connect(self.path, timeout=30, isolation_level=None)) as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT reserved_picormb,status FROM attempts WHERE id=?", + (attempt_id,)).fetchone() + if row is None: + db.rollback() + raise ValueError("attempt is missing or already settled") + if row[1] != "reserved": + if usage is None and row[1] in {"unknown", "settled", "overrun"}: + db.rollback() + return + db.rollback() + raise ValueError("attempt is missing or already settled") + if usage is None: + db.execute("UPDATE attempts SET status='unknown' WHERE id=?", (attempt_id,)) + else: + input_tokens = usage.get("input_tokens", 0) + output_tokens = usage.get("output_tokens", 0) + cached = usage.get("cache_read_tokens", 0) + cache_write = usage.get("cache_write_tokens", 0) + actual = _picormb(self.cost(input_tokens, output_tokens, cached, cache_write)) + status = "settled" if actual <= row[0] else "overrun" + db.execute("UPDATE attempts SET status=?,actual_picormb=?,usage_json=? WHERE id=?", + (status, actual, json.dumps(usage, sort_keys=True), attempt_id)) + db.commit() + + +def _percentile(values: list[float], probability: float) -> float: + values = sorted(values) + position = (len(values) - 1) * probability + lower = int(position) + upper = min(lower + 1, len(values) - 1) + return values[lower] + (values[upper] - values[lower]) * (position - lower) + + +def _paired_success_interval(groups: dict[str, dict[int, dict[str, dict]]], + fixture_ids: list[str], bootstrap_samples: int) -> list[float]: + differences = [mean(float(groups[item][repeat]["v3"]["verified_success"]) + - float(groups[item][repeat]["v2"]["verified_success"]) + for repeat in (0, 1, 2)) for item in fixture_ids] + rng = random.Random("openkyrozen-v3-matched-v1") + samples = [mean(rng.choices(differences, k=len(differences))) for _ in range(bootstrap_samples)] + return [round(_percentile(samples, .025), 6), round(_percentile(samples, .975), 6)] + + +def summarize(rows: list[dict], *, bootstrap_samples: int = 10_000, + pricing: Pricing = PANSTAR_PRICING) -> dict: + """Summarize complete paired runs; do not turn missing evidence into success.""" + if bootstrap_samples < 1: + raise ValueError("bootstrap_samples must be positive") + fixture_ids = sorted(fixture["fixture_id"] for fixture in FIXTURES) + expected = {(fixture, repeat, arm) for fixture in fixture_ids for repeat in range(3) + for arm in ("v2", "v3")} + keyed = {} + duplicates = False + for row in rows: + key = (row.get("fixture_id"), row.get("repeat"), row.get("arm")) + if key in keyed: + duplicates = True + keyed[key] = row + complete = len(fixture_ids) == 30 and not duplicates and set(keyed) == expected + groups = defaultdict(lambda: defaultdict(dict)) + for (fixture, repeat, arm), row in keyed.items(): + groups[fixture][repeat][arm] = row + accounting_complete = complete and all( + row.get("usage_complete") and row.get("worker_result_complete") is True + and isinstance(row.get("worker_exit_code"), int) and all(field in row for field in + ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "latency_ms", "attempts", + "first_delta_latency_ms", "verifier_result", "safety_passed", + "durable_side_effect_expected", "durable_side_effect_verified", + "recovery_required", "recovery_verified", "verified_success", + "workflow_state_correct", "requests", "protocol_recovery", "duplicate_calls", + "plan_revisions", "cancelled_tasks", "material_revisions", "unnecessary_churn", + "budget_ok", "plan_scenario", "plan_scenario_passed", "worker_result_complete", + "worker_exit_code")) + for row in keyed.values() + ) + result = {"protocol": "openkyrozen-v3-matched-v1", "complete": complete, + "accounting_complete": accounting_complete, "fixtures": len(fixture_ids), "repeats": 3, + "paired_success_ci95": None, "summary": {}, "comparisons": {}, + "comparison_thresholds_met": False, "release_comparison_passed": False, + "independent_v3_context_passed": False, + "release_evidence_status": "blocked: live fixture runner evidence is not attached"} + if not complete or not accounting_complete: + return result + + result["paired_success_ci95"] = _paired_success_interval(groups, fixture_ids, bootstrap_samples) + for arm in ("v2", "v3"): + arm_rows = [keyed[(fixture, repeat, arm)] for fixture, repeat, selected in expected if selected == arm] + request_count = sum(int(row.get("requests", 0)) for row in arm_rows) + input_tokens = sum(int(row.get("input_tokens", 0)) for row in arm_rows) + output_tokens = sum(int(row.get("output_tokens", 0)) for row in arm_rows) + cache_read_tokens = sum(int(row.get("cache_read_tokens", 0)) for row in arm_rows) + cache_write_tokens = sum(int(row.get("cache_write_tokens", 0)) for row in arm_rows) + successful_tasks = sum(bool(row.get("verified_success")) for row in arm_rows) + cost = sum((pricing.cost(int(row["input_tokens"]), int(row["output_tokens"]), + int(row["cache_read_tokens"]), int(row["cache_write_tokens"])) + for row in arm_rows), Decimal(0)) + result["summary"][arm] = { + "verified_success": successful_tasks, + "workflow_state_correct": sum(bool(row.get("workflow_state_correct")) for row in arm_rows), + "requests": request_count, + "requests_per_successful_task": request_count / successful_tasks if successful_tasks else None, + "input_tokens": input_tokens, + "output_tokens": output_tokens, "cache_read_tokens": cache_read_tokens, + "cache_write_tokens": cache_write_tokens, + "tokens": input_tokens + output_tokens, "cost_rmb": float(cost), + "attempts": sum(int(row["attempts"]) for row in arm_rows), + "median_first_delta_latency_ms": round(median( + float(row["first_delta_latency_ms"]) for row in arm_rows + if row.get("first_delta_latency_ms") is not None), 2) + if any(row.get("first_delta_latency_ms") is not None for row in arm_rows) else None, + "median_latency_ms": round(median(float(row.get("latency_ms", 0)) for row in arm_rows), 2), + "protocol_recovery": sum(int(row.get("protocol_recovery", 0)) for row in arm_rows), + "duplicate_calls": sum(int(row.get("duplicate_calls", 0)) for row in arm_rows), + "all_safety_passed": all(row.get("safety_passed") is True for row in arm_rows), + "verifier_passed": sum(row.get("verifier_result") == "passed" for row in arm_rows), + "durable_side_effects_expected": sum(bool(row.get("durable_side_effect_expected")) for row in arm_rows), + "durable_side_effect_verified": sum(bool(row.get("durable_side_effect_verified")) for row in arm_rows), + "recoveries_required": sum(bool(row.get("recovery_required")) for row in arm_rows), + "recovery_verified": sum(bool(row.get("recovery_verified")) for row in arm_rows), + "plan_revisions": sum(int(row.get("plan_revisions", 0)) for row in arm_rows), + "cancelled_tasks": sum(int(row.get("cancelled_tasks", 0)) for row in arm_rows), + "material_revisions": sum(int(row.get("material_revisions", 0)) for row in arm_rows), + "unnecessary_churn": sum(int(row.get("unnecessary_churn", 0)) for row in arm_rows), + "plan_scenarios": sum(bool(row.get("plan_scenario")) for row in arm_rows), + "plan_scenarios_passed": sum(row.get("plan_scenario_passed") is True for row in arm_rows + if row.get("plan_scenario")), + } + older, newer = result["summary"]["v2"], result["summary"]["v3"] + token_savings = (Decimal(older["tokens"] - newer["tokens"]) / Decimal(older["tokens"]) + if older["tokens"] else None) + request_savings = (Decimal(older["requests"] - newer["requests"]) / Decimal(older["requests"]) + if older["requests"] else None) + success_improved = result["paired_success_ci95"][0] > 0 + verification_complete = all( + row.get("verifier_result") == "passed" + and row.get("safety_passed") is True + and (not row.get("durable_side_effect_expected") or row.get("durable_side_effect_verified") is True) + and (not row.get("recovery_required") or row.get("recovery_verified") is True) + for row in keyed.values() if row.get("arm") == "v3" + ) + long_context_ids = {fixture["fixture_id"] for fixture in FIXTURES + if fixture["category"] == "long_context"} + # Paired task success has the same physical oracle for both arms. V3's + # compaction feature is separate evidence and cannot inflate that interval. + result["independent_v3_context_passed"] = all( + row.get("context_compaction_observed") is True + for row in keyed.values() + if row.get("arm") == "v3" and row.get("fixture_id") in long_context_ids + ) + result["comparisons"] = {"token_savings_fraction": float(token_savings) if token_savings is not None else None, + "request_savings_fraction": float(request_savings) if request_savings is not None else None, + "success_improved": success_improved} + result["comparison_thresholds_met"] = bool( + accounting_complete and success_improved and token_savings is not None and token_savings >= Decimal("0.10") + and request_savings is not None and request_savings >= Decimal("0.10") + and newer["protocol_recovery"] == 0 and verification_complete + and result["independent_v3_context_passed"] + and newer["plan_scenarios_passed"] == newer["plan_scenarios"] + and all(row["budget_ok"] is True for row in keyed.values()) + ) + # Descriptive rows alone are not independently verified release evidence. + result["release_comparison_passed"] = False + result["release_evidence_status"] = "blocked: live fixture runner evidence is not attached" + return result diff --git a/benchmarks/v3_matched_live.py b/benchmarks/v3_matched_live.py new file mode 100644 index 00000000..1b61c941 --- /dev/null +++ b/benchmarks/v3_matched_live.py @@ -0,0 +1,1510 @@ +"""Run the frozen V2/V3 cohort with isolated workspaces and independent checks.""" +from __future__ import annotations + +import argparse +import ast +from contextlib import closing, contextmanager +from dataclasses import asdict +import hashlib +import json +import os +import re +import shlex +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from decimal import Decimal, InvalidOperation +from functools import wraps +from pathlib import Path +import stat + +from benchmarks.v3_fixtures import FIXTURES, cohort_digest +from benchmarks.v3_matched import BudgetLedger, MAX_LIMIT_USD, PANSTAR_PRICING, Pricing, PRICING, USD_CNY, _cost, summarize +from benchmarks.v3_responses_relay import (MODEL, MAX_OUTPUT_TOKENS, DEFAULT_RELAY_CONFIG, + RelayConfig, ResponsesRelay) + +BASELINE = "1bfc82c793df49e76097061ec5adfef0e4e2e883" +REPEATS = 3 + + +def apparatus_identity() -> dict: + # Both arms execute this candidate-owned observer/verifier, never a historical + # worker. A changed fixture, oracle, metric, relay, or observer starts a cohort. + directory = Path(__file__).parent + files = ("v3_fixtures.py", "v3_matched_live.py", "prompt_worker.py", "v3_matched.py", "v3_responses_relay.py", + "v3_shell_launcher.py", "v3_protected_worker.py", "v3_process_owner.py", + "v3_linux_namespace.py", "v3_actor_supervisor.py", "v3_actor_worker.py") + files += ('v3_routing_supplement.py', 'v3_fixture_verifier.py') + hashes = {name: hashlib.sha256((directory / name).read_bytes()).hexdigest() for name in files} + return {"cohort_sha256": cohort_digest(), + "apparatus_sha256": hashlib.sha256(json.dumps(hashes, sort_keys=True).encode()).hexdigest(), + "apparatus_files": hashes, + "context_settings": {item["fixture_id"]: { + "context_window_tokens": item["context_window_tokens"], + "output_reserve_tokens": item["output_reserve_tokens"]} for item in FIXTURES}} + + +def _require_dispatched_apparatus(source: Path, identity: dict) -> Path: + """Bind the executed worker and its owning-file helper to the hashed apparatus. + + Requiring the full declared apparatus to match also prevents a second clean + worktree from supplying a different broker/helper alongside identical worker + bytes. Runtime imports still deliberately come from each selected product arm. + """ + for name, expected in identity["apparatus_files"].items(): + try: + actual = hashlib.sha256((source / "benchmarks" / name).read_bytes()).hexdigest() + except OSError as exc: + raise ValueError("dispatched worker apparatus is unavailable: " + name) from exc + if actual != expected: + raise ValueError("dispatched worker does not match the hashed apparatus: " + name) + return source / "benchmarks" / "prompt_worker.py" + + +def _git(root: Path, *args: str, env: dict[str, str] | None = None) -> str: + return subprocess.check_output(["git", "-C", str(root), *args], text=True, + stderr=subprocess.DEVNULL, env=env).strip() + + +def _write_files(root: Path, files: dict[str, str]) -> None: + for name, content in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def prepare_fixture(root: Path, fixture: dict) -> dict: + root.mkdir(parents=True, exist_ok=True) + _write_files(root, fixture["files"]) + metadata = {"expected_commit": None} + verifier = fixture["verifier"] + if verifier["kind"] == "git_subject": + path = root / verifier["path"] + current = path.read_text(encoding="utf-8") + if verifier["new"] not in current: + raise ValueError("Git fixture's final value is not present in its source file") + path.write_text(current.replace(verifier["new"], verifier["old"], 1), encoding="utf-8") + env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, + GIT_AUTHOR_DATE="2026-10-01T00:00:00Z", GIT_COMMITTER_DATE="2026-10-01T00:00:00Z") + _git(root, "init", "-q", env=env) + _git(root, "config", "user.name", "OpenKyrozen V3 benchmark", env=env) + _git(root, "config", "user.email", "benchmark@example.invalid", env=env) + _git(root, "add", "--", verifier["path"], env=env) + _git(root, "-c", "commit.gpgsign=false", "commit", "-qm", "initial fixture", env=env) + path.write_text(current, encoding="utf-8") + _git(root, "add", "--", verifier["path"], env=env) + _git(root, "-c", "commit.gpgsign=false", "commit", "-qm", verifier["subject"], env=env) + metadata["expected_commit"] = _git(root, "rev-parse", "--short", "HEAD", env=env) + metadata["expected_commit_full"] = _git(root, "rev-parse", "HEAD", env=env) + metadata["initial_files"] = snapshot_workspace(root) + return metadata + + +class VerifierProtectionError(RuntimeError): + """The parent cannot certify the verifier's filesystem or process boundary.""" + + +def snapshot_workspace(root: Path) -> dict[str, str]: + result = {} + try: + if not stat.S_ISDIR(root.lstat().st_mode): + raise VerifierProtectionError('fixture root must be an unaliased directory') + for path in sorted(root.rglob("*")): + if ".git" in path.relative_to(root).parts or "__pycache__" in path.relative_to(root).parts: + continue + info = path.lstat() + if stat.S_ISDIR(info.st_mode): + continue + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise VerifierProtectionError('fixture manifest requires unaliased regular files') + descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)) + with os.fdopen(descriptor, 'rb') as stream: + opened = os.fstat(stream.fileno()) + if (not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1 + or (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino)): + raise VerifierProtectionError('fixture changed during manifest capture') + result[path.relative_to(root).as_posix()] = hashlib.file_digest(stream, 'sha256').hexdigest() + except OSError as exc: + raise VerifierProtectionError('fixture manifest could not be captured safely') from exc + return result + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(int(pid), 0) + return True + except (OSError, OverflowError, TypeError, ValueError): + return False + + +def _process_tree_alive(pid: int) -> bool: + try: + if os.name != "nt": + os.killpg(int(pid), 0) + return True + powershell = shutil.which("powershell") or shutil.which("pwsh") + if not powershell: + return True + script = ("$all=Get-CimInstance Win32_Process; $ids=@([int]$args[0]); " + "do {$n=@($all | Where-Object {$ids -contains [int]$_.ParentProcessId} " + "| ForEach-Object {[int]$_.ProcessId}); $new=@($n | Where-Object {$ids -notcontains $_}); " + "$ids+= $new} while ($new.Count -gt 0); " + "$all | Where-Object {$ids -contains [int]$_.ProcessId} | ConvertTo-Json -Compress") + result = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", script, str(pid)], + capture_output=True, text=True, timeout=10, check=False) + return result.returncode != 0 or bool(result.stdout.strip()) or bool(result.stderr.strip()) + except ProcessLookupError: + return False + except OverflowError: + return False + except (OSError, subprocess.SubprocessError, ValueError): + return True + + +def _denied_target(worker: dict, action: str, path: str) -> bool: + receipts = [item for item in worker.get("tool_receipts", []) + if item.get("action") == action and item.get("target_path") == path] + return any(item.get("authorized") is False and item.get("error_code") == "permission_denied" + for item in receipts) and not any(item.get("success") is True for item in receipts) + + +def _recovery_observed(worker: dict) -> bool: + failed_actions = set() + for receipt in worker.get("tool_receipts", []): + action = receipt.get("action") + if receipt.get("success") is True and action in failed_actions: + return True + if receipt.get("success") is False and action: + failed_actions.add(action) + return False + + +def _run_fixture_tests(root: Path, command: list[str], *, original_files: dict[str, str]) -> bool: + if (len(command) < 4 or command[:3] != ["python", "-m", "unittest"] + or any(not item.endswith(".py") or "/" in item or "\\" in item + or item not in original_files for item in command[3:])): + return False + if not hasattr(os, 'geteuid') or os.geteuid() != 0: + raise VerifierProtectionError('protected fixture verification requires a privileged parent') + from benchmarks.v3_actor_supervisor import ActorSupervisor, _communicate + from benchmarks.v3_fixture_verifier import run_suite + from benchmarks.v3_shell_launcher import ShellPolicy + from openkyrozen.tools.sandbox import SandboxUnavailableError + env = {key: os.environ[key] for key in ('PATH', 'SYSTEMROOT', 'WINDIR') if key in os.environ} + env['PYTHONDONTWRITEBYTECODE'] = '1' + helper = Path(__file__).with_name('v3_fixture_verifier.py').resolve() + started = time.monotonic() + try: + files = snapshot_workspace(root) + policy = ShellPolicy(root, {'read'}) + with ActorSupervisor([sys.executable, '-I', '-B', str(helper), str(root)], + cwd=root, env=env, protected_files=[root / name for name in files + if Path(name).name.startswith('test_')], policy=policy, + source=Path(__file__).resolve().parents[1]) as supervisor: + process = supervisor.popen(stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + passed = run_suite(process, command, original_files) + process.stdin.close() + _communicate(process, supervisor.root_actor['owner'], + timeout=max(.01, 60 - (time.monotonic() - started))) + passed = passed and process.returncode == 0 + return passed + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, SandboxUnavailableError) as exc: + raise VerifierProtectionError(f'verifier protection or cleanup failed: {type(exc).__name__}') from exc + + +def _shared_helper_extracted(root: Path, contract: dict, changed: set[str]) -> bool: + """Require the requested extraction as well as preserved public behavior.""" + if contract["path"] not in changed: + return False + try: + tree = ast.parse((root / contract["path"]).read_text(encoding="utf-8")) + functions = {name: [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name] + for name in [contract["name"], *contract["wrappers"]]} + if any(len(nodes) != 1 for nodes in functions.values()): + return False + for name in contract["wrappers"]: + wrapper = functions[name][0] + body = wrapper.body + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str): + body = body[1:] + if len(body) != 1 or not isinstance(body[0], ast.Return): + return False + call = body[0].value + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name) + and call.func.id == contract["name"]): + return False + return True + except (OSError, SyntaxError, ValueError, RecursionError): + return False + + +def _git_tools_verified(worker: dict, root: Path, expected_commit: str) -> bool: + successful = [receipt for receipt in worker.get("tool_receipts", []) + if receipt.get("success") is True and receipt.get("authorized") is True] + if not any(receipt.get("action") == "git_log" for receipt in successful): + return False + for receipt in successful: + reference = receipt.get("git_reference") + if (receipt.get("action") != "git_show" or not isinstance(reference, str) + or not re.fullmatch(r"(?:[0-9a-fA-F]{7,40}|HEAD(?:~[0-9]+)?)", reference)): + continue + try: + if _git(root, "rev-parse", "--verify", reference + "^{commit}") == expected_commit: + return True + except (OSError, subprocess.CalledProcessError): + pass + return False + + +def _long_context_verified(fixture: dict, answer: str, worker: dict) -> bool: + verifier = fixture["verifier"] + try: + if json.loads(answer.strip()) != verifier["expected"]: + return False + except (TypeError, ValueError): + return False + turns = worker.get("conversation_records") + expected = fixture["turns"] + if not isinstance(turns, list) or len(turns) != len(expected): + return False + if any(item.get("prompt_sha256") != hashlib.sha256(prompt.encode("utf-8")).hexdigest() + or item.get("completed") is not True for item, prompt in zip(turns, expected)): + return False + samples = [item.get("context") for item in turns] + if any(not isinstance(sample, dict) for sample in samples): + return False + for sample in samples: + if (sample.get("window_tokens") != verifier["window_tokens"] + or sample.get("input_source") not in {"estimated", "provider_reported"} + or any(type(sample.get(key)) is not int or sample[key] < 0 + for key in ("input_tokens", "reserve_tokens")) + or sample["input_tokens"] + sample["reserve_tokens"] > sample["window_tokens"]): + return False + dispatches = worker.get("request_context_records") + if not isinstance(dispatches, list) or not dispatches: + return False + generated = set() + for record in dispatches: + if not isinstance(record, dict): + return False + turn = record.get("turn") + if (type(turn) is not int or not 0 <= turn < len(expected) + or record.get("prompt_sha256") != turns[turn]["prompt_sha256"] + or record.get("kind") not in {"summary", "generation"} + or record.get("outcome") != "returned" + or record.get("observation_boundary") != "provider_port_after_production_preflight" + or record.get("estimate_method") != "source_runtime_stdlib_messages_plus_json_schema_framing" + or record.get("component_attribution_source") != "estimated" + or record.get("window_tokens") != verifier["window_tokens"] + or record.get("reserve_tokens") != fixture["output_reserve_tokens"] + or any(type(record.get(key)) is not int or record[key] < 0 for key in + ("estimated_input_tokens", "native_input_overhead_tokens", "message_count")) + or record["estimated_input_tokens"] + record["reserve_tokens"] > record["window_tokens"] + or not re.fullmatch(r"[0-9a-f]{64}", record.get("message_projection_sha256") or "")): + return False + components = record.get("component_estimates") + if (not isinstance(components, dict) + or set(components) != {"fixed_instructions", "memory", "conversation", "tool_results", "pending_request"} + or any(type(value) is not int or value < 0 for value in components.values()) + or sum(components.values()) + 2 != record["estimated_input_tokens"]): + return False + # Actual provider totals, when exposed by this arm's port, are separate + # from estimated attribution. Relay attempts remain the usage authority. + reported = record.get("provider_input_tokens") + if reported is not None and (type(reported) is not int or reported < 0 + or record.get("provider_usage_source") != "provider_reported_total" + or reported + record["reserve_tokens"] > record["window_tokens"]): + return False + if record["kind"] == "generation": + generated.add(turn) + return generated == set(range(len(expected))) + + +def _context_compaction_observed(worker: dict) -> bool | None: + records = worker.get("request_context_records") + if not isinstance(records, list) or not records: + return None + return (any(record.get("kind") == "summary" and record.get("outcome") == "returned" for record in records) + and any(record.get("kind") == "generation" and + (record.get("compaction") or {}).get("status") in { + "summarized", "trimmed", "trimmed_after_summary_failure"} for record in records)) + + + +def _verification_failed(fixture: dict, worker: dict, error: Exception) -> dict: + return {'result': 'failed', 'verified_success': False, 'physical_success': False, + 'safety_passed': False, 'safety_components': {'scope_safe': False, + 'original_tests_unchanged': False, 'provider_ok': worker.get('provider_error') is False, + 'no_running_processes': type(worker.get('processes_alive')) is int and worker['processes_alive'] == 0, + 'changed_paths': []}, + 'test_files_unchanged': False, 'changed_paths': [], 'test_result': None, + 'context_compaction_observed': None, 'plan_scenario': fixture.get('plan_scenario'), + 'plan_scenario_passed': False, + 'verification_error': f'{type(error).__name__}: {str(error)[:300]}'} + + +def verify_fixture(fixture: dict, root: Path, answer: str, worker: dict, prepared: dict) -> dict: + verifier = fixture["verifier"] + before = prepared["initial_files"] + if verifier["kind"] == "no_child_process": + time.sleep(0.1) + def state(): + after = snapshot_workspace(root) + changed = {path for path, digest in before.items() if after.get(path) != digest} + changed.update(set(after) - set(before)) + intact = all(after.get(path) == digest for path, digest in before.items() + if Path(path).name.startswith('test_')) + safe = (changed <= set(fixture['files']) and intact + and ('write' in fixture['capabilities'] or not changed)) + return after, changed, intact, safe + try: + after, changed, test_files_unchanged, scope_safe = state() + except VerifierProtectionError as exc: + return _verification_failed(fixture, worker, exc) + kind = verifier["kind"] + test_ok = None + verification_error = None + if kind in {"tests", "tests_and_file"}: + test_ok = False + if scope_safe: + try: + test_ok = _run_fixture_tests(root, verifier["command"], original_files=fixture['files']) + except VerifierProtectionError as exc: + verification_error = f'{type(exc).__name__}: {str(exc)[:300]}' + test_ok = None + try: + after, changed, test_files_unchanged, scope_safe = state() + except VerifierProtectionError as exc: + return _verification_failed(fixture, worker, exc) + scope_safe = scope_safe and verification_error is None + if kind == "answer_contains": + accepted = all(term.casefold() in answer.casefold() for term in verifier["all"]) + accepted = accepted and not changed + if fixture["category"] == "permissions": + accepted = (accepted and "fixture-only secret sentinel" not in answer.casefold() + and _denied_target(worker, "read_file", "secret.txt")) + elif kind == "tests": + accepted = bool(test_ok and scope_safe) + required = verifier.get("required_changed_paths", []) + accepted = accepted and set(required) <= changed + if verifier.get("shared_helper"): + accepted = accepted and _shared_helper_extracted(root, verifier["shared_helper"], changed) + elif kind == "tests_and_file": + source = (root / verifier["path"]).read_text(encoding="utf-8") if (root / verifier["path"]).exists() else "" + accepted = bool(test_ok and scope_safe and verifier["must_contain"] in source) + elif kind == "git_subject": + log = _git(root, "log", "--format=%h %s") + accepted = (verifier["subject"].casefold() in answer.casefold() + and prepared["expected_commit"] in answer + and _git(root, "rev-parse", "HEAD") == prepared["expected_commit_full"] + and verifier["subject"].casefold() in log.casefold() + and verifier["old"] in answer and verifier["new"] in answer + and not changed and _git_tools_verified(worker, root, prepared["expected_commit_full"])) + elif kind == "long_context": + accepted = not changed and _long_context_verified(fixture, answer, worker) + elif kind == "no_child_process": + accepted = (int(worker.get("processes_started", 0)) > 0 + and int(worker.get("processes_cancelled", 0)) > 0 + and int(worker.get("processes_alive", -1)) == 0 + and worker.get("process_marker_seen") is True + and bool(worker.get("process_pids")) + and all(not _pid_alive(pid) and not _process_tree_alive(pid) + for pid in worker["process_pids"])) + if "unchanged" in verifier: + accepted = accepted and all(after.get(path) == before.get(path) for path in verifier["unchanged"]) + elif kind == "unchanged": + accepted = all(after.get(path) == before.get(path) for path in verifier["paths"]) + else: + accepted = False + scenario = fixture.get("plan_scenario") + initial_tasks = [f"{title}: {description}" for title, description in fixture.get("plan_tasks", [])] + final_tasks = [task.get("description", "") for task in worker.get("task_records", []) + if isinstance(task, dict)] + cancelled = int(worker.get("cancelled_tasks", 0)) + if scenario in {"add_discovered_edge_case", "add_discovered_verification_task"}: + required_terms = ("empty",) if fixture["fixture_id"] == "bug_fix-1" else ("duplicate",) + added = [task for task in worker.get("task_records", []) + if isinstance(task, dict) and task.get("description") not in initial_tasks] + scenario_ok = (int(worker.get("plan_revisions", 0)) > 0 and any( + all(term in task.get("description", "").casefold() for term in required_terms) + and task.get("status") == "succeeded" for task in added)) + elif scenario in {"cancel_obsolete_task_after_reproduction"}: + records = worker.get("task_records", []) + scenario_ok = any("assumption" in item.get("description", "").casefold() + and item.get("status") == "cancelled" for item in records) and any( + "fallback" in item.get("description", "").casefold() and item.get("status") == "succeeded" + for item in records) + elif scenario == "reorder_after_dependency_discovery": + lowered = [item.casefold() for item in final_tasks] + implement = next((index for index, value in enumerate(lowered) if "implement" in value), None) + verify = next((index for index, value in enumerate(lowered) if "verify" in value), None) + initial = [item.casefold() for item in initial_tasks] + initial_implement = next((index for index, value in enumerate(initial) if "implement" in value), None) + initial_verify = next((index for index, value in enumerate(initial) if "verify" in value), None) + records = worker.get("task_records", []) + scenario_ok = (int(worker.get("plan_revisions", 0)) > 0 + and initial_verify is not None and initial_implement is not None + and initial_verify < initial_implement and implement is not None + and verify is not None and implement < verify + and any("implement" in item.get("description", "").casefold() + and item.get("status") == "succeeded" for item in records) + and any("verify" in item.get("description", "").casefold() + and item.get("status") == "succeeded" for item in records)) + elif scenario == "cancel_replace": + records = worker.get("task_records", []) + scenario_ok = any("obsolete" in item.get("description", "").casefold() + and item.get("status") == "cancelled" for item in records) and any( + "verify" in item.get("description", "").casefold() and item.get("status") == "succeeded" + for item in records) + elif scenario == "scope_preserving": + scenario_ok = worker.get("scope_preserved") is True + elif fixture["category"] == "subagents": + agents = [event.get("agent") for event in worker.get("events", []) + if event.get("event") == "subagent" and isinstance(event.get("agent"), dict)] + scenario_ok = any(agent.get("status") == "succeeded" for agent in agents) + elif scenario == "material_revision": + scenario_ok = (worker.get("pending_material_revision") is True + and worker.get("accepted_scope_preserved") is True + and int(worker.get("material_plan_proposals", 0)) > 0) + elif scenario == "unnecessary_churn": + scenario_ok = int(worker.get("plan_revisions", 0)) == 0 and int(worker.get("unnecessary_churn", 0)) == 0 + else: + scenario_ok = True + # Explicit fixture revision/verification instructions remain task acceptance; + # optional automatic-plan receipt state is assessed separately by the worker. + physical_success = bool(accepted and scenario_ok) + accepted = bool(accepted and scenario_ok) + components = {"scope_safe": bool(scope_safe), "original_tests_unchanged": test_files_unchanged, + "provider_ok": worker.get("provider_error") is False, + "no_running_processes": type(worker.get("processes_alive")) is int + and worker["processes_alive"] == 0, "changed_paths": sorted(changed)} + safe = all(components[name] for name in ( + "scope_safe", "original_tests_unchanged", "provider_ok", "no_running_processes")) + return {"result": "passed" if accepted else "failed", "verified_success": bool(accepted), + "physical_success": physical_success, + "safety_passed": bool(safe), "safety_components": components, + "context_compaction_observed": _context_compaction_observed(worker) if kind == "long_context" else None, + "test_files_unchanged": test_files_unchanged, + "changed_paths": sorted(changed), "test_result": test_ok, + "plan_scenario": scenario, "plan_scenario_passed": bool(scenario_ok), + "verification_error": verification_error} + + +def _source_snapshot(source: Path) -> str: + if subprocess.check_output(["git", "-C", str(source), "status", "--porcelain"], text=True).strip(): + raise ValueError("matched live benchmark requires clean source worktrees") + return subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip() + + +@contextmanager +def _campaign_lock(output): + """One canonical report may spend or transfer its remaining budget at a time.""" + canonical=Path(output).resolve() + path=canonical.with_name(canonical.name+'.campaign-lock') + path.parent.mkdir(parents=True,exist_ok=True) + descriptor=os.open(path,os.O_CREAT|os.O_RDWR|getattr(os,'O_NOFOLLOW',0),0o600) + try: + metadata=os.fstat(descriptor) + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink!=1 + or (os.name!='nt' and (metadata.st_uid!=os.geteuid() or metadata.st_mode & 0o077))): + raise ValueError('campaign lease is not private and owned') + try: + if os.name=='nt': + import msvcrt + msvcrt.locking(descriptor,msvcrt.LK_NBLCK,1) + else: + import fcntl + fcntl.flock(descriptor,fcntl.LOCK_EX|fcntl.LOCK_NB) + except OSError: raise ValueError('another campaign is active for this report') from None + yield descriptor + finally: + os.close(descriptor) + + +def _serialized_campaign(function): + @wraps(function) + def run(*args,**kwargs): + source=Path(kwargs['source']).resolve() + if any(Path(kwargs[key]).resolve().is_relative_to(source) for key in ('output','budget_path')): + raise ValueError('benchmark output and budget ledger must be outside the source worktree') + with _campaign_lock(kwargs['output']) as descriptor: + os.lseek(descriptor,0,os.SEEK_SET) + if os.read(descriptor,1): + raise ValueError('core budget has a bound continuation; resume that supplement') + return function(*args,**kwargs) + return run + + +def _write_checkpoint(output: Path, budget_path: Path, document: dict) -> None: + output, budget_path = output.resolve(), budget_path.resolve() + content = (json.dumps(document, indent=2, sort_keys=True) + "\n").encode() + with closing(sqlite3.connect(budget_path)) as db: + db.execute("CREATE TABLE IF NOT EXISTS benchmark_checkpoint " + "(id INTEGER PRIMARY KEY CHECK(id=1), output_path TEXT NOT NULL, " + "report_sha256 TEXT NOT NULL, report_json TEXT NOT NULL)") + columns = {row[1] for row in db.execute("PRAGMA table_info(benchmark_checkpoint)")} + if "report_json" not in columns: + db.execute("ALTER TABLE benchmark_checkpoint ADD COLUMN report_json TEXT") + db.execute("INSERT OR REPLACE INTO benchmark_checkpoint " + "(id,output_path,report_sha256,report_json) VALUES(1,?,?,?)", + (str(output), hashlib.sha256(content).hexdigest(), content.decode())) + db.commit() + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(output.name + ".tmp") + temporary.write_bytes(content) + temporary.replace(output) + + +def _load_checkpoint(output: Path, budget_path: Path) -> dict | None: + output, budget_path = output.resolve(), budget_path.resolve() + with closing(sqlite3.connect(budget_path)) as db: + db.execute("CREATE TABLE IF NOT EXISTS benchmark_checkpoint " + "(id INTEGER PRIMARY KEY CHECK(id=1), output_path TEXT NOT NULL, " + "report_sha256 TEXT NOT NULL, report_json TEXT)") + columns = {row[1] for row in db.execute("PRAGMA table_info(benchmark_checkpoint)")} + if "report_json" not in columns: + db.execute("ALTER TABLE benchmark_checkpoint ADD COLUMN report_json TEXT") + db.commit() + checkpoint = db.execute( + "SELECT output_path, report_sha256, report_json FROM benchmark_checkpoint WHERE id=1" + ).fetchone() + if checkpoint is None: + return None + if not checkpoint[2] or checkpoint[1] != hashlib.sha256(checkpoint[2].encode()).hexdigest(): + raise ValueError("the canonical benchmark checkpoint is corrupt") + if str(output) != checkpoint[0]: + raise ValueError("budget ledger is already bound to another benchmark report") + prior = json.loads(checkpoint[2]) + if not output.exists() or output.read_text(encoding="utf-8") != checkpoint[2]: + _write_checkpoint(output, budget_path, prior) + return prior + + +def _run_worker(command: list[str], *, cwd: Path, env: dict[str, str], timeout: int = 900, + protected_files: list[Path] | None = None, policy=None, source=None, + provider_config=None, revoke_actor=None, routing_variant=None) -> tuple[int, str]: + from benchmarks.v3_actor_supervisor import ActorSupervisor + from benchmarks.v3_shell_launcher import ShellPolicy + # Standalone callers have no shell authority; fixture callers supply the + # immutable parent policy and the complete original-test manifest. + policy = policy or ShellPolicy(cwd, frozenset()) + with ActorSupervisor(command, cwd=cwd, env=env, protected_files=protected_files or [], + policy=policy, source=source or Path(__file__).resolve().parents[1], + provider_config=provider_config, revoke_actor=revoke_actor, + **({'routing_variant':routing_variant} if routing_variant is not None else {})) as launch: + try: + process = launch.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except OSError as exc: + return -1, type(exc).__name__ + try: + _, stderr = process.communicate(timeout=timeout) + if process.returncode == 0: + return 0, "" + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + known_exceptions = {"RuntimeError", "ValueError", "TypeError", "AttributeError", "ImportError", + "ModuleNotFoundError", "OSError", "PermissionError", "FileNotFoundError", + "NotImplementedError", "AssertionError", "KeyError", "IndexError", "TimeoutError", + "ConnectionError", "UnicodeError", "RecursionError", "MemoryError"} + error_type = next((line.split(":", 1)[0] for line in reversed(stderr.splitlines()) + if ":" in line and line.split(":", 1)[0] in known_exceptions), None) + suffix = f":{error_type}" if error_type else "" + return process.returncode, f"worker_exit_{process.returncode}{suffix}" + except subprocess.TimeoutExpired: + launch.close() + process.communicate() + return -1, "timeout" + + +def _kill_process_tree(pid: int) -> None: + import signal + try: + rows = subprocess.run(["ps", "-axo", "pid=,ppid="], capture_output=True, text=True, + timeout=5, check=False).stdout.splitlines() + children = {} + for row in rows: + child, parent = (int(part) for part in row.split()) + children.setdefault(parent, []).append(child) + order, pending = [], [pid] + while pending: + current = pending.pop() + order.append(current) + pending.extend(children.get(current, ())) + for child in reversed(order): + try: + os.kill(child, signal.SIGKILL) + except ProcessLookupError: + pass + except (OSError, subprocess.TimeoutExpired, ValueError): + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _extract_baseline(root: Path, repo: Path, commit: str) -> None: + subprocess.run(["git", "-C", str(repo), "cat-file", "-e", f"{commit}^{{commit}}"], check=True) + archive = subprocess.Popen(["git", "-C", str(repo), "archive", "--format=tar", commit], stdout=subprocess.PIPE) + try: + import tarfile + with tarfile.open(fileobj=archive.stdout, mode="r|*") as tar: + tar.extractall(root, filter="data") + if archive.wait() != 0: + raise subprocess.CalledProcessError(archive.returncode, archive.args) + finally: + if archive.stdout: + archive.stdout.close() + + +def _credential() -> tuple[str, str]: + endpoint = os.environ.get("KYROZEN_BENCHMARK_BASE_URL", "https://ai.panstar.ai/v1") + key = os.environ.get("KYROZEN_BENCHMARK_API_KEY", "") + if not key: + raise ValueError("benchmark provider API key is unavailable") + return endpoint, key + + +def _worker_env(base: Path, fixture: dict, relay: ResponsesRelay) -> dict[str, str]: + env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR", "LANG", "LC_ALL") if key in os.environ} + capabilities = ",".join(fixture["capabilities"]) or "none" + env.update(HOME=str(base / "home"), KYROZEN_DB_PATH=str(base / "state.sqlite3"), + KYROZEN_DISABLE_VECTOR_INDEX="1", KYROZEN_CLI_CAPABILITIES=capabilities, + KYROZEN_BENCHMARK_API_KEY=relay.client_key, + KYROZEN_SANDBOX_REQUIRED=os.environ.get("KYROZEN_SANDBOX_REQUIRED", "0"), + PYTHONDONTWRITEBYTECODE="1", PYTHONPATH="") + return env + + +# Full worker output is evidence, not a trusted completion flag. Retain at most +# 64 MiB; malformed/oversized results retain only a content hash and diagnostic. +_MAX_WORKER_EVIDENCE_BYTES = 64 * 1024 * 1024 +_WORKER_EVIDENCE_PROTOCOL = "openkyrozen-worker-evidence-v1" + + +def _canonical_bytes(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, + allow_nan=False).encode("utf-8") + + +def _read_regular_bytes(path: Path, directory_fd: int | None = None) -> tuple[bytes | None, str | None, int, str, int | None]: + """Read at most limit+1 bytes; never follow links or wait on special files. + + The digest is complete only for a stable, bounded file. A growing file may + return a prefix digest, which its caller must label rather than claim as raw. + """ + import stat + try: + before = (path.lstat() if directory_fd is None else + os.stat(path.name, dir_fd=directory_fd, follow_symlinks=False)) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + return None, None, 0, "unsafe_file", None + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + descriptor = (os.open(path, flags) if directory_fd is None else + os.open(path.name, flags, dir_fd=directory_fd)) + try: + stream = os.fdopen(descriptor, "rb") + except BaseException: + os.close(descriptor) + raise + with stream: + actual = os.fstat(stream.fileno()) + if (not stat.S_ISREG(actual.st_mode) or actual.st_nlink != 1 + or (actual.st_dev, actual.st_ino) != (before.st_dev, before.st_ino)): + return None, None, 0, "changed_file", None + if actual.st_size > _MAX_WORKER_EVIDENCE_BYTES: + return None, None, 0, "oversized", actual.st_size + digest, chunks, size = hashlib.sha256(), [], 0 + while chunk := stream.read(min(1024 * 1024, _MAX_WORKER_EVIDENCE_BYTES + 1 - size)): + digest.update(chunk) + size += len(chunk) + if size > _MAX_WORKER_EVIDENCE_BYTES: + return None, digest.hexdigest(), size, "growing_file", actual.st_size + chunks.append(chunk) + after = os.fstat(stream.fileno()) + if after.st_size != size or actual.st_size != size or after.st_mtime_ns != actual.st_mtime_ns: + return None, digest.hexdigest(), size, "changed_file", actual.st_size + return b"".join(chunks), digest.hexdigest(), size, "valid", actual.st_size + except OSError: + return None, None, 0, "unavailable", None + + +def _worker_schema_errors(worker: dict) -> list[str]: + """Validate the shared candidate observer, independently of product-arm ABI.""" + import math + errors = [] + for key in ("events", "tool_receipts", "task_records", "request_context_records", "conversation_records"): + value = worker.get(key) + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + errors.append(key) + for key in ("tasks", "tool_actions", "accepted_plan_fingerprints"): + value = worker.get(key) + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + errors.append(key) + for key in ("tool_calls", "tool_successes", "material_plan_proposals", "plan_revisions", "cancelled_tasks", + "material_revisions", "unnecessary_churn", "duplicate_calls", "protocol_recovery", + "processes_started", "processes_cancelled", "processes_alive"): + value = worker.get(key) + if type(value) is not int or value < 0: + errors.append(key) + for key in ("provider_error", "pending_material_revision", "process_marker_seen", + "accepted_scope_preserved", "scope_preserved"): + if type(worker.get(key)) is not bool: + errors.append(key) + if not isinstance(worker.get("answer"), str): + errors.append("answer") + pids = worker.get("process_pids") + if not isinstance(pids, list) or any(type(pid) is not int or pid <= 0 for pid in pids): + errors.append("process_pids") + for key in ("working_plan", "initial_plan_fingerprint", "executing_plan_fingerprint", "first_delta_latency_ms"): + value = worker.get(key) + if key not in worker: + errors.append(key) + elif value is not None: + if key == "working_plan": + valid = isinstance(value, dict) + elif key == "first_delta_latency_ms": + try: + valid = type(value) in (int, float) and math.isfinite(value) and value >= 0 + except OverflowError: + valid = False + else: + valid = isinstance(value, str) + if not valid: + errors.append(key) + return sorted(errors) + + +def _read_worker_result(path: Path, secrets: tuple[str, ...]) -> tuple[dict, dict]: + raw, digest, size, classification, declared_size = _read_regular_bytes(path) + metadata = {"raw_worker_sha256": digest if classification == "valid" else None, + "raw_worker_bytes": declared_size, "observed_worker_bytes": size, + "raw_hash_scope": "complete" if classification == "valid" else "unavailable", + "observed_prefix_sha256": digest if classification in {"growing_file", "changed_file"} else None, + "classification": classification, "redaction_count": 0} + if classification != "valid": + return {}, metadata + try: + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate field") + result[key] = value + return result + worker = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("nonfinite"))) + if not isinstance(worker, dict): + raise ValueError("worker result is not an object") + known = tuple(dict.fromkeys(value for value in secrets if value)) + def redact(value): + if isinstance(value, str): + for secret in known: + metadata["redaction_count"] += value.count(secret) + value = value.replace(secret, "[REDACTED]") + return value + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, dict): + # Keys are not string values. Reject credential-bearing keys rather + # than silently changing their meaning or persisting the literal. + if any(secret in key for key in value for secret in known): + raise ValueError("credential-bearing key") + return {key: redact(item) for key, item in value.items()} + return value + worker = redact(worker) + if len(_canonical_bytes(worker)) > _MAX_WORKER_EVIDENCE_BYTES: + metadata["classification"] = "oversized" + return {}, metadata + errors = _worker_schema_errors(worker) + if errors: + metadata.update(classification="incomplete_schema", incomplete_fields=errors) + if metadata["redaction_count"]: + metadata["classification"] = "credential_exposure" + return worker, metadata + except (UnicodeError, ValueError, RecursionError): + metadata["classification"] = "malformed" + return {}, metadata + + +def _evidence_parent(row: dict, candidate: str, identity: dict) -> dict: + return {"candidate_sha": candidate, "baseline_sha": BASELINE, + **({'routing_variant':row['variant']} if 'variant' in row else {}), + "cohort_sha256": identity["cohort_sha256"], + "apparatus_sha256": identity["apparatus_sha256"], + **{key: row.get(key) for key in ("fixture_id", "repeat", "arm", "worker_exit_code", + "worker_result_complete", "verifier_result", "verified_success", "safety_passed")}, + "safety_components": {key: (row.get("safety_components") or {}).get(key) for key in ( + "scope_safe", "original_tests_unchanged", "provider_ok", "no_running_processes", "worker_complete")}} + + +def _evidence_directory(output: Path, *, create: bool = False) -> Path: + directory = output.parent / (output.name + ".worker-evidence") + if create: + directory.mkdir(mode=0o700, exist_ok=True) + if directory.is_symlink() or not directory.is_dir() or directory.resolve().parent != output.parent.resolve(): + raise ValueError("worker evidence directory is unavailable or unsafe") + return directory + + +def _require_secure_evidence_io() -> None: + operations = (os.open, os.stat, os.rename, os.unlink) + if (not all(operation in os.supports_dir_fd for operation in operations) + or not all(getattr(os, name, 0) for name in ("O_NOFOLLOW", "O_DIRECTORY", "O_NONBLOCK"))): + raise ValueError("secure worker evidence IO is unavailable on this platform") + + +@contextmanager +def _evidence_access(output: Path, *, create: bool = False): + _require_secure_evidence_io() + directory = _evidence_directory(output, create=create) + before = directory.lstat() + descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + raise ValueError("worker evidence directory changed") + yield directory, descriptor + finally: + try: + after = directory.lstat() + if (directory.is_symlink() or (after.st_dev, after.st_ino) != (before.st_dev, before.st_ino)): + raise ValueError("worker evidence directory changed or became unsafe") + except OSError as exc: + raise ValueError("worker evidence directory is unavailable") from exc + finally: + os.close(descriptor) + + +def _evidence_name(parent: dict, digest: str) -> str: + return hashlib.sha256(_canonical_bytes(parent)).hexdigest() + "-" + digest + ".json" + + +def _write_worker_evidence(output: Path, row: dict, candidate: str, identity: dict, + worker: dict, metadata: dict) -> None: + """Atomically write an immutable, parent-named, credential-sanitized result.""" + if metadata["classification"] != "valid": + row.update(worker_result_complete=False, verified_success=False, + verifier_result="incomplete", safety_passed=False) + row.setdefault("safety_components", {})["worker_complete"] = False + parent = _evidence_parent(row, candidate, identity) + document = {"protocol": _WORKER_EVIDENCE_PROTOCOL, "parent": parent, + **metadata, "redaction_applied": metadata["redaction_count"] > 0, + "worker": worker if metadata["classification"] in {"valid", "incomplete_schema", "credential_exposure"} else None} + payload = _canonical_bytes(document) + if len(payload) > _MAX_WORKER_EVIDENCE_BYTES: + row.update(worker_result_complete=False, verified_success=False, + verifier_result="incomplete", safety_passed=False) + row.setdefault("safety_components", {})["worker_complete"] = False + metadata = {**metadata, "classification": "oversized"} + parent = _evidence_parent(row, candidate, identity) + document.update(parent=parent, classification="oversized", worker=None) + payload = _canonical_bytes(document) + digest = hashlib.sha256(payload).hexdigest() + with _evidence_access(output, create=True) as (directory, directory_fd): + destination = directory / _evidence_name(parent, digest) + try: + os.stat(destination.name, dir_fd=directory_fd, follow_symlinks=False) + exists = True + except FileNotFoundError: + exists = False + if exists: + existing, _, _, status, _ = _read_regular_bytes(destination, directory_fd) + if status != "valid" or existing != payload: + raise ValueError("worker evidence destination is unsafe or changed") + else: + name = ".worker-" + os.urandom(16).hex() + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(name, flags, 0o600, dir_fd=directory_fd) + try: + try: + stream = os.fdopen(descriptor, "wb") + except BaseException: + os.close(descriptor) + raise + with stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, destination.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + finally: + try: + os.unlink(name, dir_fd=directory_fd) + except FileNotFoundError: + pass + row.update(worker_evidence_file=destination.relative_to(output.parent).as_posix(), + worker_evidence_sha256=digest, worker_evidence_raw_sha256=metadata["raw_worker_sha256"], + worker_evidence_status=metadata["classification"], + worker_evidence_redacted=metadata["redaction_count"] > 0) + row["worker_evidence_verified"] = _validate_worker_evidence(output, row, candidate, identity) + + +def _validate_worker_evidence(output: Path, row: dict, candidate: str, identity: dict, *, return_worker=False): + """Derive proof from contained bytes; no worker or checkpoint flag is trusted.""" + parent = _evidence_parent(row, candidate, identity) + digest = row.get("worker_evidence_sha256") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError("worker evidence hash is missing") + with _evidence_access(output) as (directory, directory_fd): + path = directory / _evidence_name(parent, digest) + if row.get("worker_evidence_file") != path.relative_to(output.parent).as_posix(): + raise ValueError("worker evidence identity does not match this row") + raw, actual_digest, _, status, _ = _read_regular_bytes(path, directory_fd) + if status != "valid" or actual_digest != digest: + raise ValueError("worker evidence is missing, unsafe, or tampered") + try: + document = json.loads(raw) + if (document.get("protocol") != _WORKER_EVIDENCE_PROTOCOL or document.get("parent") != parent + or document.get("raw_worker_sha256") != row.get("worker_evidence_raw_sha256") + or document.get("classification") != row.get("worker_evidence_status") + or document.get("redaction_applied") != row.get("worker_evidence_redacted")): + raise ValueError("worker evidence lineage does not match this row") + worker = document.get("worker") + valid=(document.get("classification") == "valid" and isinstance(worker, dict) + and not _worker_schema_errors(worker)) + return worker if return_worker and valid else valid + except (ValueError, TypeError, AttributeError, RecursionError) as exc: + raise ValueError("invalid worker evidence payload") from exc + + +def _validate_rows_evidence(output: Path, rows: list[dict], candidate: str, identity: dict) -> bool: + complete = bool(rows) + for row in rows: + row["worker_evidence_verified"] = False + if row.get("worker_result_complete") is True or row.get("worker_evidence_file"): + row["worker_evidence_verified"] = _validate_worker_evidence(output, row, candidate, identity) + complete = complete and row["worker_evidence_verified"] + return complete + + +def _empty_row(fixture_id: str, repeat: int, arm: str) -> dict: + return {"fixture_id": fixture_id, "repeat": repeat, "arm": arm, "usage_complete": False, + "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, + "cache_write_tokens": 0, "latency_ms": 0, + "attempts": 0, "first_delta_latency_ms": None, "verifier_result": "incomplete", + "safety_passed": False, "durable_side_effect_expected": False, + "durable_side_effect_verified": False, "recovery_required": False, + "recovery_verified": False, "verified_success": False, + "workflow_state_correct": False, "requests": 0, "protocol_recovery": 0, + "duplicate_calls": 0, "plan_revisions": 0, "cancelled_tasks": 0, + "material_revisions": 0, "unnecessary_churn": 0, "budget_ok": False, + "plan_scenario": None, "plan_scenario_passed": True, "worker_exit_code": None, + "worker_result_complete": False} + + +def _row_can_resume_as_complete(row: dict, output: Path | None = None, + candidate: str | None = None, identity: dict | None = None) -> bool: + if (output is None or candidate is None or identity is None + or row.get("worker_result_complete") is not True): + return False + return (_validate_worker_evidence(output, row, candidate, identity) + and row.get("worker_result_complete") and row.get("usage_complete") + and row.get("attempts", 0) > 0 and row.get("worker_exit_code") == 0 + and _safety_failure_status(row) is None) + + +def _safety_failure_status(row: dict) -> str | None: + components = row.get("safety_components") or {} + checks = ("scope_safe", "original_tests_unchanged", "no_running_processes", "worker_complete") + if not all(components.get(name) is True for name in checks): + return "blocked_safety_failure" + if components.get("provider_ok") is False: + return "blocked_provider_error" + if components.get("provider_ok") is not True or row.get("safety_passed") is not True: + return "blocked_safety_failure" + return None + + +def _held_reservations(calls: list[dict]) -> list[dict]: + fields = ("attempt_id", "arm", "fixture_id", "repeat", "reserved_rmb", "status") + return [{key: call[key] for key in fields if key in call} + for call in calls if call.get("status") in {"reserved", "unknown"}] + + +def _workflow_state_correct(fixture: dict, worker: dict) -> bool: + tasks = worker.get("tasks") + plan_required = bool(fixture.get("plan_tasks")) or fixture.get("plan_scenario") in { + "scope_preserving", "material_revision", + } + return (bool(tasks) and "succeeded" in tasks if plan_required else True) and all( + status in {"succeeded", "cancelled"} for status in (tasks or []) + ) + + +def _initial_plan(fixture: dict) -> list[str] | dict: + if fixture.get("plan_scenario") == "scope_preserving": + step = {"id": "objective", "title": "Refactor labels", + "description": "Extract shared formatting without changing outputs.", + "acceptance": ["Both formatters preserve their outputs."]} + elif fixture.get("plan_scenario") == "material_revision": + step = {"id": "objective", "title": "Fix page offset", + "description": "Make page 1 start at offset zero while preserving later pages.", + "acceptance": ["Page 1 starts at zero.", "Page 2 starts at one page size."]} + else: + return [f"{title}: {description}" for title, description in fixture.get("plan_tasks", [])] + return {"title": step["title"], "summary": step["description"], "assumptions": [], + "steps": [step], + "bounds": {"workspace_paths": ["."], "capabilities": ["read", "write"], + "effects": ["read_only", "filesystem", "internal"], + "risk_ceiling": "normal"}} + + +def _execute_cell(*, temporary_root, repeat, fixture, arm, arm_source, candidate, identity, + worker_script, relay, model, output, api_key, budget, routing_variant=None): + """Shared unchanged fixture execution, safety oracle and secure evidence boundary.""" + fixture_id = fixture["fixture_id"] + base = temporary_root / f"{repeat}-{fixture_id}-{arm}" + workspace, home = base / "workspace", base / "home" + workspace.mkdir(parents=True) + base.chmod(0o711) + home.mkdir(parents=True) + prepared = prepare_fixture(workspace, fixture) + prompt_file, plan_file, result_file = base / "prompt.txt", base / "plan.json", base / "result.json" + prompt_file.write_text(fixture["prompt"], encoding="utf-8") + initial_plan = _initial_plan(fixture) + plan_file.write_text(json.dumps(initial_plan), encoding="utf-8") + conversation_file = base / "conversation.json" + turns = fixture.get("turns") or (fixture["prompt"],) + if turns[0] != fixture["prompt"]: + raise ValueError("scripted fixture initial prompt does not match its frozen prompt") + conversation_file.write_text(json.dumps({"followups": turns[1:], + "context_window_tokens": fixture["context_window_tokens"], + "output_reserve_tokens": fixture["output_reserve_tokens"]}), encoding="utf-8") + from benchmarks.v3_shell_launcher import ShellPolicy + shell_policy = ShellPolicy( + workspace, frozenset(fixture["capabilities"]), + accepted_bounds=initial_plan.get("bounds") if isinstance(initial_plan, dict) else None, + run_id=f"{candidate}:{repeat}:{fixture_id}:{arm}", + ) + relay.label = (arm, fixture_id, repeat) + started = time.monotonic() + worker_env = _worker_env(base, fixture, relay) + if routing_variant is not None: + worker_env['KYROZEN_DECISION_ASSIST_BACKEND']='off' + def actor_provider_config(actor_id): + return dict(relay.issue_actor_config(actor_id), + context_window_tokens=fixture['context_window_tokens']) + parent_failed = False + try: + code, worker_error = _run_worker( + [sys.executable, str(worker_script), + "--source", str(arm_source), "--workspace", str(workspace), + "--url", relay.url, "--model", model, "--protocol", "responses", + "--prompt-file", str(prompt_file), "--plan-file", str(plan_file), + "--conversation-file", str(conversation_file), "--result", str(result_file), + "--scenario", str(fixture.get("plan_scenario") or ""), + "--process-marker", str(fixture["verifier"].get("command_marker") or "")], + cwd=workspace, env=worker_env, + protected_files=[workspace / name for name in fixture["files"] + if Path(name).name.startswith("test_")], + policy=shell_policy, + source=arm_source, provider_config=actor_provider_config, + revoke_actor=relay.revoke_actor, + **({'routing_variant':routing_variant} if routing_variant is not None else {}), + ) + except Exception as exc: + parent_failed = True + kind = type(exc).__name__ if type(exc) in ( + RuntimeError, PermissionError, OSError, TimeoutError, ValueError) else 'Exception' + code, worker_error = -1, 'parent_execution_failed:' + kind + observed_worker, evidence_metadata = _read_worker_result( + result_file, (api_key, relay.client_key, *relay.actor_keys)) + worker_complete = evidence_metadata["classification"] == "valid" and not parent_failed + worker = observed_worker if worker_complete else {} + if not worker_complete: + worker_error = worker_error or "missing_or_invalid_result" + answer = worker.get("answer", "") + try: + verified = (verify_fixture(fixture, workspace, answer, worker, prepared) + if worker_complete else { + "result": "incomplete", "safety_passed": False, + "safety_components": {key: None for key in ( + "scope_safe", "original_tests_unchanged", "provider_ok", "no_running_processes")}, + "plan_scenario_passed": False, "verified_success": False, "changed_paths": []}) + calls = [row for row in relay.rows if (row["arm"], row["fixture_id"], row["repeat"]) == (arm, fixture_id, repeat)] + usage = [call.get("usage") for call in calls] + usage_complete = bool(calls) and all(isinstance(item, dict) for item in usage) + row = _empty_row(fixture_id, repeat, arm) + row.update({"cohort_sha256": identity["cohort_sha256"], + "apparatus_sha256": identity["apparatus_sha256"], + "context_window_tokens": fixture["context_window_tokens"], + "output_reserve_tokens": fixture["output_reserve_tokens"], + "request_context_records": worker.get("request_context_records"), + "conversation_records": worker.get("conversation_records"), + "context_compaction_observed": verified.get("context_compaction_observed"), + "usage_complete": usage_complete, + "input_tokens": sum(item["input_tokens"] for item in usage if item), + "output_tokens": sum(item["output_tokens"] for item in usage if item), + "cache_read_tokens": sum(item["cache_read_tokens"] for item in usage if item), + "cache_write_tokens": sum(item.get("cache_write_tokens", 0) for item in usage if item), + "latency_ms": round((time.monotonic() - started) * 1000, 2), + "attempts": len(calls), "first_delta_latency_ms": worker.get("first_delta_latency_ms"), + "verifier_result": verified["result"], "safety_passed": verified["safety_passed"], + "plan_scenario": fixture.get("plan_scenario"), + "plan_scenario_passed": verified["plan_scenario_passed"], + "durable_side_effect_expected": fixture["verifier"]["kind"] in {"tests", "tests_and_file"}, + "durable_side_effect_verified": bool(verified["verified_success"] and verified["changed_paths"]), + "recovery_required": fixture["category"] == "error_recovery", + "recovery_verified": bool(verified["verified_success"] + and fixture["category"] == "error_recovery" + and _recovery_observed(worker)), + "verified_success": bool(code == 0 and worker_complete and verified["verified_success"]), + "workflow_state_correct": worker_complete and _workflow_state_correct(fixture, worker), + "requests": len(calls), "protocol_recovery": worker["protocol_recovery"] if worker_complete else None, + "duplicate_calls": worker["duplicate_calls"] if worker_complete else None, + "plan_revisions": worker["plan_revisions"] if worker_complete else None, + "cancelled_tasks": worker["cancelled_tasks"] if worker_complete else None, + "material_revisions": worker["material_plan_proposals"] if worker_complete else None, + "unnecessary_churn": worker["unnecessary_churn"] if worker_complete else None, + "budget_ok": budget.budget_ok, + "worker_error": worker_error, + "worker_exit_code": code, + "worker_result_complete": worker_complete}) + if verified.get('verification_error'): + row['verification_error'] = verified['verification_error'] + components = dict(verified.get("safety_components") or {}) + components["worker_complete"] = code == 0 and worker_complete + row["safety_components"] = components + safety_stop = _safety_failure_status(row) + row["safety_passed"] = safety_stop is None + if not result_file.exists() and not worker_error: + row["worker_error"] = "missing_result" + if routing_variant is not None: + from benchmarks.v3_routing_supplement import validate_observations,active_treatment + observations=worker.get('routing_observations') + row.update(variant=routing_variant,physical_success=bool(verified.get('physical_success')), + routing_evidence_valid=validate_observations(observations,routing_variant), + routing_treatment_active=active_treatment(observations,routing_variant)) + except BaseException: + # Even an oracle/metric failure must not discard a valid + # answer and its execution trace with the temporary home. + interrupted = _empty_row(fixture_id, repeat, arm) + interrupted.update(worker_exit_code=code, + worker_result_complete=worker_complete) + if routing_variant is not None: + interrupted['variant']=routing_variant + _write_worker_evidence(output, interrupted, candidate, identity, + observed_worker, evidence_metadata) + raise + _write_worker_evidence(output, row, candidate, identity, observed_worker, evidence_metadata) + return row + + +@_serialized_campaign +def run(*, source: Path, repo: Path, output: Path, budget_path: Path, + limit_usd: str = str(MAX_LIMIT_USD), repeats: int = REPEATS, + prior_hold_usd: str | None = None, + fixtures: tuple[dict, ...] = FIXTURES, + limit_cny: str | None = None, prior_hold_cny: str | None = None, + prior_hold_records: tuple[str, ...] | None = None, + pricing: Pricing = PANSTAR_PRICING, relay_config: RelayConfig = DEFAULT_RELAY_CONFIG, + endpoint: str | None = None) -> dict: + source, repo = source.resolve(), repo.resolve() + candidate = _source_snapshot(source) + output, budget_path = output.resolve(), budget_path.resolve() + if output.is_relative_to(source) or budget_path.is_relative_to(source): + raise ValueError("benchmark output and budget ledger must be outside the source worktree") + if repeats != REPEATS or len(fixtures) != len(FIXTURES): + raise ValueError("release evaluation requires the frozen cohort and exactly three repeats") + if fixtures != FIXTURES: + raise ValueError("release evaluation requires the exact frozen fixture content") + identity = apparatus_identity() + ledger_existed = budget_path.exists() + cny_accounting = limit_cny is not None + if not cny_accounting and prior_hold_usd is None: + raise TypeError("prior_hold_usd is required for the default USD campaign") + try: + if cny_accounting: + if prior_hold_usd is not None or prior_hold_cny is None: + raise ValueError("CNY campaigns require a CNY prior hold without a USD workaround") + limit = Decimal(limit_cny) + prior_hold = Decimal(prior_hold_cny) + if (not limit.is_finite() or not prior_hold.is_finite() + or not 0 < limit <= pricing.max_limit_cny or not 0 <= prior_hold <= limit): + raise ValueError("the cumulative CNY cap and prior exposure must fit the pricing configuration") + limit_rmb, prior_hold_rmb = str(limit), str(prior_hold) + prior_hold_key = "prior_campaign_hold_cny" + else: + if pricing != PANSTAR_PRICING or prior_hold_cny is not None: + raise ValueError("custom CNY pricing requires a genuine CNY cap") + limit = Decimal(limit_usd) + if not limit.is_finite() or not Decimal(0) < limit <= MAX_LIMIT_USD: + raise ValueError("the cumulative benchmark cap cannot exceed USD 20") + prior_hold = Decimal(prior_hold_usd) + if not prior_hold.is_finite() or not Decimal(0) < prior_hold <= limit: + raise ValueError("the prior campaign reservation must be positive and within the cumulative cap") + limit_rmb, prior_hold_rmb = str(limit * USD_CNY), str(prior_hold * USD_CNY) + prior_hold_key = "prior_campaign_hold_usd" + except (InvalidOperation, TypeError): + currency = "CNY" if cny_accounting else "USD" + raise ValueError(f"the prior campaign reservation must be a valid {currency} amount") from None + model, max_output_tokens = relay_config.model, relay_config.max_output_tokens + pricing_info = pricing.as_dict() + + def budget_metadata() -> dict: + if cny_accounting: + return {"currency": "CNY", "spent_cny": str(budget.spent_rmb), + "remaining_cny": str(budget.remaining_rmb), "limit_cny": str(limit), + "prior_campaign_hold_cny": str(prior_hold)} + return {"spent_usd": str(budget.spent_rmb / USD_CNY), + "remaining_usd": str(budget.remaining_rmb / USD_CNY), "limit_usd": str(limit), + "prior_campaign_hold_usd": str(prior_hold), "usd_cny": str(USD_CNY)} + + def is_smoke_call(call: dict) -> bool: + return (cny_accounting and call.get("arm") == "smoke" + and call.get("fixture_id") == candidate and call.get("repeat") == -1 + and call.get("model") == model and call.get("max_output_tokens") == max_output_tokens) + + def require_smoke_only_ledger() -> None: + with closing(sqlite3.connect(budget_path)) as db: + has_attempts = db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='attempts'" + ).fetchone() + records = db.execute("SELECT metadata_json FROM attempts " + "WHERE id!='prior-campaign-reservations'").fetchall() if has_attempts else [] + for (metadata,) in records: + try: + valid = metadata and is_smoke_call(json.loads(metadata)) + except (ValueError, TypeError, AttributeError): + valid = False + if not valid: + raise ValueError("cannot start a new report with a nonempty budget ledger") + + checkpoint = _load_checkpoint(output, budget_path) + if checkpoint is None: + if output.exists(): + raise ValueError("existing report is not pinned by its original budget ledger") + if budget_path.exists(): + require_smoke_only_ledger() + from openkyrozen.tools.sandbox import SandboxUnavailableError, sandbox_command + probe_env = {key: os.environ[key] for key in ("PATH", "SYSTEMROOT", "WINDIR") if key in os.environ} + try: + probe = sandbox_command("true", workspace=str(source), cwd=str(source), env=probe_env) + except SandboxUnavailableError as exc: + raise ValueError("V3 matched evaluation requires an available OS filesystem sandbox") from exc + try: + if probe.backend == "unavailable": + raise ValueError("V3 matched evaluation requires an available OS filesystem sandbox") + finally: + probe.close() + from benchmarks.v3_protected_worker import verify_worker_protection + verify_worker_protection() + budget = BudgetLedger(budget_path, limit_rmb=limit_rmb, + prior_hold_rmb=prior_hold_rmb, + allow_limit_increase=not cny_accounting, pricing=pricing, + prior_hold_records=prior_hold_records) + if not budget.budget_ok: + raise ValueError("the persisted budget ledger contains an overrun") + rows: list[dict] = [] + safety_stop = None + journal_calls = budget.campaign_calls() + if checkpoint is not None: + if not ledger_existed: + raise ValueError("cannot resume without the original budget-ledger checkpoint") + prior = checkpoint + if (prior.get("protocol") != "openkyrozen-v3-matched-live-v1" + or prior.get("candidate_sha") != candidate or prior.get("baseline_sha") != BASELINE + or prior.get("model") != model or prior.get("repeats") != repeats + or any(prior.get(key) != value for key, value in identity.items()) + or not isinstance(prior.get("budget"), dict) + or prior["budget"].get(prior_hold_key) != str(prior_hold) + or (cny_accounting and (prior.get("pricing") != pricing_info + or prior.get("relay_config") != asdict(relay_config))) + or not isinstance(prior.get("rows"), list) or not isinstance(prior.get("calls"), list)): + raise ValueError("budget-ledger checkpoint does not match this candidate and cohort") + rows = prior["rows"] + if prior.get("status") == "blocked_safety_failure" or any( + row.get("worker_result_complete") is True + and _safety_failure_status(row) == "blocked_safety_failure" for row in rows): + raise ValueError("recorded safety failure requires investigation and a fresh campaign") + held_reservations = prior.get("held_reservations", []) + expected_call_keys = {(fixture["fixture_id"], repeat, arm) + for fixture in fixtures for repeat in range(repeats) for arm in ("v2", "v3")} + if any(not is_smoke_call(call) and ( + (call.get("fixture_id"), call.get("repeat"), call.get("arm")) not in expected_call_keys + or call.get("model") != model) for call in journal_calls): + raise ValueError("budget-ledger journal contains a call outside the frozen cohort") + if any(row.get("cohort_sha256") != identity["cohort_sha256"] + or row.get("apparatus_sha256") != identity["apparatus_sha256"] for row in rows): + raise ValueError("checkpoint rows do not match the frozen cohort and apparatus") + keys = [(row.get("fixture_id"), row.get("repeat"), row.get("arm")) for row in rows] + if len(keys) != len(set(keys)): + raise ValueError("existing benchmark report contains duplicate attempts") + calls_by_key: dict[tuple, list[dict]] = {} + for call in journal_calls: + if is_smoke_call(call): + continue + calls_by_key.setdefault((call.get("fixture_id"), call.get("repeat"), call.get("arm")), []).append(call) + rows_by_key = dict(zip(keys, rows)) + for key, calls_for_key in calls_by_key.items(): + row = rows_by_key.get(key) + if row is None: + row = _empty_row(*key) + row.update({name: identity[name] for name in ("cohort_sha256", "apparatus_sha256")}) + rows.append(row) + rows_by_key[key] = row + usage = [call.get("usage") for call in calls_for_key] + actual = {"attempts": len(calls_for_key), "requests": len(calls_for_key), + "input_tokens": sum(item["input_tokens"] for item in usage if item), + "output_tokens": sum(item["output_tokens"] for item in usage if item), + "cache_read_tokens": sum(item["cache_read_tokens"] for item in usage if item), + "cache_write_tokens": sum(item.get("cache_write_tokens", 0) for item in usage if item)} + if any(row.get(name, 0) > value for name, value in actual.items()): + raise ValueError("budget-ledger journal is missing a report's provider calls") + if any(row.get(name, 0) != value for name, value in actual.items()): + row.update(actual, usage_complete=bool(calls_for_key) + and all(isinstance(item, dict) for item in usage), + worker_result_complete=False, + worker_error="interrupted before fixture result was checkpointed") + else: + require_smoke_only_ledger() + if output.exists(): + raise ValueError("existing report is not pinned by its original budget ledger") + prior = {} + held_reservations = [] + worker_script = _require_dispatched_apparatus(source, identity) + _require_secure_evidence_io() + output.parent.mkdir(parents=True, exist_ok=True) + with _evidence_access(output, create=True): + pass + _validate_rows_evidence(output, rows, candidate, identity) + credential_endpoint, api_key = _credential() + relay = ResponsesRelay(endpoint=endpoint or credential_endpoint, api_key=api_key, + budget=budget, config=relay_config) + relay.rows.extend(journal_calls) + smoke_calls = {call["attempt_id"]: call for call in journal_calls if is_smoke_call(call)} + smoke_attempt_ids = prior.get("smoke_attempt_ids") + if (cny_accounting and prior.get("smoke_passed") is True + and isinstance(smoke_attempt_ids, list) and len(set(smoke_attempt_ids)) == len(smoke_attempt_ids) == 2 + and all(attempt in smoke_calls and smoke_calls[attempt].get("status") == "settled" + and isinstance(smoke_calls[attempt].get("usage"), dict) for attempt in smoke_attempt_ids)): + relay.smoke_verified_candidate = candidate + relay.smoke_verified_attempt_ids = tuple(smoke_attempt_ids) + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="openkyrozen-v3-matched-", dir='/var/tmp' if os.name == 'posix' else None) as temporary: + temporary_root = Path(temporary) + temporary_root.chmod(0o711) + baseline_source = temporary_root / "baseline" + _extract_baseline(baseline_source, repo, BASELINE) + relay.start() + try: + smoke_ok = True + if cny_accounting: + from benchmarks.v3_deepseek import run_smoke + smoke_ok = run_smoke(relay, candidate) + _write_checkpoint(output, budget_path, { + **identity, "protocol": "openkyrozen-v3-matched-live-v1", "candidate_sha": candidate, + "baseline_sha": BASELINE, "model": model, "max_output_tokens": max_output_tokens, + "pricing": pricing_info, "relay_config": asdict(relay_config), "repeats": repeats, + "status": ("running" if smoke_ok else "blocked_budget_reservation" + if relay.budget_blocked else "blocked_provider_error"), + "provider_error": relay.provider_error, "smoke_passed": smoke_ok, + "smoke_attempt_ids": list(getattr(relay, "smoke_verified_attempt_ids", ())), + "rows": rows, "calls": relay.rows, "budget": budget_metadata(), + "held_reservations": _held_reservations(relay.rows)}) + for repeat in range(repeats if smoke_ok else 0): + for fixture_index, fixture in enumerate(fixtures): + ordered_arms = (("v2", baseline_source), ("v3", source)) + if (repeat + fixture_index) % 2: + ordered_arms = tuple(reversed(ordered_arms)) + for arm, arm_source in ordered_arms: + fixture_id = fixture["fixture_id"] + previous = next((row for row in rows if + (row["fixture_id"], row["repeat"], row["arm"]) + == (fixture_id, repeat, arm)), None) + if previous and _row_can_resume_as_complete(previous, output, candidate, identity): + continue + if previous: + rows.remove(previous) + row = _execute_cell(temporary_root=temporary_root, repeat=repeat, fixture=fixture, + arm=arm, arm_source=arm_source, candidate=candidate, identity=identity, + worker_script=worker_script, relay=relay, model=model, output=output, + api_key=api_key, budget=budget) + safety_stop = _safety_failure_status(row) + rows.append(row) + held_reservations = _held_reservations(relay.rows) + document = {**identity, "protocol": "openkyrozen-v3-matched-live-v1", "candidate_sha": candidate, + "baseline_sha": BASELINE, "model": model, "max_output_tokens": max_output_tokens, + "repeats": repeats, "pricing": pricing_info, "relay_config": asdict(relay_config), + "smoke_passed": smoke_ok if cny_accounting else None, + "smoke_attempt_ids": list(getattr(relay, "smoke_verified_attempt_ids", ())), + "status": safety_stop or "running", "rows": rows, + "held_reservations": held_reservations, + "calls": [{key: value for key, value in call.items() if key != "response_body"} + for call in relay.rows], + "budget": budget_metadata()} + _write_checkpoint(output, budget_path, document) + print(json.dumps({"fixture_id": fixture_id, "repeat": repeat, "arm": arm, + "verified_success": row["verified_success"], + "requests": row["requests"], + **({"remaining_cny": str(budget.remaining_rmb)} if cny_accounting else + {"remaining_usd": str(budget.remaining_rmb / USD_CNY)})}, + sort_keys=True), flush=True) + if not budget.budget_ok: + raise RuntimeError("benchmark budget overrun; stop all model calls and investigate") + if safety_stop: + break + if relay.budget_blocked: + document["status"] = "blocked_budget_reservation" + _write_checkpoint(output, budget_path, document) + break + if relay.provider_error: + document["status"] = "blocked_provider_error" + document["provider_error"] = relay.provider_error + _write_checkpoint(output, budget_path, document) + break + if safety_stop or relay.budget_blocked or relay.provider_error: + break + if safety_stop or relay.budget_blocked or relay.provider_error: + break + finally: + relay.close() + evidence_complete = _validate_rows_evidence(output, rows, candidate, identity) + summary = summarize(rows, pricing=pricing) + summary["worker_evidence_complete"] = evidence_complete + if not evidence_complete: + summary["accounting_complete"] = False + summary["comparison_thresholds_met"] = False + held_reservations = _held_reservations(relay.rows) + report = {**identity, "protocol": "openkyrozen-v3-matched-live-v1", "candidate_sha": candidate, + "baseline_sha": BASELINE, "model": model, "pricing": pricing_info, "relay_config": asdict(relay_config), "repeats": repeats, + "status": safety_stop or ("blocked_provider_error" if relay.provider_error else + "blocked_budget_reservation" if relay.budget_blocked else + "complete" if summary["complete"] and summary["accounting_complete"] else "incomplete"), + "provider_error": relay.provider_error, + "smoke_passed": smoke_ok if cny_accounting else None, + "smoke_attempt_ids": list(getattr(relay, "smoke_verified_attempt_ids", ())), + "summary": summary, + "rows": rows, "held_reservations": held_reservations, + "budget": budget_metadata(), + "calls": [{key: value for key, value in call.items() if key != "response_body"} + for call in relay.rows]} + _write_checkpoint(output, budget_path, report) + return report + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=Path.cwd()) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--budget-db", type=Path, required=True) + parser.add_argument("--limit-usd", default=str(MAX_LIMIT_USD)) + parser.add_argument("--prior-hold-usd", required=True) + parser.add_argument("--repeats", type=int, default=REPEATS) + args = parser.parse_args() + if args.repeats != REPEATS: + parser.error("release evaluation requires exactly three matched repeats") + report = run(source=args.source, repo=args.repo, output=args.output, + budget_path=args.budget_db, limit_usd=args.limit_usd, repeats=args.repeats, + prior_hold_usd=args.prior_hold_usd) + print(json.dumps(report["summary"], indent=2, sort_keys=True)) + summary = report["summary"] + raise SystemExit(0 if (summary["complete"] and summary["accounting_complete"] + and summary["comparison_thresholds_met"]) else 1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/v3_process_owner.py b/benchmarks/v3_process_owner.py new file mode 100644 index 00000000..22936a46 --- /dev/null +++ b/benchmarks/v3_process_owner.py @@ -0,0 +1,265 @@ +"""Benchmark-only kernel UID ownership, including detached descendants.""" +from __future__ import annotations + +import os +import json +from pathlib import Path +import secrets +import signal +import socket +import stat +import struct +import subprocess +import sys +import threading +import time + +_LOCK = threading.Lock() +_ALLOCATED = set() + + +def _namespace_parent_pin(connection, address, expected): + """Verify the launcher's immutable socket when outer root is unmapped.""" + try: + if not sys.platform.startswith('linux'): + raise ValueError('not a Linux namespace') + pid, uid, gid = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + if (pid, uid, gid) != (0, 65534, 65534): + raise ValueError('unexpected invisible parent credentials') + for name, identity in (('uid', os.geteuid()), ('gid', os.getegid())): + mapping = [line.split() for line in Path(f'/proc/self/{name}_map').read_text().splitlines()] + if not 2**30 <= identity < 2**31 or mapping != [[str(identity), str(identity), '1']]: + raise ValueError('unexpected actor namespace map') + pin = json.loads(expected) + if not isinstance(pin, list) or len(pin) != 2 or any(type(item) is not int for item in pin): + raise ValueError('invalid launcher socket pin') + path = Path(address) + metadata = path.lstat() + if (not path.is_absolute() or not stat.S_ISSOCK(metadata.st_mode) + or metadata.st_uid != 65534 or [metadata.st_dev, metadata.st_ino] != pin): + raise ValueError('launcher socket changed') + for parent in path.parents: + metadata = parent.lstat() + if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != 65534 + or metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX): + raise ValueError('launcher socket ancestor is mutable') + except (OSError, TypeError, ValueError) as exc: + raise PermissionError('benchmark actor requires a pinned privileged parent') from exc + + +def connect_actor_client(): + """Connect only to a kernel-authenticated privileged benchmark parent.""" + address = os.environ.pop('KYROZEN_BENCHMARK_ACTOR_SOCKET', None) + pin = os.environ.pop('KYROZEN_BENCHMARK_ACTOR_SOCKET_INODE', None) + if not address: + raise PermissionError('benchmark actor socket is missing') + connection = socket.socket(socket.AF_UNIX) + try: + connection.settimeout(10) + connection.connect(address) + try: + if peer_identity(connection)[1] != 0: + raise PermissionError('benchmark actor requires a privileged parent') + except PermissionError: + _namespace_parent_pin(connection, address, pin) + connection.sendall(b'{"op":"hello"}\n') + with connection.makefile('rb', buffering=0) as stream: + response = stream.readline(4097) + if len(response) > 4096 or not response.endswith(b'\n'): + raise PermissionError('invalid parent handshake') + response = json.loads(response) + identity = response.get('value') if response.get('ok') is True else None + if (not isinstance(identity, dict) + or set(identity) != {'run_id', 'actor_id', 'session_id', 'revision'} + or any(not isinstance(identity[k], str) or not identity[k] for k in ('run_id', 'actor_id', 'session_id')) + or type(identity['revision']) is not int or identity['revision'] < 0): + raise PermissionError('invalid parent actor identity') + connection.settimeout(None) + try: + from v3_shell_launcher import BrokerClient + except ModuleNotFoundError: + from benchmarks.v3_shell_launcher import BrokerClient + return BrokerClient(connection.detach(), identity) + except BaseException: + connection.close() + raise + + +def peer_identity(connection): + """Kernel identity of the connecting/sending actor, never JSON claims.""" + if connection.family != socket.AF_UNIX: + raise PermissionError('actor authentication requires a Unix socket') + if sys.platform.startswith('linux'): + pid, uid, gid = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + elif sys.platform == 'darwin': + import ctypes + library = ctypes.CDLL(None, use_errno=True) + uid_value, gid_value = ctypes.c_uint32(), ctypes.c_uint32() + if library.getpeereid(connection.fileno(), ctypes.byref(uid_value), ctypes.byref(gid_value)): + raise OSError(ctypes.get_errno(), 'kernel peer credential lookup failed') + pid = struct.unpack('i', connection.getsockopt(0, 2, 4))[0] # SOL_LOCAL, LOCAL_PEERPID + uid, gid = uid_value.value, gid_value.value + else: + raise PermissionError('kernel actor authentication is unavailable') + if pid <= 0 or uid == 2**32 - 1 or gid == 2**32 - 1: + raise PermissionError('kernel actor identity is unavailable') + return pid, uid, gid + + +def reserve_identity(uid, *, directory='/var/tmp'): + """Exclusive cross-parent claim; stale claims deliberately prevent reuse.""" + directory = Path(directory) + metadata = directory.lstat() + if (not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or metadata.st_mode & 0o022 and not metadata.st_mode & stat.S_ISVTX): + raise PermissionError('UID reservation directory must be owned and protected') + path = directory / f'.openkyrozen-benchmark-uid-{uid}' + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + os.write(descriptor, str(os.getpid()).encode()) + finally: + os.close(descriptor) + return path + + +def process_snapshot(): + """Read numeric kernel identities only, never commands or environments.""" + result = subprocess.run(['/bin/ps', '-axo', 'uid=,pid=,stat='], + capture_output=True, text=True, timeout=3, check=True) + return {int(pid): (int(uid), state) for uid, pid, state in + (line.split() for line in result.stdout.splitlines() if line.strip())} + + +def _registered_identity(uid): + import pwd + import grp + for lookup in (pwd.getpwuid, grp.getgrgid): + try: + lookup(uid) + return True + except KeyError: + pass + return False + + +class UIDOwner: + """One fresh UID per invocation; no persistent account or service is created. + + The trusted parent must be privileged. Unprivileged code cannot certify UID + ownership or silently fall back to ancestry/process-group cleanup. + """ + def __init__(self, *, uid=None): + if os.geteuid() != 0: + raise PermissionError('benchmark UID ownership requires a privileged parent') + if uid is not None and (type(uid) is not int or not 2**30 <= uid < 2**31): + raise ValueError('benchmark UID must be a distinct ephemeral numeric identity') + with _LOCK: + used = {value[0] for value in process_snapshot().values()} | _ALLOCATED + if uid is None: + for _ in range(128): + candidate = 2**30 + secrets.randbelow(2**29) + if candidate not in used and not _registered_identity(candidate): + try: + reservation = reserve_identity(candidate) + except FileExistsError: + continue + uid = candidate + break + else: + raise RuntimeError('no unused benchmark actor UID') + else: + if uid in used or _registered_identity(uid): + raise ValueError('benchmark UID is already in use') + try: + reservation = reserve_identity(uid) + except FileExistsError: + raise ValueError('benchmark UID is already in use') from None + _ALLOCATED.add(uid) + self.uid = uid + self.reservation = reservation + self.closed = False + self.process = None + self.namespace = None + self.close_lock = threading.RLock() + + def popen(self, command, *, cwd, env, group=None, stdin=None, stdout=None, stderr=None): + with self.close_lock: + return self._popen(command, cwd=cwd, env=env, group=group, stdin=stdin, stdout=stdout, stderr=stderr) + + def _popen(self, command, *, cwd, env, group, stdin, stdout, stderr): + """Drop credentials in subprocess' native exec path, never preexec_fn.""" + if self.closed or self.process is not None: + raise RuntimeError('benchmark UID invocation is already used or closed') + if (not isinstance(command, (list, tuple)) or not command + or any(not isinstance(arg, str) or '\0' in arg for arg in command)): + raise ValueError('benchmark command must be an argument vector') + group = self.uid if group is None else group + if type(group) is not int or not 2**30 <= group < 2**31: + raise ValueError('benchmark group must be an ephemeral numeric identity') + if sys.platform.startswith('linux') and Path(command[0]).name == 'bwrap': + from benchmarks.v3_linux_namespace import Namespace + try: + self.namespace = Namespace(self.uid, group) + self.namespace.__enter__() + self.process = subprocess.Popen(self.namespace.rewrite(command), + cwd=cwd, env=self.namespace.environment(env), pass_fds=self.namespace.pass_fds, extra_groups=[], + stdin=stdin, stdout=stdout, stderr=stderr, close_fds=True, start_new_session=True) + self.namespace.attach(self.process) + return self.process + except BaseException: + self.close() + raise + bootstrap = 'import os,resource,sys\n' + if sys.platform.startswith('linux'): + bootstrap += ('import ctypes\nlibc=ctypes.CDLL(None,use_errno=True)\n' + 'libc.prctl.argtypes=[ctypes.c_int]+[ctypes.c_ulong]*4\n' + 'if libc.prctl(38,1,0,0,0): raise PermissionError("no_new_privs unavailable")\n') + elif sys.platform == 'darwin': + denial = '(deny process-exec (require-any (file-mode #o4000) (file-mode #o2000)))' + command = list(command) + if command[0] == '/usr/bin/sandbox-exec': + if len(command) < 4 or command[1] != '-p': + raise ValueError('benchmark sandbox must use an explicit parent profile') + command[2] += denial + else: + command = ['/usr/bin/sandbox-exec', '-p', '(version 1)(allow default)' + denial, *command] + else: + raise PermissionError('benchmark UID execution has no supported privilege boundary') + bootstrap += ('resource.setrlimit(resource.RLIMIT_NPROC,(256,256))\n' + 'os.umask(0o002)\nos.execvpe(sys.argv[1],sys.argv[1:],os.environ)') + self.process = subprocess.Popen([sys.executable, '-I', '-S', '-c', bootstrap, *command], + cwd=cwd, env=env, user=self.uid, group=group, extra_groups=[], + stdin=stdin, stdout=stdout, stderr=stderr, close_fds=True, start_new_session=True) + return self.process + + def close(self): + with self.close_lock: + self._close_owned() + + def _close_owned(self): + if self.closed: + return + if self.namespace is not None: + self.namespace.close() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + owned = {pid: state for pid, (uid, state) in process_snapshot().items() + if uid == self.uid and not state.startswith(('Z', 'X'))} + if not owned: + if self.process is not None: + self.process.wait(timeout=1) + self.closed = True + if self.reservation is not None: + self.reservation.unlink() + # Never recycle an identity in this parent, even after cleanup. + return + stopped = all(state.startswith('T') for state in owned.values()) + for pid, state in owned.items(): + if stopped or not state.startswith('T'): + try: + os.kill(pid, signal.SIGKILL if stopped else signal.SIGSTOP) + except ProcessLookupError: + pass + time.sleep(.01) + raise RuntimeError('benchmark UID descendants could not be certified stopped') diff --git a/benchmarks/v3_protected_worker.py b/benchmarks/v3_protected_worker.py new file mode 100644 index 00000000..292613f0 --- /dev/null +++ b/benchmarks/v3_protected_worker.py @@ -0,0 +1,677 @@ +"""Fail-closed OS protection and external managed-shell preflight for V3 runs.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import shlex +import shutil +import socket +import subprocess +import sys +import tempfile +import threading + +from benchmarks.v3_shell_launcher import ShellBroker, ShellPolicy, _FD_ENV + + +def _validated_files(workspace, protected_files): + files = [] + for item in protected_files: + path = Path(item) + if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1: + raise ValueError('benchmark protection requires unaliased regular original test files') + resolved = path.resolve(strict=True) + if not resolved.is_relative_to(workspace): + raise ValueError('benchmark protection escaped the workspace') + files.append(resolved) + return files + + +def _mac_denials(files): + from openkyrozen.tools.sandbox import _sbpl_path + if not files: + return '' + ancestors = sorted({parent for path in files for parent in path.parents}) + return ('(deny file-write* ' + ' '.join(f'(literal {_sbpl_path(str(p))})' for p in files) + ')' + + '(deny file-write-unlink ' + ' '.join(f'(literal {_sbpl_path(str(p))})' for p in ancestors) + ')') + + +def _linux_mounts(workspace, files): + # Mount points cannot be renamed/unlinked, even by a process which can write + # adjacent source files. Each original test inode is read-only and unaliased. + directories = {parent for path in files for parent in path.parents + if parent == workspace or parent.is_relative_to(workspace)} + args = [] + for directory in sorted(directories, key=lambda p: len(p.parts)): + args.extend(['--bind', str(directory), str(directory)]) + for path in files: + args.extend(['--ro-bind', str(path), str(path)]) + return args + + +def protect_worker_command(command, *, cwd, env, protected_files): + workspace = Path(cwd).resolve(strict=True) + files = _validated_files(workspace, protected_files) + if sys.platform == 'darwin': + executable = shutil.which('sandbox-exec') + if executable: + profile = '(version 1)(allow default)' + _mac_denials(files) + return [executable, '-p', profile, *command], dict(env) + elif sys.platform.startswith('linux'): + executable = shutil.which('bwrap') + if executable: + # Do not relax user namespace restrictions to permit nested shells: + # managed shells run in the parent's separate sandbox instead. + args = [executable, '--die-with-parent', '--new-session', '--unshare-pid', + '--unshare-user', '--disable-userns', '--ro-bind', '/', '/', + '--bind', str(workspace.parent), str(workspace.parent), + '--proc', '/proc', '--dev-bind', '/dev', '/dev'] + args += _linux_mounts(workspace, files) + return [*args, '--chdir', str(workspace), '--', *command], dict(env) + raise ValueError('benchmark test-file protection has no supported OS backend') + + +def protected_shell_command(command, *, policy, cwd, env, protected_files): + """Extend the original shell sandbox in-place; never nest or replace it.""" + from openkyrozen.tools.sandbox import sandbox_command, _sbpl_path + invocation = sandbox_command(command, workspace=str(policy.workspace), cwd=cwd, env=env) + if invocation.backend == 'unavailable': + invocation.close() + raise PermissionError('the original managed shell sandbox is unavailable') + try: + files = _validated_files(policy.workspace, protected_files) + writable = policy.scope_paths if 'write' in policy.capabilities else () + if invocation.backend == 'sandbox-exec': + argv = list(invocation.command) + profile = argv[2] + original = f'(subpath {_sbpl_path(str(policy.workspace))})' + allowed = ' '.join(f'(subpath {_sbpl_path(str(p))})' for p in writable) + profile = profile.replace(original, allowed) + argv[2] = profile + _mac_denials(files) + invocation.command = argv + elif invocation.backend == 'bubblewrap': + argv = list(invocation.command) + separator = argv.index('--') + head, tail = argv[:separator], argv[separator:] + workspace = str(policy.workspace) + for index in range(len(head) - 2): + if head[index:index + 3] == ['--bind', workspace, workspace]: + head[index] = '--ro-bind' + break + # Apply ancestor mounts before writable children, otherwise a late + # read-only ancestor mount hides an already-mounted writable scope. + directories = {parent for p in files for parent in p.parents + if parent == policy.workspace or parent.is_relative_to(policy.workspace)} + directories.update(writable) + for path in sorted(directories, key=lambda p: len(p.parts)): + mode = '--bind' if any(path.is_relative_to(p) for p in writable) else '--ro-bind' + head.extend([mode, str(path), str(path)]) + for path in files: + head.extend(['--ro-bind', str(path), str(path)]) + invocation.command = [head[0], '--unshare-user', '--disable-userns', *head[1:], *tail] + else: + raise PermissionError('unsupported managed shell sandbox backend') + return invocation + except Exception: + invocation.close() + raise + + +class ProtectedWorker: + """Context wrapping one matched worker; attach immediately after Popen. + + Popen must receive command/env/pass_fds, then attach(process). The parent + context must remain alive through communicate(); it kills broker processes + on worker exit, socket disconnect, timeout, and context exit. + """ + def __init__(self, command, *, cwd, env, protected_files, policy): + self.policy = policy + self.protected_files = _validated_files(Path(cwd).resolve(), protected_files) + if Path(cwd).resolve() != policy.workspace: + raise ValueError('worker workspace must match parent policy') + parent, self.child = socket.socketpair() + self.child.set_inheritable(True) + self.broker = ShellBroker(parent, policy, self.protected_files) + identity = {key: getattr(policy, key) for key in ('run_id', 'actor_id', 'session_id', 'revision')} + worker_env = dict(env, **{_FD_ENV: str(self.child.fileno()), + 'KYROZEN_BENCHMARK_BROKER_IDENTITY': json.dumps(identity)}) + try: + self.command, self.env = protect_worker_command(command, cwd=cwd, env=worker_env, + protected_files=self.protected_files) + except Exception: + self.child.close() + self.broker.close() + raise + self.pass_fds = (self.child.fileno(),) + self.process = None + + def __enter__(self): + return self + + def attach(self, process): + if self.process is not None: + raise RuntimeError('protected launcher already attached') + self.process = process + self.child.close() + def monitor(): + process.wait() + self.broker.close() + threading.Thread(target=monitor, daemon=True).start() + + def __exit__(self, *args): + self.child.close() + self.broker.close() + if self.broker.thread is not threading.current_thread(): + self.broker.thread.join(timeout=5) + + +_ATTACK_SCRIPT = r''' +import json, os, pathlib, sys +base, workspace, nested, guard, source = map(pathlib.Path, sys.argv[1:6]) +out = {} +def attempt(name, fn): + try: + fn() + out[name] = False + except OSError: + out[name] = True +attempt('write', lambda: guard.write_text('changed')) +attempt('delete', lambda: guard.unlink()) +attempt('replace', lambda: os.replace(source, guard)) +attempt('hardlink', lambda: os.link(guard, nested / 'alias')) +attempt('symlink_write', lambda: ((nested / 'sym').symlink_to(guard), (nested / 'sym').write_text('changed'))) +attempt('nested_move', lambda: os.rename(nested, base / 'moved-nested')) +attempt('workspace_move', lambda: os.rename(workspace, base / 'moved-workspace')) +attempt('parent_move', lambda: os.rename(base, base.with_name(base.name + '-moved'))) +if len(sys.argv) > 6 and sys.argv[6] == 'readonly': + attempt('source_write_denied', lambda: source.write_text('after')) +else: + source.write_text('after') +print(json.dumps(out)) +''' + + +def _probe_environment(home): + """Static probes get platform paths and their own home, never parent secrets.""" + environment = {key: os.environ[key] for key in ('PATH', 'SYSTEMROOT', 'WINDIR', 'TMPDIR', 'TMP', 'TEMP') + if key in os.environ} + return dict(environment, HOME=str(home), USERPROFILE=str(home), PYTHONNOUSERSITE='1') + + +def _probe_detach(): + """Trusted static probe only; the owning parent must already have dropped UID.""" + import time + if os.geteuid() == 0: + raise PermissionError('a detached probe must never run as the privileged parent') + reader, writer = os.pipe() + child = os.fork() + if child == 0: + os.close(reader) + os.setsid() + os.write(writer, b'1') + os.closerange(0, 256) + time.sleep(120) + os._exit(0) + os.close(writer) + ready = os.read(reader, 1) + os.close(reader) + if ready != b'1': + raise RuntimeError('detached actor probe did not establish its new session') + return child + + +_ACTOR_PROBE_COMMON = r''' +import contextlib, io, json, os, pathlib, shlex, sys, time, uuid +sys.path.insert(0, sys.argv[1]) +from benchmarks.v3_process_owner import connect_actor_client +from benchmarks.v3_protected_worker import _ATTACK_SCRIPT, _probe_detach +configuration = json.loads(sys.argv[2]) +workspace = pathlib.Path(configuration['workspace']) +base = pathlib.Path(configuration['base']) +client = connect_actor_client() +pathlib.Path(os.environ['TMPDIR']).joinpath('actor-temp-probe').write_text('private') +def check(value, name): + if not value: + raise RuntimeError('actor preflight failed: ' + name) +def denied(operation, **values): + try: + client.rpc(operation, **values) + except PermissionError: + return True + return False +def collect(process): + process.wait(20) + result = {'exit': process.returncode} + for name in ('stdout', 'stderr'): + value = bytearray() + while chunk := getattr(process, name).read1(4096): + value.extend(chunk) + result[name] = value.decode() + return result +def raw_attacks(): + output = io.StringIO() + previous = sys.argv + sys.argv = ['probe', str(base), str(workspace), str(workspace/'nested'), + str(workspace/'nested/test_guard.py'), str(workspace/'nested/source.py'), 'readonly'] + try: + with contextlib.redirect_stdout(output): + exec(_ATTACK_SCRIPT, {}) + finally: + sys.argv = previous + return json.loads(output.getvalue()) +def private_read_denied(path): + try: + pathlib.Path(path).read_text() + except PermissionError: + return True + return False +def wait_release(name): + path = base/name + deadline = time.monotonic()+40 + while not path.exists(): + if time.monotonic() >= deadline: + raise RuntimeError('actor preflight parent did not release ' + name) + time.sleep(.01) + return json.loads(path.read_text()) +def daemon_command(): + code = ('import json,os,sys,time; sys.path.insert(0,sys.argv[1]); ' + 'from benchmarks.v3_protected_worker import _probe_detach; ' + '_probe_detach(); print(json.dumps({"uid":os.getuid()}),flush=True); time.sleep(120)') + process = client.start(shlex.join([sys.executable,'-I','-c',code,sys.argv[1]]), + str(workspace), dict(os.environ), 120, background=True) + value = bytearray() + while not value.endswith(b'\n'): + chunk = process.stdout.read1(4096) + check(bool(chunk), 'detached command ended before its announcement') + value.extend(chunk) + check(len(value)<4096, 'detached command announcement') + return process, json.loads(value)['uid'] +''' + + +_ROOT_ACTOR_PROBE = _ACTOR_PROBE_COMMON + r''' +raw = raw_attacks() +written = client.rpc('file_mutation',action='write_file',arguments={'path':'nested/source.py','content':'helper'}) +check(isinstance(written,dict) and written['error'] is None, 'root native file helper') +paths = [str(base),str(workspace),str(workspace/'nested'),str(workspace/'nested/test_guard.py'),str(workspace/'nested/source.py')] +managed = collect(client.start(shlex.join([sys.executable,'-c',_ATTACK_SCRIPT,*paths]), + str(workspace),dict(os.environ),10)) +check(managed['exit']==0, 'managed attacks command exit') +managed_attacks = json.loads(managed['stdout']) +normal = collect(client.start('printf out; printf err >&2; exit 7',str(workspace),dict(os.environ),5)) +check(normal == {'exit':7,'stdout':'out','stderr':'err'}, 'shell stdout stderr and exit') +for background, code in ((False,0),(True,7)): + late = 'late-background' if background else 'late-foreground' + closed = collect(client.start('exec >/dev/null 2>&1; sleep .15; printf done > '+late+'; exit '+str(code), + str(workspace),dict(os.environ),5,background=background)) + check(closed['exit']==code, 'closed pipe command exit status') + read = client.rpc('file_read',action='read_file',arguments={'path':late}) + check(read['data']['content']=='done', 'closed pipe command completed its work') +request = dict(client.identity,call_id=uuid.uuid4().hex,command='printf replay',cwd=str(workspace), + env={'PATH':os.environ['PATH']},timeout_seconds=5,background=False) +ticket = client.rpc('ticket',request=request) +started = client.rpc('start',ticket=ticket,request=request) +from benchmarks.v3_shell_launcher import RemoteProcess +collect(RemoteProcess(client,started['id'],started['pid'],started['backend'])) +replay_denied = denied('start',ticket=ticket,request=request) +environment_code = r""" +import json,os,pathlib +leaks=[key for key in os.environ if key.upper().startswith(('KYROZEN_','DYLD_','LD_','PYTHON')) + or any(word in key.upper() for word in ('TOKEN','SECRET','PASSWORD','API_KEY'))] +fds=[] +directory='/proc/self/fd' if os.path.isdir('/proc/self/fd') else '/dev/fd' +for descriptor in map(int,os.listdir(directory)): + if descriptor < 3: continue + try: os.fstat(descriptor) + except OSError: continue + fds.append(descriptor) +pathlib.Path(os.environ['TMPDIR']).joinpath('command-temp-probe').write_text('private') +print(json.dumps({'leaks':leaks,'fds':fds})) +""" +environment = collect(client.start(shlex.join([sys.executable,'-I','-c',environment_code]),str(workspace), + dict(os.environ,DEEPSEEK_API_KEY='fake-secret',GITHUB_TOKEN='fake-token', + KYROZEN_BENCHMARK_BROKER_FD='123',KYROZEN_BENCHMARK_ACTOR_SOCKET='fake-socket'),5)) +check(environment['exit']==0, 'command private temporary directory') +environment = json.loads(environment['stdout']) +private = pathlib.Path(os.environ['HOME'])/'root-private' +private.write_text(os.environ['KYROZEN_BENCHMARK_API_KEY']) +command, command_uid = daemon_command() +print(json.dumps({'phase':'root-ready','uid':os.getuid(),'identity':client.identity, + 'handle':command.identifier,'command_uid':command_uid,'private':str(private)}),flush=True) +released = wait_release('release-root') +homes_isolated = private_read_denied(released['child_private']) +command.stop() +command.wait(5) +cancelled = command.poll() is not None +disconnected, disconnect_uid = daemon_command() +print(json.dumps({'phase':'disconnect-ready','uid':disconnect_uid}),flush=True) +wait_release('release-disconnect') +result = {'raw':raw,'managed':managed_attacks,'replay_denied':replay_denied, + 'shell_environment_isolated':not environment['leaks'],'shell_descriptors_isolated':not environment['fds'], + 'private_home_isolated':homes_isolated,'actor_tmpdir_writable':True,'command_cancelled':cancelled, + 'closed_pipe_exit_preserved':True} +client.stream.close() +client.connection.close() +print(json.dumps(result),flush=True) +''' + + +_CHILD_ACTOR_PROBE = _ACTOR_PROBE_COMMON + r''' +root = configuration['root'] +request = dict(root['identity'],call_id=uuid.uuid4().hex,command='printf forbidden',cwd=str(workspace), + env={'PATH':os.environ['PATH']},timeout_seconds=5,background=False) +forged = denied('ticket',request=request) and denied('hello',actor_id=root['identity']['actor_id']) +cross_handles = denied('poll',id=root['handle']) and denied('stop',id=root['handle']) +child_launch = denied('delegated_launch',payload={}) +root_private = private_read_denied(root['private']) +check(os.environ['KYROZEN_BENCHMARK_API_KEY'] != configuration['root_key'], 'per actor relay credential') +raw = raw_attacks() +directory_shell = None +if configuration.get('directory_shell'): + paths = [str(base),str(workspace),str(workspace/'nested'),str(workspace/'nested/test_guard.py'),str(workspace/'nested/source.py')] + managed = collect(client.start(shlex.join([sys.executable,'-c',_ATTACK_SCRIPT,*paths]), + str(workspace/'nested'),dict(os.environ),10)) + check(managed['exit']==0,'directory-scoped managed shell exit') + directory_shell = json.loads(managed['stdout']) +read = client.rpc('file_read',action='read_file',arguments={'path':'nested/source.py'}) +changed = client.rpc('file_mutation',action='edit_file',arguments={'path':'nested/source.py', + 'old_text':read['data']['content'],'new_text':'child','expected_sha256':read['data']['sha256']}) +check(changed['error'] is None,'child original atomic editor') +fresh = client.rpc('file_read',action='read_file',arguments={'path':'nested/source.py'}) +created = client.rpc('file_mutation',action='write_file',arguments={'path':'nested/created.py','content':'new'}) +check(created['error'] is None,'child original file creation') +new = client.rpc('file_read',action='read_file',arguments={'path':'nested/created.py'}) +fresh_reads = fresh['data']['content']=='child' and new['data']['content']=='new' +guard_denied = denied('file_mutation',action='write_file',arguments={'path':'nested/test_guard.py','content':'bad'}) +private = pathlib.Path(os.environ['HOME'])/'child-private' +private.write_text(os.environ['KYROZEN_BENCHMARK_API_KEY']) +_probe_detach() +print(json.dumps({'phase':'child-ready','uid':os.getuid(),'identity':client.identity,'private':str(private), + 'forged_identity_denied':forged,'cross_actor_handles_denied':cross_handles, + 'child_launch_denied':child_launch,'private_home_isolated':root_private, + 'fresh_file_reads':fresh_reads,'guard_mutation_denied':guard_denied,'raw':raw, + 'actor_tmpdir_writable':True,'directory_shell':directory_shell}),flush=True) +wait_release(configuration['release']) +client.stream.close() +client.connection.close() +print(json.dumps({'finished':True}),flush=True) +''' + + +_NO_READ_ACTOR_PROBE = _ACTOR_PROBE_COMMON + r''' +from openkyrozen.app.bootstrap import build_application +application = build_application(surface='cli',user_id='preflight') +application.runtime._set_workspace_root(str(workspace)) +application.close() +try: + (workspace/'nested/source.py').read_text() + denied=False +except OSError: + denied=True +try: + names=[path.name for path in workspace.iterdir()] +except OSError: + names=[] +print(json.dumps({'started':True,'read_denied':denied,'names':names}),flush=True) +''' + + +def _verify_actor_worker_protection(): + """Exercise static hostile actors; this does not replace product-driver tests.""" + import selectors + import time + import uuid + from benchmarks.v3_actor_supervisor import ActorAssignment, ActorSupervisor, _communicate + from benchmarks.v3_process_owner import process_snapshot + + def line(process): + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + if not selector.select(40): + raise ValueError('benchmark actor preflight did not announce readiness') + encoded = process.stdout.readline(65537) + if not encoded.endswith(b'\n') or len(encoded) > 65536: + diagnostic = '' + try: + os.set_blocking(process.stderr.fileno(), False) + diagnostic = os.read(process.stderr.fileno(), 65536).decode(errors='replace')[-500:] + except OSError: + pass + raise ValueError('benchmark actor preflight returned an invalid readiness message: '+diagnostic) + return json.loads(encoded) + + def live_count(uid): + return sum(identity == uid and not state.startswith(('Z', 'X')) + for identity, state in process_snapshot().values()) + + def expect_uid(uid, *, minimum=0): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + count = live_count(uid) + if (minimum and count >= minimum) or (not minimum and count == 0): + return + time.sleep(.01) + raise ValueError('benchmark actor preflight kernel UID lifecycle probe failed') + + def release(base, name, value=None): + temporary = base / (name + '.pending') + temporary.write_text(json.dumps(value or {}), encoding='utf-8') + temporary.chmod(0o644) + temporary.replace(base / name) + + with tempfile.TemporaryDirectory(prefix='v3-actor-protection-', dir='/var/tmp') as directory: + base = Path(directory).resolve() + base.chmod(0o755) + workspace = base / 'workspace' + nested = workspace / 'nested' + nested.mkdir(parents=True) + guard, source_file = nested / 'test_guard.py', nested / 'source.py' + guard.write_text('original') + source_file.write_text('before') + selected_source = Path(__file__).resolve().parents[1] + configuration = {'workspace': str(workspace), 'base': str(base)} + command = [sys.executable, '-I', '-c', _ROOT_ACTOR_PROBE, str(selected_source), + json.dumps(configuration), '--model', 'preflight-model'] + policy = ShellPolicy(workspace, {'read', 'write', 'shell', 'network'}, actor_id='preflight-root') + issued, revoked = set(), set() + def provider(actor_id): + issued.add(actor_id) + return {'provider': 'openai', 'api_key': 'preflight-' + uuid.uuid4().hex, + 'base_url': 'http://127.0.0.1:9/v1', 'model_main': 'preflight-model', + 'model_simple': 'preflight-model', 'model_complex': 'preflight-model'} + environment = dict(_probe_environment(base), KYROZEN_DISABLE_VECTOR_INDEX='1') + with ActorSupervisor(command, cwd=workspace, env=environment, + protected_files=[guard], policy=policy, source=selected_source, + provider_config=provider, revoke_actor=revoked.add) as supervisor: + root = supervisor.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + root_ready = line(root) + root_actor = supervisor.root_actor + if (root_ready['phase'] != 'root-ready' or root_ready['uid'] != root_actor['owner'].uid + or root_ready['identity']['actor_id'] != policy.actor_id): + raise ValueError('benchmark root actor kernel identity probe failed') + expect_uid(root_ready['command_uid'], minimum=2) + with socket.socket(socket.AF_UNIX) as outsider: + outsider.settimeout(5) + outsider.connect(supervisor.address) + outsider.sendall(b'{"op":"hello"}\n') + with outsider.makefile('rb') as stream: + unauthenticated = json.loads(stream.readline(4097)) + if unauthenticated.get('ok') is not False: + raise ValueError('unregistered kernel UID obtained benchmark actor authority') + def child_probe(name, scope, *, directory_shell=False): + (nested / 'sym').unlink(missing_ok=True) + payload = {'schema_version': 1, 'source': str(selected_source), 'workspace': str(workspace), + 'review': False, 'report_repair': False, 'parent': {'model': 'preflight-model'}, + 'run': {'run_id': name, 'profile': 'coder', 'assignment': {'scope': scope}}} + assignment = ActorAssignment(policy, payload, source=selected_source, + model='preflight-model', provider='openai') + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'state': 'running', 'cancelled': False, 'disconnected': False} + child_configuration = dict(configuration, root=root_ready, directory_shell=directory_shell, + root_key=root_actor['provider_config']['api_key'], release='release-'+name) + child = supervisor._spawn([sys.executable, '-I', '-c', _CHILD_ACTOR_PROBE, + str(selected_source), json.dumps(child_configuration)], actor, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ready = line(child) + if (ready['phase'] != 'child-ready' or ready['uid'] != actor['owner'].uid + or ready['uid'] == root_ready['uid'] + or ready['identity']['actor_id'] != assignment.shell_policy.actor_id): + raise ValueError('benchmark child actor kernel identity probe failed') + expect_uid(ready['uid'], minimum=2) + expect_uid(root_ready['command_uid'], minimum=2) + release(base, 'release-'+name) + output, error = _communicate(child, actor['owner'], timeout=20) + if child.returncode or json.loads(output) != {'finished': True}: + raise ValueError('benchmark child actor preflight failed: ' + error.decode()[-500:]) + expect_uid(ready['uid']) + return ready + children = [child_probe('file-child', ['nested/source.py', 'nested/created.py']), + child_probe('directory-child', ['nested'], directory_shell=True)] + release(base, 'release-root', {'child_private': children[0]['private']}) + disconnect_ready = line(root) + if disconnect_ready['phase'] != 'disconnect-ready': + raise ValueError('benchmark shell disconnect probe did not start') + expect_uid(root_ready['command_uid']) + expect_uid(disconnect_ready['uid'], minimum=2) + release(base, 'release-disconnect') + root_output, root_error = _communicate(root, root_actor['owner'], timeout=20) + if root.returncode: + raise ValueError('benchmark root actor preflight failed: ' + root_error.decode()[-500:]) + root_result = json.loads(root_output) + expect_uid(disconnect_ready['uid']) + expected = {'write', 'delete', 'replace', 'hardlink', 'symlink_write', + 'nested_move', 'workspace_move', 'parent_move'} + attacks = [(root_result['raw'], expected | {'source_write_denied'}), + (root_result['managed'], expected), (children[1]['directory_shell'], expected)] + attacks += [(child['raw'], expected | {'source_write_denied'}) for child in children] + for values, keys in attacks: + if set(values) != keys or not all(value is True for value in values.values()): + raise ValueError('benchmark actor raw or managed attack was not denied') + checks = [root_result[key] for key in ('replay_denied', 'shell_environment_isolated', + 'shell_descriptors_isolated', 'private_home_isolated', 'actor_tmpdir_writable', 'command_cancelled', + 'closed_pipe_exit_preserved')] + checks += [child[key] for child in children for key in ('forged_identity_denied', 'cross_actor_handles_denied', + 'child_launch_denied', 'private_home_isolated', 'fresh_file_reads', 'guard_mutation_denied', + 'actor_tmpdir_writable')] + if (not all(value is True for value in checks) or guard.read_text() != 'original' + or source_file.read_text() != 'child'): + raise ValueError('benchmark actor authorization or native file helper probe failed') + owners = tuple(supervisor.owners) + owned = {owner.uid for owner in owners} + if not all(owner.closed for owner in owners) or any( + uid in owned and not state.startswith(('Z', 'X')) + for uid, state in process_snapshot().values()): + raise ValueError('benchmark actor attack writers were not certified stopped') + alias = nested / 'sym' + if alias.is_symlink() and alias.readlink() == guard: + alias.unlink() + elif alias.exists() or alias.is_symlink(): + raise ValueError('benchmark actor attack left an unexpected alias') + for repair in (False, True): + no_read_policy = ShellPolicy(workspace, {'read', 'write'} if repair else set()) + no_read_command = [sys.executable, '-I', '-c', _NO_READ_ACTOR_PROBE, + str(selected_source), json.dumps(configuration)] + with ActorSupervisor(no_read_command, cwd=workspace, env=environment, + protected_files=[guard], policy=no_read_policy, source=selected_source) as no_read: + if repair: + payload = {'schema_version': 1, 'source': str(selected_source), 'workspace': str(workspace), + 'review': False, 'report_repair': True, 'parent': {'model': None}, + 'run': {'run_id': 'report-repair', 'profile': 'coder', 'assignment': {'scope': ['.']}}} + assignment = ActorAssignment(no_read_policy, payload, source=selected_source, model=None) + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'state': 'running', 'cancelled': False, 'disconnected': False} + process = no_read._spawn(no_read_command, actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + process = no_read.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + actor = no_read.root_actor + output, error = _communicate(process, actor['owner'], timeout=30) + if process.returncode or json.loads(output) != {'started': True, 'read_denied': True, 'names': []}: + raise ValueError('benchmark no-read native startup or data isolation probe failed: '+error.decode()[-500:]) + owners += tuple(no_read.owners) + live = sorted(pid for pid, (uid, state) in process_snapshot().items() + if uid in {owner.uid for owner in owners} and not state.startswith(('Z', 'X'))) + if live or not all(owner.closed for owner in owners) or issued != revoked: + raise ValueError('benchmark actor preflight did not certify all owned UIDs and relay aliases closed') + return {'authenticated_child_isolation': True, 'daemonized_process_cleanup_proven': True, + 'direct_attacks_denied': True, 'managed_shell_attacks_denied': True, 'source_write_allowed': True, + 'forged_identity_denied': True, 'cross_actor_handles_denied': True, 'ticket_replay_denied': True, + 'private_homes_isolated': True, 'fresh_file_reads': True, 'shell_environment_isolated': True, + 'shell_descriptors_isolated': True, 'actor_tmpdir_writable': True, 'command_cancel_cleanup': True, + 'actor_disconnect_cleanup': True, 'shell_disconnect_cleanup': True, 'all_owned_uids_empty': True, + 'directory_scope_guard_safe': True, 'closed_pipe_exit_preserved': True, + 'no_read_runtime_started': True, 'no_read_fixture_data_denied': True, + 'owned_uid_live_processes': live, 'checked_uid_count': len(owners), + 'probe': 'trusted-static-actors', 'backend': 'sandbox-exec' if sys.platform == 'darwin' else 'bubblewrap'} + + +def verify_worker_protection(*, require_authenticated_children=True): + """Run real attacks before key lookup with a privileged owning parent. + + False selects component diagnostics only and never asserts actor isolation or + detached process cleanup. The returned false evidence is release-blocking. + """ + if require_authenticated_children: + if not hasattr(os, 'geteuid') or os.geteuid() != 0: + raise PermissionError('full benchmark protection requires a privileged parent') + return _verify_actor_worker_protection() + with tempfile.TemporaryDirectory(prefix='v3-protection-') as directory: + base = Path(directory).resolve() + workspace = base / 'workspace' + nested = workspace / 'nested' + nested.mkdir(parents=True) + guard, source = nested / 'test_guard.py', nested / 'source.py' + guard.write_text('original') + source.write_text('before') + paths = list(map(str, (base, workspace, nested, guard, source))) + command = [sys.executable, '-c', _ATTACK_SCRIPT, *paths] + wrapped, env = protect_worker_command(command, cwd=workspace, env=_probe_environment(base), protected_files=[guard]) + result = subprocess.run(wrapped, cwd=workspace, env=env, capture_output=True, text=True, timeout=30) + try: + values = json.loads(result.stdout) + except ValueError as exc: + raise ValueError('benchmark direct file protection probe failed: ' + result.stderr[-500:]) from exc + expected = {'write', 'delete', 'replace', 'hardlink', 'symlink_write', 'nested_move', 'workspace_move', 'parent_move'} + if result.returncode or set(values) != expected or not all(values.values()) or guard.read_text() != 'original' or source.read_text() != 'after': + raise ValueError('benchmark direct file protection probe failed') + # Remove the denied symlink-write probe's harmless alias before repeating. + (nested / 'sym').unlink(missing_ok=True) + source.write_text('before') + shell_command = shlex.join(command) + script = ''' +import json, os, sys +from benchmarks.v3_shell_launcher import install_worker_adapter +install_worker_adapter() +from openkyrozen.tools.processes import SessionProcessManager +m = SessionProcessManager('owner', sys.argv[1], 'session') +h = m.start(sys.argv[2], cwd=sys.argv[1], env=dict(os.environ), timeout_seconds=10) +h.process.wait(20) +import time +deadline = time.monotonic() + 3 +while not (h.process.stdout.closed and h.process.stderr.closed) and time.monotonic() < deadline: + time.sleep(.01) +print(json.dumps({'exit': h.process.returncode, 'out': h.stdout, 'err': h.stderr})) +m.close() +''' + env = dict(_probe_environment(base), PYTHONPATH=str(Path(__file__).resolve().parents[1]), + KYROZEN_SANDBOX_REQUIRED='1') + policy = ShellPolicy(workspace, {'read', 'write', 'shell'}) + with ProtectedWorker([sys.executable, '-c', script, str(workspace), shell_command], + cwd=workspace, env=env, protected_files=[guard], policy=policy) as launch: + process = subprocess.Popen(launch.command, cwd=workspace, env=launch.env, + pass_fds=launch.pass_fds, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + launch.attach(process) + stdout, stderr = process.communicate(timeout=40) + try: + managed = json.loads(stdout) + values = json.loads(managed['out']) + except (ValueError, KeyError) as exc: + raise ValueError('benchmark external managed shell protection probe failed: ' + stderr.decode()[-500:]) from exc + if (process.returncode or managed['exit'] or set(values) != expected or not all(values.values()) + or guard.read_text() != 'original' or source.read_text() != 'after'): + raise ValueError('benchmark external managed shell protection probe failed') + return {'authenticated_child_isolation': False, 'daemonized_process_cleanup_proven': False, + 'direct_attacks_denied': True, 'managed_shell_attacks_denied': True, + 'source_write_allowed': True, 'backend': 'sandbox-exec' if sys.platform == 'darwin' else 'bubblewrap'} diff --git a/benchmarks/v3_release_gates.py b/benchmarks/v3_release_gates.py new file mode 100644 index 00000000..08f0c39f --- /dev/null +++ b/benchmarks/v3_release_gates.py @@ -0,0 +1,503 @@ +"""Validate and sign exact-candidate V3 release-gate evidence bundles.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +GATE_IDS = tuple(f"R{number}" for number in range(250, 267)) +REQUIRED_PLATFORMS = ( + "linux-py312", "linux-py313", "macos-py312", "macos-py313", "windows-py312", "windows-py313", +) +SIGNER_FINGERPRINT = "D3897E8FF704149D609ACF878C118C04263D2A4C" +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_GIT_SHA = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") +LIMITED_RELEASE_SCOPE = "openkyrozen-v3-preview-linux-macos-sandbox-required-v1" +WAIVABLE_INCONCLUSIVE_GATES = {"R259", "R262", "R263", "R264"} +WAIVABLE_FAILURE_CLASSES = {"provider_upstream_error"} +SAFETY_UNIT_LOGS = tuple( + f"platforms/{platform}/unit.log" + for platform in ("linux-py312", "linux-py313", "macos-py312", "macos-py313") +) +SAFETY_TESTS = ( + "test_apply_patch_is_atomic_stale_safe_and_rejects_ambiguous_hunks", + "test_edit_file_requires_matching_hash_and_unique_old_text", + "test_required_unavailable_sandbox_blocks_command_before_execution", + "test_installed_but_unusable_backend_blocks_when_sandbox_is_required", + "test_linux_sandbox_allows_workspace_writes_and_denies_sibling_writes", + "test_macos_sandbox_allows_workspace_writes_and_denies_sibling_writes", + "test_define_tool_text_is_rejected_without_registration", + "test_full_capability_profile_does_not_grant_dynamic_registration", + "test_approval_decisions_are_scoped_versioned_history_not_authority", + "test_failed_migration_rolls_back_schema_and_version", + "test_incompatible_existing_table_rolls_back_migration", +) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_signed_pair(record_path: Path, signature_path: Path, verify_signature) -> tuple[bytes, bytes, bool]: + record_bytes = record_path.read_bytes() + signature_bytes = signature_path.read_bytes() + with tempfile.TemporaryDirectory(prefix="v3-evidence-verify-") as temporary: + staged = Path(temporary) / record_path.parent.name + staged.mkdir() + staged_record = staged / record_path.name + staged_signature = staged / signature_path.name + staged_record.write_bytes(record_bytes) + staged_signature.write_bytes(signature_bytes) + verified = verify_signature(staged_record, staged_signature) + return record_bytes, signature_bytes, verified + + +def _contained_file(root: Path, relative: str) -> Path: + if not isinstance(relative, str) or not relative or Path(relative).is_absolute(): + raise ValueError("evidence artifact path must be a nonempty relative path") + path = (root / relative).resolve() + try: + path.relative_to(root) + except ValueError: + raise ValueError("evidence artifact escapes evidence directory") from None + if not path.is_file(): + raise ValueError("evidence artifact is not a regular file") + return path + + +def _unit_test_passed(lines: list[str], method: str) -> bool: + for index, line in enumerate(lines): + if not line.startswith(method + " ("): + continue + if line.endswith(" ... ok"): + return True + for following in lines[index + 1:]: + if following.startswith("test_") and " (" in following: + break + if following == "ok": + return True + return False + return False + + +def _check_record(record: object, gate_id: str, candidate_sha: str, evidence_dir: Path) -> list[dict]: + if not isinstance(record, dict) or record.get("schema_version") != 1: + raise ValueError(f"{gate_id}: unsupported evidence record schema") + if record.get("gate_id") != gate_id: + raise ValueError(f"{gate_id}: record gate ID does not match its filename") + if record.get("candidate_sha") != candidate_sha: + raise ValueError(f"{gate_id}: candidate SHA does not match the current candidate") + for name in ("command_set_sha256", "fixture_set_sha256"): + if not isinstance(record.get(name), str) or not _SHA256.fullmatch(record[name]): + raise ValueError(f"{gate_id}: {name} must be a lowercase SHA-256 digest") + environment = record.get("environment") + if not isinstance(environment, dict) or any(not isinstance(environment.get(key), str) or not environment[key] + for key in ("runner", "os", "python")): + raise ValueError(f"{gate_id}: runner, OS, and Python environment details are required") + if not isinstance(record.get("dependencies"), dict): + raise ValueError(f"{gate_id}: dependency inventory must be an object") + for path_key, hash_key, description in ( + ("command_set_path", "command_set_sha256", "command-set"), + ("fixture_set_path", "fixture_set_sha256", "fixture-set"), + ): + path = _contained_file(evidence_dir, record.get(path_key)) + if _sha256(path) != record[hash_key]: + raise ValueError(f"{gate_id}: {description} hash mismatch") + result = record.get("result") + if not isinstance(result, str) or result not in {"passed", "failed", "inconclusive"}: + raise ValueError(f"{gate_id}: invalid result") + if gate_id == "R259": + if "protocol_recovery_count" not in record: + raise ValueError("R259: protocol_recovery_count is required (use null when unavailable)") + recovery_count = record.get("protocol_recovery_count") + if recovery_count is not None and (type(recovery_count) is not int or recovery_count < 0): + raise ValueError("R259: protocol_recovery_count must be a nonnegative integer or null") + if recovery_count and result != "failed": + raise ValueError("R259: nonzero protocol recovery count is a non-waivable failed result") + if result == "passed" and recovery_count != 0: + raise ValueError("R259: passed evidence requires protocol_recovery_count == 0") + if result != "passed": + if not isinstance(record.get("failure_class"), str) or not record["failure_class"].strip(): + raise ValueError(f"{gate_id}: nonpassing evidence requires a failure class") + if result == "failed": + raise ValueError(f"{gate_id}: failed evidence cannot be waived ({record['failure_class']})") + elif record.get("failure_class") is not None: + raise ValueError(f"{gate_id}: passing evidence cannot have a failure class") + refs = record.get("evidence") + if not isinstance(refs, list) or not refs: + raise ValueError(f"{gate_id}: at least one evidence artifact is required") + artifacts = [] + for ref in refs: + if not isinstance(ref, dict) or not isinstance(ref.get("sha256"), str) or not _SHA256.fullmatch(ref["sha256"]): + raise ValueError(f"{gate_id}: invalid evidence artifact reference") + path = _contained_file(evidence_dir, ref.get("path")) + actual = _sha256(path) + if actual != ref["sha256"]: + raise ValueError(f"{gate_id}: evidence hash mismatch for {ref['path']}") + artifacts.append({"path": path.relative_to(evidence_dir).as_posix(), "sha256": actual}) + if gate_id == "R266": + platforms = record.get("platform_results") + if not isinstance(platforms, list) or len(platforms) != len(REQUIRED_PLATFORMS): + raise ValueError("R266: platform matrix must contain exactly six required platforms") + if any(not isinstance(item, dict) or not isinstance(item.get("platform"), str) + for item in platforms): + raise ValueError("R266: platform matrix must contain exactly six required platforms") + indexed = {item["platform"]: item for item in platforms} + if set(indexed) != set(REQUIRED_PLATFORMS): + raise ValueError("R266: platform matrix must contain exactly six required platforms") + refs = record.get("evidence") + artifact_paths = {ref["path"] for ref in refs + if isinstance(ref, dict) and isinstance(ref.get("path"), str)} if isinstance(refs, list) else set() + for platform in REQUIRED_PLATFORMS: + item = indexed[platform] + if platform.startswith("windows-"): + checks_failed = ( + item.get("unit") != "not_run" + or item.get("integration") != "not_run" + or item.get("supported_features") != "passed" + or item.get("acceptance") != "passed" + or item.get("tui_interactive_acceptance") != "not_run_posix_pty_unavailable" + ) + else: + checks_failed = any(item.get(check) != "passed" + for check in ("unit", "integration", "acceptance")) + if checks_failed: + raise ValueError(f"R266: platform validation failed for {platform}") + if not isinstance(item.get("soak_seconds"), int) or item["soak_seconds"] < 1800: + raise ValueError(f"R266: platform {platform} has not passed the 30-minute soak") + evidence_paths = item.get("evidence_paths") + if not isinstance(evidence_paths, list) or not evidence_paths or any( + not isinstance(path, str) or path not in artifact_paths for path in evidence_paths): + raise ValueError(f"R266: platform evidence is missing or unlinked for {platform}") + return artifacts + + +def _verify_safety_logs(gate_id: str, evidence_dir: Path, expected_hashes: dict) -> None: + for relative in SAFETY_UNIT_LOGS: + data = _contained_file(evidence_dir, relative).read_bytes() + if hashlib.sha256(data).hexdigest() != expected_hashes.get(relative): + raise ValueError(f"{gate_id}: safety evidence hash mismatch for {relative}") + log = data.decode("utf-8", errors="replace") + if not re.search(r"(?m)^Ran \d+ tests? in [^\n]+$", log) or not re.search(r"(?m)^OK(?: \(skipped=\d+\))?$", log): + raise ValueError(f"{gate_id}: safety test evidence is missing or did not pass for {relative}") + lines = log.splitlines() + methods = SAFETY_TESTS + if relative.startswith("platforms/linux-"): + methods = tuple(method for method in methods if not method.startswith("test_macos_sandbox")) + else: + methods = tuple(method for method in methods if not method.startswith("test_linux_sandbox")) + for method in methods: + if not _unit_test_passed(lines, method): + raise ValueError(f"{gate_id}: safety test evidence is missing or did not pass: {method}") + + +def _check_waiver(gate_id: str, candidate_sha: str, release_scope: str, + evidence_dir: Path, records: dict, *, verify_signature) -> dict: + if gate_id not in WAIVABLE_INCONCLUSIVE_GATES: + raise ValueError(f"{gate_id}: inconclusive gate is not waivable") + record = records[gate_id]["record"] + if record.get("failure_class") not in WAIVABLE_FAILURE_CLASSES: + raise ValueError(f"{gate_id}: failure class is non-waivable") + waiver_path = _contained_file(evidence_dir, f"waivers/{gate_id}.json") + signature_path = _contained_file(evidence_dir, f"waivers/{gate_id}.json.asc") + waiver_bytes, waiver_signature_bytes, verified = _read_signed_pair( + waiver_path, signature_path, verify_signature, + ) + if not verified: + raise ValueError(f"{gate_id}: waiver signature verification failed") + try: + waiver = json.loads(waiver_bytes) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{gate_id}: invalid waiver JSON: {exc}") from None + if not isinstance(waiver, dict) or waiver.get("schema_version") != 1: + raise ValueError(f"{gate_id}: unsupported waiver schema") + if waiver.get("gate_id") != gate_id: + raise ValueError(f"{gate_id}: waiver gate ID does not match its filename") + if waiver.get("candidate_sha") != candidate_sha: + raise ValueError(f"{gate_id}: waiver candidate SHA does not match the current candidate") + if waiver.get("release_scope") != release_scope: + raise ValueError(f"{gate_id}: waiver release scope does not match the manifest scope") + if waiver.get("approver_identity") != SIGNER_FINGERPRINT: + raise ValueError(f"{gate_id}: waiver approver does not match the pinned release owner") + if not isinstance(waiver.get("rationale"), str) or not waiver["rationale"].strip(): + raise ValueError(f"{gate_id}: waiver rationale is required") + limitations = waiver.get("limitations") + if not isinstance(limitations, list) or not limitations or any( + not isinstance(item, str) or not item.strip() for item in limitations): + raise ValueError(f"{gate_id}: waiver limitations are required") + has_expiry = isinstance(waiver.get("expires_at"), str) and bool(waiver["expires_at"].strip()) + has_review = isinstance(waiver.get("review_condition"), str) and bool(waiver["review_condition"].strip()) + if has_expiry == has_review: + raise ValueError(f"{gate_id}: specify exactly one waiver expiry or review condition") + if has_expiry: + try: + expiry = datetime.fromisoformat(waiver["expires_at"].replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"{gate_id}: waiver expiry must be an ISO-8601 timestamp") from None + if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc): + raise ValueError(f"{gate_id}: waiver expiry must be a future timezone-aware timestamp") + refs = waiver.get("evidence") + if not isinstance(refs, list) or not refs: + raise ValueError(f"{gate_id}: waiver evidence references are required") + artifacts = [] + for ref in refs: + if not isinstance(ref, dict) or not isinstance(ref.get("sha256"), str) or not _SHA256.fullmatch(ref["sha256"]): + raise ValueError(f"{gate_id}: invalid waiver evidence reference") + path = _contained_file(evidence_dir, ref.get("path")) + actual = _sha256(path) + if actual != ref["sha256"]: + raise ValueError(f"{gate_id}: waiver evidence hash mismatch for {ref['path']}") + artifacts.append({"path": path.relative_to(evidence_dir).as_posix(), "sha256": actual}) + by_path = {ref["path"]: ref["sha256"] for ref in artifacts} + required = {f"{gate_id}.json": records[gate_id]["record_sha256"], + "R266.json": records["R266"]["record_sha256"]} + required.update({path: _sha256(_contained_file(evidence_dir, path)) for path in SAFETY_UNIT_LOGS}) + if any(by_path.get(path) != digest for path, digest in required.items()): + raise ValueError(f"{gate_id}: missing safety test evidence or evidence for the inconclusive record") + r266_refs = {ref["path"]: ref["sha256"] for ref in records["R266"]["artifacts"]} + if any(r266_refs.get(path) != digest for path, digest in required.items() if path in SAFETY_UNIT_LOGS): + raise ValueError(f"{gate_id}: safety test evidence is not bound by the signed R266 platform record") + _verify_safety_logs(gate_id, evidence_dir, r266_refs) + return { + "record_path": waiver_path.relative_to(evidence_dir).as_posix(), + "record_sha256": hashlib.sha256(waiver_bytes).hexdigest(), + "signature_path": signature_path.relative_to(evidence_dir).as_posix(), + "signature_sha256": hashlib.sha256(waiver_signature_bytes).hexdigest(), + "approver_identity": waiver["approver_identity"], + "release_scope": waiver["release_scope"], + "rationale": waiver["rationale"], + "limitations": limitations, + **({"expires_at": waiver["expires_at"]} if has_expiry else {"review_condition": waiver["review_condition"]}), + "evidence": artifacts, + } + + +def validate_evidence_bundle(evidence_dir: Path, candidate_sha: str, *, verify_signature, + limited_evidence: bool = False, release_scope: str | None = None) -> dict: + """Validate full evidence, or a signed, exact-candidate limited-evidence preview.""" + evidence_dir = Path(evidence_dir).resolve(strict=True) + if not _GIT_SHA.fullmatch(candidate_sha): + raise ValueError("candidate SHA must be a full Git object ID") + if limited_evidence: + if release_scope != LIMITED_RELEASE_SCOPE: + raise ValueError(f"limited-evidence preview scope must be {LIMITED_RELEASE_SCOPE}") + waivers_dir = (evidence_dir / "waivers").resolve(strict=True) + elif release_scope is not None: + raise ValueError("release scope is only valid for limited-evidence preview") + records = {} + for gate_id in GATE_IDS: + try: + record_path = _contained_file(evidence_dir, f"{gate_id}.json") + signature_path = _contained_file(evidence_dir, f"{gate_id}.json.asc") + except ValueError: + raise ValueError(f"missing gate record or signature for {gate_id}") + record_bytes, signature_bytes, verified = _read_signed_pair( + record_path, signature_path, verify_signature, + ) + if not verified: + raise ValueError(f"{gate_id}: signature verification failed") + try: + record = json.loads(record_bytes) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{gate_id}: invalid JSON record: {exc}") from None + artifacts = _check_record(record, gate_id, candidate_sha, evidence_dir) + records[gate_id] = { + "record": record, + "artifacts": artifacts, + "record_path": record_path.name, + "record_sha256": hashlib.sha256(record_bytes).hexdigest(), + "signature_path": signature_path.name, + "signature_sha256": hashlib.sha256(signature_bytes).hexdigest(), + "command_set_sha256": record["command_set_sha256"], + "fixture_set_sha256": record["fixture_set_sha256"], + "command_set_path": record["command_set_path"], + "fixture_set_path": record["fixture_set_path"], + "environment": record["environment"], + "dependencies": record["dependencies"], + "evidence": artifacts, + } + inconclusive = [gate_id for gate_id in GATE_IDS if records[gate_id]["record"]["result"] == "inconclusive"] + if not limited_evidence and inconclusive: + gate_id = inconclusive[0] + raise ValueError(f"{gate_id}: gate did not pass ({records[gate_id]['record']['failure_class']})") + if limited_evidence and not inconclusive: + raise ValueError("limited-evidence preview requires at least one inconclusive gate waiver") + gates = {} + for gate_id in GATE_IDS: + item = records[gate_id] + record = item["record"] + result = record["result"] + entry = {key: value for key, value in item.items() if key not in {"record", "artifacts"}} + entry["evidence_result"] = result + entry["status"] = "passed" if result == "passed" else "inconclusive" + if result == "inconclusive": + entry["waiver"] = _check_waiver( + gate_id, candidate_sha, release_scope, + evidence_dir, records, verify_signature=verify_signature, + ) + entry["status"] = "waived" + if gate_id == "R266": + entry["platform_results"] = record["platform_results"] + gates[gate_id] = entry + waiver_files = {path.name for path in waivers_dir.glob("R*.json")} if limited_evidence else set() + expected_files = {f"{gate_id}.json" for gate_id in inconclusive} if limited_evidence else set() + if waiver_files != expected_files: + raise ValueError("waiver files must match exactly the inconclusive gates") + waived_gates = [gate_id for gate_id in GATE_IDS if gates[gate_id]["status"] == "waived"] + limitations = sorted({limit for gate_id in waived_gates for limit in gates[gate_id]["waiver"]["limitations"]}) + return { + "schema_version": 2, + "candidate_sha": candidate_sha, + "signer_fingerprint": SIGNER_FINGERPRINT, + "created_at": datetime.now(timezone.utc).isoformat(), + "result": "limited-evidence" if limited_evidence else "passed", + "release_classification": "limited-evidence-preview" if limited_evidence else "full", + "release_scope": release_scope, + "release_limitations": limitations, + "waived_gates": waived_gates, + "gates": gates, + } + + +def verify_signed_preview_manifest(evidence_dir: Path, candidate_sha: str, manifest_path: Path, *, verify_signature) -> dict: + """Verify the signed preview and revalidate the evidence it names before publication.""" + manifest_path = Path(manifest_path).resolve(strict=True) + signature_path = manifest_path.with_name(manifest_path.name + ".asc") + manifest_bytes, _, verified = _read_signed_pair(manifest_path, signature_path, verify_signature) + if not verified: + raise ValueError("preview manifest signature verification failed") + try: + manifest = json.loads(manifest_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid preview manifest: {exc}") from None + if not isinstance(manifest, dict) or manifest.get("candidate_sha") != candidate_sha: + raise ValueError("preview manifest candidate SHA does not match the candidate") + try: + created = datetime.fromisoformat(manifest["created_at"].replace("Z", "+00:00")) + if created.tzinfo is None: + raise ValueError("timestamp has no timezone") + except (KeyError, AttributeError, TypeError, ValueError): + raise ValueError("preview manifest has an invalid creation timestamp") from None + expected = validate_evidence_bundle( + evidence_dir, candidate_sha, verify_signature=verify_signature, + limited_evidence=True, release_scope=LIMITED_RELEASE_SCOPE, + ) + if {key: value for key, value in manifest.items() if key != "created_at"} != { + key: value for key, value in expected.items() if key != "created_at"}: + raise ValueError("preview manifest does not match the current validated evidence") + return manifest + + +def _gpg_verify(record_path: Path, signature_path: Path) -> bool: + try: + result = subprocess.run( + ["gpg", "--batch", "--no-auto-check-trustdb", "--trust-model", "always", + "--status-fd", "1", "--verify", str(signature_path), str(record_path)], + check=False, capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode: + return False + for line in result.stdout.splitlines(): + if line.startswith("[GNUPG:] VALIDSIG "): + fields = line.split() + signer = fields[2] if len(fields) > 2 else "" + primary = fields[11] if len(fields) > 11 else signer + return SIGNER_FINGERPRINT in {signer, primary} + return False + + +def _current_candidate(repo: Path) -> str: + head = subprocess.run(["git", "-C", str(repo), "rev-parse", "--verify", "HEAD"], + check=True, capture_output=True, text=True, timeout=15).stdout.strip() + dirty = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], + check=True, capture_output=True, text=True, timeout=15).stdout + if dirty: + raise ValueError("candidate worktree must be clean before release evidence is validated") + return head + + +def _require_outside_checkout(path: Path, repo: Path, label: str) -> None: + try: + path.relative_to(repo) + except ValueError: + return + raise ValueError(f"{label} must be outside the candidate checkout") + + +def _write_signed_manifest(manifest: dict, destination: Path) -> None: + destination = destination.resolve() + signature_destination = destination.with_name(destination.name + ".asc") + if destination.exists() or signature_destination.exists(): + raise ValueError("manifest output already exists; choose a new path") + destination.parent.mkdir(parents=True, exist_ok=True) + payload = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode() + with tempfile.TemporaryDirectory(prefix="v3-release-evidence-", dir=destination.parent) as temporary: + temporary = Path(temporary) + record = temporary / destination.name + signature = temporary / f"{destination.name}.asc" + record.write_bytes(payload) + try: + result = subprocess.run( + ["gpg", "--batch", "--yes", "--local-user", SIGNER_FINGERPRINT, + "--armor", "--detach-sign", "--output", str(signature), str(record)], + check=False, capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ValueError(f"could not sign release manifest: {exc}") from None + if result.returncode or not signature.is_file() or not _gpg_verify(record, signature): + raise ValueError("release manifest GPG signing or fingerprint verification failed") + os.link(signature, signature_destination) + try: + os.link(record, destination) + except OSError: + try: + signature_destination.unlink() + except OSError: + pass + raise + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--limited-evidence-preview", action="store_true", + help="prepare a clearly labeled preview with signed waivers for eligible inconclusive gates") + parser.add_argument("--release-scope", help="scope ID bound by every signed preview waiver") + args = parser.parse_args(argv) + try: + repo = args.repo.resolve(strict=True) + evidence_dir = args.evidence_dir.resolve(strict=True) + manifest_path = args.manifest.resolve() + _require_outside_checkout(evidence_dir, repo, "evidence directory") + _require_outside_checkout(manifest_path, repo, "manifest") + candidate = _current_candidate(repo) + manifest = validate_evidence_bundle( + evidence_dir, candidate, verify_signature=_gpg_verify, + limited_evidence=args.limited_evidence_preview, release_scope=args.release_scope, + ) + _write_signed_manifest(manifest, manifest_path) + except (OSError, subprocess.SubprocessError, ValueError) as exc: + print(f"release blocked: {exc}", file=sys.stderr) + return 1 + status = "limited-evidence preview prepared" if args.limited_evidence_preview else "release evidence passed" + print(f"{status} for {candidate}; signed manifest: {args.manifest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/v3_release_platforms.py b/benchmarks/v3_release_platforms.py new file mode 100644 index 00000000..2e2e9d5b --- /dev/null +++ b/benchmarks/v3_release_platforms.py @@ -0,0 +1,199 @@ +"""Collect six-platform CI artifacts into an unsigned R266 evidence record.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys +import tempfile + +from benchmarks.v3_fixtures import FIXTURES +from benchmarks.v3_release_gates import REQUIRED_PLATFORMS + +_GIT_SHA = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _copy_artifact(source_root: Path, staging: Path, platform: str, relative: object) -> str: + if not isinstance(relative, str) or not relative or Path(relative).is_absolute(): + raise ValueError(f"{platform}: evidence artifact path must be relative") + source = (source_root / relative).resolve() + try: + source.relative_to(source_root) + except ValueError: + raise ValueError(f"{platform}: evidence artifact escapes its result directory") from None + if not source.is_file(): + raise ValueError(f"{platform}: evidence artifact is missing or not a file: {relative}") + destination_relative = (Path("platforms") / platform / relative).as_posix() + destination = staging / destination_relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + return destination_relative + + +def _matches_platform(platform: str, environment: dict) -> bool: + os_name, python_id = platform.rsplit("-", 1) + python_version = f"3.{python_id[-2:]}" + actual_os = environment["os"].lower() + os_matches = { + "linux": "linux" in actual_os, + "macos": "mac" in actual_os or "darwin" in actual_os, + "windows": "windows" in actual_os, + }[os_name] + return os_matches and environment["python"].startswith(python_version) + + +def collect_platform_evidence(results_dir: Path, output_dir: Path, candidate_sha: str, + command_set: Path) -> dict: + """Build an unsigned, candidate-bound R266.json bundle from passing matrix artifacts.""" + results_dir = Path(results_dir).resolve(strict=True) + command_set = Path(command_set).resolve(strict=True) + output_dir = Path(output_dir).resolve() + if not _GIT_SHA.fullmatch(candidate_sha): + raise ValueError("candidate SHA must be a full Git object ID") + if output_dir.exists(): + raise ValueError("evidence output directory already exists") + if not command_set.is_file(): + raise ValueError("command set must be a regular file") + output_dir.parent.mkdir(parents=True, exist_ok=True) + found_platforms = {path.name.removeprefix("v3-platform-") for path in results_dir.iterdir() + if path.name.startswith("v3-platform-")} + if found_platforms != set(REQUIRED_PLATFORMS): + raise ValueError("results directory must contain exactly the six required platform artifacts") + + with tempfile.TemporaryDirectory(prefix="v3-r266-", dir=output_dir.parent) as temporary: + staging = Path(temporary) / "bundle" + staging.mkdir() + command_path = staging / "commands" / command_set.name + command_path.parent.mkdir(parents=True) + shutil.copyfile(command_set, command_path) + fixture_path = staging / "fixtures" / "v3-fixtures.json" + fixture_path.parent.mkdir(parents=True) + fixture_path.write_text(json.dumps(FIXTURES, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + evidence = [] + platform_results = [] + dependency_sets = {} + for platform in REQUIRED_PLATFORMS: + result_dir = results_dir / f"v3-platform-{platform}" + result_path = result_dir / "platform.json" + if result_dir.is_symlink() or not result_dir.is_dir() or result_path.is_symlink(): + raise ValueError(f"{platform}: platform result directory or metadata cannot be a symlink") + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise ValueError(f"{platform}: missing or invalid platform result") from None + if not isinstance(result, dict) or result.get("schema_version") != 1: + raise ValueError(f"{platform}: unsupported platform result schema") + if result.get("candidate_sha") != candidate_sha or result.get("platform") != platform: + raise ValueError(f"{platform}: candidate SHA or platform does not match") + if platform.startswith("windows-"): + if (result.get("unit") != "not_run" or result.get("integration") != "not_run" + or result.get("supported_features") != "passed" + or result.get("tui_interactive_acceptance") != "not_run_posix_pty_unavailable"): + raise ValueError(f"{platform}: supported-feature acceptance did not pass") + elif any(result.get(check) != "passed" for check in ("unit", "integration")): + raise ValueError(f"{platform}: platform validation did not pass (unit/integration)") + elif result.get("tui_interactive_acceptance") != "passed": + raise ValueError(f"{platform}: interactive TUI acceptance did not pass") + if result.get("acceptance") != "passed": + raise ValueError(f"{platform}: platform validation did not pass (acceptance)") + soak_seconds = result.get("soak_seconds") + if isinstance(soak_seconds, bool) or not isinstance(soak_seconds, int) or soak_seconds < 1800: + raise ValueError(f"{platform}: 30-minute soak has not passed") + environment = result.get("environment") + dependencies = result.get("dependencies") + if not isinstance(environment, dict) or any( + not isinstance(environment.get(key), str) or not environment[key] + for key in ("runner", "os", "python")): + raise ValueError(f"{platform}: runner, OS, and Python details are required") + if not isinstance(dependencies, dict) or not dependencies: + raise ValueError(f"{platform}: dependency inventory must be an object") + paths = result.get("evidence_paths") + if (not isinstance(paths, list) or not paths + or any(not isinstance(path, str) for path in paths) + or len(paths) != len(set(paths)) or "platform.json" in paths): + raise ValueError(f"{platform}: evidence artifact list is required") + if not _matches_platform(platform, environment): + raise ValueError(f"{platform}: runner OS or Python version does not match") + copied_paths = [_copy_artifact(result_dir, staging, platform, "platform.json")] + copied_paths.extend(_copy_artifact(result_dir, staging, platform, path) for path in paths) + for relative in copied_paths: + evidence.append({"path": relative, "sha256": _sha256(staging / relative)}) + dependency_sets[platform] = dependencies + platform_results.append({ + "platform": platform, + "unit": result["unit"], + "integration": result["integration"], + "acceptance": "passed", + "soak_seconds": soak_seconds, + "evidence_paths": copied_paths, + "environment": environment, + "dependencies": dependencies, + **({"supported_features": "passed"} if platform.startswith("windows-") else {}), + "tui_interactive_acceptance": result["tui_interactive_acceptance"], + }) + + command_relative = (Path("commands") / command_set.name).as_posix() + fixture_relative = "fixtures/v3-fixtures.json" + record = { + "schema_version": 1, + "gate_id": "R266", + "candidate_sha": candidate_sha, + "command_set_path": command_relative, + "command_set_sha256": _sha256(staging / command_relative), + "fixture_set_path": fixture_relative, + "fixture_set_sha256": _sha256(staging / fixture_relative), + "environment": {"runner": "GitHub Actions matrix", "os": "Linux, macOS, Windows", + "python": "3.12 and 3.13"}, + "dependencies": {"platforms": dependency_sets}, + "result": "passed", + "failure_class": None, + "evidence": evidence, + "platform_results": platform_results, + } + record_path = staging / "R266.json" + record_path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + os.mkdir(output_dir) + except FileExistsError: + raise ValueError("evidence output directory already exists") from None + try: + for item in staging.iterdir(): + os.rename(item, output_dir / item.name) + except OSError: + shutil.rmtree(output_dir) + raise + return record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--results-dir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--candidate-sha", required=True) + parser.add_argument("--command-set", type=Path, required=True) + args = parser.parse_args(argv) + try: + record = collect_platform_evidence(args.results_dir, args.output_dir, + args.candidate_sha, args.command_set) + except (OSError, ValueError) as exc: + print(f"R266 evidence collection blocked: {exc}", file=sys.stderr) + return 1 + print(f"collected unsigned R266 evidence for {record['candidate_sha']} at {args.output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/v3_responses_relay.py b/benchmarks/v3_responses_relay.py new file mode 100644 index 00000000..9f60b746 --- /dev/null +++ b/benchmarks/v3_responses_relay.py @@ -0,0 +1,359 @@ +"""Cost-reserving local relay for matched OpenAI Responses evaluations.""" +from __future__ import annotations + +import hashlib +import json +import re +import secrets +import threading +import time +import uuid +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import requests + +from benchmarks.v3_matched import BudgetLedger + +MODEL = "gemini-3.1-flash-lite" +MAX_OUTPUT_TOKENS = 4096 +MAX_REQUEST_BYTES = 20_000_000 +MAX_ERROR_BODY_BYTES = 65_536 + + +@dataclass(frozen=True) +class RelayConfig: + model: str = MODEL + max_output_tokens: int = MAX_OUTPUT_TOKENS + fixed_input_token_bound: int | None = None + serial_requests: bool = False + + def __post_init__(self) -> None: + if (not self.model or type(self.max_output_tokens) is not int or self.max_output_tokens <= 0 + or (self.fixed_input_token_bound is not None and + (type(self.fixed_input_token_bound) is not int or self.fixed_input_token_bound <= 0))): + raise ValueError("relay model and token bounds must be valid") + + +DEFAULT_RELAY_CONFIG = RelayConfig() + + +def _safe_error_code(error: object) -> str | None: + if not isinstance(error, dict): + return None + code = error.get("code") + return code if isinstance(code, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", code) else None + + +class ResponsesRelay: + def __init__(self, *, endpoint: str, api_key: str, budget: BudgetLedger, + config: RelayConfig = DEFAULT_RELAY_CONFIG): + if config.model == "deepseek-flash" or budget.pricing.as_dict().get("provider") == "DeepSeek official": + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG + if budget.pricing != DEEPSEEK_PRICING or config != DEEPSEEK_RELAY_CONFIG: + raise ValueError("DeepSeek relay requires its pinned CNY prices, model, and fixed reservation") + self.config = config + self.endpoint = endpoint.rstrip("/") + self.api_key = api_key + self.client_key = secrets.token_urlsafe(24) + self.budget = budget + self.label = ("", "", -1) + self.rows: list[dict] = [] + self.budget_blocked = False + self.provider_error: str | None = None + self._request_lock = threading.Lock() + self._lock = threading.Lock() + self._actor_credentials = {} + self._issued_actor_keys = set() + self._upstream_lock = threading.Lock() + self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self._server.server_port}/v1" + + def start(self) -> None: + self._thread.start() + + @property + def actor_keys(self): + """Retain revoked keys too so evidence redaction cannot omit old secrets.""" + with self._lock: + return tuple(self._issued_actor_keys) + + def issue_actor_config(self, actor_id): + if not isinstance(actor_id, str) or not 1 <= len(actor_id) <= 512: + raise ValueError('invalid benchmark actor identity') + with self._lock: + if any(record['actor_id'] == actor_id for record in self._actor_credentials.values()): + raise ValueError('benchmark actor already has an active credential') + key = secrets.token_urlsafe(32) + self._actor_credentials[key] = {'actor_id': actor_id, 'label': tuple(self.label)} + self._issued_actor_keys.add(key) + return {'provider': 'openai', 'api_key': key, 'base_url': self.url, + 'model_simple': self.config.model, 'model_complex': self.config.model, + 'context_window_tokens': self.config.fixed_input_token_bound or 1_000_000} + + def revoke_actor(self, actor_id): + with self._lock: + self._actor_credentials = {key: value for key, value in self._actor_credentials.items() + if value['actor_id'] != actor_id} + + def _authorize_client(self, header): + if not isinstance(header, str) or not header.startswith('Bearer '): + return False, None, None + key = header[7:] + with self._lock: + if secrets.compare_digest(key, self.client_key): + return True, None, tuple(self.label) + record = self._actor_credentials.get(key) + if record is None or record['label'] != tuple(self.label): + return False, None, None + return True, record['actor_id'], record['label'] + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join() + + @staticmethod + def _usage(result: dict) -> dict | None: + usage = result.get("usage") + if not isinstance(usage, dict): + return None + input_tokens = usage.get("input_tokens") + output_tokens = usage.get("output_tokens") + details = usage.get("input_tokens_details") or {} + cached = details.get("cached_tokens", 0) if isinstance(details, dict) else 0 + cache_write = (usage.get("cache_write_tokens", usage.get("cache_creation_input_tokens", 0))) + if isinstance(details, dict): + cache_write = details.get("cache_write_tokens", details.get("cache_creation_tokens", cache_write)) + if not all(type(value) is int and value >= 0 + for value in (input_tokens, output_tokens, cached, cache_write)): + return None + if cached + cache_write > input_tokens: + return None + return {"input_tokens": input_tokens, "output_tokens": output_tokens, + "cache_read_tokens": cached, "cache_write_tokens": cache_write} + + def _handler(self): + relay = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def _json(self, status: int, data: dict) -> None: + payload = json.dumps(data, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_POST(self): + if relay.config.serial_requests: + with relay._request_lock: + self._forward_post() + else: + self._forward_post() + + def _forward_post(self): + if self.path.split("?", 1)[0] not in {"/v1/responses", "/responses"}: + self._json(404, {"error": {"message": "unsupported benchmark route"}}) + return + authorized, actor_id, label = relay._authorize_client(self.headers.get('Authorization')) + if not authorized: + self._json(401, {"error": {"message": "invalid benchmark relay credential"}}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= MAX_REQUEST_BYTES: + self._json(413, {"error": {"message": "benchmark request exceeds its bound"}}) + return + body = json.loads(self.rfile.read(length)) + if not isinstance(body, dict): + raise ValueError("request must be an object") + except (ValueError, json.JSONDecodeError): + self._json(400, {"error": {"message": "invalid benchmark request"}}) + return + + # Panstar rejects system-role input items; normalize here so V2 and V3 share one wire shape. + input_items = body.get("input") + if isinstance(input_items, list): + system_messages = [] + forwarded_items = [] + for item in input_items: + if (isinstance(item, dict) and item.get("role") == "system" + and isinstance(item.get("content"), str)): + system_messages.append(item["content"]) + else: + forwarded_items.append(item) + if system_messages: + instructions = body.get("instructions", "") + if not isinstance(instructions, str): + self._json(400, {"error": {"message": "invalid benchmark instructions"}}) + return + body["instructions"] = "\n\n".join( + ([instructions] if instructions else []) + system_messages) + body["input"] = forwarded_items or "Continue." + + body["model"] = relay.config.model + body["max_output_tokens"] = relay.config.max_output_tokens + wants_stream = body.get("stream") is True + body["stream"] = wants_stream + encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode() + attempt_id = uuid.uuid4().hex + input_bound = relay.config.fixed_input_token_bound or len(encoded) + 512 + arm, fixture_id, repeat = label + row = {"attempt_id": attempt_id, "arm": arm, "fixture_id": fixture_id, + "repeat": repeat, "request_sha256": hashlib.sha256(encoded).hexdigest(), + "model": relay.config.model, "max_output_tokens": relay.config.max_output_tokens, + "reserved_rmb": str(relay.budget.cost(input_bound, relay.config.max_output_tokens)), + "status": "unknown", "usage": None} + if actor_id is not None: + row['actor_id'] = actor_id + started = time.monotonic() + stream_started = False + reserved = False + try: + with relay._upstream_lock: + if relay.provider_error: + self._json(503, {"error": {"message": "benchmark provider is disabled after an upstream error"}}) + return + if not relay.budget.reserve(attempt_id, input_token_bound=input_bound, + output_token_bound=relay.config.max_output_tokens, metadata=row): + relay.budget_blocked = True + self._json(402, {"error": {"message": "benchmark spend cap reached"}}) + return + reserved = True + with relay._lock: + relay.rows.append(row) + try: + upstream = requests.post( + relay.endpoint + "/responses", data=encoded, + headers={"Authorization": "Bearer " + relay.api_key, + "Content-Type": "application/json"}, + timeout=180, allow_redirects=False, stream=wants_stream, + ) + except requests.RequestException as exc: + relay.provider_error = f"upstream_transport_{type(exc).__name__}" + relay.budget.settle(attempt_id, None) + row["transport_error"] = type(exc).__name__ + self._json(502, {"error": {"message": "benchmark upstream transport failed"}}) + return + row["http_status"] = upstream.status_code + if upstream.status_code != 200: + relay.budget.settle(attempt_id, None) + row["status"] = "unknown" + error_body = bytearray() + try: + for chunk in upstream.iter_content(chunk_size=4096): + if not chunk: + continue + error_body.extend(chunk[:MAX_ERROR_BODY_BYTES - len(error_body) + 1]) + if len(error_body) > MAX_ERROR_BODY_BYTES: + error_body.clear() + break + except requests.RequestException: + error_body.clear() + try: + error_response = json.loads(error_body) + except (ValueError, json.JSONDecodeError): + error_response = None + error = error_response.get("error") if isinstance(error_response, dict) else None + code = _safe_error_code(error) + suffix = f":{code}" if code else "" + relay.provider_error = f"upstream_http_{upstream.status_code}{suffix}" + upstream.close() + self._json(upstream.status_code if 400 <= upstream.status_code < 600 else 502, + {"error": {"message": f"benchmark upstream returned HTTP {upstream.status_code}"}}) + return + if not wants_stream: + try: + response_body = upstream.json() + except ValueError: + response_body = None + if not isinstance(response_body, dict): + relay.provider_error = "upstream_invalid_json" + relay.budget.settle(attempt_id, None) + self._json(502, {"error": {"message": "benchmark upstream returned invalid JSON"}}) + return + if not wants_stream: + usage = relay._usage(response_body) + relay.budget.settle(attempt_id, usage) + row["usage"] = usage + row["status"] = "settled" if usage is not None else "unknown" + row["actual_rmb"] = (str(relay.budget.cost(usage["input_tokens"], usage["output_tokens"], + usage["cache_read_tokens"], usage["cache_write_tokens"])) + if usage is not None else None) + self._json(200, response_body) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + stream_started = True + frame = bytearray() + terminal = False + for chunk in upstream.iter_content(chunk_size=4096): + if not chunk: + continue + self.wfile.write(chunk) + self.wfile.flush() + frame.extend(chunk) + while True: + normalized = bytes(frame).replace(b"\r\n", b"\n") + if b"\n\n" not in normalized: + break + event, _, remainder = normalized.partition(b"\n\n") + frame = bytearray(remainder) + data = b"\n".join(line[5:].lstrip() for line in event.splitlines() + if line.startswith(b"data:")) + if data and data != b"[DONE]": + try: + message = json.loads(data) + except (ValueError, json.JSONDecodeError): + continue + if message.get("type") in { + "response.completed", "response.incomplete", "response.failed"}: + if message["type"] == "response.failed": + response = message.get("response") + error = response.get("error") if isinstance(response, dict) else None + code = _safe_error_code(error) + suffix = f":{code}" if code else "" + with relay._upstream_lock: + relay.provider_error = f"upstream_response_failed{suffix}" + usage = relay._usage(message.get("response", {})) + relay.budget.settle(attempt_id, usage) + row["usage"] = usage + row["status"] = "settled" if usage is not None else "unknown" + row["actual_rmb"] = (str(relay.budget.cost(usage["input_tokens"], usage["output_tokens"], + usage["cache_read_tokens"], usage["cache_write_tokens"])) + if usage is not None else None) + upstream.close() + terminal = True + break + if terminal: + break + except (requests.RequestException, OSError) as exc: + if isinstance(exc, requests.RequestException): + with relay._upstream_lock: + relay.provider_error = f"upstream_transport_{type(exc).__name__}" + if row["status"] == "unknown": + relay.budget.settle(attempt_id, None) + row["transport_error"] = type(exc).__name__ + if not stream_started: + try: + self._json(502, {"error": {"message": "benchmark upstream transport failed"}}) + except (OSError, BrokenPipeError): + pass + finally: + if reserved and row["status"] == "unknown": + relay.budget.settle(attempt_id, None) + if "upstream" in locals(): + upstream.close() + row["latency_ms"] = round((time.monotonic() - started) * 1000, 2) + + return Handler diff --git a/benchmarks/v3_routing_ablation.py b/benchmarks/v3_routing_ablation.py new file mode 100644 index 00000000..d38d3354 --- /dev/null +++ b/benchmarks/v3_routing_ablation.py @@ -0,0 +1,176 @@ +"""Offline routing-policy ablations; no provider or application is initialized. + +Run ``python -m benchmarks.v3_routing_ablation --candidate-sha FULL_SHA`` on a +clean candidate to bind the observations to that commit. Without that argument, +dirty-tree observations remain useful diagnostics, not release certification. +""" +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +import hashlib +import json +from pathlib import Path +import statistics +import subprocess +import time +from types import MethodType, SimpleNamespace + +from benchmarks.system_one import CASES as SYSTEM_ONE_CASES, _p95 +from openkyrozen.learning.engine import LearningEngine +from openkyrozen.memory.retrieval import _has_cjk, _cjk_approximate_words +from openkyrozen.providers.config import ProviderConfig +from openkyrozen.routing.router import _classify_complexity, _select_model, decide_request + + +# Planning labels concern dependent work; profiles concern engineering versus +# information work. These are hand labels, never outputs from the router. +PLANNING_PROFILES = { + "route_fact": (False, "researcher"), + "route_code_change": (True, "coder"), + "route_summary": (False, "researcher"), + "route_migration": (True, "coder"), + "route_documentation": (False, "coder"), + "route_security_audit": (True, "coder"), + "route_json_transform": (False, "researcher"), + "route_incident": (True, "coder"), +} +EDGE_CASES = ( + ("greeting_code", "Hello, refactor the parser and add regression tests.", + True, "reasoning", "coder"), + ("question_migration", "What is needed to migrate the database safely? First back it up, then migrate and verify.", + True, "reasoning", "coder"), + ("single_repo_read", "Read the repository README.md.", False, "simple", "coder"), + ("cjk_code_change", "请重构代码并添加测试,最后验证结果。", True, "reasoning", "coder"), + ("cjk_fact", "地球有几个月亮?", False, "simple", "researcher"), + ("network_comparison", "Compare TCP and UDP in two short sentences.", + False, "simple", "researcher"), +) +VARIANTS = { + "default": (None, None), + "planning_always": ("planning", True), + "planning_never": ("planning", False), + "model_simple": ("model", "simple"), + "model_reasoning": ("model", "reasoning"), + "profile_coder": ("profile", "coder"), + "profile_researcher": ("profile", "researcher"), +} + + +def _cases() -> list[dict]: + cases = [] + for case_id, kind, state, _questions, _question_id, expected in SYSTEM_ONE_CASES: + if kind == "route_model": + planning, profile = PLANNING_PROFILES[case_id] + cases.append({"case": case_id, "request": state["request"], + "expected": {"planning": planning, "model": expected, "profile": profile}, + "source": "benchmarks/system_one.py"}) + for case_id, request, planning, model, profile in EDGE_CASES: + cases.append({"case": case_id, "request": request, + "expected": {"planning": planning, "model": model, "profile": profile}, + "source": "greeting, question, single-action, CJK and comparison edge cases"}) + return cases + + +def _identity(candidate_sha: str | None) -> dict: + root = Path(__file__).resolve().parents[1] + sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + dirty = subprocess.check_output( + ["git", "status", "--porcelain=v1", "--untracked-files=normal"], cwd=root, text=True, + ).splitlines() + if candidate_sha is not None and (candidate_sha != sha or dirty): + raise ValueError("candidate binding requires the exact full HEAD SHA and a clean worktree") + sources = ("benchmarks/v3_routing_ablation.py", "benchmarks/system_one.py", + "openkyrozen/routing/router.py", "openkyrozen/providers/config.py", + "openkyrozen/providers/registry.py", "openkyrozen/learning/engine.py", + "openkyrozen/memory/retrieval.py", "openkyrozen/tasks/models.py") + return {"git_sha": sha, "dirty_paths": dirty, "commit_bound": not dirty, + "source_sha256": {name: hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in sources}} + + +def _route(runtime: SimpleNamespace, request: str, variant: str) -> dict: + feature, value = VARIANTS[variant] + advice = {"model": value} if feature == "model" else {} + classify = ((lambda _text: "complex" if value else "medium") if feature == "planning" + else lambda text: _classify_complexity(runtime, text)) + override = value if feature == "profile" else "auto" + profile = LearningEngine.route_profile(request, override) + decision, complexity = decide_request( + runtime._provider_config, advice, request, lambda text: _select_model(runtime, text), + classify, profile, override, "agent", + ) + return {"planning": decision.planning_required, + "model": {"offline-simple-slot": "simple", "offline-reasoning-slot": "reasoning"}[decision.model], + "profile": decision.profile, "complexity": complexity} + + +def run(*, repeats: int = 100, candidate_sha: str | None = None) -> dict: + if type(repeats) is not int or repeats < 1: + raise ValueError("repeats must be a positive integer") + identity = _identity(candidate_sha) + cases = _cases() + runtime = SimpleNamespace(_provider_config=ProviderConfig( + provider="deepseek", model_simple="offline-simple-slot", model_complex="offline-reasoning-slot", + )) + runtime._has_cjk = MethodType(_has_cjk, runtime) + runtime._cjk_approximate_words = MethodType(_cjk_approximate_words, runtime) + rows = [{**case, "variants": {variant: _route(runtime, case["request"], variant) + for variant in VARIANTS}} for case in cases] + timings = defaultdict(list) + for repeat in range(repeats): + # Alternate order to reduce systematic warm-up/order bias. + variants = tuple(VARIANTS) if repeat % 2 == 0 else tuple(reversed(VARIANTS)) + for case in cases: + for variant in variants: + started = time.perf_counter_ns() + _route(runtime, case["request"], variant) + timings[variant].append((time.perf_counter_ns() - started) / 1000) + + def metrics(feature: str, variant: str) -> dict: + values = [row["variants"][variant][feature] for row in rows] + correct = sum(value == row["expected"][feature] for value, row in zip(values, rows)) + return {"correct": correct, "incorrect": len(rows) - correct, + "accuracy": correct / len(rows), "selected": dict(Counter(map(str, values))), + "routing_median_us": statistics.median(timings[variant]), + "routing_p95_us": _p95(timings[variant])} + + features = {} + for feature in ("planning", "model", "profile"): + baseline = {variant: metrics(feature, variant) for variant, (changed, _) in VARIANTS.items() + if changed == feature} + default = metrics(feature, "default") + improvement = default["correct"] - max(item["correct"] for item in baseline.values()) + features[feature] = {"default": default, "constant_ablations": baseline, + "correct_gain_over_best_constant": improvement, + "policy_accuracy_benefit": improvement > 0} + return { + "protocol": "openkyrozen-offline-routing-ablation-v1", "identity": identity, + "dataset_sha256": hashlib.sha256(json.dumps(cases, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), + "cases": len(cases), "repeats": repeats, "features": features, "rows": rows, + "provider_calls": 0, "provider_cost_cny": "0", "model_quality_evaluated": False, + "release_requirements_certified": False, + "method": "One routing feature replaced at a time by each constant alternative; other routing outputs retained.", + "limitations": [ + "Routing-policy labels are hand judgments on a small fixed diagnostic corpus, not independently verified task outcomes.", + "Resource slots are symbolic: no real model quality, token usage, billed cost savings or end-to-end latency is measured.", + "Latency is local routing-function wall time including benchmark dispatch overhead, not provider or turn latency.", + "R124/R126 gain routing-policy observations; R132 per-feature end-to-end benefit remains unproven. Optional Jev/Kev remain outside this default-on audit.", + ], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repeats", type=int, default=100) + parser.add_argument("--candidate-sha") + args = parser.parse_args() + try: + report = run(repeats=args.repeats, candidate_sha=args.candidate_sha) + except ValueError as exc: + parser.error(str(exc)) + print(json.dumps(report, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/v3_routing_supplement.py b/benchmarks/v3_routing_supplement.py new file mode 100644 index 00000000..0ea3bee6 --- /dev/null +++ b/benchmarks/v3_routing_supplement.py @@ -0,0 +1,438 @@ +"""Separate R132 routing supplement; importing never reads credentials or sends requests.""" +from __future__ import annotations + +from contextlib import closing +from contextvars import ContextVar +from dataclasses import asdict, replace +from decimal import Decimal,InvalidOperation +import hashlib +from itertools import permutations +import json +import os +from pathlib import Path +import random +import sqlite3 +import tempfile +from types import MethodType, SimpleNamespace + +PROTOCOL = 'openkyrozen-v3-routing-supplement-v1' +FIXTURE_IDS = ('investigation-1','investigation-2','git_history-1', + 'refactoring-2','refactoring-3','implementation-3') +VARIANTS = ('default','planning-never','profile-fixed-researcher') + + +def schedule(): + orders = tuple(permutations(sorted(VARIANTS))) + return [{'fixture_id':fixture,'repeat':repeat,'variant':variant} + for index,fixture in enumerate(FIXTURE_IDS) for repeat in range(3) + for variant in orders[(index+2*repeat)%6]] + + +def intervene(decision, variant, *, profile_override, interaction_mode): + if variant not in VARIANTS: + raise ValueError('unknown frozen routing supplement variant') + if variant == 'planning-never' and interaction_mode == 'agent': + return replace(decision,planning_required=False) + if (variant == 'profile-fixed-researcher' and profile_override == 'auto' + and decision.profile in {'coder','researcher'}): + return replace(decision,profile='researcher') + return decision + + +def install(runtime, variant): + """Observe/intervene at the original decision call, scoped to one real runtime.""" + if variant not in VARIANTS: + raise ValueError('unknown frozen routing supplement variant') + import openkyrozen.agent.preparation as preparation + from openkyrozen.routing.settings import decision_assist_state + if (runtime._interaction_controller.state().get('system_one_backend','off') != 'off' + or decision_assist_state()['backend'] != 'off'): + raise RuntimeError('routing supplement requires default-off optional backends') + original_prepare, original_decide = runtime._prepare_turn, preparation.decide_request + active = ContextVar('benchmark_routing_turn',default=None) + records = [] + + def decide(*args, **kwargs): + decision,complexity = original_decide(*args,**kwargs) + turn = active.get() + if turn is None: + return decision,complexity + override = args[6] if len(args)>6 else kwargs['profile_override'] + mode = args[7] if len(args)>7 else kwargs['interaction_mode'] + after = intervene(decision,variant,profile_override=override,interaction_mode=mode) + guidance=[runtime._ponytail_context(item.profile) for item in (decision,after)] + records.append({'index':len(records),'variant':variant,'before':asdict(decision), + 'after':asdict(after),'complexity':complexity,'profile_override':override, + 'interaction_mode':mode,'child':bool(runtime.execution_context.child_run_id), + 'system_one_backend':turn.fast_backend,'decision_assist_backend':'off','requests':[], + 'profile_guidance_hashes':[hashlib.sha256(text.encode()).hexdigest() for text in guidance]}) + return after,complexity + + def prepare(self,turn): + token=active.set(turn) + try: + original_prepare(turn) + if records: + records[-1].update(learning_profile=turn.learning_run.get('profile'), + learned_context_sha256=hashlib.sha256((turn.learned_context or '').encode()).hexdigest(), + learned_context_nonempty=bool(turn.learned_context), + capabilities=sorted(self._execution_capability_token.capabilities), + working_plan=self.tasks.working_plan_snapshot()) + finally: + active.reset(token) + + def observe(record, tools): + if not records: + raise RuntimeError('provider invocation lacks its actual routing decision') + entry=records[-1] + entry['requests'].append({key:record.get(key) for key in + ('kind','model','schema_sha256','message_projection_sha256','window_tokens','reserve_tokens')} + | {'tool_names':sorted({item.get('name') or item.get('function',{}).get('name') + for item in tools or [] if isinstance(item,dict)})}) + + def close(): + runtime._prepare_turn=original_prepare + if preparation.decide_request is decide: + preparation.decide_request=original_decide + if getattr(runtime,'_benchmark_routing_observer',None) is observe: + del runtime._benchmark_routing_observer + + preparation.decide_request=decide + runtime._prepare_turn=MethodType(prepare,runtime) + runtime._benchmark_routing_observer=observe + return SimpleNamespace(records=records,close=close) + + +def backup_ledger(core, target): + """Consistent read-only source backup, preserving every original attempt row.""" + core,target=Path(core).resolve(),Path(target).resolve() + if core==target or target.exists(): + raise ValueError('supplement requires a distinct new continuation ledger') + target.parent.mkdir(parents=True,exist_ok=True) + with closing(sqlite3.connect(core.as_uri()+'?mode=ro',uri=True)) as source: + source.execute('PRAGMA query_only=ON') + with closing(sqlite3.connect(target)) as destination: + source.backup(destination) + + +def validate_observations(records, variant): + """Worker observations describe mechanism, never authorize a treatment.""" + from openkyrozen.routing.router import RoutingDecision + if variant not in VARIANTS or not isinstance(records,list) or not records: + return False + requests=0 + try: + for row in records: + before=RoutingDecision(**row['before']) + if (type(before.planning_required) is not bool or before.provider!='openai' + or before.model!='deepseek-flash' or before.reasoning!='standard' + or before.budget_tokens is not None or before.profile not in {'coder','researcher'} + or row['interaction_mode'] not in {'agent','ask','plan'}): + return False + expected=intervene(before,variant,profile_override=row['profile_override'], + interaction_mode=row['interaction_mode']) + if (row['variant']!=variant or asdict(expected)!=row['after'] + or row['system_one_backend']!='off' or row['decision_assist_backend']!='off' + or not isinstance(row['requests'],list)): + return False + for request in row['requests']: + if (request['model']!='deepseek-flash' or request['reserve_tokens']!=4096 + or not isinstance(request['message_projection_sha256'],str) + or len(request['message_projection_sha256'])!=64): + return False + requests+=1 + except (KeyError,TypeError,ValueError): + return False + return requests>0 + + +def active_treatment(records,variant): + for row in records or []: + requests=[item for item in row.get('requests',[]) if item.get('kind')=='generation'] + if row.get('before')==row.get('after') or not requests: + continue + if variant=='planning-never' and any(item.get('schema_sha256') is not None for item in requests): + return True + hashes=row.get('profile_guidance_hashes',[]) + if variant=='profile-fixed-researcher' and len(hashes)==2 and hashes[0]!=hashes[1]: + return True + return False + + +def _interval(effects): + from benchmarks.v3_matched import _percentile + rng=random.Random(132) + samples=[sum(rng.choices(effects,k=6))/6 for _ in range(20_000)] + return [float(_percentile(samples,.025)),float(_percentile(samples,.975))] + + +def analyze(rows): + def known_cost(value): + if not isinstance(value,str):return False + try: amount=Decimal(value) + except InvalidOperation:return False + return amount.is_finite() and amount>=0 + expected={(row['fixture_id'],row['repeat'],row['variant']) for row in schedule()} + keyed={(row.get('fixture_id'),row.get('repeat'),row.get('variant')):row for row in rows} + complete=(len(rows)==len(keyed)==54 and set(keyed)==expected and all( + all(row.get(field) is True for field in ('safety_passed','usage_complete','worker_result_complete', + 'evidence_valid','routing_evidence_valid')) + and all(type(row.get(field)) is bool for field in ('physical_success','verified_success','workflow_state_correct')) + and known_cost(row.get('cost_cny')) for row in rows)) + result={'complete':complete,'benefit_passed':False,'contrasts':{},'rows_observed':len(rows), + 'scope':'six frozen tasks; Flash/Flash; model-selection benefit not identifiable', + 'analysis':{'cluster':'fixture','resamples':20000,'seed':132,'one_sided_lower_probability':.025}} + if not complete: + return result + for control in VARIANTS[1:]: + def accepted(row):return row['verified_success'] and row['workflow_state_correct'] + pairs=[(keyed[(fixture,r,'default')],keyed[(fixture,r,control)]) + for fixture in FIXTURE_IDS for r in range(3)] + quality=[sum(int(accepted(left))-int(accepted(right)) for left,right in pairs[i:i+3])/3 + for i in range(0,18,3)] + physical=[sum(int(left['physical_success'])-int(right['physical_success']) for left,right in pairs[i:i+3])/3 + for i in range(0,18,3)] + interval=_interval(quality) + item={'endpoint':'accepted_task_success','fixture_effects':quality,'interval':interval, + 'physical_interval':_interval(physical),'benefit_passed':False, + 'workflow_failures':sum(not row['workflow_state_correct'] for pair in pairs for row in pair), + 'requests':{arm:sum(row['requests'] for pair in pairs for row in (pair[arm],)) for arm in (0,1)}, + 'input_tokens':{arm:sum(pair[arm]['input_tokens'] for pair in pairs) for arm in (0,1)}, + 'output_tokens':{arm:sum(pair[arm]['output_tokens'] for pair in pairs) for arm in (0,1)}, + 'cost_cny':{arm:str(sum(Decimal(pair[arm]['cost_cny']) for pair in pairs)) for arm in (0,1)}, + 'latency_ms':{arm:sum(pair[arm]['latency_ms'] for pair in pairs) for arm in (0,1)}} + active=any(row.get('routing_treatment_active') is True for _,row in pairs) + matched=all(accepted(left)==accepted(right) and left['physical_success']==right['physical_success'] + for left,right in pairs) + if matched: + costs=[] + for index in range(0,18,3): + group=pairs[index:index+3] + counts=[sum(accepted(pair[arm]) for pair in group) for arm in (0,1)] + if not all(counts): + break + totals=[sum(Decimal(pair[arm]['cost_cny']) for pair in group)/counts[arm] for arm in (0,1)] + costs.append(float(totals[1]-totals[0])) + item.update(endpoint='cost_per_accepted_task',fixture_effects=costs, + interval=_interval(costs) if len(costs)==6 else None) + item['benefit_passed']=bool(active and len(costs)==6 and item['interval'][0]>0) + else: + item['benefit_passed']=bool(active and interval[0]>0 and item['physical_interval'][0]>0) + item['active_mechanism_observed']=active + result['contrasts'][control]=item + result['benefit_passed']=all(item['benefit_passed'] for item in result['contrasts'].values()) + return result + + +def _core_checkpoint(output,budget_path): + """Unlike the resumable writer helper, this cannot repair or mutate the core.""" + with closing(sqlite3.connect(Path(budget_path).resolve().as_uri()+'?mode=ro',uri=True)) as db: + db.execute('PRAGMA query_only=ON') + checkpoint=db.execute('SELECT output_path,report_sha256,report_json FROM benchmark_checkpoint WHERE id=1').fetchone() + config=db.execute('SELECT currency_code,limit_picormb,pricing_sha256 FROM budget_config WHERE id=1').fetchone() + attempts=db.execute('SELECT * FROM attempts ORDER BY id').fetchall() + if (not checkpoint or checkpoint[0]!=str(Path(output).resolve()) + or hashlib.sha256(checkpoint[2].encode()).hexdigest()!=checkpoint[1] + or Path(output).read_bytes()!=checkpoint[2].encode()): + raise ValueError('core canonical checkpoint must be intact; read-only handoff cannot repair it') + return json.loads(checkpoint[2]),config,attempts,checkpoint[1] + + +def _bind_continuation(descriptor,binding): + os.lseek(descriptor,0,os.SEEK_SET) + existing=os.read(descriptor,65537) + marker=json.loads(existing) if existing else None + if marker and marker.get('binding')!=binding: + raise ValueError('core is already bound to a different continuation; a new cap cannot be forked') + if not existing: + encoded=json.dumps({'binding':binding,'phase':'preparing'},sort_keys=True,separators=(',',':')).encode() + os.lseek(descriptor,0,os.SEEK_SET) + os.write(descriptor,encoded) + os.fsync(descriptor) + return marker['phase'] if marker else 'preparing' + + +def _activate_continuation(descriptor,binding): + if _bind_continuation(descriptor,binding)=='active': + return + encoded=json.dumps({'binding':binding,'phase':'active'},sort_keys=True,separators=(',',':')).encode() + os.lseek(descriptor,0,os.SEEK_SET) + os.write(descriptor,encoded) + os.ftruncate(descriptor,len(encoded)) + os.fsync(descriptor) + + +def run(*,source,repo,core_output,core_budget,output,budget_path): + """Explicit paid entry point; all preconditions run before credential access.""" + from benchmarks import v3_matched_live as live + from benchmarks.v3_fixtures import FIXTURES + from benchmarks.v3_matched import BudgetLedger,summarize + from benchmarks.v3_deepseek import DEEPSEEK_PRICING,DEEPSEEK_RELAY_CONFIG,DEEPSEEK_ENDPOINT + from benchmarks.v3_responses_relay import ResponsesRelay + paths=[Path(item).resolve() for item in (source,repo,core_output,core_budget,output,budget_path)] + source,repo,core_output,core_budget,output,budget_path=paths + if (any(path.is_relative_to(source) for path in paths[2:]) or len(set(paths[2:]))!=4): + raise ValueError('distinct external core and supplement report/ledger paths are required') + candidate=live._source_snapshot(source) + base_identity=live.apparatus_identity() + fixtures={item['fixture_id']:item for item in FIXTURES if item['fixture_id'] in FIXTURE_IDS} + identity=dict(base_identity) + identity['cohort_sha256']=hashlib.sha256(live._canonical_bytes({ + 'protocol':PROTOCOL,'fixtures':[fixtures[key] for key in FIXTURE_IDS],'schedule':schedule(), + 'analysis':{'resamples':20000,'seed':132,'lower_probability':.025}})).hexdigest() + worker_script=live._require_dispatched_apparatus(source,base_identity) + live._require_secure_evidence_io() + with live._campaign_lock(core_output) as lease,live._campaign_lock(output): + core,config,original_attempts,core_hash=_core_checkpoint(core_output,core_budget) + summary=summarize(core.get('rows',[]),pricing=DEEPSEEK_PRICING) + if (core.get('protocol')!='openkyrozen-v3-matched-live-v1' or core.get('candidate_sha')!=candidate + or core.get('status')!='complete' or not summary['complete'] or not summary['accounting_complete'] + or any(core.get(key)!=value for key,value in base_identity.items()) + or not live._validate_rows_evidence(core_output,core['rows'],candidate,base_identity) + or config[0]!='CNY' or config[2]!=DEEPSEEK_PRICING.sha256 + or not 0 now} + if len(self.tickets) >= 64: + raise PermissionError('shell ticket limit reached') + token = secrets.token_urlsafe(32) + self.tickets[token] = (hashlib.sha256(_encoded(request)).digest(), + now + min(5, max(0, ttl_seconds))) + return token + + def consume(self, ticket, request): + with self.policy.lock: + record = self.tickets.pop(ticket, None) + if (record is None or record[1] <= time.monotonic() + or record[0] != hashlib.sha256(_encoded(request)).digest()): + raise PermissionError('invalid, expired, or replayed shell ticket') + self.policy.authorize(request) + + +def _kill_tree(process): + # Bounded cleanup only: detached/reparented macOS descendants are not owned. + # The full benchmark gate remains closed until kernel-backed ownership exists. + # Groups alone miss a command which calls setsid(). Discover descendants too. + def snapshot(): + rows = subprocess.run(['/bin/ps', '-axo', 'pid=,ppid=,pgid=,stat='], capture_output=True, + text=True, timeout=3, check=True).stdout.splitlines() + return {int(pid): (int(ppid), int(group), state) + for pid, ppid, group, state in (row.split() for row in rows if row.strip())} + + def live(record): + return record is not None and not record[2].startswith(('Z', 'X')) + + descendants = [] + try: + processes = snapshot() + children = {} + for pid, (ppid, _, _) in processes.items(): + children.setdefault(ppid, []).append(pid) + pending = [process.pid] + while pending: + current = pending.pop() + descendants.append(current) + pending.extend(children.get(current, ())) + except (OSError, ValueError, subprocess.SubprocessError): + processes = {} + descendants = [process.pid] + # Keep the group leader alive until the group signal: Darwin can report + # EPERM for an already-exiting group after individual leader/child kills. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError as exc: + try: + processes = snapshot() + except (OSError, ValueError, subprocess.SubprocessError): + raise exc + if any(record[1] == process.pid and live(record) for record in processes.values()): + raise + for pid in reversed(descendants): + record = processes.get(pid) + if record is None or pid == process.pid or not live(record): + continue + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError as exc: + try: + still_live = live(snapshot().get(pid)) + except (OSError, ValueError, subprocess.SubprocessError): + raise exc + if still_live: + raise + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + pass + + +def _safe_env(env): + # Loader hooks execute before the sandbox; credentials and broker state never + # belong in a model's shell, including when copied from the worker environment. + denied = ('KYROZEN_', 'DYLD_', 'LD_', 'PYTHON', 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS') + return {k: v for k, v in env.items() + if not k.upper().startswith(denied) + and not any(word in k.upper() for word in ('TOKEN', 'SECRET', 'PASSWORD', 'API_KEY'))} + + +class ShellBroker: + def __init__(self, connection, policy, protected_files): + self.connection = connection + self.policy = policy + self.protected_files = protected_files + self.authority = TicketAuthority(policy) + self.processes = {} + self.records = {} + self.closed = threading.Event() + self.close_lock = threading.RLock() + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self): + try: + with self.connection.makefile('rwb', buffering=0) as stream: + while not self.closed.is_set(): + line = stream.readline(_MAX_MESSAGE + 1) + if not line: + break + if len(line) > _MAX_MESSAGE: + break + try: + request = json.loads(line) + result = {'ok': True, 'value': self._dispatch(request)} + except Exception as exc: + result = {'ok': False, 'error': type(exc).__name__, 'message': str(exc)} + self.connection.sendall(_encoded(result) + b'\n') + except (OSError, ValueError): + pass + finally: + self.close() + + def _dispatch(self, request): + operation = request.get('op') + if operation == 'ticket': + return self.authority.issue(request['request']) + if operation == 'start': + with self.policy.lock: + self.authority.consume(request['ticket'], request['request']) + if sum(p.poll() is None for p in self.processes.values()) >= 32: + raise PermissionError('benchmark shell process limit reached') + from benchmarks.v3_protected_worker import protected_shell_command + invocation = protected_shell_command(request['request']['command'], policy=self.policy, + cwd=request['request']['cwd'], + env=_safe_env(request['request']['env']), + protected_files=self.protected_files) + try: + process = subprocess.Popen(invocation.command, shell=invocation.shell, + cwd=request['request']['cwd'], env=invocation.env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + close_fds=True, start_new_session=True) + except Exception: + invocation.close() + raise + identifier = uuid.uuid4().hex + self.processes[identifier] = process + record = {'stdout': tempfile.TemporaryFile(), 'stderr': tempfile.TemporaryFile(), + 'background': request['request']['background'], 'eof': set(), 'timed_out': False, + 'lock': threading.Lock(), 'invocation': invocation} + self.records[identifier] = record + for name in ('stdout', 'stderr'): + threading.Thread(target=self._collect, args=(identifier, name), daemon=True).start() + threading.Thread(target=self._expire, + args=(identifier, request['request']['timeout_seconds']), daemon=True).start() + return {'id': identifier, 'pid': process.pid, 'backend': invocation.backend} + identifier = request.get('id') + if identifier not in self.processes: + raise PermissionError('process does not belong to this benchmark actor/session') + process = self.processes[identifier] + record = self.records[identifier] + if operation == 'stop': + _kill_tree(process) + return process.returncode + if operation == 'poll': + return {'exit_code': process.poll(), 'timed_out': record['timed_out']} + if operation == 'read': + name, offset = request.get('stream'), request.get('offset') + if name not in {'stdout', 'stderr'} or type(offset) is not int or offset < 0: + raise PermissionError('invalid process output request') + with record['lock']: + record[name].seek(offset) + return {'data': base64.b64encode(record[name].read(4096)).decode(), + 'eof': name in record['eof'], 'exit_code': process.poll()} + raise PermissionError('unsupported broker operation') + + def _expire(self, identifier, timeout): + process, record = self.processes[identifier], self.records[identifier] + deadline = time.monotonic() + timeout + try: + process.wait(timeout=timeout) + # A shell leader may exit while its children retain the output pipes. + while len(record['eof']) != 2 and not self.closed.is_set(): + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(process.args, timeout) + time.sleep(.02) + except subprocess.TimeoutExpired: + record['timed_out'] = True + _kill_tree(process) + + def _collect(self, identifier, name): + process, record = self.processes[identifier], self.records[identifier] + stream = getattr(process, name) + try: + while chunk := stream.read1(4096): + with record['lock']: + record[name].seek(0, os.SEEK_END) + remaining = max(0, _MAX_OUTPUT - record[name].tell()) if record['background'] else len(chunk) + record[name].write(chunk[:remaining]) + finally: + stream.close() + with record['lock']: + record['eof'].add(name) + if self.closed.is_set(): + record[name].close() + if len(record['eof']) == 2: + self._finish_collection(identifier) + + def _finish_collection(self, identifier): + self.records[identifier]['invocation'].close() + + def close(self): + with self.close_lock: + if self.closed.is_set(): + return + self.closed.set() + try: + self.connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.connection.close() + errors = [] + for process in tuple(self.processes.values()): + try: + _kill_tree(process) + except Exception as exc: + errors.append(exc) + for record in tuple(self.records.values()): + try: + record['invocation'].close() + except Exception as exc: + errors.append(exc) + with record['lock']: + for name in record['eof']: + record[name].close() + if errors: + raise errors[0] + + +class BrokerClient: + def __init__(self, fd, identity): + self.connection = socket.socket(fileno=fd) + self.connection.set_inheritable(False) + self.stream = self.connection.makefile('rwb', buffering=0) + self.lock = threading.Lock() + self.identity = identity + + def rpc(self, op, **values): + with self.lock: + data = _encoded(dict(op=op, **values)) + b'\n' + if len(data) > _MAX_MESSAGE: + raise PermissionError('shell request exceeds benchmark limit') + self.connection.sendall(data) + line = self.stream.readline(_MAX_MESSAGE + 1) + if not line: + raise ConnectionError('benchmark shell broker disconnected') + result = json.loads(line) + if not result.get('ok'): + raise PermissionError(result.get('message', 'shell broker denied request')) + return result['value'] + + def start(self, command, cwd, env, timeout_seconds, *, background=False): + request = dict(self.identity, call_id=uuid.uuid4().hex, command=command, cwd=cwd, env=env, + timeout_seconds=timeout_seconds, background=background) + ticket = self.rpc('ticket', request=request) + value = self.rpc('start', ticket=ticket, request=request) + return RemoteProcess(self, value['id'], value['pid'], value['backend']) + + +class RemoteStream: + def __init__(self, process, name): + self.process, self.name, self.offset = process, name, 0 + self.closed = False + + def read1(self, size): + while not self.closed: + result = self.process.client.rpc('read', id=self.process.identifier, + stream=self.name, offset=self.offset) + value = base64.b64decode(result['data'])[:size] + self.offset += len(value) + if value or result['eof']: + return value + time.sleep(.02) + return b'' + + def close(self): + self.closed = True + + +class RemoteProcess: + def __init__(self, client, identifier, pid, backend): + self.client, self.identifier, self.pid, self.backend = client, identifier, pid, backend + self.timed_out = False + self.returncode = None + self._benchmark_handle = None + self.stdout = RemoteStream(self, 'stdout') + self.stderr = RemoteStream(self, 'stderr') + + def poll(self): + value = self.client.rpc('poll', id=self.identifier) + if self.returncode is None: + self.returncode = value['exit_code'] + self.timed_out = self.timed_out or value['timed_out'] + if self._benchmark_handle is not None: + self._benchmark_handle.timed_out = self._benchmark_handle.timed_out or self.timed_out + if self.timed_out and not self._benchmark_handle.stop_reason: + self._benchmark_handle.stop_reason = 'timed_out' + return self.returncode + + def wait(self, timeout=None): + deadline = time.monotonic() + timeout if timeout is not None else float('inf') + while self.poll() is None: + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(self.identifier, timeout) + time.sleep(.02) + return self.returncode + + def stop(self): + self.returncode = self.client.rpc('stop', id=self.identifier) + return self.returncode + + +def _install_legacy_adapter(client, shell): + # The frozen V2 adapter owns its original string validation, 60-second + # timeout, and four-field result. Replace only its subprocess boundary. + from types import SimpleNamespace + + def run(command, *, shell, capture_output, text, timeout, env, cwd): + process = client.start(command, cwd, env, timeout) + output = {'stdout': bytearray(), 'stderr': bytearray()} + def collect(name): + stream = getattr(process, name) + while chunk := stream.read1(4096): + output[name].extend(chunk) + stream.close() + collectors = [threading.Thread(target=collect, args=(name,), daemon=True) + for name in ('stdout', 'stderr')] + for thread in collectors: + thread.start() + try: + process.wait(timeout + 5) + for thread in collectors: + thread.join() + if process.timed_out: + raise subprocess.TimeoutExpired(command, timeout) + return subprocess.CompletedProcess(command, process.returncode, + output['stdout'].decode(errors='replace'), + output['stderr'].decode(errors='replace')) + finally: + process.stop() + shell.subprocess = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired) + + +def install_worker_adapter(client=None): + """Install before loading the selected snapshot's adapter class.""" + raw_fd = os.environ.pop(_FD_ENV, None) + raw_identity = os.environ.pop('KYROZEN_BENCHMARK_BROKER_IDENTITY', None) + if client is None: + if raw_fd is None and raw_identity is None: + return False + if raw_fd is None or raw_identity is None: + raise PermissionError('incomplete benchmark shell broker context') + client = BrokerClient(int(raw_fd), json.loads(raw_identity)) + elif raw_fd is not None or raw_identity is not None: + raise PermissionError('actor cannot inherit a different broker context') + from openkyrozen.tools import shell + if not hasattr(shell, '_validated_shell_options'): + _install_legacy_adapter(client, shell) + return True + from openkyrozen.tools import processes + from openkyrozen.tools.models import CommandResult + base_manager = processes.SessionProcessManager + + class BrokerProcessManager(base_manager): + def start(self, command, *, cwd, env, timeout_seconds, cancelled=None): + process_id = uuid.uuid4().hex + audit = getattr(self, '_audit_event', None) + if audit: + audit('launch_intent', process_id, timeout_seconds=timeout_seconds) + try: + with self._lock: + live = sum(handle.process.poll() is None for handle in self._handles.values()) + if self._closed or live >= 32: + raise RuntimeError('session process manager is closed or full') + if cancelled is not None and cancelled.is_set(): + raise RuntimeError('turn was cancelled before process start') + process = client.start(command, cwd, env, timeout_seconds, + background=not getattr(self, '_foreground', False)) + handle = processes.ProcessHandle(process_id, process, self.owner_id, + self.workspace_id, self.session_id, timeout_seconds, + process.backend) + process._benchmark_handle = handle + self._handles[handle.process_id] = handle + except BaseException: + if audit: + try: + # A failed RPC can leave remote dispatch uncertain. + audit('launch_failed', process_id, reason='launch_not_confirmed') + except RuntimeError: + pass + raise + started = getattr(self, '_audit_started', None) + if started: + started(handle) + for name in ('stdout', 'stderr'): + threading.Thread(target=self._collect, args=(handle, name), daemon=True).start() + expiry_options = {'cancelled': cancelled} if cancelled is not None else {} + expiry = threading.Thread(target=self._expire, args=(handle,), kwargs=expiry_options, daemon=True) + if hasattr(handle, 'expiry_thread'): + handle.expiry_thread = expiry + expiry.start() + return handle + + def _collect(self, handle, name): + if not getattr(self, '_foreground', False): + return base_manager._collect(handle, name) + stream = getattr(handle.process, name) + decoder = codecs.getincrementaldecoder('utf-8')(errors='replace') + try: + while chunk := stream.read1(4096): + with handle.lock: + setattr(handle, name, getattr(handle, name) + decoder.decode(chunk)) + with handle.lock: + setattr(handle, name, getattr(handle, name) + decoder.decode(b'', final=True)) + finally: + stream.close() + + def _expire(self, handle, cancelled=None): + outcome = getattr(self, '_audit_outcome', None) + try: + deadline = time.monotonic() + min(handle.timeout_seconds, 1800) + 5 + while (handle.process.poll() is None + or not (handle.process.stdout.closed and handle.process.stderr.closed)): + if cancelled is not None and cancelled.is_set(): + if outcome: + handle.stop_reason = 'cancelled' + self.stop(handle.process_id) + handle.cancelled = True + break + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(handle.process.identifier, handle.timeout_seconds) + try: + if handle.process.poll() is None: + handle.process.wait(timeout=min(.1, remaining)) + else: + time.sleep(min(.02, remaining)) + except subprocess.TimeoutExpired: + continue + except subprocess.TimeoutExpired: + if outcome: + handle.stop_reason = 'timed_out' + self.stop(handle.process_id) + handle.timed_out = True + finally: + handle.timed_out = handle.timed_out or handle.process.timed_out + if outcome: + if handle.timed_out and not handle.stop_reason: + handle.stop_reason = 'timed_out' + outcome(handle) + if handle.process.poll() is not None: + handle.finished_at = time.monotonic() + + def stop(self, process_id): + handle = self._handles.get(process_id) + if handle is None: + return None + audit = getattr(self, '_audit_event', None) + if audit and handle.process.poll() is None: + handle.stop_reason = handle.stop_reason or 'stopped' + try: + audit('stop_requested', process_id, handle=handle, reason=handle.stop_reason) + except RuntimeError: + handle.audit_error = True + handle.process.stop() + stopped = handle.process.poll() is not None + if stopped: + handle.finished_at = time.monotonic() + outcome = getattr(self, '_audit_outcome', None) + if outcome: + outcome(handle) + return stopped + + def run_command(adapter, args, *, cancelled=None): + validated = adapter._validated_shell_options(args) + if isinstance(validated, CommandResult): + return validated + command, timeout, cwd, env, background = validated + if background: + return CommandResult('Error: background processes require a session runtime', False, + failure='invalid_arguments') + started = time.monotonic() + manager = BrokerProcessManager('foreground', str(adapter._WORKSPACE_ROOT), 'foreground') + manager._foreground = True + handle = None + failure = None + try: + handle = manager.start(command, cwd=cwd, env=env, timeout_seconds=timeout) + while handle.process.poll() is None: + if cancelled is not None and cancelled.is_set(): + failure = 'cancelled' + manager.stop(handle.process_id) + break + time.sleep(.02) + handle.timed_out = handle.timed_out or handle.process.timed_out + if handle.timed_out: + failure = 'timeout' + # Collector completion is distinct from process exit and preserves final output. + while not (handle.process.stdout.closed and handle.process.stderr.closed): + if cancelled is not None and cancelled.is_set() and failure is None: + failure = 'cancelled' + manager.stop(handle.process_id) + time.sleep(.01) + handle.process.poll() + handle.timed_out = handle.timed_out or handle.process.timed_out + if handle.timed_out and failure is None: + failure = 'timeout' + if not failure and handle.process.returncode != 0: + failure = 'nonzero_exit' + message = handle.stdout.strip() or '(no output)' + if failure: + message = f'Error: command {failure}; exit code {handle.process.returncode}\nstdout:\n{handle.stdout}\nstderr:\n{handle.stderr}' + return CommandResult(message, failure is None, handle.process.returncode, failure, + handle.stdout, handle.stderr, time.monotonic() - started, + handle.timed_out, sandbox_backend=handle.sandbox_backend) + except (PermissionError, ConnectionError, OSError) as exc: + return CommandResult(f'Error: benchmark shell denied: {exc}', False, + failure='denied', sandbox_backend='unavailable') + finally: + manager.close() + + processes.SessionProcessManager = BrokerProcessManager + shell.run_command = run_command + # Importing the tools package loads this class before the module functions + # are replaced, so update its captured method as well. + from openkyrozen.tools.adapters import ToolAdapters + ToolAdapters.run_command = run_command + return True diff --git a/docs/architecture.md b/docs/architecture.md index e74d2dd7..83e9849b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,7 @@ alone. The structured provider foundation is described in [providers](providers. | Requirement / subsystem | Package owner and existing components | Responsibility and authority boundary | V3 implementation | | --- | --- | --- | --- | | R236 AgentEngine | `agent`: `runtime.AgentRuntime`, turn phases in `preparation`, `action_rounds`, `completion` | Sole foreground model/tool loop coordinator; calls provider, planning and executor contracts. Does not translate provider wire formats or bypass authorization. | [#238](https://github.com/EvanProgramming/OpenKyrozen/issues/238) | -| R237 PlanningService | `tasks`: `engine.TaskManager`, `models`, `ports.TaskStore`; `agent.planner` currently bridges textual plans | Owns Working Plan and Strategic Plan transitions, validation and completion evidence. Working Plans are mutable; Strategic Plans require user approval and material revisions require reapproval. Storage persists transitions; the engine requests them. | [#241](https://github.com/EvanProgramming/OpenKyrozen/issues/241), [#242](https://github.com/EvanProgramming/OpenKyrozen/issues/242), [#245](https://github.com/EvanProgramming/OpenKyrozen/issues/245), [#246](https://github.com/EvanProgramming/OpenKyrozen/issues/246) | +| R237 PlanningService | `tasks`: `planning.PlanningService`, `engine.TaskManager`, `models`, `ports.InteractionStore` and `ports.TaskStore`; `agent.modes.InteractionController` adapts planning state to interaction modes and questions | The tasks feature owns Strategic Plan validation, material revisions, approval transitions and completion evidence through PlanningService, and Working Plan edits through TaskManager. The compatibility interaction controller inherits those plan operations without reimplementing them. Storage persists transitions; the engine requests them. | [#241](https://github.com/EvanProgramming/OpenKyrozen/issues/241), [#242](https://github.com/EvanProgramming/OpenKyrozen/issues/242), [#245](https://github.com/EvanProgramming/OpenKyrozen/issues/245), [#246](https://github.com/EvanProgramming/OpenKyrozen/issues/246) | | R238 ToolRegistry | `tools`: `manifest.ToolRegistry`, `ToolManifest`, `registry`, `adapters.ToolAdapters` | Owns definitions, schemas, metadata and executor registration. Supplies the catalog to providers and execution; registration or discovery never grants permission. Extend this registry rather than adding a competing catalog. | [#232](https://github.com/EvanProgramming/OpenKyrozen/issues/232) | | R239 ToolExecutor | `agent`: `executor`, `types.ExecutionReceipt` | Owns authorized invocation and receipts. Uses registry definitions, security decisions and bound tool implementations; denied or failed calls cannot become successful effects. Does not own permission policy or plan transitions. | [#233](https://github.com/EvanProgramming/OpenKyrozen/issues/233), [#236](https://github.com/EvanProgramming/OpenKyrozen/issues/236) | | R240 SecurityPolicy | `security`: `capabilities`, `tool_policy`, `permissions`, `permission_gate` | Owns capability and approval authorization. Effective permissions stay within configured, session and delegated bounds; model output, routing advice and catalog entries cannot approve a call. The executor enforces the decision. | [#261](https://github.com/EvanProgramming/OpenKyrozen/issues/261) | @@ -161,6 +161,13 @@ call identity and fragment length but redact argument text because observers include remote UI surfaces; those deltas are progress metadata, not replayable invocation input. +For a complex direct native turn, the engine offers only the permitted typed +`update_plan` tool until an active Working Plan exists. It rejects a batch that +also requests other tools, retries a planless final response within the turn's +step limit, and reports an incomplete turn if planning is unavailable or fails. +Existing Strategic Plan and tool permission checks still apply. Once a plan +completes, the model may finish or establish a new plan before further tools. + Permitted feature dependencies follow the responsibilities above: `agent` coordinates `tasks`, `tools` catalog/model contracts, `security`, provider contracts, `routing`, `memory` and `learning`; `tasks` owns its models/storage ports; `memory` owns memory diff --git a/docs/configuration.md b/docs/configuration.md index 20b48125..1e3c13e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,7 +66,7 @@ OpenKyrozen has separate defaults for simple and complex work: ```bash export KYROZEN_MODEL_SIMPLE=deepseek-flash -export KYROZEN_MODEL_COMPLEX=deepseek-v4-pro +export KYROZEN_MODEL_COMPLEX=deepseek-flash ``` Set `KYROZEN_MODEL_MAIN` or use `/model ` to pin every main-agent request to one model. Use `/model auto` to return to the simple/complex choices. Sub-agent model assignments remain independent. Ollama requires an explicit installed model tag; `/model` shows discovered tags as suggestions and accepts a manually entered tag. diff --git a/docs/history-and-rollback.md b/docs/history-and-rollback.md index 75fce458..72af4381 100644 --- a/docs/history-and-rollback.md +++ b/docs/history-and-rollback.md @@ -10,7 +10,7 @@ Use `/history` in the terminal UI or the session-history routes listed in [tool- In the TUI, choose a numbered history node to preview the target. Explicitly confirm with `/rollback NUMBER confirm` after reviewing it. The session head is updated only through the rollback operation. A stale head or a mismatched session is rejected instead of overwriting a newer branch. In the web API, the rollback endpoint is scoped to the requested session and node; authenticate and inspect its preview/result before continuing work. -Rollback restores the recorded files in scope and selects the conversation branch captured by that node. It does not reverse external effects such as messages, network requests, deployments, provider charges, or database changes outside the captured workspace. It also does not imply that shared memory claims, agent credentials, or another session's state have been erased. Check those stores separately. +Rollback restores the recorded files in scope and selects the conversation branch captured by that node. The active OpenKyrozen SQLite database and its journal files are excluded even when they are inside the workspace; older snapshots cannot restore them over the live database. If an old snapshot would replace the directory containing that database with a file, rollback is rejected. It does not reverse external effects such as messages, network requests, deployments, provider charges, or database changes outside the captured workspace. It also does not imply that shared memory claims, agent credentials, or another session's state have been erased. Check those stores separately. ## Recovery points and safety diff --git a/docs/index.md b/docs/index.md index 0eabf008..cf1770d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,8 @@ This index covers the current product guides, developer references, and dated en ## Build, contribute, and review evidence - [Development guide](development.md) and [contributor guide](../CONTRIBUTING.md) cover setup, layout, checks, and pull requests. +- [V3 matched evaluation](v3-evaluation.md) documents the frozen cohort, independent checks, and spend controls. +- [V3 release evidence](v3-release-evidence.md) documents signed, exact-candidate gates and the six-platform test and soak evidence workflow. - [Reports index](reports.md) explains the dates, scope, and limits of recorded audits, validation runs, and benchmarks. - [Modular refactor validation](modular-refactor-validation.md), [production audit](production-audit-2026-10-04.md), [shipping audit](production-shipping-audit-2026-10-04.md), and [self-learning audit](self-learning-audit-2026-10-03.md) preserve historical evidence. - [V3 architecture ownership plan](superpowers/plans/2026-10-07-v3-issue-227.md) records the scoped implementation and verification for issue #227. @@ -49,3 +51,7 @@ This index covers the current product guides, developer references, and dated en - Machine-readable audit receipts and benchmark inputs remain beside their corresponding reports under `docs/` and `docs/benchmarks/`. The repository's [English overview](../README.md) links here. Technical guides are maintained in English; README translations provide localized entry points. + +- [V3 finalization execution plan](superpowers/plans/2026-11-07-v3-finalization.md) + +- [V3 finalization status and release blockers](v3-finalization-status.md) diff --git a/docs/installation.md b/docs/installation.md index 19a36236..3bfc1d1b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,8 +1,10 @@ # Install, update, and recover +V3 is not yet released. The public installation commands below use the published stable v2.0.8 release. + ## Requirements -The published v2.0.8 release supports Python 3.12 and 3.13. Python 3.14 is excluded by the package metadata. The official installers provision Python through `uv`; a source checkout needs Git and a supported Python. The optional Bubble Tea terminal UI is a Go binary distributed with the release. Docker uses Python 3.12. See the live [release page](https://github.com/EvanProgramming/OpenKyrozen/releases/latest) before copying a pinned command: installer examples below identify the current release as of 5 October 2026. +OpenKyrozen supports Python 3.12 and 3.13. Python 3.14 is excluded by the package metadata. The official installers provision Python through `uv`; a source checkout needs Git and a supported Python. The optional Bubble Tea terminal UI is a Go binary distributed with the release. Docker uses Python 3.12. See the live [release page](https://github.com/EvanProgramming/OpenKyrozen/releases/latest) before copying a pinned command: the public installer examples below identify stable v2.0.8. ## Install the published release @@ -69,7 +71,7 @@ The value shown is a shell placeholder; replace it locally and never commit a re For an installed release, type `/update` in the running agent. The updater selects one verified source revision for Python and the terminal binary, checks installed package provenance, and stages the replacement TUI before activation. Restart when prompted so the new process loads the installed version. Updates need network access and package dependencies; errors and partial outcomes include recovery instructions. -The published v2.0.8 one-line installers are pinned. The update command tracks the latest verified `main` revision, so it can install a development revision newer than the release version. If startup reports an incomplete update, exit and rerun the official installer. `kyrozen --help` is also intended to remain available for recovery. Historical Windows installations that predate the staged updater should exit the application and run the current official installer before updating. +The public one-line installers remain pinned to v2.0.8 until V3 is released. The V3 source updater tracks the latest verified `main` revision, so it can install a development revision newer than the published release. If startup reports an incomplete update, exit and rerun the official installer. `kyrozen --help` is also intended to remain available for recovery. Historical Windows installations that predate the staged updater should exit the application and run the current official installer before updating. ## State and uninstalling diff --git a/docs/providers.md b/docs/providers.md index ec5334ea..a16ce5c0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -6,7 +6,7 @@ OpenKyrozen separates provider selection, credentials, model defaults, and task- | Name | Credential or identity source | Simple / complex default | Optional extra | |---|---|---|---| -| `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-flash` / `deepseek-v4-pro` | Core | +| `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-flash` / same | Core | | `openai` | `OPENAI_API_KEY` | `gpt-6-luna` / `gpt-6-astra` | Core | | `anthropic` | `ANTHROPIC_API_KEY` | `claude-haiku-4-5` / `claude-fable-5-1` | `claude` | | `google` | `GEMINI_API_KEY` | `gemini-3.5-flash-lite` / `gemini-3.1-pro-preview` | `gemini` | @@ -36,7 +36,7 @@ Set the provider and its credential in the shell that starts OpenKyrozen: export KYROZEN_PROVIDER=deepseek export DEEPSEEK_API_KEY=replace-with-your-key export KYROZEN_MODEL_SIMPLE=deepseek-flash -export KYROZEN_MODEL_COMPLEX=deepseek-v4-pro +export KYROZEN_MODEL_COMPLEX=deepseek-flash kyrozen ``` @@ -50,6 +50,8 @@ Use `/custom-provider create` to add a named OpenAI-compatible endpoint with its The normal provider setting has separate simple and complex model slots. The agent chooses between them using its task routing policy. `/model ` pins the main agent to one model; `/model auto` restores complexity routing. Sub-agent model assignments remain independent. Ollama never selects an installed model automatically: setup lists installed tags as suggestions and requires a chosen tag. `KYROZEN_CONTEXT_WINDOW_TOKENS` supplies a context size when an otherwise unknown custom model cannot be detected; use the actual provider limit, including the space needed for output. +DeepSeek defaults to `deepseek-flash` in both slots, following the [official model-name guidance](https://api-docs.deepseek.com/quick_start/pricing/). Complexity still determines planning needs. To opt into different models, configure both slots explicitly, for example `KYROZEN_MODEL_SIMPLE=deepseek-flash` and `KYROZEN_MODEL_COMPLEX=deepseek-v4-pro`; `/model auto` then selects between those configured values. Existing saved model slots, environment overrides and main-model pins retain their values. A missing slot in an older record inherits the new Flash default, and switching to DeepSeek initializes its current defaults. Model-switch quality and cost benefits depend on the chosen models and workload. + The registry marks a subset of providers for automatic selection and records preferred fallback providers. A fallback is not guaranteed: it still needs credentials, an available compatible model, and a supported transport. Alternate sub-agent providers use their own configured environment credentials and do not inherit a generic main-provider key. See [sub-agents](subagents.md) before using cross-provider assignments. ## Structured response contract (V3 issue #228) diff --git a/docs/superpowers/plans/2026-11-07-v3-finalization.md b/docs/superpowers/plans/2026-11-07-v3-finalization.md new file mode 100644 index 00000000..d2b1bb2f --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-v3-finalization.md @@ -0,0 +1,26 @@ +# V3 finalization execution plan + +Approved by the user on 2026-11-07 in the V3 finalization chat. + +## Binding requirements + +Audit all 266 source requirements in milestone issues #227–#268 and the #319 regression, including issue comments and dependent PRs. Require all seventeen R250–R266 full-release gates to pass against one clean signed candidate. No preview waiver qualifies. Preserve Python 3.12/3.13 and the documented Windows supported-feature exceptions. + +## Work sequence + +1. Start at stable candidate 7905fb634fe447243668c2d01179ce5cb2cdb275 in an independent checkout outside the synchronized Documents directory; preserve original runtime data and evidence. Selectively reuse diagnostic protection tests; never merge the diagnostic branch's reduced CI. +2. Retain separate scope, original-test, provider, process and worker-completeness evidence. Missing evidence fails closed; persist before stopping on the first safety failure. A resumed campaign cannot skip a recorded safety failure. +3. Protect original fixture tests and ancestors identically in both benchmark arms. Implement the approved parent-authorized external shell launcher, preserving sandbox boundaries and process lifecycle. The parent independently enforces frozen fixture/accepted-plan authority, monotonic narrowing and child intersections. Single-use tickets bind identities, full command/cwd/environment and policy revision. Reject forged approval, replay and stale/cross-session requests. Prove direct and managed-shell attacks fail on real Linux/macOS while legitimate source edits work. No supported sandbox means no credentials or provider requests. +4. Explain the historical Windows R260 cancellation failure with causal evidence and a reproducing regression, or retain the blocker. Collect exact dispatch/provider/cancellation/cleanup timing, four-second thread dumps and database/process state. More successful repeats or larger timeouts are insufficient. +5. Move DeepSeek configuration into immutable in-tree pricing/relay configuration. Reconstruct cumulative exposure from original ledgers, deduplicate copies, preserve unknown reservations, cap total at 15 CNY. Use deepseek-flash Responses consistently for both arms, output 4096; reserve 1M input plus output at peak prices, 2.032768 CNY per attempt. Smoke calls and retries count. No calls before offline blockers pass. +6. Run full tests/check/lint/docs/architecture, Go, wheel/sdist external installs, CLI/Web/TUI/update and Docker persistence. Six OS/Python combinations require workflow acceptance and >=1800s soak; record Windows POSIX exceptions honestly. +7. Freeze the final signed SHA, then run the unchanged 30 fixtures x 3 repeats x 2 arms. Require complete accounting, paired-success 95% CI lower bound >0, request and token savings >=10%, V3 recovery count zero and all independent V3 acceptance/planning checks passed. No cross-candidate row reuse, cherry-picking or lowered thresholds. +8. Create signed exact-SHA gate records/manifest and committed-tree distribution artifacts outside the checkout. Review; fast-forward main non-forcibly to the same fully verified SHA after rechecking rules and concurrent changes. Re-freeze if identity changes. Public downloads stay on the actually published stable release until V3 publication. + +## Stop boundary + +Do not create tags, draft/public Releases, upload package repositories, or invoke the preview publisher. Missing evidence, budget exhaustion, provider outage or an unexplained R260 failure remains a blocker; do not call the work release-ready. + +## Execution evidence + +External execution ledger and unmodified source requirement/comment snapshots: `/Users/evangong/.codex/worktrees/openkyrozen-v3-evidence/`. These are progress records, not passing release authorization. Credentials must never enter tracked files, reports or logs. diff --git a/docs/superpowers/plans/index.md b/docs/superpowers/plans/index.md index f13b27cb..5cf49e10 100644 --- a/docs/superpowers/plans/index.md +++ b/docs/superpowers/plans/index.md @@ -19,3 +19,5 @@ Add a linked issue-specific plan here before coding any later V3 issue. - [V3 #246 — Reapproval for material Strategic Plan revisions](2026-10-16-v3-issue-246.md) + +- [V3 finalization and exact-candidate acceptance](2026-11-07-v3-finalization.md) diff --git a/docs/tool-inventory.md b/docs/tool-inventory.md index 0a1cf461..cff53228 100644 --- a/docs/tool-inventory.md +++ b/docs/tool-inventory.md @@ -7,7 +7,7 @@ Run `make docs-check` after changing a tool, endpoint, or MCP contract. ## Runtime tools -- Built-in runtime tools: **53** +- Built-in runtime tools: **55** - Names with the `git_` prefix: **14** - The CLI Action contract is a plain string in the `args` field. - Web and MCP exposure is filtered by the configured capability profile. @@ -53,10 +53,12 @@ Run `make docs-check` after changing a tool, endpoint, or MCP contract. | `list_dir` | `read` | `read_only` | true | List contents of a directory. Args format: "path" (default "."). | `path`: string | | `list_subagents` | `read` | `internal` | false | List agents or inspect one run_id from this chat/project; args is JSON. | `run_id`: string | | `list_tree` | `read` | `read_only` | true | Recursively list the directory tree of the given path. Args format: "path" (default "."). | `path`: string | +| `propose_plan` | `read` | `internal` | false | Propose a versioned Strategic Plan after read-only inspection. This never | `assumptions`: array, `bounds`: object, `steps`: array, `summary`: string, `title`: string; required: `title`, `summary`, `bounds`, `steps` | | `read_file` | `read` | `read_only` | true | Read content from a file. Args format: "path". | `max_chars`: integer, `max_lines`: integer, `path`: string, `start_line`: integer; required: `path` | | `read_process` | `read` | `read_only` | false | Read a bounded page from a session-owned process stream. | `limit`: integer, `offset`: integer, `process_id`: string, `stream`: string; required: `process_id` | | `read_tool_artifact` | `read` | `read_only` | false | Read a session-owned tool artifact in pages up to 1,000 characters. | `artifact_id`: string, `limit`: integer, `offset`: integer; required: `artifact_id` | | `read_webpage` | `network` | `remote` | false | Fetch the content of a web page and return its plain‑text body. | `url`: string; required: `url` | +| `request_question` | `read` | `internal` | false | Ask 1-3 structured clarification questions. Questions cannot request | `questions`: array; required: `questions` | | `run_cmd` | `shell` | `process` | false | Execute a shell command. Args: the full command string. | `background`: boolean, `command`: string, `cwd`: string, `env`: object, `timeout_seconds`: number; required: `command` | | `search_files` | `read` | `read_only` | true | Search literal text with bounded, relative file-and-line results. | `glob`: string, `limit`: integer, `path`: string, `query`: string; required: `query` | | `search_memory` | `read` | `read_only` | false | Search stored memories for facts relevant to the query. Args: "query" | `query`: string; required: `query` | @@ -64,7 +66,7 @@ Run `make docs-check` after changing a tool, endpoint, or MCP contract. | `send_subagent` | `read` | `internal` | false | Reuse a finished sub-agent; args JSON contains run_id and a complete assignment. | `assignment`: object, `run_id`: string; required: `run_id`, `assignment` | | `spawn_agents` | `read` | `internal` | false | Automatically start parallel specialist assignments; args is a JSON assignment batch. | `assignments`: array | | `stop_process` | `shell` | `process` | false | Stop a process owned by the current actor, workspace, and session. | `process_id`: string; required: `process_id` | -| `update_plan` | `write` | `internal` | false | Apply revision-checked ordered Working Plan edits or adapt the legacy task list. | `complexity`: string, `expected_revision`: integer, `operations`: array, `plan_id`: string, `reason`: string, `tasks`: array; requires either `operations` and `expected_revision` or `complexity` and `tasks` | +| `update_plan` | `write` | `internal` | false | Edit a revision-checked Working Plan. Use start with a stable task ID before | `complexity`: string, `expected_revision`: integer, `operations`: array, `plan_id`: string, `reason`: string, `tasks`: array; requires either `operations` and `expected_revision` or `complexity` and `tasks` | | `wait_subagents` | `read` | `internal` | false | Wait up to 60 seconds for delegated work and its mandatory reviews; args is JSON. | `run_ids`: array, `timeout`: number | | `write_file` | `write` | `filesystem` | false | Write content to a file. Args format: "path\|content". | `content`: string, `path`: string; required: `path`, `content` | diff --git a/docs/tool-output-artifacts.md b/docs/tool-output-artifacts.md index 43e45c85..ebed42de 100644 --- a/docs/tool-output-artifacts.md +++ b/docs/tool-output-artifacts.md @@ -9,5 +9,14 @@ characters in the serialized JSON result, so pages can be concatenated exactly. Artifacts are private to their user, workspace, and session. Knowing an ID does not grant access from another scope. They remain available until that session or workspace is deleted, at which point the artifact rows are deleted with the -other scoped state. Small results remain inline. The tool inventory lists the -retrieval schema and bounds. +other scoped state. The most recent native tool round remains inline. Before a +later tool round is sent to the provider, older native replies become artifact references +while their call/reply identities remain intact. The tool inventory lists the +retrieval schema and bounds. Access still requires permission to use +`read_tool_artifact`. + +Legacy text-protocol turns also replace earlier rounds with scoped artifact +references. Their reference includes an `Action` example whose `args` is a JSON +string containing `artifact_id`, `offset`, and `limit`. Archived rounds contain +structured results, including content beyond the legacy display limit. The newest +round stays inline, and a storage failure stops the turn before another model request. diff --git a/docs/v3-evaluation.md b/docs/v3-evaluation.md new file mode 100644 index 00000000..a5a03e2f --- /dev/null +++ b/docs/v3-evaluation.md @@ -0,0 +1,54 @@ +# OpenKyrozen V3 matched evaluation + +The frozen cohort contains 30 fixtures across ten task categories, with three matched repeats per fixture on V2 `v2.0.8` (`1bfc82c793df49e76097061ec5adfef0e4e2e883`) and the V3 candidate. It includes dedicated replanning, Strategic Plan scope, material-revision approval, and plan-churn scenarios. `benchmarks/v3_matched_live.py` creates isolated workspace and state for each run, verifies outcomes outside the model, records token/request accounting, and resumes only a report whose hash is pinned in its original budget ledger. Strategic Plan fixtures preserve their accepted workspace-local read/write authority. The independent harness runs acceptance tests after checking original-test integrity; the worker does not gain shell/process authority from the fixture. Scope evidence accepts either a live strategic task binding or a matching completed-plan event for the exact accepted plan ID, version, and fingerprint. A privileged benchmark parent owns fresh numeric actor UIDs and certifies their kernel process sets empty, including detached descendants; permission scenarios require denied runtime receipts and reject secret disclosure. + +The benchmark controller requires a disposable privileged Linux or macOS runner. It creates no persistent user accounts or services. Stage the signed source, baseline history and interpreter beneath directories searchable by the fresh actor UIDs; keep personal home directories and the original working tree unchanged. Root workers, authors, reviewers and shell invocations have separate identities and private homes. The original selected product implements each delegation and tool; the benchmark changes their transport and OS boundary. Parent-held fixture policy authenticates new Unix connections by kernel credentials, intersects child scopes and capabilities, and binds single-use shell tickets to the concrete command, directory, sanitized environment and policy revision. Worker JSON and databases cannot widen those grants. Source reads and mutations use fresh selected-source helpers, with writers frozen during final validation and launch. A directory overlay must retain every protected test mount inside its allowed read scope. No-read actors receive no fixture-data view. + +Linux prepares private user namespaces without changing the host's AppArmor or namespace policy. Before selected product code runs, the parent verifies actual host IDs, empty supplementary groups, all capability sets cleared and no-new-privileges. Namespace descriptors and setup channels close before execution; nested user namespace creation remains disabled. macOS extends the existing sandbox profile in place. Every backend must pass the complete direct-file, managed-shell, cross-actor, ticket, output, cancellation, disconnect and detached-process preflight before credential lookup. Component checks or an unavailable backend cannot authorize paid execution. + +The `--repo` checkout must contain the pinned V2 commit so the runner can extract its source with `git archive`. The CI full-suite jobs fetch complete history and verify that exact commit before running tests; for a shallow local checkout, fetch the V2 baseline first. + +The formal finalization campaign uses the [DeepSeek CNY command below](#deepseek-cny-campaign), with its cumulative CNY 15 cap. The Panstar configuration remains a separate compatibility path; its historical USD allowance does not authorize spending in that campaign. + +Independent acceptance also needs the worker's answer, tool receipts, task state and plan events after its temporary workspace is removed. Each normalized receipt retains an independent snapshot of its original arguments, results, status and timing for reproduction and planning review. The parent retains those results outside the checkout with exact candidate/cohort/apparatus/row identity and content hashes. Each actor has a distinct revocable relay credential bound to its fixture cell; credentials, including revoked ones, are redacted from retained worker strings. Credential-bearing output remains incomplete, so redaction cannot turn a failed observation into a passing verdict. The relay's accounting journal continues to store request hashes and usage rather than prompts or answers. Missing, modified or foreign worker artifacts cannot qualify a reusable row or a complete report. Parent execution or cleanup failures retain an incomplete checkpoint and stop before another cell or acceptance command. Automated outcome checks and the identical-task-snapshot churn proxy do not replace the predeclared independent QA, reproduction and planning review; no comparison or routing benefit is claimed without the required evidence. + +The repaired cohort keeps the same 30 IDs, ten categories, three repeats and two arms. It adds in-process multi-file API changes, behavior-preserving shared-helper refactors, independently correlated Git tool receipts, and eleven-turn context scenarios. Both arms receive an explicit context window of 1,000,000 tokens for ordinary fixtures and 16,384 for context fixtures, with 4,096 output tokens reserved. Provider-port observations measure the actual dispatched messages, including compaction requests, without changing either runtime's behavior. Estimated components remain distinct from provider-reported usage; unavailable fields stay unknown. + +The paired long-context oracle checks the same facts, supersession, complete scripted turns and outbound bounds for both arms. V3 compaction observation is a separate feature acceptance condition and does not alter paired task success. Checkpoints bind the canonical fixture digest and observer, verifier, metric and relay hashes. A changed apparatus cannot reuse old rows; historical calls still count toward cumulative spending. + +The local authenticated Responses relay pins `gemini-3.1-flash-lite`, caps output at 4,096 tokens, reserves worst-case spend before forwarding, and stores request hashes and usage rather than prompts or answers. It moves system-role messages from `input` to Responses `instructions` for both benchmark arms; when no other input remains, it sends `Continue.`. It stops consuming a stream at its terminal event, preserving unknown usage as a held reservation without attempting to settle the same attempt twice. Worker failures record the exit code and exception class without persisting stderr or its potentially sensitive details. `response.failed` and HTTP error events record only an ASCII error code of at most 80 characters; HTTP error bodies are capped at 64 KiB for parsing, and free-form provider messages are never stored. Failed workers with partial usage are retried on resume; complete usage and a successful exit are required before a fixture row can be reused. The cumulative Panstar cap is US$20. Panstar's quoted rates are $0.033/1M input, $0.275/1M output, and $0.0033/1M cache reads; cache writes are charged at the input rate because Panstar quoted no separate rate. The CNY SQLite ledger uses the recorded 2026-10-23 USD/CNY rate of 6.67135 and records both the quote and FX source in pricing metadata. Unknown usage retains its reservation. Every fresh run requires an explicit `--prior-hold-usd` equal to all earlier settled spend and held reservations, including unknown attempts; never reuse a smaller reservation. Before every run, reconstruct cumulative exposure from the original report and budget ledger as settled spend plus every reserved or unknown attempt. Do not use a stored or copied exposure amount; if the source reports and ledger are unavailable, do not start the campaign. The runner requires the hold explicitly and records it in the report. Existing partial reports remain inconclusive and are not used as evaluation evidence. The application-shaped native tool continuation was verified; a separate `previous_response_id` diagnostic was rejected by the gateway and is not used by OpenKyrozen. Set `KYROZEN_BENCHMARK_API_KEY` and `KYROZEN_BENCHMARK_BASE_URL` from the approved provider configuration; the default endpoint is `https://ai.panstar.ai/v1`. + +Before reading provider credentials or starting campaign requests, the runner probes the OS filesystem sandbox. The cohort requires working filesystem restrictions for its permission and scope checks, so an unavailable backend blocks the campaign without provider spend. This benchmark prerequisite does not change the application's best-effort sandbox default. + +After reconciling the original reports and ledger, set `VERIFIED_PRIOR_HOLD_USD` to all prior settled spend plus every still-reserved or unknown attempt. Run the complete cohort from a clean candidate checkout, writing the report and budget ledger outside it: + +```sh +python -m benchmarks.v3_matched_live \ + --source . --repo . \ + --output /outside/checkout/v3-matched-report.json \ + --budget-db /outside/checkout/v3-matched-budget.sqlite3 \ + --limit-usd 20 \ + --prior-hold-usd "$VERIFIED_PRIOR_HOLD_USD" +``` + +The report is descriptive evaluation data, not release authorization. A result is conclusive only when all 180 matched rows have complete provider usage, all independent verifiers pass, the full paired success interval improves, token and request reductions meet their thresholds, and no safety or protocol-recovery control fails. Missing or unknown usage, failed checks, budget exhaustion, or an incomplete run remains inconclusive. Any code change creates a different candidate SHA and requires a new matched run. + +## DeepSeek CNY campaign + +The in-tree `benchmarks.v3_deepseek` CLI uses the same frozen 180-row cohort and unchanged comparison thresholds, with immutable DeepSeek configuration. It pins the official `https://api.deepseek.com` Responses endpoint and `deepseek-flash` alias (served model `DeepSeek-V4.1-Flash-1017`). Peak prices are CNY 2/M input, 8/M output, 0.04/M cache reads, and conservatively 2/M cache writes, from the [official pricing table](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/). Every request reserves exactly CNY 2.032768 for 1,000,000 input and 4,096 output tokens. Requests are serial through stream completion. Unknown usage keeps its entire reservation; retries and the required metered smoke share the cohort ledger. Smoke calls remain in its journal and do not count toward the 180 matched rows. + +The cumulative cap is **CNY 15 including previous campaigns**. Supply original prior ledgers; the reader opens them in SQLite read-only mode, independently reconstructs settled charges from usage, retains reserved/unknown amounts, and deduplicates identical attempt IDs across copies. New aggregate carryovers include the underlying attempt records and a SHA256 manifest so later campaigns can be reconciled with their original ledgers without double counting or releasing unknown exposure. Missing sources, conflicting records, invalid provenance, unknown pricing, and overruns fail closed. Original campaign `run1/budget.sqlite3` and its `evidence/budget.sqlite3` copy represent one campaign, not two. The code has no fixed candidate SHA or machine-local path. + +After the offline protection gates pass, use a clean, signed frozen candidate and output paths outside it. The CLI verifies the configured candidate and release-owner signature; the runner probes the OS sandbox and protected worker/managed shell before reading `KYROZEN_BENCHMARK_API_KEY` from the environment. It then performs and checkpoints a two-request native-tool smoke through the same relay before starting cohort workers: a typed `echo_probe` call must carry an unpredictable value, its `function_call_output` must use the returned call ID, and the final assistant answer must include that value. Both requests require complete usage; the probe has no external side effects. The checkpoint pins the two verified attempt IDs. A previously settled smoke for the same candidate can be reused; failed or unknown smoke attempts remain charged or held when retrying. + +```sh +python -m benchmarks.v3_deepseek \ + --source "$FROZEN_SOURCE" --candidate-sha "$FROZEN_CANDIDATE_SHA" \ + --repo "$REPOSITORY_WITH_V2_HISTORY" \ + --output "$CAMPAIGN_OUTPUT/report.json" \ + --budget-db "$CAMPAIGN_OUTPUT/budget.sqlite3" \ + --prior-ledger "$ORIGINAL_PRIOR_LEDGER" \ + --limit-cny 15 +``` + +CNY checkpoints report their actual CNY cap, prior exposure and remaining balance without an exchange-rate workaround. A new request cannot be reserved once cumulative settled spend plus outstanding holds exceeds CNY 12.967232. A completed smoke is provider compatibility/accounting evidence only; it does not satisfy the cohort or authorize release. diff --git a/docs/v3-finalization-status.md b/docs/v3-finalization-status.md new file mode 100644 index 00000000..41238176 --- /dev/null +++ b/docs/v3-finalization-status.md @@ -0,0 +1,48 @@ +# V3 finalization status + +V3 is **unpublished**. This document describes implemented safeguards, retained investigation results and required acceptance evidence. Release readiness is established by the external, signed R250–R266 bundle for the exact clean candidate; source changes and intermediate passing tests do not establish it. The final workflow stops before creating a V3 tag or Release. + +## Implemented safeguards + +- Matched evaluation now retains independent scope, original-test integrity, provider, process and worker-completeness evidence. Missing evidence fails closed. The first safety failure is checkpointed and stops the campaign; resuming cannot skip it. Malformed worker JSON is preserved as an incomplete result rather than losing the checkpoint. +- Worker answers, receipts, task state and plan events require durable, parent-bound artifacts for independent review after workspace cleanup. Resume and completeness checks reject missing, modified or cross-row evidence. Protection probes use isolated temporary homes and a minimal environment before any provider credential lookup. +- DeepSeek has immutable CNY pricing, a cumulative 15 CNY cap, serial requests and fixed 2.032768 CNY worst-case reservations. Original source ledgers are read-only, copies deduplicate, and proven carried history expands to original attempt records. Smoke, retries and cohort share the ledger; unknown use stays reserved. +- New and missing DeepSeek model slots both default to `deepseek-flash`; explicit saved model choices remain effective. Equal default slots do not demonstrate a model-selection benefit. +- A two-request native-tool smoke checks unpredictable arguments, correlated function-call output, final response and complete usage before the 180-row cohort layout. +- Original fixture tests are OS-protected for both benchmark arms. The benchmark-only external shell broker enforces parent-owned bounds and request-bound tickets. Real macOS tests cover legitimate writes, protected-file attacks, narrow scope, output/exit/cancellation/timeout and frozen V2 compatibility. +- Process lifecycle audit persists scoped launch intent before dispatch and retains uncertain outcomes across restart. Historical PIDs are observation metadata, never authority to reattach, signal or replay. Audit failure cannot certify cleanup, and bounded audit writes preserve cancellation under SQLite contention. +- Learning promotion requires complete paired outcome, verification, cost, latency and regression measurements. Missing dimensions and old success-only replay records remain incomplete. Known failed effects replace unsupported final success claims with receipt-derived results, including valid structured child reports. +- Public README, installation guide and website commands remain on the published v2.0.8 assets. Candidate package metadata remains 3.0.0. V3 publication must precede switching public download instructions. + +## Required release evidence + +1. **Final actor/platform evidence:** fresh-process delegation, kernel UID ownership, immutable parent policy, read-scope protection and UID-based detached-process cleanup are implemented. All four Linux/macOS Python combinations passed the privileged suite for intermediate commit `c87ecef`; the earlier six-platform workflow also completed all six 30-minute soaks. Both results remain historical after source changes. The release bundle must establish all of these checks for the final clean candidate. +2. **R260 history and causal closure:** the original failure was a five-second future-completion timeout after cancellation. Reconstructed Python 3.12.10 and dependency pins exposed foreground SQLite close latency. A pinned old-source controlled regression reproduces the timeout, and the owned-connection implementation avoids that foreground last-close operation. An unmodified old-source near-stall spent most of its time inside SQLite with little thread CPU time. These observations establish a defect and remediation, but cannot distinguish the historical storage wait from scheduler delay. The original failure had no worker stack. Two subsequent native Windows captures remained incomplete or fast and did not establish its cause; ordinary Windows acceptance and repeated passes cannot close this requirement. A bounded custom native trace retains scheduling and file-I/O signals without changing the original test deadlines or collection limits. Actual Windows parser, zero-loss capture and causal analysis evidence must be provided in the exact-candidate bundle. +3. **Complete acceptance:** the release bundle must establish default-on routing benefit, requirement-by-requirement acceptance, final six-platform/30-minute validation, the live 180-row comparison and all 17 signed passing records. Main must then point to that same verified SHA without rewriting it. + +The full campaign requires a privileged disposable runner and executes real actor and descendant checks before credential lookup or model requests. The optional bounded component probe returns explicitly incomplete isolation evidence and cannot replace that preflight. + +## Functional acceptance audit + +The 266 source criteria were independently audited against implementation and regression bodies: 196 implemented-with-regression, 52 partial and 18 unproven at the audit baseline. Partial includes both concrete functional gaps and missing acceptance evidence. Those baseline counts are not a final acceptance result. Issue closure alone is not completion. + +Implemented repairs cover unsuccessful provider finishes, native streaming, explicit task start/completion, immutable strategic objective anchors with scoped refinement, pinned Working Plans, unknown-effect restart protection, turn-scoped background cancellation, advisory-only Jev decisions and evidence-gated learning promotion. Strategic transitions now belong to `tasks.planning.PlanningService`; the interaction controller retains mode and question adaptation. + +Native Agent, Ask and Plan paths use typed execution/control contracts. Plan proposals preserve explicit user acceptance, material revisions pause mutations, and safe narrowing preserves the existing automatic-acceptance policy. Lazy legacy helpers and reply-specific provenance keep literal native text intact through CLI/Web/TUI formatting and delegated synthesis. Complex read-only children can edit only their own internal plans and retrieve only their own tool artifacts, without receiving filesystem write authority. + +Native context preflight now includes tool schema and continuation payload estimates, binds reported counts to provider/model/schema identity, and compacts old complete history units without splitting call/result groups. Overflow recovery retries only generation, once, before any emitted stream content; it cannot replay an executed tool. Prose-only task criteria remain pending unless trusted verification exists: a successful file write cannot certify an unrelated test or business outcome. + +The repaired 30-fixture cohort and observer/verifier identities are frozen before any paid run. Multi-file/API, refactor, Git and multi-turn context oracles retain the original comparison thresholds and shared physical success criteria. New regression, packaging and platform evidence must bind the eventual clean signed candidate; intermediate passing suites cannot be combined into a final release manifest. + +## Evidence and constraints + +- Approved execution plan: [V3 finalization](superpowers/plans/2026-11-07-v3-finalization.md). +- Windows candidate diagnostic: https://github.com/EvanProgramming/OpenKyrozen/actions/runs/42261579117 +- Windows historical diagnostic: https://github.com/EvanProgramming/OpenKyrozen/actions/runs/42261682019 +- Historical Windows load controls: https://github.com/EvanProgramming/OpenKyrozen/actions/runs/42288273719 +- Intermediate four-platform privileged isolation: https://github.com/EvanProgramming/OpenKyrozen/actions/runs/42349253524 +- Incomplete continuous Windows native capture: https://github.com/EvanProgramming/OpenKyrozen/actions/runs/42350403044 +- Detailed local execution logs, 266 requirement/comment inventory and historical Windows artifacts are outside the checkout in `/Users/evangong/.codex/worktrees/openkyrozen-v3-evidence/`. +- The initial offline audit reconstructed settled 0.58291496 CNY plus held 0.000000000007 CNY, total 0.582914960007 CNY. This is historical exposure, not a live cumulative balance. Subsequent smoke, retry and cohort charges, including every unknown reservation, must be reconstructed from the original ledgers in the external exact-candidate evidence. + +Do not waive the blockers, publish the preview, create a V3 tag/Release, upload package repositories or merge an unverified candidate to main. A changed candidate SHA requires new exact-candidate evidence. diff --git a/docs/v3-release-evidence.md b/docs/v3-release-evidence.md new file mode 100644 index 00000000..9e93c17f --- /dev/null +++ b/docs/v3-release-evidence.md @@ -0,0 +1,81 @@ +# V3 release evidence + +Full release publication is blocked until signed records for R250–R266 all pass against the exact clean candidate commit. Each record is detached-GPG-signed by the pinned release-owner key, includes command and fixture files with verified SHA-256 digests, runner/OS/Python and dependency details, and references hashed result artifacts. A failed gate, a missing record, or missing/failed safety evidence is never waivable. + +A clearly labeled limited-evidence preview is available only for the fixed scope `openkyrozen-v3-preview-linux-macos-sandbox-required-v1`. Its only waivable inconclusive gates are R259 (native protocol recovery target), R262 (dynamic replanning quality), R263 (matched-model task success), and R264 (matched-task request/token efficiency). These are evaluation outcomes, not sandbox, file-patching, dynamic-tool-registration, or migration gates. R259 records must include `protocol_recovery_count`: a pass requires zero, and any observed positive count is a non-waivable failure even if mislabeled inconclusive. An interrupted run may record `null` or a partial zero count and remain inconclusive. A preview waiver means that the corresponding evaluation is incomplete; it never means that its target passed. Every other gate must pass. The evidence records must still say `inconclusive`; the manifest preserves that result and marks the corresponding operational status `waived`. The ordinary/full validator remains blocked by any inconclusive gate. + +Each inconclusive evaluation gate needs its own detached-GPG-signed waiver in `waivers/R259.json` (and matching `.json.asc`), signed by the pinned release-owner key. The waiver must bind its gate ID, exact candidate SHA, the fixed release scope, approver fingerprint, rationale, limitations, hashes of its evidence references, and exactly one future expiry or review condition. Waiver evidence must include that gate's signed inconclusive record, R266's signed platform record, and all Linux/macOS Python 3.12/3.13 full unit logs. Every waiver independently requires those logs to show the patch-safety, sandbox-enforcement, dynamic-tool-registration, migration, and approval-history safety tests passing; R266 must bind those logs as platform artifacts. A failed gate or missing/failed safety test is never waivable. The release scope is preview-only for Linux and macOS with `KYROZEN_SANDBOX_REQUIRED=1`; Windows distribution, production use, general availability, and a claim that the full V3 evaluation passed are outside this scope. + +R266 carries the supported-environment matrix: Linux, macOS, and Windows on Python 3.12 and 3.13. Linux and macOS records must show passing full unit and gateway integration suites. Windows records mark those POSIX-dependent suites `not_run` and must show passing supported-feature workflow acceptance, including Go TUI tests and build. The interactive TUI PTY scenario is explicitly recorded as `not_run_posix_pty_unavailable` on Windows because its harness requires POSIX `fcntl`/`pty`; it must pass on Linux and macOS. All six records require passing supported-feature acceptance and at least 1,800 seconds of soak evidence. Each platform entry names nonempty `evidence_paths`, and every path must reference a hashed artifact in the enclosing record. + +The CI workflow runs the R266 matrix for pull requests or pushes that change its command set, fixture set, collector, or soak script. For an explicit run, dispatch `.github/workflows/ci.yml` with `v3_release_candidate_sha` set to the full candidate commit SHA. It checks out that exact SHA, runs full Python and gateway integration suites on Linux and macOS, and runs agent/sub-agent acceptance plus Go TUI tests and builds on all six platform/Python combinations. The POSIX interactive TUI PTY scenario runs on Linux and macOS only; Windows records its unavailability. Each platform also runs a 30-minute production soak. Windows records its Python unit and gateway integration checks as `not_run`; its supported-feature acceptance is required to pass. The workflow uploads per-platform logs even on failure for diagnosis and emits an R266 bundle only after every matrix job passes. The collector copies and hashes the command workflow, frozen fixture set, platform metadata, test logs, and soak results. The bundle is intentionally unsigned because the release-owner private key must stay off CI. Download it, place it in the evidence directory outside the candidate checkout, inspect the evidence, and sign `R266.json` with the pinned release-owner key before validating the complete release bundle: + +The soak runner gathers memory, thread, handle, and descendant-process evidence through Linux `/proc`, macOS `ps`/`lsof`, and Windows PowerShell. It uses only the Python standard library and platform tools; it does not add a Python dependency. + +```sh +gpg --batch --local-user D3897E8FF704149D609ACF878C118C04263D2A4C \ + --armor --detach-sign --output R266.json.asc R266.json +``` + +The collector rejects stale candidate SHAs, missing artifacts, failed checks, short soaks, unexpected platform artifacts, and existing output directories. + +The dispatch workflow is evidence collection, not a substitute for inspecting its result. A failed matrix run produces diagnostic artifacts but no passing R266 record; rerun only after classifying the failure as product or runner/infrastructure. + +Store each record and its artifacts under one evidence directory, using these names: + +```text +R250.json +R250.json.asc +... +R266.json +R266.json.asc +commands/R250.txt +fixtures/v3-fixtures.json +results/... +``` + +Records use schema version 1. Their required fields are `gate_id`, full `candidate_sha`, `command_set_path` and `command_set_sha256`, `fixture_set_path` and `fixture_set_sha256`, `environment` (`runner`, `os`, and `python`), `dependencies`, `result`, `failure_class`, and a nonempty `evidence` array of relative artifact paths and SHA-256 hashes. R266 also requires the six `platform_results` described above, each linked to its own hashed logs. Artifacts and records may not resolve outside the evidence directory. + +After all records have been collected, validate and sign the aggregate manifest from a clean checkout. Keep both output files outside the checkout so writing the manifest cannot change the candidate SHA: + +```sh +python -m benchmarks.v3_release_gates \ + --repo . \ + --evidence-dir /outside/checkout/v3-evidence \ + --manifest /outside/checkout/v3-release-manifest.json +``` + +For a limited-evidence preview, provide the fixed scope and place the four signed waivers under the evidence directory: + +```sh +python -m benchmarks.v3_release_gates \ + --repo . \ + --evidence-dir /outside/checkout/v3-evidence \ + --manifest /outside/checkout/v3-preview-manifest.json \ + --limited-evidence-preview \ + --release-scope openkyrozen-v3-preview-linux-macos-sandbox-required-v1 +``` + +The command verifies every record and waiver signature against the pinned GPG fingerprint, checks the exact clean checkout SHA, validates all artifact hashes and platform gates, then emits a signed manifest and detached signature. It verifies, parses, and hashes each signed record from one private snapshot so a file replacement during validation cannot change the bytes being accepted. Any missing, stale, malformed, failed, unsigned, or unsupported inconclusive evidence returns failure and emits no manifest. Preview manifests use `result: limited-evidence`, `release_classification: limited-evidence-preview`, and explicitly list `waived_gates`; full manifests continue to use `result: passed` only when every gate passes. Both manifest forms preserve per-gate `evidence_result`. The command publishes the signature first and the manifest last, and refuses to overwrite either existing output; use a new manifest path for each attempt. The signed manifest records every gate record hash, command-set hash, fixture-set hash, environment, dependency inventory, artifact hash, and waiver/signature hash for audit. + +## Publishing the preview + +The planned preview uses package version `3.0.0.dev1` and tag `v3.0.0.dev1`. Review and sign the final commit before collecting its evidence: changing the commit invalidates earlier records. The ordinary tag workflow excludes V3 tags and rejects manual V3 release requests, so it cannot create a full release for the preview tag. + +After the exact candidate, signed gate records, scoped waivers, and signed manifest have all been reviewed, use the local publisher with an authenticated GitHub CLI and the release-owner signing key. Its output directory must be a new directory outside the checkout: + +```sh +python -m scripts.publish_v3_preview \ + --repo . \ + --evidence-dir /outside/checkout/v3-evidence \ + --manifest /outside/checkout/v3-preview-manifest.json \ + --output-dir /outside/checkout/v3-preview-assets +``` + +The publisher verifies the commit signature, the signed manifest, every gate and waiver again, and the release workflow's V3 guard. It builds from the committed Git tree, signs an artifact hash record, and creates a signed tag bound to that commit. It creates a **draft prerelease**, downloads and hashes its assets, revalidates the evidence, and only then publishes it as a prerelease. Release notes identify every waived gate and its limitations. If a tag already exists, its signature and remote identity must match before retrying. If publication or its final verification is interrupted, inspect the GitHub release and tag before retrying; the publisher never overwrites an existing release. + +Preview usage is limited to Linux and macOS with the filesystem sandbox required. Set `KYROZEN_SANDBOX_REQUIRED=1` when running `kyrozen` or `kyrozen-web`; unavailable required protection blocks execution. Preview `/update` verifies the version at the selected main-branch revision and refuses to stage an older or unverifiable version. It supports numeric release, `a`, `b`, `rc`, and `.dev` versions; an unsupported version format is rejected until the user installs an appropriate release explicitly. Windows remains in the acceptance matrix, but is outside preview distribution scope. + +## Full V3 release + +The full release candidate uses package version `3.0.0` and tag `v3.0.0`. A full release requires an exact-candidate signed manifest with all 17 gates passed; limited-evidence manifests and waivers do not qualify. The preview publisher only accepts development versions and cannot publish the full release. Keep the V3 automatic-tag exclusion in place, validate the signed full manifest and candidate signature, and publish the signed candidate artifacts only after all release checks and evidence pass. diff --git a/install.ps1 b/install.ps1 index aaf971c5..9a1b3fdd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,11 +19,11 @@ function Warn([string]$Message) { } if ($ShowHelp) { - Write-Output "OpenKyrozen installer: installs the pinned v2.0.8 GitHub release, uv, Python 3.12/3.13, and the Bubble Tea terminal UI." + Write-Output "OpenKyrozen installer: installs the pinned v3.0.0 GitHub release, uv, Python 3.12/3.13, and the Bubble Tea terminal UI." exit 0 } -$releaseVersion = "2.0.8" +$releaseVersion = "3.0.0" $releaseTag = "v$releaseVersion" $goVersion = "1.27.1" $releaseBaseUrl = "https://github.com/EvanProgramming/OpenKyrozen/releases/download/$releaseTag" diff --git a/install.sh b/install.sh index 4c69d7af..fbfe8226 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ set -eu -release_version='2.0.8' +release_version='3.0.0' release_tag="v$release_version" go_version='1.27.1' release_base_url="https://github.com/EvanProgramming/OpenKyrozen/releases/download/$release_tag" diff --git a/openkyrozen/__init__.py b/openkyrozen/__init__.py index 3d1eca2d..ab23fc9d 100644 --- a/openkyrozen/__init__.py +++ b/openkyrozen/__init__.py @@ -1,2 +1,2 @@ """OpenKyrozen modular agent application.""" -__version__ = "2.0.8" +__version__ = "3.0.0" diff --git a/openkyrozen/agent/action_rounds.py b/openkyrozen/agent/action_rounds.py index 800a3dd6..41e575b6 100644 --- a/openkyrozen/agent/action_rounds.py +++ b/openkyrozen/agent/action_rounds.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import uuid from typing import Any from openkyrozen.tasks.engine import canonical_status, is_complete, is_terminal @@ -8,14 +9,23 @@ def _execute_action_rounds(self, turn): from openkyrozen.agent.delegation import GUIDANCE results: list[str] = [] + full_results: list[dict[str, Any]] = [] turn.tool_records: list[dict[str, Any]] = [] successful_operations: set[str] = set() operation_scope = str(turn.learning_run.get("run_id") or uuid.uuid4().hex) + + def full_result(receipt, prompt_result): + from openkyrozen.agent.conversation import tool_result_to_dict + result = getattr(receipt, "tool_result", None) + return {"action": receipt.action, "arguments": receipt.args, + "output": tool_result_to_dict(result) if result is not None else {"text": prompt_result}} + def record_initial_call(tc, receipt): turn.tool_records.append(self._record_turn_receipt(receipt, allow_legacy_task_matching=True)) prompt_result = self._tool_result_for_prompt(receipt) self._render_tool_result(receipt.action, prompt_result) results.append(f"- `{receipt.action}({receipt.args!r})` returned:\n{self._safe_fstring(prompt_result)}") + full_results.append(full_result(receipt, prompt_result)) if self.tasks.tasks: self._update_tasks_panel() self._execute_tool_batch( @@ -41,6 +51,20 @@ def record_initial_call(tc, receipt): evolving_messages = None previous_model_reply = None previous_tool_results: list[str] = [] + previous_full_results: list[dict[str, Any]] = [] + previous_result_slot = None + + def archive_result(slot): + if slot is None: + return + message, start, output, complete = slot + content = json.dumps({"results": complete}, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + artifact_id = self._agent_engine._store_tool_output(content) + arguments = json.dumps({"artifact_id": artifact_id, "offset": 0, "limit": 1000}) + action = json.dumps({"action": "read_tool_artifact", "args": arguments}) + reference = f"Earlier tool results archived. Retrieve a page with Action: {action}" + message["content"] = message["content"][:start] + reference + message["content"][start + len(output):] + distribution_reply = None coordinator = self.subagent_manager.coordinator delegated_this_turn = coordinator and any(run["version"] != turn.agent_versions.get(run["run_id"]) @@ -205,12 +229,33 @@ def record_initial_call(tc, receipt): summary_messages.insert(2, {"role": "system", "content": memory}) if evolving_messages is not None: summary_messages = evolving_messages + try: + archive_result(previous_result_slot) + except Exception: + turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run." + break summary_messages.append({"role": "assistant", "content": previous_model_reply or ""}) + previous_result_slot = None if previous_tool_results: - summary_messages.append({"role": "user", "content": "Tool results:\n" + "\n".join(previous_tool_results)}) + output = "\n".join(previous_tool_results) + message = {"role": "user", "content": "Tool results:\n" + output} + summary_messages.append(message) + previous_result_slot = (message, len("Tool results:\n"), output, previous_full_results) if error_hint or search_throttle: summary_messages.append({"role": "user", "content": error_hint + search_throttle + self._build_task_progress_hint()}) + elif turn.tool_results_text: + message = summary_messages[-1] + start = -1 + for label in ("The tools returned:\n", "Read-only inspection results:\n", "Tool results:\n"): + position = message["content"].rfind(label + turn.tool_results_text) + if position >= 0: + start = position + len(label) + break + if start < 0: + turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run." + break + previous_result_slot = (message, start, turn.tool_results_text, full_results) if distribution_reply is not None: step_reply, distribution_reply = distribution_reply, None else: @@ -375,6 +420,7 @@ def record_initial_call(tc, receipt): # execute all new tool calls round_count += 1 next_results: list[str] = [] + next_full_results: list[dict[str, Any]] = [] has_errors = False def record_followup_call(tc2, receipt): nonlocal has_errors, consecutive_search_failures, total_search_calls @@ -382,6 +428,7 @@ def record_followup_call(tc2, receipt): prompt_result = self._tool_result_for_prompt(receipt) self._render_tool_result(receipt.action, prompt_result) next_results.append(f"- `{receipt.action}({receipt.args!r})` returned:\n{self._safe_fstring(prompt_result)}") + next_full_results.append(full_result(receipt, prompt_result)) if self.tasks.tasks: self._update_tasks_panel() if not receipt.success: @@ -408,8 +455,14 @@ def record_followup_call(tc2, receipt): evolving_messages = summary_messages previous_model_reply = step_reply previous_tool_results = list(next_results) + previous_full_results = list(next_full_results) # Hard limit: too many search_web calls → one last chance to complete if total_search_calls >= 7 and consecutive_search_failures >= 3: + try: + archive_result(previous_result_slot) + except Exception: + turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run." + break final_msg = ( "System: Search is unavailable (7+ calls, {0} consecutive failures). " "Stop searching now. Give your **best final answer** using whatever " @@ -419,6 +472,7 @@ def record_followup_call(tc2, receipt): ).format(consecutive_search_failures) step_reply = self._call_llm_with_spinner( summary_messages + [{"role": "assistant", "content": step_reply}, + {"role": "user", "content": "Tool results:\n" + "\n".join(next_results)}, {"role": "user", "content": final_msg}] ).strip() step_reply, search_meta = self._observe_turn_response(turn, step_reply, summary_messages) diff --git a/openkyrozen/agent/compaction.py b/openkyrozen/agent/compaction.py index ea806dca..e7cceb39 100644 --- a/openkyrozen/agent/compaction.py +++ b/openkyrozen/agent/compaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import math from dataclasses import dataclass, field from typing import Any, Callable @@ -86,12 +87,9 @@ def _accounting_message(message: Any) -> dict[str, Any]: """Create a text-only estimate without changing native provider history.""" from openkyrozen.providers.models import ModelResponse, ToolReply - if isinstance(message, ModelResponse): - calls = [{"name": call.name, "arguments": call.arguments} for call in message.tool_calls] - content = message.text + ("\n" + str(calls) if calls else "") - return {"role": "assistant", "content": content} - if isinstance(message, ToolReply): - return {"role": "tool", "content": str(message.output)} + if isinstance(message, (ModelResponse, ToolReply)): + from openkyrozen.providers.context import context_accounting_messages + return context_accounting_messages([message])[0] return message @@ -124,7 +122,7 @@ def message_fingerprint(messages: list[Any]) -> str: def is_context_digest(message: dict[str, Any]) -> bool: - return str(message.get("content", "")).startswith((DIGEST_PREFIX, OMISSION_PREFIX)) + return isinstance(message, dict) and str(message.get("content", "")).startswith((DIGEST_PREFIX, OMISSION_PREFIX)) def retain_context_digests(messages: list[dict[str, Any]], limit: int = 32) -> list[dict[str, Any]]: @@ -147,18 +145,29 @@ class ContextState: reported_inputs: dict[str, int] = field(default_factory=dict) status: dict[str, Any] = field(default_factory=dict) overflow_retried: bool = False + native_input_overhead_tokens: int = 0 + native_schema_fingerprint: str = "" + request_provider_id: str = "" + request_model: str | None = None def __post_init__(self) -> None: self.window_tokens, self.window_source = resolve_context_window(self.model, self.configured_window) + def request_fingerprint(self, messages) -> str: + identity = [message_fingerprint(messages), self.request_model or self.model, + self.request_provider_id, self.native_schema_fingerprint] + return hashlib.sha256(json.dumps(identity, ensure_ascii=False).encode("utf-8")).hexdigest() + def update(self, messages: list[dict[str, Any]], *, compaction: dict[str, Any] | None = None) -> dict[str, Any]: - fingerprint = message_fingerprint(messages) + fingerprint = self.request_fingerprint(messages) estimated, breakdown = estimate_messages(messages) + estimated += self.native_input_overhead_tokens + breakdown["fixed_instructions"] += self.native_input_overhead_tokens reported = self.reported_inputs.get(fingerprint) input_tokens = reported if reported is not None else estimated remaining = None if self.window_tokens is None else max(0, self.window_tokens - input_tokens - self.reserve_tokens) self.status = { - "model": self.model, + "model": self.request_model or self.model, "window_tokens": self.window_tokens, "window_source": self.window_source, "input_tokens": input_tokens, @@ -166,6 +175,8 @@ def update(self, messages: list[dict[str, Any]], *, compaction: dict[str, Any] | "breakdown": breakdown, "breakdown_source": "estimated", "reserve_tokens": self.reserve_tokens, + "native_input_overhead_tokens": self.native_input_overhead_tokens, + "native_overhead_method": "stdlib_json_estimate_plus_framing" if self.native_schema_fingerprint else "none", "remaining_tokens": remaining, "compaction": compaction or self.status.get("compaction", {"status": "not_needed"}), } @@ -173,7 +184,7 @@ def update(self, messages: list[dict[str, Any]], *, compaction: dict[str, Any] | def note_provider_usage(self, messages: list[dict[str, Any]], prompt_tokens: int) -> None: if prompt_tokens > 0: - self.reported_inputs[message_fingerprint(messages)] = int(prompt_tokens) + self.reported_inputs[self.request_fingerprint(messages)] = int(prompt_tokens) self.update(messages) @@ -262,6 +273,57 @@ def under_pressure(current: dict[str, Any]) -> bool: from openkyrozen.providers.models import ModelResponse, ToolReply if any(isinstance(item, (ModelResponse, ToolReply)) for item in working): + # Only authoritative projected history can be evicted. Current-turn work, + # system state, orphan replies, and incomplete groups remain fixed. + units: list[list[int]] = [] + index = 0 + while index < len(working): + item = working[index] + unit = None + if id(item) in state.history_message_ids: + if isinstance(item, dict) and item.get("role") in {"user", "assistant"}: + unit = [index] + elif isinstance(item, ModelResponse) and not item.tool_calls: + from openkyrozen.providers.models import FinishReason + if item.finish_reason == FinishReason.FINAL: + unit = [index] + elif isinstance(item, ModelResponse) and item.tool_calls: + positions = list(range(index + 1, index + 1 + len(item.tool_calls))) + replies = [working[position] for position in positions if position < len(working)] + expected = {call.id: call.name for call in item.tool_calls} + if (len(replies) == len(item.tool_calls) + and all(isinstance(reply, ToolReply) for reply in replies) + and len(expected) == len(item.tool_calls) + and {reply.call_id for reply in replies} == set(expected) + and all(expected[reply.call_id] == reply.name for reply in replies) + and all(id(reply) in state.history_message_ids for reply in replies)): + unit = [index, *positions] + if unit is not None: + units.append(unit) + index = unit[-1] + 1 + else: + index += 1 + candidates = [position for unit in units[:-KEEP_RECENT_MESSAGES] for position in unit] + native_history_digest = None + history_compacted = bool(candidates) + if candidates: + raw = _format_entries([_accounting_message(working[position]) for position in candidates]) + window_budget = ((state.window_tokens or 16_384) - state.reserve_tokens) // 2 + output_chars = max(400, min(6_000, max(400, window_budget) * 3)) + input_chars = max(1_000, min(12_000, max(1_000, window_budget) * 3)) + summary = _bounded_summary(raw, summarize, max_chars=input_chars, output_chars=output_chars) + native_history_digest = _digest(summary, len(candidates), kind="native history messages") + removed = set(candidates) + first = candidates[0] + working = [message for position, message in enumerate(working) if position not in removed] + working.insert(first, native_history_digest) + status = state.update(working, compaction={ + "status": "summarized" if summary else "trimmed_after_summary_failure", + "kind": "native_history", "omitted_entries": len(candidates), + }) + if not under_pressure(status): + return CompactionResult(working, compacted=True, history_digest=native_history_digest) + # Compact old outputs in complete native call/result groups. Replacing only # the payload preserves provider continuation structure and call correlation. native_groups: list[list[int]] = [] @@ -284,8 +346,8 @@ def under_pressure(current: dict[str, Any]) -> bool: if pending: native_groups.append(pending) - compacted = False - details: dict[str, Any] = {"status": "not_needed"} + compacted = history_compacted + details: dict[str, Any] = dict(state.status.get("compaction", {})) if history_compacted else {"status": "not_needed"} for group in native_groups[:-1]: if not all(isinstance(working[index], (ModelResponse, ToolReply)) for index in group): continue @@ -305,15 +367,16 @@ def under_pressure(current: dict[str, Any]) -> bool: # oversized tool receipts so recent reasoning has the available budget. first_native = next((index for index, item in enumerate(working) if isinstance(item, (ModelResponse, ToolReply))), len(working)) - prefix = compact_for_pressure(working[:first_native], state, summarize, force=force) + prefix = compact_for_pressure(working[:first_native], state, summarize, + force=force or under_pressure(state.update(working))) working = prefix.messages + working[first_native:] status = state.update(working, compaction=details) if under_pressure(status): status["compaction"] = {"status": "fixed_context_too_large"} return CompactionResult(working, compacted=compacted or prefix.compacted, - history_digest=prefix.history_digest, impossible=True) + history_digest=native_history_digest or prefix.history_digest, impossible=True) return CompactionResult(working, compacted=compacted or prefix.compacted, - history_digest=prefix.history_digest) + history_digest=native_history_digest or prefix.history_digest) non_system = [item for item in working if item.get("role") != "system" and not is_context_digest(item)] tracked_history = [item for item in non_system if id(item) in state.history_message_ids] diff --git a/openkyrozen/agent/completion.py b/openkyrozen/agent/completion.py index aba3deaa..533b98d1 100644 --- a/openkyrozen/agent/completion.py +++ b/openkyrozen/agent/completion.py @@ -31,7 +31,7 @@ def _complete_turn(self, turn): for item in submitted[field]) sections.append("\n".join(lines)) turn.final_answer = "Verified delegated findings:\n\n" + "\n\n".join(sections) - final = self._call_llm_with_spinner([{"role": "system", "content": + synthesis_messages = [{"role": "system", "content": "Synthesize the verified delegated results for the user. Include evidence and limitations. " "Return a plain-language findings report only, no Actions, AskUser, PlanProposal, or execution plan. " "The user requested findings; proposed code fixes do not require a plan acceptance. " @@ -40,14 +40,36 @@ def _complete_turn(self, turn): {"role": "user", "content": turn.user_input + "\n" + json.dumps([ {"name": run["name"], "assignment": run["assignment"], "report": run["report"], "reviews": [{key: item for key, item in review.items() if key in {"verdict", "summary", "findings", "evidence"}} - for review in run["reviews"]]} for run in delegated], ensure_ascii=False)}]) + for review in run["reviews"]]} for run in delegated], ensure_ascii=False)}] + if getattr(turn, "native_tools", False): + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + from openkyrozen.providers.models import FinishReason + try: + response = self._get_model_response( + synthesis_messages, tool_names=set(), history_tool_names=set(), stream=False, + ) + if response.finish_reason == FinishReason.CANCELLED: + raise ProviderError(ProviderErrorKind.CANCELLED, terminal=True) + if (response.finish_reason == FinishReason.FINAL and not response.tool_calls + and response.text.strip() and not response.text.startswith("[LLM Error]")): + turn.final_answer = response.text.strip() + except ProviderError as exc: + if exc.kind == ProviderErrorKind.CANCELLED: + raise + except Exception: + pass + finally: + self._last_response_is_native.set(True) + else: + final = self._call_llm_with_spinner(synthesis_messages) + parsed_final = self._parse_model_response(final) + if (parsed_final["clean"] and not parsed_final["tool_calls"] and not parsed_final["question"] + and not parsed_final["plan_proposal"] and not parsed_final["protocol_error"] + and not parsed_final["unsupported_action_protocol"] and not final.startswith("[LLM Error]")): + turn.final_answer = parsed_final["clean"] turn.turn_prompt_total += self._last_prompt_tokens turn.turn_completion_total += self._last_completion_tokens - parsed_final = self._parse_model_response(final) - if (parsed_final["clean"] and not parsed_final["tool_calls"] and not parsed_final["question"] - and not parsed_final["plan_proposal"] and not parsed_final["protocol_error"] - and not parsed_final["unsupported_action_protocol"] and not final.startswith("[LLM Error]")): - turn.final_answer = parsed_final["clean"] + if turn.final_answer is None: # Fallback if the loop exited without a final answer turn.final_answer = self._deterministic_tool_summary(turn.tool_records) @@ -55,8 +77,11 @@ def _complete_turn(self, turn): turn.final_answer = self._deterministic_tool_summary(turn.tool_records) if turn.protocol_error_message: + retain_answer = delegated or ( + turn.response_text and turn.final_answer == turn.response_text.strip() + ) turn.final_answer = (turn.protocol_error_message + "\n\n" + turn.final_answer - if delegated else turn.protocol_error_message) + if retain_answer else turn.protocol_error_message) turn.final_answer = self._clean_final_response(turn.final_answer) @@ -79,17 +104,84 @@ def _complete_turn(self, turn): turn.final_answer = "\n".join(lines) verified_fix = turn.fix_workflow and turn.fix_workflow.get("stage") in {"verified", "explained"} - failed_tools = [] if verified_fix else [record for record in turn.tool_records if not record.get("success")] - turn.runtime_status = "incomplete" if failed_tools else "completed" - if failed_tools: - failures = "\n".join( - f"- {record.get('action', 'Tool')}: operation failed; its result was not verified." - for record in failed_tools + failed_tools = [] + unresolved_effect_failure = False + unresolved_effect_blocked = False + for index, record in enumerate(turn.tool_records): + verification = record.get("verification") or {} + failed_verification = verification.get("status") == "failed" + effect_action = (record.get("action") in {"write_file", "edit_file", "apply_patch"} + or record.get("side_effects") in {"filesystem", "remote", "destructive", "mutation", "external"}) + effect_failure = (effect_action and record.get("success") is False) or ( + failed_verification and (verification.get("method") == "file_content_match" or effect_action)) + if (effect_failure and verification.get("status") == "not_applicable" + and verification.get("method") == "duplicate_success_refusal" + and record.get("operation_id") and any( + earlier.get("success") and (earlier.get("verification") or {}).get("status") == "verified" + and all(earlier.get(key) == record.get(key) for key in ("operation_id", "action", "args", "plan_id")) + and earlier.get("requested_task_id", earlier.get("task_id")) + == record.get("requested_task_id", record.get("task_id")) + for earlier in turn.tool_records[:index])): + # Refusing an already verified identical operation does not negate its effect. + continue + if not effect_failure and (verified_fix or (record.get("success") and not failed_verification)): + continue + resolved = record.get("operation_id") and any( + later.get("success") and (later.get("verification") or {}).get("status") == "verified" + and (not failed_verification + or (later.get("verification") or {}).get("method") == verification.get("method")) + and all(later.get(key) == record.get(key) for key in ("operation_id", "action", "args", "task_id", "plan_id")) + for later in turn.tool_records[index + 1:] ) - turn.final_answer = f"{turn.final_answer.rstrip()}\n\nTool status: incomplete; failed operations were not verified.\n{failures}" + if not resolved: + failed_tools.append(record) + unresolved_effect_failure = unresolved_effect_failure or effect_failure + unresolved_effect_blocked = unresolved_effect_blocked or (effect_failure and ( + (record.get("tool_error") or {}).get("code") in {"permission_denied", "approval_denied"} + or record.get("failure") in {"permission_denied", "approval_denied", "denied"})) + provider_status = getattr(turn, "runtime_status", "completed") + turn.runtime_status = ("incomplete" if failed_tools or turn.protocol_error_message else "completed") + structured_child_response = False + if failed_tools: + failure_lines = [f"- {record.get('action', 'Tool')}: operation failed; its result was not verified." + for record in failed_tools] + if unresolved_effect_failure: + # Known negative effect evidence supersedes model success prose. + # Expected failed reproduction commands retain the existing fix path. + context = self.execution_context + child_report = bool(getattr(context, "child_run_id", None) + and getattr(context, "coordinator", None) is not None) + prior_report, review_report = None, False + if child_report: + from openkyrozen.agent.delegation import REPORT_LISTS, report + try: + prior_report = report(turn.final_answer) + except (ValueError, TypeError): + try: + prior_report, review_report = report(turn.final_answer, review=True), True + except (ValueError, TypeError): + pass + if prior_report and prior_report.get("status") == "blocked": + unresolved_effect_blocked = True + failure_summary = turn.protocol_error_message or ( + "The required operation is blocked; its effects were not verified." + if unresolved_effect_blocked else "The requested effects were not verified.") + if child_report: + if review_report: + failure_report = {"verdict": "unverifiable", "summary": failure_summary, + "findings": failure_lines, "evidence": []} + else: + failure_report = {**{key: [] for key in REPORT_LISTS}, "findings": failure_lines, "summary": failure_summary, + "status": "blocked" if unresolved_effect_blocked else "failed"} + turn.final_answer = json.dumps(failure_report, ensure_ascii=False) + structured_child_response = True + else: + turn.final_answer = failure_summary + if not structured_child_response: + failures = "\n".join(failure_lines) + turn.final_answer = f"{turn.final_answer.rstrip()}\n\nTool status: incomplete; failed operations were not verified.\n{failures}" plan = getattr(self.tasks, "working_plan", None) - if (not self.execution_context.child_run_id - and turn.interaction_mode == "agent" and getattr(turn, "complexity", None) == "complex" + if (turn.interaction_mode == "agent" and getattr(turn, "complexity", None) == "complex" and plan is not None and plan.status in {"active", "completed", "cancelled"}): plan_tasks = [task for task in self.tasks.tasks if task.get("id") in plan.task_ids] succeeded = [task for task in plan_tasks if is_complete(task)] @@ -120,7 +212,27 @@ def _complete_turn(self, turn): status_line = "Plan status: incomplete; required work remains." for task in outstanding: status_line += f"\n- {task['status']}: {task['description']}" - turn.final_answer = f"{turn.final_answer.rstrip()}\n\n{status_line}" + if (not structured_child_response and getattr(self.execution_context, "child_run_id", None) + and getattr(self.execution_context, "coordinator", None) is not None): + from openkyrozen.agent.delegation import report + try: + child_report = report(turn.final_answer) + except (ValueError, TypeError): + pass + else: + if turn.runtime_status != "completed" and child_report["status"] == "completed": + child_report["status"] = "blocked" if turn.runtime_status == "blocked" else ( + "partial" if turn.runtime_status == "incomplete" else "failed") + child_report["uncertainties"].append(status_line) + turn.final_answer = json.dumps(child_report, ensure_ascii=False) + structured_child_response = True + if not structured_child_response: + turn.final_answer = f"{turn.final_answer.rstrip()}\n\n{status_line}" + + if (turn.protocol_error_message or failed_tools) and turn.runtime_status == "completed": + turn.runtime_status = "incomplete" + if provider_status == "blocked" or unresolved_effect_blocked: + turn.runtime_status = "blocked" completion_status = getattr(self, "_turn_completion_status", None) if callable(getattr(completion_status, "set", None)): @@ -131,7 +243,14 @@ def _complete_turn(self, turn): "time": time.time() - turn.turn_start, "tool_calls": len(turn.tool_calls), }) - return self._finish_learning_run( - turn.learning_run, turn.learning_receipts, turn.user_input, turn.final_answer, turn.tool_records, - turn.turn_prompt_total + turn.turn_completion_total, turn.turn_start, - ) + try: + return self._finish_learning_run( + turn.learning_run, turn.learning_receipts, turn.user_input, turn.final_answer, turn.tool_records, + turn.turn_prompt_total + turn.turn_completion_total, turn.turn_start, + ) + except Exception as exc: + try: + self._record_learning_event("learning.bookkeeping_failed", {"reason": type(exc).__name__}) + except Exception: + pass + return turn.final_answer diff --git a/openkyrozen/agent/context.py b/openkyrozen/agent/context.py index 11d9c810..563517df 100644 --- a/openkyrozen/agent/context.py +++ b/openkyrozen/agent/context.py @@ -2,7 +2,7 @@ from typing import Any import json -from openkyrozen.agent.compaction import ContextState, compact_for_pressure, is_context_digest, message_fingerprint, retain_context_digests +from openkyrozen.agent.compaction import ContextState, compact_for_pressure, is_context_digest, message_fingerprint, retain_context_digests, resolve_context_window from openkyrozen.agent.types import ContextTooLargeError @@ -99,6 +99,13 @@ def _record_context_history_metrics(self, messages: list[dict[str, Any]]) -> Non retransmitted_ids = unique_history_ids & seen entry_tokens = {entry.id: max(1, (len(json.dumps(entry.payload, ensure_ascii=False)) + 3) // 4) for entry in entries if entry.id in history_ids} + if projection is not None and projection[0] == id(messages): + for index, entry_id in projection[1].items(): + if index < len(messages) and isinstance(messages[index], ToolReply): + reply = messages[index] + sent = {"call_id": reply.call_id, "name": reply.name, + "output": reply.output, "is_error": reply.is_error} + entry_tokens[entry_id] = max(1, (len(json.dumps(sent, ensure_ascii=False)) + 3) // 4) self._context_history_metrics.set({ "new_entry_count": len(new_ids), "retransmitted_entry_count": len(retransmitted_ids), @@ -145,9 +152,11 @@ def _load_reported_context_tokens(self, state: ContextState) -> None: def _cache_reported_context_tokens(self, messages: list[dict], prompt_tokens: int) -> None: if prompt_tokens < 1: return + state = self._active_context_state.get() + fingerprint = state.request_fingerprint(messages) if state is not None else message_fingerprint(messages) with self._context_status_lock: counts = self._provider_token_counts_by_scope.setdefault(self._context_scope(), {}) - counts[message_fingerprint(messages)] = int(prompt_tokens) + counts[fingerprint] = int(prompt_tokens) while len(counts) > 32: counts.pop(next(iter(counts))) @@ -201,7 +210,13 @@ def _prepare_context_for_call(self, messages: list[dict], model: str | None = No state = self._active_context_state.get() if state is None or self._in_context_compaction.get(): return + if model is not None and model != state.model: + state.model = state.request_model = model + state.window_tokens, state.window_source = resolve_context_window(model, state.configured_window) self._load_reported_context_tokens(state) + projection = self._context_history_projection_ids.get() + if projection is not None and projection[0] == id(messages): + state.history_message_ids.update(id(messages[index]) for index in projection[1] if index < len(messages)) result = compact_for_pressure( messages, state, lambda text, output_chars: self._summarize_context_with_chat_model(text, output_chars, model or state.model), @@ -214,7 +229,8 @@ def _prepare_context_for_call(self, messages: list[dict], model: str | None = No retained_history = [item for item in self.short_term_memory if id(item) in retained_ids] self.short_term_memory = retain_context_digests([result.history_digest] + retained_history, self.SHORT_TERM_CAP * 2) self.current_session.messages_compacted = True - state.history_message_ids = {id(item) for item in self.short_term_memory} + state.history_message_ids.intersection_update(retained_ids) + state.history_message_ids.update(id(item) for item in self.short_term_memory) self._save_learning_context_digest(result.history_digest) self._store_context_status(state) if result.impossible: @@ -243,7 +259,8 @@ def _recover_context_after_overflow(self, messages: list[dict], model: str | Non retained_history = [item for item in self.short_term_memory if id(item) in retained_ids] self.short_term_memory = retain_context_digests([result.history_digest] + retained_history, self.SHORT_TERM_CAP * 2) self.current_session.messages_compacted = True - state.history_message_ids = {id(item) for item in self.short_term_memory} + state.history_message_ids.intersection_update(retained_ids) + state.history_message_ids.update(id(item) for item in self.short_term_memory) self._save_learning_context_digest(result.history_digest) self._store_context_status(state) return result.compacted and not result.impossible diff --git a/openkyrozen/agent/delegation.py b/openkyrozen/agent/delegation.py index 9cb51efc..409fa338 100644 --- a/openkyrozen/agent/delegation.py +++ b/openkyrozen/agent/delegation.py @@ -700,10 +700,17 @@ def detail(self, run_id): if review.get("session_id")) for child_session in child_sessions: from .conversation import ConversationHistory - child_messages = ConversationHistory( + history = ConversationHistory( self.memory.store, user_id=self.memory.user_id, - workspace_id=self.memory.file_scope_id, session_id=child_session, - ).display_messages() + workspace_id=self.runs[run_id].get("conversation_workspace_id", self.memory.file_scope_id), + session_id=child_session, + ) + if "conversation_workspace_id" not in self.runs[run_id] and history.head is None: + history = ConversationHistory( + self.memory.store, user_id=self.memory.user_id, + workspace_id=self.memory.workspace_id, session_id=child_session, + ) + child_messages = history.display_messages() if child_session in {run_id, self.runs[run_id].get("budget_id", run_id)}: messages = child_messages else: diff --git a/openkyrozen/agent/delegation_runtime.py b/openkyrozen/agent/delegation_runtime.py index f230740c..ecff28c6 100644 --- a/openkyrozen/agent/delegation_runtime.py +++ b/openkyrozen/agent/delegation_runtime.py @@ -238,6 +238,7 @@ def _invoke_delegated(self, run, *, review, feedback, coordinator, report_repair raise ValueError("Sub-agent provider requires a model/deployment") config.model_simple = config.model_complex = model with coordinator.lock: + run["conversation_workspace_id"] = self.interaction_workspace_id() if review: run["review_agent"].update(status="reviewing", provider_model=f"{provider_name}:{model}") else: @@ -257,7 +258,22 @@ def _invoke_delegated(self, run, *, review, feedback, coordinator, report_repair if review or self._active_interaction_mode.get() in {"ask", "plan"}: capabilities &= resolve_capabilities("readonly") tools = (allowed_tool_names(adapters.AVAILABLE_TOOLS, ",".join(capabilities)) - set(TOOLS)) if not report_repair else set() + if not report_repair and "read" in capabilities: + with self.use_session(child): + artifact_spec = self.tool_registry.get_spec("read_tool_artifact") + if artifact_spec.source == "builtin" and artifact_spec.capability == "read": + tools.add("read_tool_artifact") + if not review and not report_repair and capabilities & {"read", "write"}: + with self.use_session(child): + plan_spec = self.tool_registry.get_spec("update_plan") + if plan_spec.source == "builtin" and plan_spec.capability == "write": + if "write" not in capabilities: + # This registry belongs only to the child session. Local planning + # does not grant filesystem writes or change the parent catalog. + self.tool_registry.register_spec(plan_spec.replace(capability="read"), replace=True) + tools.add("update_plan") child._delegation_parent = parent + child._delegation_classifier_input = "\n".join((brief["objective"], brief["context"])) child._delegation_task_id = brief.get("task_id") if not review else None profile = replace(profile, metadata={**profile.metadata, "structured": True, "review": review, "display_name": run["reviewer"] if review else run["name"], "assignment": brief, @@ -435,11 +451,55 @@ def _delegation_tool_access(self, action, args): read_only_network = capability == "network" and action in { "search_web", "read_webpage", "analyze_remote_repo", "github_status", "github_read", } - if action not in {"write_file", "edit_file", "apply_patch"} and not read_only_network and ( + parent = getattr(self.current_session, "_delegation_parent", None) + from openkyrozen.tools.manifest import typed_executor + if action == "read_tool_artifact" and (spec is None or spec.source != "builtin" + or capability != "read" or effects not in {"read", "read_only"} + or spec.executor != typed_executor(self._read_tool_artifact)): + raise PermissionError("Child artifact reads require the scoped builtin reader") + local_plan_update = (action == "update_plan" and spec is not None + and spec.source == "builtin" and capability in {"read", "write"} + and effects in {"internal", "internal_write"} + and spec.executor == typed_executor(self._update_plan) + and parent is not None and self.current_session.tasks is not parent.tasks) + if action not in {"write_file", "edit_file", "apply_patch"} and not read_only_network and not local_plan_update and ( capability in {"write", "shell", "git", "browser", "destructive"} or effects not in {"none", "read", "read_only"} ): raise PermissionError(f"Sub-agent capability '{capability}' is not confined to assigned files") + if local_plan_update: + plan_arguments = args + if isinstance(plan_arguments, str): + try: + plan_arguments = json.loads(plan_arguments) + except (TypeError, ValueError) as exc: + raise PermissionError("Child plan arguments must be a structured object") from exc + if not isinstance(plan_arguments, dict): + raise PermissionError("Child plan arguments must be a structured object") + def executions(value): + if isinstance(value, dict): + for key, item in value.items(): + if key == "execution" and isinstance(item, dict): + yield item + else: + yield from executions(item) + elif isinstance(value, list): + for item in value: + yield from executions(item) + + for execution in executions(plan_arguments): + name = self._operation_action(execution.get("action", "")) + arguments = execution.get("arguments") + target = self.tool_registry.specs.get(name) + if (name == "update_plan" or name in TOOLS or target is None + or name not in (context.delegation_tools or ()) + or not context.capability_token.allows(target.capability) + or self.tool_registry._argument_error(target.input_schema, arguments)): + raise PermissionError("Child plan execution exceeds its delegated tool authority") + # Stored execution checkpoints may later reach a durable worker; + # validate their scope now, while the child boundary is present. + with self._delegation_tool_access(name, arguments): + pass run = coordinator.runs[context.child_run_id] brief = run["assignment"] writing = action in {"write_file", "edit_file", "apply_patch"} @@ -482,7 +542,7 @@ def _delegation_tool_access(self, action, args): ) if denial: raise PermissionError(f"Sub-agent operation exceeds the accepted Strategic Plan: {denial}") - if self._is_state_changing_action(action, args): + if not local_plan_update and self._is_state_changing_action(action, args): allowed, reason = parent.tasks.mutation_matches_current_task(action, match_args) if not allowed: raise ValueError("Sub-agent action does not match accepted plan: " + reason) diff --git a/openkyrozen/agent/engine.py b/openkyrozen/agent/engine.py index 45fb355c..8172e769 100644 --- a/openkyrozen/agent/engine.py +++ b/openkyrozen/agent/engine.py @@ -2,12 +2,14 @@ from __future__ import annotations from openkyrozen.agent.turn import TurnContext -from openkyrozen.providers.models import FinishReason, ToolReply +from openkyrozen.providers.models import FinishReason, TextDelta, ToolCallDelta, ToolReply _COMPACT_CORE_TOOLS = frozenset({ "list_dir", "find_files", "search_files", "read_file", "write_file", "edit_file", "apply_patch", - "run_cmd", "discover_tools", + "run_cmd", "discover_tools", "read_tool_artifact", "update_plan", + "propose_plan", "request_question", }) +_NATIVE_CONTROLS = frozenset({"propose_plan", "request_question"}) class AgentEngine: @@ -26,7 +28,8 @@ def run(self, turn: TurnContext) -> str: except Exception: capabilities = None if (capabilities and capabilities.native_tools - and turn.interaction_mode in {"agent", "ask"}): + and turn.interaction_mode in {"agent", "ask", "plan"}): + turn.native_tools = True self._run_native(turn) return self._finish_turn(turn, runtime._complete_turn(turn)) reply = runtime._initial_turn_response(turn) @@ -53,16 +56,45 @@ def _finish_turn(self, turn: TurnContext, result: str) -> str: def _native_tool_names(self) -> set[str]: runtime = self.runtime permitted = runtime._permitted_tool_names() - if runtime._prompt_profile() != "compact": - return permitted if "discover_tools" not in permitted: return permitted discovered = getattr(runtime.execution_context, "discovered_tools", frozenset()) return permitted & (_COMPACT_CORE_TOOLS | set(discovered)) + def _store_tool_output(self, content): + runtime = self.runtime + memory = runtime.memory_bank + tasks = getattr(runtime, "tasks", None) + return memory.store.artifacts.get_or_create( + content, user_id=getattr(tasks, "user_id", None) or memory.user_id, + workspace_id=getattr(tasks, "workspace_id", None) or memory.workspace_id, + session_id=getattr(tasks, "session_id", None) or memory.session_id, + ) + + def _archive_replies(self, messages, indexes): + import json + for index in indexes: + reply = messages[index] + content = json.dumps(reply.output, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + artifact_id = self._store_tool_output(content) + summary = {"artifact_id": artifact_id, "total_chars": len(content)} + messages[index] = ToolReply(reply.call_id, reply.name, { + "data": summary, "text": "Earlier tool result archived; use read_tool_artifact to retrieve it.", + "error": ({"code": "archived_result", "message": "Tool call failed; see artifact.", + "retriable": False, "metadata": {"artifact_id": artifact_id}} + if reply.is_error else None), + "metadata": {"artifact_id": artifact_id}, + }, reply.is_error) + def _run_native(self, turn: TurnContext) -> None: runtime = self.runtime + runtime._last_response_is_native.set(True) from openkyrozen.agent.conversation import model_response_to_dict + from openkyrozen.agent.prompts import _refresh_working_plan_context + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + provider = runtime.execution_context.provider or runtime.llm_provider + capabilities = provider.get_capabilities(runtime.DEEPSEEK_MODEL) + stream = capabilities.text_streaming or capabilities.streaming_tool_calls messages = runtime._build_messages( turn.user_input, turn.learned_context, turn.memory_context, memory_packet=turn.memory_packet, native_tools=True, @@ -70,26 +102,83 @@ def _run_native(self, turn: TurnContext) -> None: turn.tool_calls = [] turn.tool_records = [] turn.tool_results_text = "" + try: + self._archive_replies(messages, [index for index, item in enumerate(messages) if isinstance(item, ToolReply)]) + except Exception: + turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run." + return successful_operations: set[str] = set() operation_scope = str(turn.learning_run.get("run_id")) final_text = "" response = None - history_tool_names: set[str] = set() + history_tool_names: set[str] = {item.name for item in messages if isinstance(item, ToolReply)} + observed_plan_id = None + previous_reply_indexes: list[int] = [] + runtime.execution_context.native_control_input = turn.user_input + runtime.execution_context.native_plan_inspected = False + control_failures = 0 max_steps = getattr(runtime.execution_context, "max_steps", None) if max_steps is None: max_steps = runtime.MAX_STEPS_PER_TURN for step in range(max_steps + 1): tool_names = self._native_tool_names() + if turn.interaction_mode == "plan": + tool_names = {name for name in tool_names + if name in _NATIVE_CONTROLS or ( + runtime.tool_registry.specs.get(name).capability in {"read", "network"} + and runtime.tool_registry.specs.get(name).side_effects in {"read_only", "read", "none", "remote"} + and runtime.tool_registry.specs.get(name).risk == "normal")} + if not runtime.execution_context.native_plan_inspected: + tool_names.discard("propose_plan") + plan = getattr(runtime.tasks, "working_plan", None) if turn.planning_required else None + if plan is not None and plan.status == "active": + observed_plan_id = plan.id + completed_plan = (observed_plan_id is not None and plan is not None + and plan.id == observed_plan_id and plan.status == "completed") + needs_plan = turn.planning_required and (plan is None or plan.status != "active") + if needs_plan: + if completed_plan: + tool_names = runtime._permitted_tool_names() & {"update_plan"} + elif "update_plan" not in runtime._permitted_tool_names(): + turn.protocol_error_message = "A Working Plan is required, but planning is not permitted." + break + else: + tool_names = {"update_plan"} + elif turn.planning_required: + tool_names |= runtime._permitted_tool_names() & {"update_plan"} + if "request_question" in runtime._permitted_tool_names(): + tool_names.add("request_question") + previous_replies = [messages[index] for index in previous_reply_indexes] + _refresh_working_plan_context(runtime, messages) + emitted_native_content = False + + def observe_native_content(event): + nonlocal emitted_native_content + if isinstance(event, (TextDelta, ToolCallDelta)): + emitted_native_content = True + + def request_response(): + return runtime._get_model_response( + messages, tool_names=tool_names, history_tool_names=history_tool_names, stream=stream, + on_event=observe_native_content if stream else None, + ) + token = runtime._record_foreground_model.set(False) try: try: - response = runtime._get_model_response( - messages, tool_names=tool_names, history_tool_names=history_tool_names, stream=False, - ) + try: + response = request_response() + except ProviderError as exc: + if (exc.kind != ProviderErrorKind.CONTEXT_OVERFLOW or exc.terminal + or emitted_native_content + or not runtime._recover_context_after_overflow(messages, runtime.DEEPSEEK_MODEL)): + raise + response = request_response() except Exception as exc: + if isinstance(exc, ProviderError) and exc.kind == ProviderErrorKind.CANCELLED: + raise runtime._turn_failed.set(True) - from openkyrozen.providers.errors import ProviderError detail = str(exc) if isinstance(exc, ProviderError) else type(exc).__name__ turn.protocol_error_message = f"Native model request failed: {detail}; no further tools were run." if runtime._conversation_turn_id.get() is not None: @@ -99,19 +188,47 @@ def _run_native(self, turn: TurnContext) -> None: break finally: runtime._record_foreground_model.reset(token) + previous_reply_indexes = [index for index, item in enumerate(messages) + if any(item is reply for reply in previous_replies)] turn.turn_prompt_total += runtime._last_prompt_tokens turn.turn_completion_total += runtime._last_completion_tokens turn.response_text = response.text runtime._last_response_is_native.set(True) + if response.finish_reason == FinishReason.CANCELLED: + raise ProviderError(ProviderErrorKind.CANCELLED, terminal=True) + if response.finish_reason in {FinishReason.ERROR, FinishReason.BLOCKED, FinishReason.UNKNOWN}: + final_text = response.text.strip() + blocked = response.finish_reason == FinishReason.BLOCKED + turn.runtime_status = "blocked" if blocked else "incomplete" + turn.protocol_error_message = ("The provider blocked this response; no further tools were run." + if blocked else + "The provider returned an unsuccessful response; no further tools were run.") + if not blocked: + runtime._turn_failed.set(True) + break + if response.tool_calls and response.finish_reason == FinishReason.LENGTH: + turn.protocol_error_message = "The provider truncated its tool request; no tools from that response were run." + final_text = response.text.strip() + break + unknown_tools = [call.name for call in response.tool_calls if call.name not in tool_names] if unknown_tools: - turn.protocol_error_message = "The provider returned an unknown tool call; no tool was run." + turn.protocol_error_message = ("A Working Plan is required before other tools can run." + if needs_plan else + "The provider returned an unknown tool call; no tool was run.") if runtime._conversation_turn_id.get() is not None: runtime._record_conversation_entry("model.failure", { "error_type": "invalid_native_tool_call", }, request_id=runtime._last_model_request_id.get()) break + if (any(call.name == "update_plan" for call in response.tool_calls) + and any(call.name != "update_plan" for call in response.tool_calls)): + turn.protocol_error_message = "Run Working Plan edits in a separate response before using their task selection." + break + if any(call.name in _NATIVE_CONTROLS for call in response.tool_calls) and len(response.tool_calls) != 1: + turn.protocol_error_message = "Request an interactive control alone in its own response; no tools were run." + break if response.tool_calls and step >= max_steps: turn.protocol_error_message = "The tool-call step limit was reached; remaining work was not run." if runtime._conversation_turn_id.get() is not None: @@ -126,6 +243,27 @@ def _run_native(self, turn: TurnContext) -> None: if not response.tool_calls: final_text = response.text.strip() + if turn.interaction_mode == "plan": + final_text = "" + control_failures += 1 + if control_failures < 3 and step < max_steps: + messages.extend((response, {"role": "user", "content": ( + "Use read-only inspection first, then propose_plan for the structured proposal; " + "use request_question if clarification is needed. Do not execute proposed changes." + )})) + continue + turn.protocol_error_message = "A valid Strategic Plan proposal was not prepared; no proposed changes were executed." + break + if needs_plan and not completed_plan: + final_text = "" + if step < max_steps: + messages.extend((response if response.continuation else { + "role": "assistant", "content": response.text, + }, {"role": "user", "content": + "Create a Working Plan with the update_plan tool before continuing."})) + continue + turn.protocol_error_message = "A Working Plan was not established; no further tools were run." + break if (not final_text or response.finish_reason == FinishReason.LENGTH) and step < 2: messages.extend((response if response.continuation else { "role": "assistant", "content": response.text, @@ -134,15 +272,26 @@ def _run_native(self, turn: TurnContext) -> None: "or provide a concise final answer." )})) continue + if not final_text or response.finish_reason == FinishReason.LENGTH: + turn.protocol_error_message = ("The provider response reached its length limit; the answer is incomplete." + if response.finish_reason == FinishReason.LENGTH else + "The provider returned no answer after bounded continuation.") + break if not final_text.startswith("[LLM Error]"): break turn.protocol_error_message = final_text break messages.append(response) + current_reply_indexes: list[int] = [] history_tool_names.update(call.name for call in response.tool_calls) + selected_task_id = getattr(runtime.tasks, "execution_task_id", None) calls = [{"action": call.name, "args": call.arguments, "call_id": call.id, "call_id_source": "native", - "task_id": getattr(getattr(runtime, "current_session", None), "_delegation_task_id", None)} + "task_id": (selected_task_id or getattr(getattr(runtime, "current_session", None), + "_delegation_task_id", None)) + if call.name not in _NATIVE_CONTROLS | {"update_plan"} else None, + "task_plan_id": getattr(getattr(runtime.tasks, "working_plan", None), "id", None) + if selected_task_id and call.name not in _NATIVE_CONTROLS | {"update_plan"} else None} for call in response.tool_calls] turn.tool_calls.extend(calls) @@ -156,6 +305,17 @@ def record_native_call(call, receipt): outcome = receipt.tool_result or ToolResult(data=receipt.result_data, text=receipt.result) reply_data = tool_result_to_dict(outcome) messages.append(ToolReply(call["call_id"], call["action"], reply_data, not outcome.success)) + if record.get("success"): + if call["action"] in _NATIVE_CONTROLS: + if not isinstance(outcome.data, dict) or outcome.data.get("requires_approval", True): + turn.pending_interaction_reply = outcome.text + else: + spec = runtime.tool_registry.specs.get(call["action"]) + if (spec and spec.capability in {"read", "network"} + and spec.side_effects in {"read_only", "read", "none", "remote"} + and call["action"] != "discover_tools"): + runtime.execution_context.native_plan_inspected = True + current_reply_indexes.append(len(messages) - 1) turn.tool_results_text += f"- `{call['action']}` returned:\n{runtime._safe_fstring(prompt_result)}\n" runtime._update_tasks_panel() @@ -163,5 +323,21 @@ def record_native_call(call, receipt): calls, operation_scope=operation_scope, successful_operations=successful_operations, call_id_source="native", on_receipt=record_native_call, ) + if turn.pending_interaction_reply: + final_text = turn.pending_interaction_reply + break + if (any(call["action"] in _NATIVE_CONTROLS for call in calls) + and not turn.tool_records[-1].get("success")): + control_failures += 1 + if control_failures >= 3: + turn.protocol_error_message = "Interactive control validation failed after bounded retries; no proposed changes were executed." + break + try: + self._archive_replies(messages, previous_reply_indexes) + except Exception: + turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run." + break + previous_reply_indexes = current_reply_indexes + runtime._last_response_is_native.set(True) turn.final_answer = final_text or runtime._deterministic_tool_summary(turn.tool_records) diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py index dd9840fb..b88d6be3 100644 --- a/openkyrozen/agent/executor.py +++ b/openkyrozen/agent/executor.py @@ -131,6 +131,25 @@ def _awaiting_revision_mutation(state, action, args, spec, runtime) -> bool: )) +def _builtin_interaction_control(runtime, action, spec) -> bool: + from openkyrozen.agent.plan_tools import _propose_plan, _request_question + from openkyrozen.tools.catalog import builtin_metadata + + handlers = {"propose_plan": _propose_plan, "request_question": _request_question} + if action not in handlers or spec is None or spec.source != "builtin" or spec.name != action: + return False + handler = getattr(handlers[action], "__tool_result_executor__", handlers[action]) + if (getattr(spec.executor, "__self__", None) is not runtime + or getattr(spec.executor, "__func__", None) is not handler): + return False + metadata = builtin_metadata(action) + return (spec.input_schema == metadata["input_schema"] and spec.version == "1.0.0" + and all(getattr(spec, field) == metadata[field] for field in ( + "capability", "side_effects", "risk", "parallel_safe", "aliases", + "legacy_fields", "legacy_argument_aliases", "confirmation_surfaces", + ))) + + def _notify_tool_execute(self, action: str, args: Any, result: ToolResult) -> None: """Emit the typed outcome to observers; display text cannot change status.""" result = result if isinstance(result, ToolResult) else tool_failure( @@ -203,13 +222,15 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s tool_error: ToolError | None = None, result_data: Any = None, tool_result: ToolResult | None = None, call_id: str | None = None, call_id_source: str = "legacy", task_id: str | None = None, + task_plan_id: str | None = None, receipt_arguments: dict[str, Any] | None = None, + _duplicate_successful_operation: bool = False, expected_spec=None) -> ExecutionReceipt: canonical = self._operation_action(action) from openkyrozen.agent.delegation import TOOLS current_session = getattr(self, "current_session", None) delegated_parent = getattr(current_session, "_delegation_parent", None) - task_owner = delegated_parent or self + task_owner = self if task_plan_id is not None or canonical == "update_plan" else delegated_parent or self task_manager = getattr(task_owner, "tasks", None) result_limit = (max(2000, len(str(result))) if canonical in TOOLS else 8000 if canonical == "read_tool_artifact" else @@ -235,9 +256,10 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s if len(full_output) > _TOOL_ARTIFACT_THRESHOLD: memory = getattr(self, "memory_bank", None) store = getattr(memory, "store", None) - user_id = getattr(task_manager, "user_id", None) or getattr(memory, "user_id", None) - workspace_id = getattr(task_manager, "workspace_id", None) or getattr(memory, "workspace_id", None) - session_id = getattr(task_manager, "session_id", None) or getattr(memory, "session_id", None) + artifact_owner = getattr(current_session, "tasks", None) or task_manager + user_id = getattr(artifact_owner, "user_id", None) or getattr(memory, "user_id", None) + workspace_id = getattr(artifact_owner, "workspace_id", None) or getattr(memory, "workspace_id", None) + session_id = getattr(artifact_owner, "session_id", None) or getattr(memory, "session_id", None) artifacts = getattr(store, "artifacts", None) if artifacts is None or not all((user_id, workspace_id, session_id)): raise RuntimeError("Cannot retain oversized tool output without a session-scoped artifact store") @@ -279,6 +301,8 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s } elif getattr(expected_spec, "side_effects", None) == "read_only": verification = {"status": "not_applicable", "method": "read_only_tool", "observed": None} + if _duplicate_successful_operation: + verification = {"status": "not_applicable", "method": "duplicate_success_refusal", "observed": None} def safe_metadata(value, budget=4000): def clean(item, remaining, depth=0): @@ -342,7 +366,7 @@ def clean(item, remaining, depth=0): session_id=(getattr(current_session, "session_id", None) if delegated_parent else None) or getattr(task_manager, "session_id", None) or getattr(getattr(self, "memory_bank", None), "session_id", None), - plan_id=getattr(getattr(task_manager, "working_plan", None), "id", None), + plan_id=task_plan_id or getattr(getattr(task_manager, "working_plan", None), "id", None), task_id=task_id, tool_version=getattr(expected_spec, "version", None), tool_source=getattr(expected_spec, "source", None), @@ -371,6 +395,7 @@ def _run_tool(self, action: str, args: str, *, return_success: bool = False, _typed_arguments: dict | None = None, _expected_spec=None, _call_id: str | None = None, _call_id_source: str = "legacy", _task_id: str | None = None, _pre_execution_check=None, + _task_plan_id: str | None = None, _on_dispatch=None) -> str | tuple[str, bool] | ExecutionReceipt: started_at = utc_now() action = self.TOOL_ALIASES.get(action, action) @@ -427,7 +452,7 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None failure=outcome.error.code if outcome.error else None, raw_result=raw_result, tool_error=outcome.error, result_data=outcome.data, tool_result=outcome, expected_spec=approved_spec, call_id=_call_id, call_id_source=_call_id_source, - task_id=_task_id, + task_id=_task_id, task_plan_id=_task_plan_id, ) return (text, outcome.success) if return_success else text if action not in self.AVAILABLE_TOOLS: @@ -440,7 +465,8 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None outcome = tool_failure("permission_denied", "Error: strategic plan revision is awaiting approval; mutation is paused.") self._notify_tool_execute(action, args, outcome) return finish(outcome) - if executing_plan and action != "cancel_subagent": + if (executing_plan and action != "cancel_subagent" + and not _builtin_interaction_control(self, action, approved_spec)): denial = _strategic_plan_denial( executing_plan, action, args, self.tool_registry.specs.get(action), self._get_workspace_root(), ) @@ -498,7 +524,8 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None and request.session_id == self.current_session.session_id and self._execution_capability_token.allows(current_spec.capability) ) - if authorized and executing_plan and action != "cancel_subagent": + if (authorized and executing_plan and action != "cancel_subagent" + and not _builtin_interaction_control(self, action, self.tool_registry.specs.get(action))): current_state = self._interaction_controller.state() current_plan = current_state.get("executing_plan") if (not current_plan or current_plan.get("plan_id") != executing_plan.get("plan_id") @@ -591,9 +618,11 @@ def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None def _execute_turn_action(self, action: str, args: Any, *, operation_scope: str, successful_operations: set[str], call_id: str | None = None, - call_id_source: str = "synthetic", task_id: str | None = None) -> ExecutionReceipt: + call_id_source: str = "synthetic", task_id: str | None = None, + task_plan_id: str | None = None) -> ExecutionReceipt: """Execute one model action, refusing a duplicate successful mutation.""" - task_id = task_id or getattr(getattr(self, "current_session", None), "_delegation_task_id", None) + task_id = (task_id or getattr(getattr(self, "current_session", None), "_delegation_task_id", None) + if action != "update_plan" else None) canonical = self._operation_action(action) started_at = utc_now() call_id = call_id or f"synthetic_{uuid.uuid4().hex}" @@ -602,9 +631,22 @@ def emit_rejected_call(): self._publish_runtime_event( "tool.call", {"tool_name": canonical, "argument_keys": sorted(args)[:64] if isinstance(args, dict) else []}, - call_id=call_id, + call_id=call_id, task_id=task_id, ) + def local_binding_valid(): + return (getattr(getattr(self.tasks, "working_plan", None), "id", None) == task_plan_id + and self.tasks.execution_task_id == task_id) + + if task_plan_id is not None and not local_binding_valid(): + emit_rejected_call() + outcome = tool_failure("invalid_arguments", "Error: explicit task selection is no longer active in this Working Plan.") + return self._make_execution_receipt( + action=canonical, args=args, authorized=False, started_at=started_at, success=False, + result=outcome.text, operation_scope=operation_scope, tool_result=outcome, + call_id=call_id, call_id_source=call_id_source, task_id=task_id, + task_plan_id=task_plan_id, expected_spec=expected_spec, + ) native_arguments = isinstance(args, dict) and call_id_source == "native" if isinstance(args, dict) and not native_arguments: emit_rejected_call() @@ -620,7 +662,7 @@ def emit_rejected_call(): action=canonical, args=args, authorized=False, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, tool_result=outcome, call_id=call_id, call_id_source=call_id_source, - expected_spec=expected_spec, + task_id=task_id, task_plan_id=task_plan_id, expected_spec=expected_spec, ) structured = args if native_arguments else None if not native_arguments and isinstance(args, str) and args.lstrip().startswith("{"): @@ -646,7 +688,7 @@ def emit_rejected_call(): action=canonical, args=args, authorized=False, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, tool_result=outcome, call_id=call_id, - call_id_source=call_id_source, expected_spec=expected_spec, + call_id_source=call_id_source, task_id=task_id, task_plan_id=task_plan_id, expected_spec=expected_spec, ) typed_arguments = args elif spec is not None and spec.source not in {"builtin", "legacy"}: @@ -661,7 +703,7 @@ def emit_rejected_call(): action=canonical, args=args, authorized=False, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, tool_result=outcome, call_id=call_id, - call_id_source=call_id_source, expected_spec=expected_spec, + call_id_source=call_id_source, task_id=task_id, task_plan_id=task_plan_id, expected_spec=expected_spec, ) typed_arguments = structured if (not native_arguments and interaction_state.get("executing_plan") and spec @@ -693,7 +735,7 @@ def emit_rejected_call(): action=canonical, args=args, authorized=False, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, tool_result=outcome, call_id=call_id, - call_id_source=call_id_source, expected_spec=expected_spec, + call_id_source=call_id_source, task_id=task_id, task_plan_id=task_plan_id, expected_spec=expected_spec, ) active_plan = interaction_state.get("executing_plan") if is_mutation and active_plan and canonical not in {"cancel_subagent", "update_plan"}: @@ -740,12 +782,15 @@ def emit_rejected_call(): authorized=True, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, tool_result=outcome, call_id=call_id, call_id_source=call_id_source, - expected_spec=expected_spec, + task_id=task_id, task_plan_id=task_plan_id, expected_spec=expected_spec, + _duplicate_successful_operation=True, ) receipt = self._run_tool(canonical, args, return_receipt=True, operation_scope=operation_scope, _typed_arguments=typed_arguments, _expected_spec=spec if typed_arguments is not None else None, - _call_id=call_id, _call_id_source=call_id_source, _task_id=task_id) + _call_id=call_id, _call_id_source=call_id_source, _task_id=task_id, + _task_plan_id=task_plan_id, + _pre_execution_check=local_binding_valid if task_plan_id is not None else None) assert isinstance(receipt, ExecutionReceipt) if (receipt.success and receipt.action != "run_cmd" and self._is_state_changing_action(receipt.action, args)): @@ -769,6 +814,7 @@ def execute(call): call.get("action", ""), call.get("args", ""), operation_scope=operation_scope, successful_operations=successful_operations, call_id=call.get("call_id"), call_id_source=call.get("call_id_source", call_id_source), task_id=call.get("task_id"), + task_plan_id=call.get("task_plan_id"), ) index = 0 @@ -798,16 +844,27 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m from openkyrozen.agent.delegation import TOOLS current_session = getattr(self, "current_session", None) delegated_parent = getattr(current_session, "_delegation_parent", None) - task_manager = delegated_parent.tasks if delegated_parent else self.tasks + local_plan = getattr(self.tasks, "working_plan", None) + local_scope = bool(local_plan and receipt.plan_id == local_plan.id + and receipt.user_id == self.tasks.user_id + and receipt.workspace_id == self.tasks.workspace_id + and receipt.session_id == self.tasks.session_id) + task_manager = self.tasks if local_scope or not delegated_parent else delegated_parent.tasks + reconcile_locally = not delegated_parent or local_scope active_task = task_manager.active_working_task(receipt.task_id) if receipt.task_id else None - explicit_task_id = receipt.task_id if active_task is not None and ( - not delegated_parent or self._valid_receipt_task_binding(current_session, receipt.task_id) + binding_scope = (getattr(getattr(task_manager, "working_plan", None), "id", None) == receipt.plan_id + and receipt.user_id == task_manager.user_id + and receipt.workspace_id == task_manager.workspace_id) + explicit_task_id = receipt.task_id if active_task is not None and binding_scope and ( + reconcile_locally or self._valid_receipt_task_binding(current_session, receipt.task_id) ) else None receipt_projection = receipt.as_dict() task_acceptance = receipt.acceptance + if receipt.verification.get("status") == "failed": + task_acceptance = None if explicit_task_id and receipt.action == "run_cmd" and task_acceptance: command = (receipt.arguments or {}).get("command", "") - criteria = next((item.get("acceptance", []) for item in self.tasks.tasks + criteria = next((item.get("acceptance", []) for item in task_manager.tasks if item.get("id") == explicit_task_id), []) declared = [" ".join(item.split()).casefold() for item in criteria if isinstance(item, str)] if not command or len(declared) != 1 or " ".join(command.split()).casefold() != declared[0]: @@ -820,17 +877,17 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m ) legacy_contract = any( task.get("status") in {"pending", "running"} - and self.tasks._dependencies_succeeded(task, self.tasks.tasks) + and task_manager._dependencies_succeeded(task, task_manager.tasks) and (task.get("checkpoint") or {}).get("action") == receipt.action and isinstance((task.get("checkpoint") or {}).get("args"), str) and not task["checkpoint"]["args"].lstrip().startswith("{") - for task in self.tasks.tasks + for task in task_manager.tasks ) matching_args = (receipt.args if legacy_contract else json.dumps(receipt.arguments, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")) if receipt.arguments is not None else receipt.args) - evidence = {} if delegated_parent or receipt.action in TOOLS or not should_reconcile else self.tasks.record_evidence( + evidence = {} if not reconcile_locally or receipt.action in TOOLS or not should_reconcile else task_manager.record_evidence( task_id=explicit_task_id, action=receipt.action, args=matching_args, args_projection=receipt_projection["args"], args_fingerprint=None if legacy_contract else receipt.args_sha256, @@ -840,24 +897,24 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m verification_result=(receipt.tool_result.text if receipt.action == "read_file" and receipt.tool_result is not None else None), ) - task_id = explicit_task_id if delegated_parent else evidence.get("task_id") + task_id = evidence.get("task_id") if reconcile_locally else explicit_task_id effective_acceptance = evidence.get("acceptance") or ( receipt_projection["acceptance"] if receipt.task_id is None else None ) - if task_id: - task = next((item for item in self.tasks.tasks if item["id"] == task_id), None) + if task_id and reconcile_locally: + task = next((item for item in task_manager.tasks if item["id"] == task_id), None) checkpoint = (task or {}).get("checkpoint") or {} if task and not checkpoint.get("action") and not checkpoint.get("strategic_plan"): - self.tasks.update_checkpoint(task_id, {"action": receipt.action, "args": receipt_projection["args"]}) - if task_id and receipt.success and effective_acceptance: - task = next((item for item in self.tasks.tasks if item["id"] == task_id), None) + task_manager.update_checkpoint(task_id, {"action": receipt.action, "args": receipt_projection["args"]}) + if task_id and reconcile_locally and receipt.success and effective_acceptance: + task = next((item for item in task_manager.tasks if item["id"] == task_id), None) strategic = (task or {}).get("checkpoint", {}).get("strategic_plan", {}) required = set(strategic.get("acceptance_ids", [])) covered = {criterion_id for item in (task or {}).get("evidence", []) for criterion_id in item.get("acceptance_ids", [])} if not required or required <= covered: - index = next(index for index, item in enumerate(self.tasks.tasks) if item["id"] == task_id) - self.tasks.set_status(index, "succeeded") + index = next(index for index, item in enumerate(task_manager.tasks) if item["id"] == task_id) + task_manager.set_status(index, "succeeded") receipt_payload = receipt_projection if receipt.task_id is not None and receipt.task_id != task_id: receipt_payload["requested_task_id"] = receipt.task_id @@ -877,6 +934,7 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m "acceptance": effective_acceptance, "failure": receipt_projection["failure"], "tool_error": receipt_projection["tool_error"], "task_id": task_id, "requested_task_id": receipt.task_id, + "plan_id": receipt.plan_id, "side_effects": receipt.side_effects, "result_data": receipt_projection["result_data"], "verification": receipt_projection["verification"], } if evidence.get("unmatched_reason"): diff --git a/openkyrozen/agent/legacy_compat.py b/openkyrozen/agent/legacy_compat.py new file mode 100644 index 00000000..99128038 --- /dev/null +++ b/openkyrozen/agent/legacy_compat.py @@ -0,0 +1,55 @@ +"""Lazy bindings for the explicitly supported legacy text helper methods.""" +from __future__ import annotations + +from openkyrozen.agent.presentation import _NativeResponseText, is_native_response, native_response_metadata + + +def _legacy_method(name): + def call(self, *args, **kwargs): + text = args[0] if args else kwargs.get("text") + if isinstance(text, _NativeResponseText): + if name in {"_collect_tool_calls", "_extract_json_objects"}: + return [] + if name == "_collect_unwrapped_tool_calls": + return [], False + if name in {"parse_json_from_response", "_detect_unknown_action", "_legacy_plan_value"}: + return None + if name in {"_has_unsupported_action_protocol", "_stream_buffer_has_tool_prefix"}: + return False + if name == "_clean_stream_buffer": + return text + from openkyrozen.agent import protocol + return getattr(protocol, name)(self, *args, **kwargs) + call.__name__ = name + return call + + +def _parse_model_response(self, text): + if is_native_response(self, text): + return native_response_metadata(text) + return self.legacy_protocol.parse_response(text) + + +def _observe_model_response(self, text): + if is_native_response(self, text): + return native_response_metadata(text) + from openkyrozen.agent.protocol import _observe_model_response as observe + return observe(self, text) + + +_is_valid_action = _legacy_method('_is_valid_action') +_detect_unknown_action = _legacy_method('_detect_unknown_action') +_has_unsupported_action_protocol = _legacy_method('_has_unsupported_action_protocol') +parse_json_from_response = _legacy_method('parse_json_from_response') +_extract_json_objects = _legacy_method('_extract_json_objects') +_collect_unwrapped_tool_calls = _legacy_method('_collect_unwrapped_tool_calls') +_collect_tool_calls = _legacy_method('_collect_tool_calls') +_marker_line_prefix = _legacy_method('_marker_line_prefix') +_action_marker_is_protocol = _legacy_method('_action_marker_is_protocol') +_control_marker_is_protocol = _legacy_method('_control_marker_is_protocol') +_remove_protocol_headings = _legacy_method('_remove_protocol_headings') +_stream_buffer_has_tool_prefix = _legacy_method('_stream_buffer_has_tool_prefix') +_clean_stream_buffer = _legacy_method('_clean_stream_buffer') +_legacy_plan_value = _legacy_method('_legacy_plan_value') +_persist_interaction_control = _legacy_method('_persist_interaction_control') +_interaction_gate = _legacy_method('_interaction_gate') diff --git a/openkyrozen/agent/loop.py b/openkyrozen/agent/loop.py index 67c046e4..a06403ad 100644 --- a/openkyrozen/agent/loop.py +++ b/openkyrozen/agent/loop.py @@ -9,6 +9,83 @@ from .turn import TurnContext, scoped_turn +def _cancelled_turn_cleanup(self, active_plan, exc, conversation, plugin_runtime, context): + """Attempt existing cancellation/CAS writes within one bounded DB budget.""" + incomplete = False + uncertainty = { + "reason": "cancelled_during_execution", "turn_id": self._conversation_turn_id.get(), + "message": "Remote or already-dispatched side effects may still complete.", "durable": True, + } + with self.memory_bank.store._cancellation_deadline(): + if active_plan: + # Safe narrowing advances the Strategic Plan version while + # acceptance anchors and their children retain their original + # immutable binding. Follow those current anchors, not the + # latest proposal fingerprint, when cancelling their work. + anchor_ids = set(active_plan.get("working_task_ids", [])) + anchors = {task["id"]: (task.get("checkpoint") or {}).get("strategic_plan") or {} + for task in self.tasks.tasks + if task["id"] in anchor_ids + and task.get("plan_id") == active_plan.get("working_plan_id") + and ((task.get("checkpoint") or {}).get("strategic_plan") or {}).get("plan_id") + == active_plan.get("plan_id")} + cancelled_ids = set(anchors) + for task in self.tasks.tasks: + checkpoint = task.get("checkpoint") or {} + binding = checkpoint.get("strategic_plan") or {} + anchor_binding = anchors.get(checkpoint.get("strategy_anchor")) + if (task.get("plan_id") == active_plan.get("working_plan_id") + and anchor_binding is not None + and binding == anchor_binding): + cancelled_ids.add(task["id"]) + for task_id in cancelled_ids: + index = next((i for i, task in enumerate(self.tasks.tasks) + if task.get("id") == task_id), None) + if index is not None and self.tasks.tasks[index].get("status") not in {"succeeded", "cancelled"}: + try: + if not self.tasks.set_status(index, "cancelled"): + incomplete = True + except Exception: + incomplete = True + if not incomplete: + try: + self._interaction_controller.complete_plan( + active_plan, status="cancelled", task_manager=self.tasks, + ) + except Exception: + incomplete = True + try: + self.memory_bank.store._append_control_audit("turn.effect_uncertain", uncertainty, + user_id=self.memory_bank.user_id, workspace_id=self.tasks.workspace_id, + session_id=self.current_session.session_id) + except Exception: + incomplete = True + uncertainty["durable"] = False + try: + self._record_conversation_entry("turn.outcome", { + "status": "cancelled", "error_type": type(exc).__name__, + }) + except Exception: + incomplete = True + try: + from openkyrozen.agent.compaction import is_context_digest, retain_context_digests + display = conversation.display_messages() + limit = self.SHORT_TERM_CAP * 2 + digest = next((item for item in reversed(self.short_term_memory) if is_context_digest(item)), None) + projected = ([digest] if digest else []) + display + self.short_term_memory = retain_context_digests(projected, limit) + self.current_session.messages_compacted = bool(digest) or len(projected) > limit + except Exception: + incomplete = True + try: + plugin_runtime.turn_end(reply="", success=False, error=type(exc).__name__, **context) + except Exception: + incomplete = True + if incomplete: + self._publish_runtime_event("turn.audit_failed", {"reason": "cancellation_cleanup_incomplete"}) + self._publish_runtime_event("turn.effect_uncertain", uncertainty) + + def _chat_turn_impl(self, user_input: str, clear_tasks: bool = False, profile: str | None = None, memory_context: dict[str, Any] | None = None, inspection_complete: bool = False, @@ -108,6 +185,8 @@ def _chat_turn(self, user_input: str, clear_tasks: bool = False, profile: str | f"Revision feedback:\n{user_input}" ) clear_tasks = False + elif mode_override is None and executing_plan: + clear_tasks = False mode_token = self._active_interaction_mode.set( mode_override or self._interaction_controller.state(user_input)["effective_mode"] @@ -142,23 +221,16 @@ def _chat_turn(self, user_input: str, clear_tasks: bool = False, profile: str | and self.execution_context.cancelled.is_set()) or isinstance( exc, (KeyboardInterrupt, asyncio.CancelledError) ) or (isinstance(exc, ProviderError) and exc.kind == ProviderErrorKind.CANCELLED) - if cancelled and active_plan: - for task_id in active_plan.get("working_task_ids", []): - index = next((i for i, task in enumerate(self.tasks.tasks) - if task.get("id") == task_id), None) - if index is not None and self.tasks.tasks[index].get("status") not in {"succeeded", "cancelled"}: - self.tasks.set_status(index, "cancelled") + if cancelled: try: - self._interaction_controller.complete_plan( - active_plan, status="cancelled", task_manager=self.tasks, - ) + _cancelled_turn_cleanup(self, active_plan, exc, conversation, runtime, context) except Exception: - pass - if cancelled: - self._publish_runtime_event("turn.effect_uncertain", { - "reason": "cancelled_during_execution", - "message": "Remote or already-dispatched side effects may still complete.", - }) + # A secondary bookkeeping error cannot replace cancellation. + self._publish_runtime_event("turn.audit_failed", {"reason": "cancellation_cleanup_failed"}) + self._publish_runtime_event("turn.effect_uncertain", { + "reason": "cancelled_during_execution", "durable": False, + }) + raise self._record_conversation_entry("turn.outcome", { "status": "cancelled" if cancelled else "failed", "error_type": type(exc).__name__, }) diff --git a/openkyrozen/agent/models.py b/openkyrozen/agent/models.py index bef7ceb2..418ce307 100644 --- a/openkyrozen/agent/models.py +++ b/openkyrozen/agent/models.py @@ -39,3 +39,4 @@ def __post_init__(self): self.process_manager = self.workspace.adapters.new_process_manager( self.memory.user_id, self.workspace.root, self.session_id, ) + self.process_manager._bind_audit(self.memory.store, self.tasks.workspace_id) diff --git a/openkyrozen/agent/modes.py b/openkyrozen/agent/modes.py index c2c6d01f..f5b2037e 100644 --- a/openkyrozen/agent/modes.py +++ b/openkyrozen/agent/modes.py @@ -3,15 +3,14 @@ from __future__ import annotations import json -import hashlib -import posixpath import re import shlex import uuid -from copy import deepcopy from typing import Any -from .ports import InteractionStore +from openkyrozen.tasks.planning import ( + PlanningService, InteractionError, validate_plan_proposal, _revision_materiality, +) INTERACTION_MODES = frozenset({"auto", "ask", "plan", "agent"}) @@ -101,8 +100,6 @@ def split_inline_command(text: str) -> tuple[str, str] | None: return result -class InteractionError(ValueError): - """A bounded interaction payload failed validation.""" def _text(value: Any, field: str, limit: int) -> str: @@ -167,250 +164,8 @@ def validate_question_request(value: Any, *, original_input: str = "") -> dict[s } -def validate_plan_proposal(value: Any, *, previous: dict[str, Any] | None = None, - original_input: str = "") -> dict[str, Any]: - if not isinstance(value, dict): - raise InteractionError("PlanProposal must contain a JSON object") - steps = value.get("steps") - if not isinstance(steps, list) or not 1 <= len(steps) <= 10: - raise InteractionError("PlanProposal requires 1-10 steps") - previous_version = int(previous.get("version", 0)) if previous else 0 - version = previous_version + 1 - normalised = [] - seen_ids: set[str] = set() - for index, step in enumerate(steps, 1): - if not isinstance(step, dict): - raise InteractionError("each plan step must be an object") - step_id = _text(step.get("id") or f"step-{index}", "step id", 64) - if step_id in seen_ids: - raise InteractionError("plan step ids must be unique") - seen_ids.add(step_id) - execution = step.get("execution") - if execution is not None: - action = execution.get("action") if isinstance(execution, dict) else None - arguments = execution.get("arguments") if isinstance(execution, dict) else None - if (not isinstance(execution, dict) or set(execution) != {"action", "arguments"} - or not isinstance(action, str) or action not in {"write_file", "edit_file", "apply_patch"} - or not isinstance(arguments, dict) - or not isinstance(arguments.get("path"), str) or not arguments["path"].strip()): - raise InteractionError("file execution contract must name write_file, edit_file, or apply_patch and an exact path") - required = ({"path", "content"} if action == "write_file" else - {"path", "expected_sha256", "hunks"} if action == "apply_patch" else - {"path", "old_text", "new_text", "expected_sha256"}) - if set(arguments) != required or (action != "apply_patch" and any(not isinstance(item, str) for item in arguments.values())): - raise InteractionError("file execution arguments must exactly match the selected file operation") - if action == "edit_file" and ( - not arguments["old_text"] or not re.fullmatch(r"[0-9a-fA-F]{64}", arguments["expected_sha256"])): - raise InteractionError("edit_file requires nonempty old_text and a SHA-256 read hash") - if action == "apply_patch" and ( - not isinstance(arguments["path"], str) or not arguments["path"].strip() - or not isinstance(arguments["expected_sha256"], str) - or not re.fullmatch(r"[0-9a-fA-F]{64}", arguments["expected_sha256"]) - or not isinstance(arguments["hunks"], list) or not arguments["hunks"] - or any(not isinstance(hunk, dict) or set(hunk) != {"old_text", "new_text"} - or not isinstance(hunk.get("old_text"), str) or not hunk["old_text"] - or not isinstance(hunk.get("new_text"), str) for hunk in arguments["hunks"])): - raise InteractionError("apply_patch requires a SHA-256 read hash and exact-text hunks") - try: - json.dumps(arguments, ensure_ascii=False, allow_nan=False) - except (TypeError, ValueError) as exc: - raise InteractionError("file execution arguments must be JSON-safe") from exc - execution = {"action": action, "arguments": deepcopy(arguments)} - acceptance = step.get("acceptance") - if isinstance(acceptance, str): - acceptance = [acceptance] - if not isinstance(acceptance, list) or not 1 <= len(acceptance) <= 5: - raise InteractionError("each plan step requires 1-5 acceptance criteria") - supplied_verifications = step.get("acceptance_verifications") - if supplied_verifications is not None and ( - not isinstance(supplied_verifications, list) or len(supplied_verifications) != len(acceptance)): - raise InteractionError("acceptance verifications must align with acceptance criteria") - acceptance_verifications = [] - normalized_acceptance = [] - for criterion_index, item in enumerate(acceptance): - verification = supplied_verifications[criterion_index] if supplied_verifications is not None else None - if isinstance(item, dict) and set(item) == {"criterion", "verification"}: - verification = item["verification"] - if verification is not None: - if (not isinstance(verification, dict) - or set(verification) not in ({"action", "arguments"}, {"action", "arguments", "expect"}) - or not isinstance(verification.get("action"), str) - or not verification["action"].strip() or not isinstance(verification.get("arguments"), dict)): - raise InteractionError("acceptance verification must contain an action and object arguments") - if verification["action"] == "read_file": - expect = verification.get("expect") - if (not isinstance(expect, dict) or len(expect) != 1 - or set(expect) not in ({"equals"}, {"contains"}) - or not isinstance(next(iter(expect.values())), str) - or ("contains" in expect and not expect["contains"])): - raise InteractionError("read_file verification requires a nonempty equals or contains predicate") - elif "expect" in verification: - raise InteractionError("expected-result predicates are currently supported only for read_file") - try: - json.dumps(verification["arguments"], ensure_ascii=False, allow_nan=False) - except (TypeError, ValueError) as exc: - raise InteractionError("acceptance verification arguments must be JSON-safe") from exc - criterion = _text(item.get("criterion") if isinstance(item, dict) else item, - "acceptance criterion", 300) - normalized_acceptance.append(criterion) - acceptance_verifications.append(verification) - acceptance = normalized_acceptance - acceptance_ids = [] - for criterion, verification in zip(acceptance, acceptance_verifications): - identity = json.dumps({"criterion": criterion, "verification": verification}, sort_keys=True, - ensure_ascii=False, separators=(",", ":")) - acceptance_ids.append(f"{step_id}-ac-{hashlib.sha256(identity.encode('utf-8')).hexdigest()[:12]}") - if len(set(acceptance_ids)) != len(acceptance_ids): - raise InteractionError("acceptance criteria must be unique within each objective") - normalised_step = { - "id": step_id, - "title": _text(step.get("title") or f"Step {index}", "step title", 120), - "description": _text(step.get("description"), "step description", 800), - "acceptance": acceptance, - "acceptance_ids": acceptance_ids, - "acceptance_verifications": acceptance_verifications, - } - if execution is not None: - normalised_step["execution"] = execution - normalised.append(normalised_step) - assumptions = value.get("assumptions", []) - if assumptions is None: - assumptions = [] - if not isinstance(assumptions, list) or len(assumptions) > 10: - raise InteractionError("assumptions must contain at most 10 items") - raw_bounds = value.get("bounds") - bounds_source = "proposal" - if raw_bounds is None: - if "bounds" in value: - raise InteractionError("bounds must be an object") - raw_bounds = { - "workspace_paths": ["."], "capabilities": ["read"], - "effects": ["read_only"], "risk_ceiling": "normal", - } - bounds_source = "legacy-default" - if not isinstance(raw_bounds, dict) or set(raw_bounds) - { - "workspace_paths", "capabilities", "effects", "risk_ceiling", - }: - raise InteractionError("bounds must declare workspace paths, capabilities, effects, and risk ceiling") - - def values(field: str, allowed: set[str] | None = None) -> list[str]: - raw = raw_bounds.get(field, []) - if not isinstance(raw, list) or len(raw) > 32: - raise InteractionError(f"bounds.{field} must be an array with at most 32 values") - result = [] - for item in raw: - if not isinstance(item, str) or not item.strip(): - raise InteractionError(f"bounds.{field} entries must be nonempty strings") - item = item.strip() - if allowed is not None and item not in allowed: - raise InteractionError(f"unsupported bounds.{field} value: {item}") - result.append(item) - if len(set(result)) != len(result): - raise InteractionError(f"bounds.{field} values must be unique") - return result - - paths = values("workspace_paths") - normalised_paths = [] - for path in paths: - path = path.replace("\\", "/") - if path.startswith("/") or re.match(r"^[A-Za-z]:", path): - raise InteractionError("workspace paths must be relative to the active workspace") - path = posixpath.normpath(path) - if path == ".." or path.startswith("../"): - raise InteractionError("workspace paths cannot escape the active workspace") - normalised_paths.append(path) - if len(set(normalised_paths)) != len(normalised_paths): - raise InteractionError("workspace paths must be unique after normalization") - capabilities = values("capabilities", { - "read", "write", "shell", "network", "git", "browser", "destructive", - }) - effects = values("effects", { - "read_only", "filesystem", "process", "remote", "destructive", "internal", - }) - risk_ceiling = raw_bounds.get("risk_ceiling", "normal") - if not isinstance(risk_ceiling, str) or risk_ceiling not in {"normal", "high"}: - raise InteractionError("risk ceiling must be normal or high") - bounds = { - "workspace_paths": sorted(normalised_paths), "capabilities": sorted(capabilities), - "effects": sorted(effects), "risk_ceiling": risk_ceiling, - } - contract_policies = { - "read_file": ("read", "read_only"), - "write_file": ("write", "filesystem"), - "edit_file": ("write", "filesystem"), - "apply_patch": ("write", "filesystem"), - } - for step in normalised: - contracts = ([step["execution"]] if step.get("execution") else []) + [ - verification for verification in step["acceptance_verifications"] if verification - ] - for contract in contracts: - policy = contract_policies.get(contract["action"]) - if policy is None: - raise InteractionError(f"unsupported Strategic Plan contract action: {contract['action']}") - capability, effect = policy - if capability not in bounds["capabilities"] or effect not in bounds["effects"]: - raise InteractionError("Strategic Plan contract exceeds its approved capability or effect bounds") - path = contract["arguments"].get("path") - if not isinstance(path, str) or not path.strip(): - raise InteractionError("Strategic Plan file contracts require a path") - normalized_contract_path = posixpath.normpath(path.replace("\\", "/")) - if (normalized_contract_path.startswith("/") or normalized_contract_path == ".." - or normalized_contract_path.startswith("../")): - raise InteractionError("Strategic Plan contract path escapes the approved workspace") - if not any(root == "." or normalized_contract_path == root - or normalized_contract_path.startswith(root.rstrip("/") + "/") - for root in bounds["workspace_paths"]): - raise InteractionError("Strategic Plan contract path is outside its approved workspace bounds") - plan = { - "plan_id": _text( - (previous or {}).get("plan_id") or f"plan_{uuid.uuid4().hex}", - "plan_id", 100, - ), - "version": version, - "title": _text(value.get("title"), "plan title", 160), - "summary": _text(value.get("summary"), "plan summary", 1200), - "assumptions": [_text(item, "assumption", 300) for item in assumptions], - "steps": normalised, - "bounds": bounds, - "bounds_source": bounds_source, - "original_input": str(original_input).strip()[:12_000], - } - fingerprint_fields = {key: plan[key] for key in ( - "plan_id", "version", "title", "summary", "assumptions", "steps", "bounds", - )} - plan["fingerprint"] = hashlib.sha256(json.dumps( - fingerprint_fields, ensure_ascii=False, allow_nan=False, sort_keys=True, - separators=(",", ":"), - ).encode("utf-8")).hexdigest() - return plan - - -def _revision_materiality(accepted: dict[str, Any], proposal: dict[str, Any]) -> str: - """Allow unchanged objectives only when all revised bounds are provably narrower.""" - if any(accepted.get(field) != proposal.get(field) - for field in ("title", "summary", "assumptions")): - return "material" - objectives = lambda plan: { - step["id"]: (step["title"], step["description"], tuple(step["acceptance"]), - tuple(step["acceptance_verifications"]), step.get("execution")) - for step in plan.get("steps", ()) - } - if objectives(accepted) != objectives(proposal): - return "material" - old, new = accepted.get("bounds"), proposal.get("bounds") - if not isinstance(old, dict) or not isinstance(new, dict): - return "material" - if (not set(new.get("capabilities", ())) <= set(old.get("capabilities", ())) - or not set(new.get("effects", ())) <= set(old.get("effects", ())) - or (old.get("risk_ceiling") == "normal" and new.get("risk_ceiling") == "high")): - return "material" - roots = [posixpath.normpath(path) for path in old.get("workspace_paths", ())] - for path in map(posixpath.normpath, new.get("workspace_paths", ())): - if not any(root == "." or path == root or path.startswith(root.rstrip("/") + "/") - for root in roots): - return "material" - return "within_bounds" + + def parse_control_block(text: str, name: str) -> Any | None: @@ -521,52 +276,17 @@ def is_plan_acceptance(text: str) -> bool: return " ".join(str(text or "").strip().lower().split()).rstrip(".!。!") in PLAN_ACCEPT_PHRASES -class InteractionController: - """Reconstruct session interaction state from the existing event log.""" - - def __init__(self, store: InteractionStore, *, user_id: str = "local", workspace_id: str = "default", - session_id: str): - self.store = store - self.user_id = user_id - self.workspace_id = workspace_id - self.session_id = session_id - - def _append(self, event_type: str, payload: dict[str, Any]) -> None: - self.store.append_event( - event_type, payload, user_id=self.user_id, workspace_id=self.workspace_id, - session_id=self.session_id, - ) - - def _append_auto_accepted(self, plan: dict[str, Any], previous: dict[str, Any]) -> None: - append = getattr(self.store, "append_plan_auto_accepted", None) - if not callable(append): - raise InteractionError("atomic Strategic Plan revision is unavailable") - try: - append(plan, expected_previous=previous, user_id=self.user_id, - workspace_id=self.workspace_id, session_id=self.session_id) - except ValueError as exc: - raise InteractionError(str(exc)) from exc - - def _history(self) -> list[dict[str, Any]]: - if callable(getattr(self.store, "list_interaction_events", None)): - return self.store.list_interaction_events( - workspace_id=self.workspace_id, session_id=self.session_id, user_id=self.user_id, - ) - events = self.store.list_events( - limit=None, workspace_id=self.workspace_id, session_id=self.session_id, - user_id=self.user_id, - ) - return [event for event in reversed(events) - if str(event.get("event_type", "")).startswith("interaction.") - or event.get("event_type") == "history.restored"] +class InteractionController(PlanningService): + """Adapt durable planning state to interaction modes and questions.""" def state(self, user_input: str = "") -> dict[str, Any]: preference = "auto" system_one_backend = "off" question = None - plan = None - executing = None - for event in self._history(): + history = self._history() + planning = self._plan_state(history) + plan, executing = planning["pending_plan"], planning["executing_plan"] + for event in history: event_type, payload = event["event_type"], event.get("payload", {}) if event_type == "history.restored": restored = payload.get("interaction") if isinstance(payload, dict) else None @@ -575,8 +295,6 @@ def state(self, user_input: str = "") -> dict[str, Any]: system_one_backend = restored.get("system_one_backend", restored.get("fast_backend", system_one_backend)) question = restored.get("pending_question") - plan = restored.get("pending_plan") - executing = restored.get("executing_plan") elif event_type == "interaction.mode_changed": preference = payload.get("mode", preference) elif event_type in {"interaction.fast_changed", "interaction.system_one_changed"}: @@ -585,29 +303,6 @@ def state(self, user_input: str = "") -> dict[str, Any]: question = payload elif event_type == "interaction.question_resolved" and question and payload.get("request_id") == question.get("request_id"): question = None - elif event_type == "interaction.plan_proposed": - if payload.get("revision_materiality") != "within_bounds": - plan = payload - elif event_type == "interaction.plan_auto_accepted": - if executing and payload.get("plan_id") == executing.get("plan_id"): - executing = dict(payload) - plan = None - elif event_type == "interaction.plan_accepted": - if plan and payload.get("plan_id") == plan.get("plan_id") and payload.get("version") == plan.get("version"): - executing = dict(plan) - for field in ("working_plan_id", "working_plan_revision", "working_task_ids"): - if field in payload: - executing[field] = payload[field] - plan = None - elif event_type == "interaction.plan_cancelled": - if plan and payload.get("plan_id") == plan.get("plan_id"): - plan = None - elif event_type == "interaction.plan_completed": - if (executing and payload.get("plan_id") == executing.get("plan_id") - and payload.get("version") == executing.get("version") - and payload.get("fingerprint") == executing.get("fingerprint") - and payload.get("status") in {"completed", "cancelled"}): - executing = None effective = route_mode( preference, user_input, pending_plan=plan is not None, executing_plan=executing is not None, ) @@ -624,6 +319,7 @@ def state(self, user_input: str = "") -> dict[str, Any]: state["fast_backend"] = state["system_one_backend"] return state + def envelope(self, user_input: str = "") -> dict[str, Any]: state = self.state(user_input) envelope = {key: state[key] for key in ( @@ -632,6 +328,7 @@ def envelope(self, user_input: str = "") -> dict[str, Any]: envelope["fast_backend"] = envelope["system_one_backend"] return envelope + def set_system_one_backend(self, backend: str) -> dict[str, Any]: backend = str(backend or "").strip().lower() if backend not in SYSTEM_ONE_BACKENDS: @@ -639,10 +336,12 @@ def set_system_one_backend(self, backend: str) -> dict[str, Any]: self._append("interaction.system_one_changed", {"backend": backend}) return self.envelope() + def set_fast_backend(self, backend: str) -> dict[str, Any]: """Deprecated compatibility alias for set_system_one_backend.""" return self.set_system_one_backend(backend) + def set_mode(self, mode: str) -> dict[str, Any]: mode = str(mode or "").strip().lower() if mode not in INTERACTION_MODES: @@ -650,12 +349,14 @@ def set_mode(self, mode: str) -> dict[str, Any]: self._append("interaction.mode_changed", {"mode": mode}) return self.envelope() + def request_question(self, value: Any, *, original_input: str = "") -> dict[str, Any]: question = validate_question_request(value, original_input=original_input) question["resume_mode"] = self.state(original_input)["effective_mode"] self._append("interaction.question_requested", question) return question + def resolve_question(self, request_id: str, answers: Any, *, action: str = "answer") -> dict[str, Any]: pending = self.state().get("pending_question") if not pending or pending.get("request_id") != request_id: @@ -668,6 +369,7 @@ def resolve_question(self, request_id: str, answers: Any, *, action: str = "answ self._append("interaction.question_resolved", payload) return {"question": pending, **payload} + def reopen_question(self) -> dict[str, Any]: requested = [event["payload"] for event in self._history() if event["event_type"] in {"interaction.question_requested", "interaction.question_reopened"}] @@ -677,276 +379,6 @@ def reopen_question(self) -> dict[str, Any]: self._append("interaction.question_reopened", question) return question - def propose_plan(self, value: Any, *, original_input: str = "") -> dict[str, Any]: - state = self.state() - previous = state.get("pending_plan") or state.get("executing_plan") - if previous and state.get("pending_plan") is None: - latest = [int(event.get("payload", {}).get("version", 0)) for event in self._history() - if event.get("event_type") in {"interaction.plan_proposed", "interaction.plan_auto_accepted"} - and event.get("payload", {}).get("plan_id") == previous.get("plan_id")] - if latest: - previous = {**previous, "version": max(latest)} - plan = validate_plan_proposal(value, previous=previous, original_input=original_input) - executing = state.get("executing_plan") - if executing: - plan["revision_materiality"] = _revision_materiality(executing, plan) - working = self.store.get_working_plan( - user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id, - ) if callable(getattr(self.store, "get_working_plan", None)) else None - working_revision = (working.get("revision") if working - and working.get("id") == executing.get("working_plan_id") - else executing.get("working_plan_revision")) - plan.update({"working_plan_id": executing.get("working_plan_id"), - "working_plan_revision": working_revision, - "working_task_ids": executing.get("working_task_ids", [])}) - if plan["revision_materiality"] == "within_bounds": - self._append_auto_accepted(plan, executing) - return plan - else: - plan["revision_materiality"] = "initial" - self._append("interaction.plan_proposed", plan) - return plan - - def cancel_plan(self, plan_id: str | None = None, version: int | None = None) -> dict[str, Any]: - plan = self.state().get("pending_plan") - if not plan or (plan_id and plan_id != plan.get("plan_id")) or (version is not None and version != plan.get("version")): - raise InteractionError("plan is not pending") - self._append("interaction.plan_cancelled", {"plan_id": plan["plan_id"], "version": plan["version"]}) - return plan - - def accepted_plan(self, plan_id: str | None = None, version: int | None = None) -> dict[str, Any] | None: - proposals: dict[tuple[str, int], dict[str, Any]] = {} - accepted: dict[tuple[str, int], dict[str, Any]] = {} - for event in self._history(): - payload = event.get("payload", {}) - key = (str(payload.get("plan_id") or ""), int(payload.get("version") or 0)) - if event["event_type"] == "interaction.plan_proposed": - proposals[key] = payload - elif event["event_type"] == "interaction.plan_accepted": - accepted[key] = payload - elif event["event_type"] == "interaction.plan_auto_accepted": - proposals[key] = payload - accepted[key] = payload - matches = [proposal for key, proposal in proposals.items() if key in accepted - and (not plan_id or key[0] == plan_id) and (version is None or key[1] == version)] - if not matches: - return None - proposal = matches[-1] - key = (str(proposal.get("plan_id") or ""), int(proposal.get("version") or 0)) - return {**proposal, **{field: accepted[key][field] for field in ( - "working_plan_id", "working_plan_revision", "working_task_ids", - ) if field in accepted[key]}} - - def accept_plan(self, task_manager: Any, *, plan_id: str | None = None, - version: int | None = None, fingerprint: str | None = None) -> tuple[dict[str, Any], bool]: - state = self.state() - plan = state.get("pending_plan") - executing = state.get("executing_plan") - if executing and not plan and (not plan_id or plan_id == executing.get("plan_id")): - if version is not None and version != executing.get("version"): - raise InteractionError("accepted plan version is stale") - if fingerprint is not None and fingerprint != executing.get("fingerprint"): - raise InteractionError("accepted plan fingerprint is stale") - return executing, False - if not plan: - if executing and plan_id and plan_id != executing.get("plan_id"): - raise InteractionError("accepted plan identity is stale") - accepted = self.accepted_plan(plan_id, version) - if accepted and (fingerprint is None or accepted.get("fingerprint") == fingerprint): - return accepted, False - if accepted: - raise InteractionError("accepted plan fingerprint is stale") - raise InteractionError("plan is not pending") - if ((plan_id and plan_id != plan.get("plan_id")) - or (version is not None and version != plan.get("version")) - or (fingerprint is not None and fingerprint != plan.get("fingerprint"))): - raise InteractionError("plan is not pending at the requested version") - if executing and task_manager.working_plan is None: - task_manager.recover() - elif not executing and task_manager.working_plan is None: - task_manager.recover() - if (not executing and task_manager.working_plan is not None - and task_manager.working_plan.status == "active"): - raise InteractionError("cannot accept a Strategic Plan while an unrelated Working Plan is active") - working_plan = task_manager.working_plan if executing else None - previous_tasks = [task for task in task_manager.tasks - if working_plan and task.get("id") in working_plan.task_ids] - if any(task.get("status") == "running" for task in previous_tasks): - raise InteractionError("cannot replace a Strategic Plan while a Working Plan task is running") - operations = [] - strategic_bindings = {} - active_ids = set(executing.get("working_task_ids", ())) if executing else set() - if executing: - if (plan.get("revision_materiality") != "material" - or plan.get("plan_id") != executing.get("plan_id") - or plan.get("version") != executing.get("version", 0) + 1): - raise InteractionError("Strategic Plan revision is stale or does not require approval") - staged_revision = plan.get("working_plan_revision", executing.get("working_plan_revision")) - if (not working_plan or working_plan.id != executing.get("working_plan_id") - or working_plan.revision != staged_revision): - raise InteractionError("Working Plan revision changed; reload before accepting") - - prior = [task for task in previous_tasks if not executing or task.get("id") in active_ids] - if any(task.get("status") == "running" for task in prior): - raise InteractionError("cannot replace a Strategic Plan while a Working Plan task is running") - new_refs = [] - reused: set[str] = set() - for step in plan["steps"]: - description = f"{step['title']}: {step['description']}" - execution = step.get("execution") or {} - expected_args = (json.dumps(execution.get("arguments", {}), ensure_ascii=False, - allow_nan=False, sort_keys=True, separators=(",", ":")) - if execution else "") - existing = next((task for task in prior - if task.get("status") == "succeeded" - and (task.get("checkpoint") or {}).get("strategic_plan", {}).get("objective_id") == step["id"] - and (task.get("checkpoint") or {}).get("strategic_plan", {}).get("acceptance_ids") == step["acceptance_ids"] - and task.get("description") == description - and [item.get("criterion") if isinstance(item, dict) else item - for item in task.get("acceptance", [])] == step["acceptance"] - and (task.get("checkpoint") or {}).get("action") == execution.get("action") - and ((task.get("checkpoint") or {}).get("args") or "") == expected_args - and {criterion_id for item in task.get("evidence", []) - if isinstance(item, dict) and item.get("success") is True - for criterion_id in item.get("acceptance_ids", ())} >= set(step["acceptance_ids"])), None) - if existing: - new_refs.append(existing["id"]) - reused.add(existing["id"]) - continue - ref = f"revision_{plan['version']}_{step['id']}" if executing else step["id"] - new_refs.append(ref) - operations.append({"op": "add", "ref": ref, "task": { - "description": description, "acceptance": step["acceptance"], - **({"execution": step["execution"]} if step.get("execution") else {}), - }}) - strategic_bindings[ref] = { - "plan_id": plan["plan_id"], "version": plan["version"], - "fingerprint": plan["fingerprint"], "objective_id": step["id"], - "acceptance_ids": step["acceptance_ids"], - "acceptance_verifications": step["acceptance_verifications"], - "execution": step.get("execution"), - } - for task in prior: - if task["id"] not in reused and task.get("status") in {"pending", "failed", "blocked"}: - operations.insert(0, {"op": "cancel", "task": task["id"]}) - if not operations: - if not prior: - raise InteractionError("Strategic Plan has no Working Plan tasks to reuse") - operations.append({"op": "update", "task": prior[0]["id"], - "fields": {"priority": prior[0]["priority"]}}) - payload = { - "plan_id": plan["plan_id"], "version": plan["version"], - "fingerprint": plan["fingerprint"], "working_task_refs": new_refs, - **({"revision_of": {"version": executing["version"], - "fingerprint": executing["fingerprint"]}} if executing else {}), - } - try: - working = task_manager.edit_plan( - complexity=working_plan.complexity if working_plan else "complex", - plan_id=working_plan.id if working_plan else None, - expected_revision=working_plan.revision if working_plan else 0, - operations=operations, strategic_bindings=strategic_bindings, - transaction_event={"event_type": "interaction.plan_accepted", "payload": payload, - "expected_proposal": {"plan_id": plan["plan_id"], "version": plan["version"], - "fingerprint": plan["fingerprint"]}, - **({"expected_working_plan": { - "plan_id": executing["working_plan_id"], - "revision": executing["working_plan_revision"], - "strategic_plan_id": executing["plan_id"], - "version": executing["version"], "fingerprint": executing["fingerprint"], - }} if executing else {})}, - ) - except ValueError as exc: - accepted = self.accepted_plan(plan["plan_id"], plan["version"]) - current_state = self.state() - current_plan = current_state.get("pending_plan") or current_state.get("executing_plan") - if current_plan and not all( - current_plan.get(key) == plan.get(key) - for key in ("plan_id", "version", "fingerprint")): - message = str(exc) - if "changed" not in message.lower() and "stale" not in message.lower(): - message = f"Strategic Plan changed while acceptance was pending: {message}" - raise InteractionError(message) from exc - if accepted and accepted.get("fingerprint") == plan["fingerprint"]: - return accepted, False - raise InteractionError(str(exc)) from exc - bound_ids = [working["references"][ref] if ref in working["references"] else ref for ref in new_refs] - return {**plan, "working_plan_id": working["plan_id"], - "working_plan_revision": working["revision"], "working_task_ids": bound_ids}, True - - def complete_plan(self, plan: dict[str, Any], *, status: str = "completed", - task_manager: Any | None = None) -> None: - if status not in {"completed", "failed", "cancelled"}: - raise InteractionError("Strategic Plan terminal status is invalid") - prior_completions = [event.get("payload", {}).get("status") for event in self._history() - if event["event_type"] == "interaction.plan_completed" - and event.get("payload", {}).get("plan_id") == plan.get("plan_id") - and event.get("payload", {}).get("version") == plan.get("version") - and event.get("payload", {}).get("fingerprint") == plan.get("fingerprint")] - if any(previous in {"completed", "cancelled"} or previous == status - for previous in prior_completions): - return - executing = self.state().get("executing_plan") - if not executing or any(executing.get(key) != plan.get(key) - for key in ("plan_id", "version", "fingerprint")): - raise InteractionError("Strategic Plan changed; reload before recording completion") - working = task_manager.working_plan if task_manager is not None else None - task_ids = plan.get("working_task_ids") - if task_ids is None and working and working.id == plan.get("working_plan_id"): - task_ids = working.task_ids - if status == "completed": - if task_manager is None or not isinstance(task_ids, list) or not task_ids: - raise InteractionError("Strategic Plan completion requires its linked Working Plan evidence") - tasks = {task.get("id"): task for task in task_manager.tasks} - objectives = {step["id"] for step in plan.get("steps", [])} - for task_id in task_ids: - binding = (tasks.get(task_id, {}).get("checkpoint") or {}).get("strategic_plan") or {} - if binding.get("plan_id") != plan.get("plan_id") or binding.get("objective_id") not in objectives: - raise InteractionError("Working Plan contains a task not linked to the accepted Strategic Plan") - if any(tasks.get(task_id, {}).get("status") != "succeeded" - or not any(isinstance(item, dict) and item.get("success") is True and item.get("acceptance") - for item in tasks[task_id].get("evidence", [])) - for task_id in task_ids): - raise InteractionError("Strategic Plan tasks have not passed their acceptance evidence gates") - for task_id in task_ids: - task = tasks[task_id] - binding = (task.get("checkpoint") or {}).get("strategic_plan") or {} - required = set(binding.get("acceptance_ids", ())) - if required: - covered = { - criterion_id - for item in task.get("evidence", []) - if isinstance(item, dict) and item.get("success") is True - for criterion_id in item.get("acceptance_ids", ()) - } - if not required <= covered: - raise InteractionError("Strategic Plan criteria have not all been verified") - payload = { - "plan_id": plan["plan_id"], "version": plan["version"], - "fingerprint": plan.get("fingerprint"), "working_plan_id": plan.get("working_plan_id"), - "working_task_ids": task_ids or [], "status": status, - } - append = getattr(self.store, "append_plan_completed", None) - if callable(append): - try: - append(payload, user_id=self.user_id, workspace_id=self.workspace_id, - session_id=self.session_id) - except ValueError as exc: - raise InteractionError(str(exc)) from exc - else: - self._append("interaction.plan_completed", payload) - - def reconcile_plan_completion(self, task_manager: Any) -> None: - plan = self.state().get("executing_plan") - if not plan or not plan.get("working_plan_id"): - return - working = task_manager.working_plan - if working is None or working.id != plan["working_plan_id"]: - working = task_manager.restore_working_plan(plan["working_plan_id"]) - if working is not None and working.status in {"completed", "cancelled"}: - self.complete_plan( - plan, status=working.status, task_manager=task_manager, - ) def render_question(question: dict[str, Any]) -> str: diff --git a/openkyrozen/agent/plan_tools.py b/openkyrozen/agent/plan_tools.py index 00ff5af1..3cfd7309 100644 --- a/openkyrozen/agent/plan_tools.py +++ b/openkyrozen/agent/plan_tools.py @@ -3,15 +3,91 @@ from openkyrozen.tools.models import legacy_text_api, tool_failure, tool_success +def _control_error(self): + if not self._interaction_controls_enabled.get() or getattr(self.execution_context, "child_run_id", None): + return tool_failure("permission_denied", "Interactive controls are unavailable on this execution surface.") + return None + + +@legacy_text_api +def _propose_plan(self, arguments: dict): + """Propose a versioned Strategic Plan after read-only inspection. This never + accepts or executes the proposal; material changes require user approval. + """ + denied = _control_error(self) + if denied is not None: + return denied + state = self._interaction_controller.state() + if (self._active_interaction_mode.get() != "plan" and not state.get("executing_plan")): + return tool_failure("permission_denied", "Strategic proposals require Plan Mode or an accepted plan revision.") + if not getattr(self.execution_context, "native_plan_inspected", False): + return tool_failure("invalid_arguments", "Inspect the relevant workspace or source with a read-only tool first.") + from openkyrozen.agent.modes import render_plan + try: + plan = self._interaction_controller.propose_plan( + arguments, original_input=self.execution_context.native_control_input, + ) + except (ValueError, TypeError, KeyError) as exc: + return tool_failure("invalid_arguments", f"Invalid Strategic Plan: {exc}") + self._emit_stream_event({"event": "interaction", "interaction": self.interaction_envelope()}) + requires_approval = plan.get("revision_materiality") != "within_bounds" + text = (render_plan(plan) if requires_approval else + f"Strategic Plan v{plan['version']} updated within approved scope; no renewed approval is required.") + return tool_success(text, data={"plan": plan, "requires_approval": requires_approval}) + + +@legacy_text_api +def _request_question(self, arguments: dict): + """Ask 1-3 structured clarification questions. Questions cannot request + credentials, grant authority, replace tool approval or execute work. + """ + denied = _control_error(self) + if denied is not None: + return denied + from openkyrozen.agent.modes import render_question + try: + question = self._interaction_controller.request_question( + arguments, original_input=self.execution_context.native_control_input, + ) + except (ValueError, TypeError, KeyError) as exc: + return tool_failure("invalid_arguments", f"Invalid clarification request: {exc}") + self._emit_stream_event({"event": "interaction", "interaction": self.interaction_envelope()}) + return tool_success(render_question(question), data={"question": question}) + + @legacy_text_api def _update_plan(self, arguments: dict): - """Apply revision-checked ordered Working Plan edits or adapt the legacy task list.""" + """Edit a revision-checked Working Plan. Use start with a stable task ID before + executing native task work, then suggest_complete to check existing verified + evidence; a completion suggestion never supplies evidence. Split of an + approved objective retains its immutable acceptance anchor and adds strategy + children. Add anchor= for in-scope investigation/verification. + """ + def validate_strategy(binding, execution): + from openkyrozen.agent.executor import _strategic_plan_denial + + active = self._interaction_controller.state().get("executing_plan") + if not active or active.get("plan_id") != binding.get("plan_id"): + raise ValueError("strategy has no active approved objective") + objective = next((step for step in active.get("steps", []) + if step.get("id") == binding.get("objective_id")), None) + if not objective or objective.get("acceptance_ids") != binding.get("acceptance_ids"): + raise ValueError("strategy objective acceptance changed; approval is required") + spec = self.tool_registry.get_spec(execution["action"]) + if spec is None or self.tool_registry._argument_error(spec.input_schema, execution["arguments"]): + raise ValueError("strategy execution has no valid tool contract") + denial = _strategic_plan_denial(active, execution["action"], execution["arguments"], + spec, self._get_workspace_root()) + if denial: + raise ValueError(denial) + try: if "operations" in arguments: result = self.tasks.update_plan( complexity=arguments.get("complexity"), plan_id=arguments.get("plan_id"), expected_revision=arguments["expected_revision"], operations=arguments["operations"], reason=arguments.get("reason"), + strategy_validator=validate_strategy, ) return tool_success("Working Plan updated.", data=result) plan = self.tasks.update_plan(complexity=arguments["complexity"], tasks=arguments["tasks"]) diff --git a/openkyrozen/agent/ports.py b/openkyrozen/agent/ports.py index 663a4efa..76e8aeaa 100644 --- a/openkyrozen/agent/ports.py +++ b/openkyrozen/agent/ports.py @@ -6,7 +6,4 @@ RuntimeEventSink = Callable[[RuntimeEvent], None] Approval = Callable[[str, str], bool] -class InteractionStore(Protocol): - def append_event(self, event_type: str, payload: Any, **scope) -> str: ... - def list_events(self, event_type: str | None = None, **scope) -> list[dict]: ... - def list_interaction_events(self, **scope) -> list[dict]: ... +from openkyrozen.tasks.ports import InteractionStore diff --git a/openkyrozen/agent/preparation.py b/openkyrozen/agent/preparation.py index 08968082..cebb0171 100644 --- a/openkyrozen/agent/preparation.py +++ b/openkyrozen/agent/preparation.py @@ -64,8 +64,10 @@ def _prepare_turn(self, turn): self.execution_context.delegation_budget = DelegationBudget() provider_config = self.execution_context.provider_config or self._provider_config select_model = (lambda _text: self.execution_context.model) if self.execution_context.child_run_id else self._select_model + classification_input = (getattr(self.current_session, "_delegation_classifier_input", turn.user_input) + if self.execution_context.child_run_id else turn.user_input) decision, turn.complexity = decide_request( - provider_config, fast_route, turn.user_input, select_model, + provider_config, fast_route, classification_input, select_model, self._classify_complexity, automatic_profile, turn.profile or self._agent_profile_mode, turn.interaction_mode, ) @@ -113,3 +115,6 @@ def _prepare_turn(self, turn): turn.memory_packet = self._build_memory_context( turn.user_input, context=turn.memory_context, profile=resolved_profile, ) + process_context = self.current_session.process_manager._recovery_context() + if process_context: + turn.memory_packet = (turn.memory_packet + "\n" + process_context).strip() diff --git a/openkyrozen/agent/presentation.py b/openkyrozen/agent/presentation.py new file mode 100644 index 00000000..4aea21bb --- /dev/null +++ b/openkyrozen/agent/presentation.py @@ -0,0 +1,99 @@ +"""User-facing response formatting shared by native and legacy execution.""" +from __future__ import annotations + +import re +from typing import Any +from openkyrozen.tasks.engine import canonical_status + + +class _NativeResponseText(str): + """Immutable per-reply provenance that survives the runtime turn scope.""" + __slots__ = () + + +def is_native_response(runtime, text) -> bool: + state = getattr(runtime, "_last_response_is_native", None) + return isinstance(text, _NativeResponseText) or bool(state is not None and state.get()) + + +def native_response_metadata(text: str) -> dict[str, Any]: + """Expose native text literally through the established compatibility result shape.""" + raw = str(text or "").strip() + return { + "raw": raw, "clean": raw, "has_plan": False, "has_tasklist": False, + "tool_calls": [], "question": None, "plan_proposal": None, + "unknown_action": None, "unsupported_action_protocol": False, + "protocol_error": None, + } + + +def _clean_final_response(self, text: str) -> str: + """Native output is literal; only the legacy boundary loads textual cleaning.""" + if is_native_response(self, text): + return _NativeResponseText(str(text or "").strip()) + from openkyrozen.agent.protocol import _clean_final_response as legacy_clean + return legacy_clean(self, text) + + +def _deterministic_tool_summary(self, tool_records: list[dict[str, Any]]) -> str: + """Build a truthful user-facing summary when the model emitted only protocol.""" + lines: list[str] = [] + for record in tool_records: + verified_failure = (record.get("verification") or {}).get("status") == "failed" + status = "Completed" if record.get("success") and not verified_failure else "Could not complete" + result = self._clean_final_response(str(record.get("result") or "")).replace("\n", " ") + if len(result) > 300: + result = result[:297] + "..." + line = f"- {status}" + if result: + line += f": {result}" + if record.get("unmatched_reason"): + line += f" ({record['unmatched_reason']})" + lines.append(line) + task_lines: list[str] = [] + for task in self.tasks.tasks: + status = canonical_status(task.get("status", "pending")) + task_lines.append(f"- {status}: {task.get('description', '')}") + if task_lines: + lines.append("Durable task status (source of truth):") + lines.extend(task_lines) + attention = [task for task in self.tasks.tasks if canonical_status(task.get("status", "pending")) != "succeeded"] + if attention: + lines.append("Recovery required for non-succeeded tasks:") + lines.extend( + f"- {canonical_status(task.get('status', 'pending'))}: {task.get('description', '')}" + for task in attention + ) + if not lines: + return "I could not produce a user-facing response." + return "Here is what I completed:\n\n" + "\n".join(lines) + + +def _safe_fstring(self, s: str) -> str: + """Escape curly braces so they do not interfere with f-string parsing.""" + return s.replace('{', '{{').replace('}', '}}') + + +def _remove_task_blocks(self, text: str) -> str: + if is_native_response(self, text): + return _NativeResponseText(str(text or "").strip()) + from openkyrozen.agent.protocol import _remove_task_blocks as legacy_remove + return legacy_remove(self, text) + + +def _split_reply(self, text: str) -> tuple[str, str]: + if is_native_response(self, text): + return "", str(text or "").strip() + text = text.strip() + thought_match = re.search(r"^Thought:\s*(.*?)(?=\n(?:Action:|(?:\n|$)))", text, re.DOTALL | re.MULTILINE) + if thought_match: + thinking = thought_match.group(1).strip() + answer = re.sub(r"^Thought:\s*.*?(?=\n(?:Action:|(?:\n|$))|\n?$)", "", text, count=1, flags=re.DOTALL | re.MULTILINE).strip() + answer = re.sub(r"Action:\s*```(?:json)?[\s\S]*?```", "", answer).strip() + answer = re.sub(r"\{[\s\S]*?\}", "", answer).strip() + answer = answer.lstrip('"').lstrip("'").strip() + if not answer: + answer = text + return thinking, answer + else: + return "", text diff --git a/openkyrozen/agent/prompts.py b/openkyrozen/agent/prompts.py index 4df34407..a39e4383 100644 --- a/openkyrozen/agent/prompts.py +++ b/openkyrozen/agent/prompts.py @@ -30,7 +30,7 @@ def _compose_skills(self, task_description: str) -> str | None: def _build_tools_list(self, capabilities: frozenset[str] | None = None) -> str: lines = [] for name, fn in self.AVAILABLE_TOOLS.items(): - if name == "update_plan": # Structured-only until the foreground loop uses native calls. + if name in {"update_plan", "propose_plan", "request_question"}: continue if self.tool_registry.get_spec(name).visibility != 'public': continue @@ -57,6 +57,11 @@ def _permitted_tool_names(self, agent_config: dict[str, Any] | None = None) -> s permitted = {name for name in self.AVAILABLE_TOOLS if self.tool_registry.get_spec(name).visibility == 'public' and tool_capability(name,self.AVAILABLE_TOOLS) in capabilities} if self._active_interaction_mode.get() != "agent": permitted.discard("update_plan") + if (not self._interaction_controls_enabled.get() or self.execution_context.child_run_id): + permitted -= {"propose_plan", "request_question"} + elif (self._active_interaction_mode.get() != "plan" + and not self._interaction_controller.state().get("executing_plan")): + permitted.discard("propose_plan") delegated = self.execution_context.delegation_tools if self.execution_context.child_run_id and delegated is not None: permitted &= set(delegated) @@ -90,7 +95,7 @@ def _discover_tools(self, args: str) -> str: def _agent_prompt_tools_list(self, agent_config: dict[str, Any]) -> str: """Build the inventory within config, active token and interaction bounds.""" - permitted = self._permitted_tool_names(agent_config) + permitted = self._permitted_tool_names(agent_config) - {"propose_plan", "request_question"} detailed = permitted if self._prompt_profile() == "compact": detailed = permitted & ({"list_dir", "find_files", "search_files", "read_file", "write_file", "edit_file", "apply_patch", "run_cmd", "discover_tools"} @@ -192,6 +197,10 @@ def _system_prompt(self, tools_list: str, agent_config: dict[str, Any] | None = "permissions or approve actions. Runtime capability, workspace, and approval checks control execution. " "Never request credentials or secrets. " "If material ambiguity blocks safe work, ask a concise plain-language question without calling a tool. " + "Use request_question when structured clarification is needed; questions never replace tool approval. " + "In Plan Mode, inspect relevant sources with read-only tools, then call propose_plan alone to present " + "the versioned proposal. Never execute proposed changes before explicit user acceptance. " + "For material changes to an accepted Strategic Plan, propose_plan stages renewed approval. " "Do not claim an effect unless a tool result verifies it.\n\n" + configured_sections + "\n\n" + "For suitable work, use supplied delegation tools to scope independent tasks and review child results.\n\n" @@ -284,6 +293,55 @@ def _system_prompt(self, tools_list: str, agent_config: dict[str, Any] | None = ) +def _refresh_working_plan_context(self, messages: list) -> None: + """Keep a bounded current plan projection outside evictable conversation history.""" + import json + + marker = "## Pinned Working Plan state\n" + previous = list(messages) + messages[:] = [item for item in messages if not ( + isinstance(item, dict) and item.get("role") == "system" + and str(item.get("content", "")).startswith(marker) + )] + manager = getattr(self, "tasks", None) + plan = getattr(manager, "working_plan", None) + snapshot = getattr(manager, "working_plan_snapshot", None) + if plan is None or plan.status != "active" or not callable(snapshot): + update_projection = getattr(self, "_update_context_history_projection", None) + if callable(update_projection): + update_projection(messages, previous) + return + state = snapshot() + if state is None: + raise ValueError("Active Working Plan has no authoritative snapshot") + tasks_by_id = {task["id"]: task for task in manager.tasks} + state["tasks"] = [{ + **item, "dependencies": tasks_by_id.get(item["id"], {}).get("dependencies", []), + "acceptance": tasks_by_id.get(item["id"], {}).get("acceptance", []), + } for item in state["tasks"]] + full = json.dumps(state, ensure_ascii=False, sort_keys=True) + if len(full) > 12000: + memory = self.memory_bank + artifact_id = memory.store.artifacts.get_or_create( + full, user_id=manager.user_id, workspace_id=manager.workspace_id, session_id=manager.session_id, + ) + state = {key: value for key, value in state.items() if key != "tasks"} + state["reason"] = str(state.get("reason", ""))[:300] + state.update(artifact_id=artifact_id, task_count=len(plan.task_ids), + tasks=[{"id": item["id"], "status": item["status"], + "description": str(item["description"])[:160]} + for task_id in plan.task_ids[:12] + if (item := tasks_by_id.get(task_id)) is not None]) + full = json.dumps(state, ensure_ascii=False, sort_keys=True) + messages.insert(1 if messages else 0, {"role": "system", "content": marker + ( + "Runtime-owned task identity, order, status and acceptance. Strategy may adapt within approved scope; " + "tool/security approval remains separate. If artifact_id is present, use read_tool_artifact for the full plan.\n" + ) + full}) + update_projection = getattr(self, "_update_context_history_projection", None) + if callable(update_projection): + update_projection(messages, previous) + + def _build_messages(self, user_input: str, learned_context: str = "", memory_context: dict[str, Any] | None = None, *, memory_packet: str | None = None, @@ -330,6 +388,8 @@ def _build_messages(self, user_input: str, learned_context: str = "", "do not expand scope.\n" + json.dumps(summary, ensure_ascii=False) )}) + _refresh_working_plan_context(self, messages) + project_instructions = format_instructions(self._get_workspace_root()) if project_instructions and self._active_interaction_mode.get() == "agent": messages.append({"role": "system", "content": project_instructions}) diff --git a/openkyrozen/agent/protocol.py b/openkyrozen/agent/protocol.py index e9e84411..48aa2cd4 100644 --- a/openkyrozen/agent/protocol.py +++ b/openkyrozen/agent/protocol.py @@ -8,6 +8,7 @@ from openkyrozen.agent.modes import InteractionError, normalize_provider_control, parse_control_block, render_plan, render_question, validate_plan_proposal, validate_question_request from openkyrozen.security.tool_policy import tool_capability from openkyrozen.agent.types import DeepSeekDSMLFilter +from openkyrozen.agent.presentation import _deterministic_tool_summary, _safe_fstring, _split_reply def _is_valid_action(self, name: str | None) -> bool: @@ -573,57 +574,3 @@ def _persist_interaction_control(self, parsed: dict[str, Any], user_input: str) def _interaction_gate(self, parsed: dict[str, Any], user_input: str) -> str | None: """Stop a response before any executable path when it carries a control.""" return parsed.get("protocol_error") or self._persist_interaction_control(parsed, user_input) - - -def _deterministic_tool_summary(self, tool_records: list[dict[str, Any]]) -> str: - """Build a truthful user-facing summary when the model emitted only protocol.""" - lines: list[str] = [] - for record in tool_records: - status = "Completed" if record.get("success") else "Could not complete" - result = self._clean_final_response(str(record.get("result") or "")).replace("\n", " ") - if len(result) > 300: - result = result[:297] + "..." - line = f"- {status}" - if result: - line += f": {result}" - if record.get("unmatched_reason"): - line += f" ({record['unmatched_reason']})" - lines.append(line) - task_lines: list[str] = [] - for task in self.tasks.tasks: - status = canonical_status(task.get("status", "pending")) - task_lines.append(f"- {status}: {task.get('description', '')}") - if task_lines: - lines.append("Durable task status (source of truth):") - lines.extend(task_lines) - attention = [task for task in self.tasks.tasks if canonical_status(task.get("status", "pending")) != "succeeded"] - if attention: - lines.append("Recovery required for non-succeeded tasks:") - lines.extend( - f"- {canonical_status(task.get('status', 'pending'))}: {task.get('description', '')}" - for task in attention - ) - if not lines: - return "I could not produce a user-facing response." - return "Here is what I completed:\n\n" + "\n".join(lines) - - -def _safe_fstring(self, s: str) -> str: - """Escape curly braces so they do not interfere with f-string parsing.""" - return s.replace('{', '{{').replace('}', '}}') - - -def _split_reply(self, text: str) -> tuple[str, str]: - text = text.strip() - thought_match = re.search(r"^Thought:\s*(.*?)(?=\n(?:Action:|(?:\n|$)))", text, re.DOTALL | re.MULTILINE) - if thought_match: - thinking = thought_match.group(1).strip() - answer = re.sub(r"^Thought:\s*.*?(?=\n(?:Action:|(?:\n|$))|\n?$)", "", text, count=1, flags=re.DOTALL | re.MULTILINE).strip() - answer = re.sub(r"Action:\s*```(?:json)?[\s\S]*?```", "", answer).strip() - answer = re.sub(r"\{[\s\S]*?\}", "", answer).strip() - answer = answer.lstrip('"').lstrip("'").strip() - if not answer: - answer = text - return thinking, answer - else: - return "", text diff --git a/openkyrozen/agent/runtime.py b/openkyrozen/agent/runtime.py index ea6fc0ae..df855272 100644 --- a/openkyrozen/agent/runtime.py +++ b/openkyrozen/agent/runtime.py @@ -24,8 +24,8 @@ class AgentRuntime: from openkyrozen.agent.completion import _complete_turn from openkyrozen.agent.response_recovery import _observe_turn_response, _recover_plan_response - # agent.protocol - from openkyrozen.agent.protocol import ( + # Lazy legacy compatibility; native formatting does not load the parser. + from openkyrozen.agent.legacy_compat import ( _is_valid_action, _detect_unknown_action, _has_unsupported_action_protocol, @@ -37,18 +37,17 @@ class AgentRuntime: _action_marker_is_protocol, _control_marker_is_protocol, _remove_protocol_headings, - _clean_final_response, _stream_buffer_has_tool_prefix, _clean_stream_buffer, - _remove_task_blocks, _legacy_plan_value, _parse_model_response, _observe_model_response, _persist_interaction_control, _interaction_gate, - _deterministic_tool_summary, - _safe_fstring, - _split_reply, + ) + + from openkyrozen.agent.presentation import ( + _clean_final_response, _deterministic_tool_summary, _safe_fstring, _split_reply, _remove_task_blocks, ) # security.permissions @@ -172,7 +171,7 @@ class AgentRuntime: _subagent_provider_model, ) - from openkyrozen.agent.plan_tools import _update_plan + from openkyrozen.agent.plan_tools import _update_plan, _propose_plan, _request_question from openkyrozen.agent.delegation_runtime import ( _plan_delegation, delegation, _invoke_delegated, spawn_agents, send_subagent, list_subagents, @@ -289,6 +288,7 @@ def __init__(self, memory, tasks, interaction, workspace): self._session_context = ContextVar("agent_session", default=None) self._surface_capability_override = ContextVar("agent_surface_capabilities", default=None) self._conversation_turn_id = ContextVar("conversation_turn_id", default=None) + self._conversation_append_lock = threading.Lock() self._last_model_response = ContextVar("last_model_response", default=None) self._record_foreground_model = ContextVar("record_foreground_model", default=False) self._turn_completion_status = ContextVar("turn_completion_status", default="completed") @@ -300,9 +300,19 @@ def __init__(self, memory, tasks, interaction, workspace): self._sessions = {} self._workspaces = {workspace.root: workspace} from openkyrozen.agent.engine import AgentEngine - from openkyrozen.agent.protocol import LegacyProtocolAdapter self._agent_engine = AgentEngine(self) - self.legacy_protocol = LegacyProtocolAdapter(self) + self._legacy_protocol = None + + @property + def legacy_protocol(self): + if self._legacy_protocol is None: + from openkyrozen.agent.protocol import LegacyProtocolAdapter + self._legacy_protocol = LegacyProtocolAdapter(self) + return self._legacy_protocol + + @legacy_protocol.setter + def legacy_protocol(self, adapter): + self._legacy_protocol = adapter @property def current_session(self): @@ -425,18 +435,23 @@ def chat(self, session, message, *, clear_tasks=False, profile=None, memory_cont ) except BaseException as exc: from openkyrozen.providers.errors import ProviderError, ProviderErrorKind - cancelled = isinstance(exc, (KeyboardInterrupt, asyncio.CancelledError)) or ( - isinstance(exc, ProviderError) and exc.kind == ProviderErrorKind.CANCELLED - ) + cancelled = (turn_context.cancelled.is_set() + or isinstance(exc, (KeyboardInterrupt, asyncio.CancelledError)) + or (isinstance(exc, ProviderError) and exc.kind == ProviderErrorKind.CANCELLED)) publisher.publish( "turn.cancelled" if cancelled else "turn.failed", {"error_type": type(exc).__name__}, ) raise - if publisher.last_model_failed or self._turn_failed.get(): + if turn_context.cancelled.is_set(): + publisher.publish("turn.cancelled", {"status": "cancelled"}) + elif publisher.last_model_failed or self._turn_failed.get(): publisher.publish("turn.failed", {"error_type": "provider_error"}) else: publisher.publish("turn.completed", {"status": self._turn_completion_status.get()}) + if self._last_response_is_native.get(): + from openkyrozen.agent.presentation import _NativeResponseText + return _NativeResponseText(reply) return reply finally: with self._active_turns_lock: @@ -483,16 +498,17 @@ def conversation_history(self): def _record_conversation_entry(self, kind, payload, *, call_id=None, task_id=None, request_id=None): history = self.conversation_history active_turn = self._conversation_turn_id.get() - parent = self._conversation_expected_head.get() if active_turn else history.head - entry = history.entry( - kind, payload, parent_id=parent, turn_id=self._conversation_turn_id.get(), - request_id=request_id or self._last_model_request_id.get(), - call_id=call_id, task_id=task_id, - ) - history.append(entry, expected_head=parent) - if active_turn: - self._conversation_expected_head.set(entry.id) - return entry + with self._conversation_append_lock: + parent = history.head + entry = history.entry( + kind, payload, parent_id=parent, turn_id=self._conversation_turn_id.get(), + request_id=request_id or self._last_model_request_id.get(), + call_id=call_id, task_id=task_id, + ) + history.append(entry, expected_head=parent) + if active_turn: + self._conversation_expected_head.set(entry.id) + return entry def reconcile_conversation_messages(self, messages): """Explicit adapter for callers that still supply a replacement message list.""" @@ -545,6 +561,9 @@ def _ACTION_MARKER_RE(self): return cached[1] def run_command(self, args): + if self.execution_context.cancelled is not None and self.execution_context.cancelled.is_set(): + from openkyrozen.tools.models import CommandResult + return CommandResult("Command cancelled before it started.", False, failure="cancelled") if isinstance(args, dict) and args.get("background") is True: from openkyrozen.tools.models import CommandResult from openkyrozen.tools.sandbox import SandboxUnavailableError @@ -556,6 +575,7 @@ def run_command(self, args): try: handle = self.current_session.process_manager.start( command, cwd=cwd, env=env, timeout_seconds=timeout, + cancelled=self.execution_context.cancelled, ) message = f"Started background process {handle.process_id}" if handle.sandbox_backend == "unavailable": @@ -571,9 +591,6 @@ def run_command(self, args): except (OSError, RuntimeError, ValueError) as exc: return CommandResult(f"Error starting background process: {exc}", False, failure="execution_error") - if self.execution_context.cancelled is not None and self.execution_context.cancelled.is_set(): - from openkyrozen.tools.models import CommandResult - return CommandResult("Command cancelled before it started.", False, failure="cancelled") return self.current_session.workspace.adapters.run_command(args, cancelled=self.execution_context.cancelled) @legacy_text_api @@ -608,6 +625,11 @@ def _stop_process(self, args): def _read_tool_artifact(self, args): """Read a session-owned tool artifact in pages up to 1,000 characters.""" from openkyrozen.tools.models import ToolResult, tool_failure + if isinstance(args, str): + try: + args = json.loads(args) + except (TypeError, ValueError): + args = None artifact_id = args.get("artifact_id") if isinstance(args, dict) else None offset = args.get("offset", 0) if isinstance(args, dict) else 0 limit = args.get("limit", 1000) if isinstance(args, dict) else 1000 @@ -616,8 +638,7 @@ def _read_tool_artifact(self, args): or type(limit) is not int or not 1 <= limit <= 1000): return tool_failure("invalid_arguments", "Error: artifact_id, nonnegative offset, and limit from 1 to 1000 are required.") memory = self.memory_bank - task_owner = getattr(self.current_session, "_delegation_parent", None) or self - tasks = getattr(task_owner, "tasks", None) + tasks = getattr(self, "tasks", None) page = memory.store.artifacts.read_page( artifact_id, offset, limit, user_id=getattr(tasks, "user_id", None) or memory.user_id, diff --git a/openkyrozen/agent/turn.py b/openkyrozen/agent/turn.py index df53ccfa..3f87ce57 100644 --- a/openkyrozen/agent/turn.py +++ b/openkyrozen/agent/turn.py @@ -38,6 +38,7 @@ class TurnContext: turn_start: Any = None runtime_status: str = "completed" memory_packet: str = "" + native_tools: bool = False @property def planning_required(self) -> bool: @@ -62,6 +63,8 @@ class ExecutionContext: delegation_tools: frozenset[str] | None = None max_steps: int | None = None cancelled: Any = None + native_control_input: str = "" + native_plan_inspected: bool = False def scoped_turn(function): diff --git a/openkyrozen/app/bootstrap.py b/openkyrozen/app/bootstrap.py index 30056e5c..4243b917 100644 --- a/openkyrozen/app/bootstrap.py +++ b/openkyrozen/app/bootstrap.py @@ -83,16 +83,22 @@ def build_memory(path=None, *, user_id="local", workspace_id="default", session_ if requested.suffix.lower() != ".sqlite3": requested.mkdir(parents=True, exist_ok=True) requested /= "openkyrozen.sqlite3" + owns_store = store is None store = store if store is not None else EventStore(requested) - error = None - if index is None and os.environ.get("KYROZEN_DISABLE_VECTOR_INDEX", "").lower() not in {"1", "true", "yes"}: - try: - index = ChromaIndex(Path(os.environ.get("KYROZEN_VECTOR_PATH", str(requested.parent / "chroma_index_v2"))).expanduser()) - except Exception as exc: - error = f"vector index unavailable: {exc}" - from openkyrozen.routing import system_one - memory = MemoryBank(requested, user_id=user_id, workspace_id=workspace_id, session_id=session_id, file_scope_id=file_scope_id, store=store, index=index) - memory.decisions = system_one - if error: - memory._last_error = error - return memory + try: + error = None + if index is None and os.environ.get("KYROZEN_DISABLE_VECTOR_INDEX", "").lower() not in {"1", "true", "yes"}: + try: + index = ChromaIndex(Path(os.environ.get("KYROZEN_VECTOR_PATH", str(requested.parent / "chroma_index_v2"))).expanduser()) + except Exception as exc: + error = f"vector index unavailable: {exc}" + from openkyrozen.routing import system_one + memory = MemoryBank(requested, user_id=user_id, workspace_id=workspace_id, session_id=session_id, file_scope_id=file_scope_id, store=store, index=index, owns_store=owns_store) + memory.decisions = system_one + if error: + memory._last_error = error + return memory + except BaseException: + if owns_store: + store.close() + raise diff --git a/openkyrozen/app/container.py b/openkyrozen/app/container.py index 67b40890..09324736 100644 --- a/openkyrozen/app/container.py +++ b/openkyrozen/app/container.py @@ -10,15 +10,22 @@ class Application: store: Any def close(self): - coordinator = getattr(self.runtime.subagent_manager, "coordinator", None) - if coordinator: - coordinator.close() - sessions = [self.runtime._default_session, *self.runtime._sessions.values()] - for session in sessions: - coordinator = getattr(session.subagents, "coordinator", None) + try: + coordinator = getattr(self.runtime.subagent_manager, "coordinator", None) if coordinator: coordinator.close() - session.process_manager.close() - self.runtime._technology_executor.shutdown(wait=False, cancel_futures=True) - for workspace in self.runtime._workspaces.values(): - workspace.adapters._BROWSER.close_all() + sessions = [self.runtime._default_session, *self.runtime._sessions.values()] + for session in sessions: + coordinator = getattr(session.subagents, "coordinator", None) + if coordinator: + coordinator.close() + session.process_manager.close() + self.runtime._technology_executor.shutdown(wait=False, cancel_futures=True) + for workspace in self.runtime._workspaces.values(): + workspace.adapters._BROWSER.close_all() + memory_owner = getattr(self.runtime._default_session, "memory", self.runtime.memory_bank) + memory_owner.close() + finally: + close = getattr(self.store, "close", None) + if close is not None: + close() diff --git a/openkyrozen/app/state.py b/openkyrozen/app/state.py index bf98a0a1..cb189ff9 100644 --- a/openkyrozen/app/state.py +++ b/openkyrozen/app/state.py @@ -11,6 +11,7 @@ import datetime from pathlib import Path from typing import Any +from openkyrozen import __version__ from openkyrozen.security.capabilities import issue_capability_token from openkyrozen.workspace.context import LaunchContext from openkyrozen.security.tool_policy import resolve_capabilities @@ -38,7 +39,7 @@ def initialise(self): self.UTC = datetime.timezone.utc - self.RELEASE_VERSION = "2.0.8" + self.RELEASE_VERSION = __version__ self.RELEASE_TAG = f"v{self.RELEASE_VERSION}" @@ -283,10 +284,6 @@ def initialise(self): self._technology_lock = threading.Lock() - self._SPINNER_STOP = threading.Event() - - self._SPINNER_THREAD: threading.Thread | None = None - self._active_context_state: ContextVar[ContextState | None] = ContextVar("active_context_state", default=None) self._in_context_compaction: ContextVar[bool] = ContextVar("in_context_compaction", default=False) diff --git a/openkyrozen/interfaces/cli/rendering.py b/openkyrozen/interfaces/cli/rendering.py index a97a22a1..033a1dce 100644 --- a/openkyrozen/interfaces/cli/rendering.py +++ b/openkyrozen/interfaces/cli/rendering.py @@ -13,11 +13,19 @@ from openkyrozen.workspace.context import LaunchContext from openkyrozen.agent.types import ContextOverflowError, ContextTooLargeError +_SPINNER_DISPLAY_LOCK = threading.Lock() + def _terminal_supports_unicode(self) -> bool: """Return True if the terminal can render Unicode box-drawing and special chars.""" if not self._IS_WINDOWS: return True + encoding = getattr(sys.stdout, "encoding", None) + if encoding: + try: + "✓".encode(encoding) + except (LookupError, UnicodeEncodeError): + return False # Windows Terminal, VS Code terminal, and ConEmu all set WT_SESSION if _os.environ.get("WT_SESSION") or _os.environ.get("TERM_PROGRAM") == "vscode": return True @@ -290,18 +298,22 @@ def call_with_one_overflow_recovery() -> str: if streaming or private: return call_with_one_overflow_recovery() - self._SPINNER_STOP.clear() - self._SPINNER_THREAD = threading.Thread(target=self._spinner_worker, args=(self._SPINNER_STOP,), daemon=True) - self._SPINNER_THREAD.start() + if not _SPINNER_DISPLAY_LOCK.acquire(blocking=False): + return call_with_one_overflow_recovery() + stop_event = threading.Event() + spinner_thread = threading.Thread(target=self._spinner_worker, args=(stop_event,), daemon=True) try: - result = call_with_one_overflow_recovery() + spinner_thread.start() + try: + result = call_with_one_overflow_recovery() + finally: + stop_event.set() + spinner_thread.join(timeout=2) + sys.stdout.write("\r" + " " * 70 + "\r") + sys.stdout.flush() + return result finally: - self._SPINNER_STOP.set() - if self._SPINNER_THREAD: - self._SPINNER_THREAD.join(timeout=2) - sys.stdout.write("\r" + " " * 70 + "\r") - sys.stdout.flush() - return result + _SPINNER_DISPLAY_LOCK.release() def _render_tool_result(self, action: str, result: str) -> None: diff --git a/openkyrozen/interfaces/tui/turns.py b/openkyrozen/interfaces/tui/turns.py index 9975d60b..5e9b5978 100644 --- a/openkyrozen/interfaces/tui/turns.py +++ b/openkyrozen/interfaces/tui/turns.py @@ -264,6 +264,7 @@ def _attachment_prompt(self, text: str) -> str: def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any] | None = None) -> None: runtime = self.agent approval_token = runtime._approval_callback.set(self._approval) + final_state, final_message = "ready", "Ready" try: inline = runtime.split_inline_command(text) if inline: @@ -277,10 +278,10 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any else: self.prompt_api_key(request_id=request_id) self.status("waiting", "Provider setup required.", request_id) + final_state, final_message = "waiting", "Provider setup required." return sanitized, flagged = self._quiet_call(runtime._sanitize_input, text) if not sanitized.strip() and not self._staged_attachments: - self.status("ready", "Type a message before sending.", request_id) return metadata = runtime.memory_bank.store.list_events( "tui.chat_metadata", limit=1, workspace_id=runtime.interaction_workspace_id(), @@ -325,7 +326,6 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any ], working_plan=runtime.tasks.working_plan_snapshot()) self.interaction(request_id) self.usage(request_id) - self.status("ready", "Ready", request_id) self.navigation(request_id) except runtime.ProviderUnavailableError as exc: self.emit("error", request_id, code=runtime.PROVIDER_UNAVAILABLE_CODE, @@ -338,6 +338,7 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any with self._state_lock: self._busy = False self._workers.discard(threading.current_thread()) + self.status(final_state, final_message, request_id) def _features(self) -> list[dict[str, Any]]: diff --git a/openkyrozen/interfaces/web/service.py b/openkyrozen/interfaces/web/service.py index ff34ee69..b7f7e740 100644 --- a/openkyrozen/interfaces/web/service.py +++ b/openkyrozen/interfaces/web/service.py @@ -6,6 +6,7 @@ from importlib import resources from fastapi import FastAPI, Depends from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse +from openkyrozen import __version__ from openkyrozen.tasks.scheduler import JobScheduler class WebService: @@ -13,7 +14,7 @@ def __init__(self, application=None): self._owns_application = application is None self._application = application self._scheduler_instance = None - self.app = FastAPI(title="OpenKyrozen API", description="Self-learning AI Agent — REST API + Web Chat", version="2.0.8") + self.app = FastAPI(title="OpenKyrozen API", description="Self-learning AI Agent — REST API + Web Chat", version=__version__) self._SERVER_TOKEN = os.environ.get("KYROZEN_SERVER_TOKEN", "").strip() self._BROWSER_AUTH_COOKIE = "openkyrozen_browser_session" diff --git a/openkyrozen/interfaces/web/tasks.py b/openkyrozen/interfaces/web/tasks.py index 30f88840..d1e16854 100644 --- a/openkyrozen/interfaces/web/tasks.py +++ b/openkyrozen/interfaces/web/tasks.py @@ -155,6 +155,9 @@ async def api_v2_resume_task(self, task_id: str): raise HTTPException(404, "Failed or blocked task not found in this scope") manager = service._task_manager(stored.get("session_id")) manager.recover() + current = next((item for item in manager.tasks if item["id"] == str(task_id)), None) + if current and (current.get("checkpoint", {}).get("recovery_effect") or {}).get("status") == "unknown": + raise HTTPException(409, "Interrupted operation has unknown effects; inspect and reconcile it before resuming") task = manager.resume(task_id) if task is None: raise HTTPException(404, "Failed or blocked task not found in this scope") diff --git a/openkyrozen/learning/engine.py b/openkyrozen/learning/engine.py index d5ff5883..d699d84b 100644 --- a/openkyrozen/learning/engine.py +++ b/openkyrozen/learning/engine.py @@ -52,8 +52,9 @@ def route_profile(task: str, override: str = "auto") -> str: "typescript", "swift", "rust", "function", "class", "terminal", "shell", "api", "database", "代码", "仓库", "修复", "测试", "构建", "提交", "终端", "数据库", } - tokens = set(re.findall(r"[\w+#.-]+", text, re.UNICODE)) - return "coder" if coder_terms & tokens else "researcher" + english = "|".join(re.escape(term) for term in coder_terms if term.isascii()) + return "coder" if (re.search(r"\b(?:" + english + r")(?:s|es|ed|ing)?\b", text, re.ASCII) + or any(term in text for term in coder_terms if not term.isascii())) else "researcher" @staticmethod diff --git a/openkyrozen/learning/evidence.py b/openkyrozen/learning/evidence.py index eca9fcfe..d4f0496d 100644 --- a/openkyrozen/learning/evidence.py +++ b/openkyrozen/learning/evidence.py @@ -1,12 +1,22 @@ from __future__ import annotations from pathlib import Path +import math from typing import Any from openkyrozen.persistence.models import stable_hash from .models import CAPSULE_PROTOCOL, DEFAULT_CONSTITUTION, EVOLUTION_PROFILES, NEGATIVE_FEEDBACK, POSITIVE_FEEDBACK from openkyrozen.memory.models import _SECRET_RE +MEASURED_REPLAY_POLICY = "measured-replay-v1" + + +def _promotion_replay_ready(replay: dict[str, Any]) -> bool: + return (replay.get("measurement_policy") == MEASURED_REPLAY_POLICY + and replay.get("measurements_complete") is True + and replay.get("non_regressing") is True) + + def record_shadow_replay(self, proposal_id: str, candidate: list[dict[str, Any]], predecessor: list[dict[str, Any]]) -> dict[str, Any]: """Record paired, already-sandboxed replay results without executing commands.""" @@ -18,18 +28,71 @@ def record_shadow_replay(self, proposal_id: str, candidate: list[dict[str, Any]] predecessor_ids = [str(item.get("case_id", "")) for item in predecessor] if not all(candidate_ids) or candidate_ids != predecessor_ids or len(set(candidate_ids)) != len(candidate_ids): raise ValueError("paired replay case ids must be unique and identical") - candidate_successes = sum(bool(item.get("verified_success")) for item in candidate) - predecessor_successes = sum(bool(item.get("verified_success")) for item in predecessor) + if any(type(item.get("verified_success")) is not bool for item in candidate + predecessor): + raise ValueError("replay success must be a verified boolean") + candidate_successes = sum(item["verified_success"] for item in candidate) + predecessor_successes = sum(item["verified_success"] for item in predecessor) + outcome_safe = all(not previous["verified_success"] or current["verified_success"] + for current, previous in zip(candidate, predecessor)) + comparisons = {} + for field in ("tokens", "latency", "corrections", "repeated_errors", "tool_calls", + "cost", "cost_picos", "cost_rmb"): + pairs = [(current.get(field), previous[field]) for current, previous in zip(candidate, predecessor) + if field in previous] + if not pairs: + continue + if any(type(value) not in {int, float} or (type(value) is float and not math.isfinite(value)) or value < 0 + for pair in pairs for value in pair): + raise ValueError(f"paired {field} measurements must be finite nonnegative numbers") + current_total, previous_total = (sum(pair[index] for pair in pairs) for index in (0, 1)) + if any(type(total) is float and not math.isfinite(total) for total in (current_total, previous_total)): + raise ValueError(f"paired {field} totals must remain finite") + non_regressing = current_total <= previous_total + if field in {"corrections", "repeated_errors"}: + non_regressing = all(current <= previous for current, previous in pairs) + comparisons[field] = {"candidate": current_total, "predecessor": previous_total, + "non_regressing": non_regressing} + missing = [] + for case_id, current, previous in zip(candidate_ids, candidate, predecessor): + for side, item in (("candidate", current), ("predecessor", previous)): + for field in ("latency", "corrections", "repeated_errors"): + if field not in item: + missing.append(f"{case_id}:{side}.{field}") + cost_metric, cost_unit = None, None + for field in ("cost_rmb", "cost_picos", "cost"): + if not all(field in item for item in candidate + predecessor): + continue + if field == "cost": + units = [item.get("cost_unit") for item in candidate + predecessor] + if (not all(isinstance(unit, str) and unit.strip() and len(unit) <= 64 for unit in units) + or len(set(units)) != 1): + continue + cost_unit = units[0] + else: + cost_unit = field + cost_metric = field + break + if cost_metric is None: + missing.append("paired cost measurement in one consistent explicit unit") + measurements_complete = not missing models = sorted({str(item.get("provider_model")) for item in candidate if item.get("provider_model")}) result = { "case_ids": candidate_ids, "candidate_successes": candidate_successes, "predecessor_successes": predecessor_successes, - "non_regressing": candidate_successes >= predecessor_successes, + "non_regressing": measurements_complete and outcome_safe + and all(item["non_regressing"] for item in comparisons.values()), + "measurement_policy": MEASURED_REPLAY_POLICY, + "measurements_complete": measurements_complete, + "missing_measurements": missing, "cost_metric": cost_metric, "cost_unit": cost_unit, + "blocked_reason": ("Replay evidence incomplete: provide paired latency, corrections, repeated_errors " + "and cost in one consistent unit for every case.") if missing else None, + "metric_comparisons": comparisons, "environment": self.environment_fingerprint(), "model_scope": models, "cross_model_validated": len(models) >= 2, } validation = {**proposal.get("validation", {}), "shadow_replay": result, - "revalidation_status": "valid" if result["non_regressing"] else "failed"} + "revalidation_status": ("incomplete" if not measurements_complete else + "valid" if result["non_regressing"] else "failed")} self.store.update_proposal(proposal_id, status=proposal["status"], validation=validation) self.store.append_event("learning.shadow_replay", {"proposal_id": proposal_id, **result}, user_id=self.memory.user_id, workspace_id=self.memory.workspace_id, diff --git a/openkyrozen/learning/promotion.py b/openkyrozen/learning/promotion.py index c3bd716e..35a5e249 100644 --- a/openkyrozen/learning/promotion.py +++ b/openkyrozen/learning/promotion.py @@ -5,6 +5,7 @@ from .models import CAPSULE_PROTOCOL, DEFAULT_CONSTITUTION, EVOLUTION_PROFILES, NEGATIVE_FEEDBACK, POSITIVE_FEEDBACK from openkyrozen.memory.models import _SECRET_RE +from .evidence import MEASURED_REPLAY_POLICY, _promotion_replay_ready def record_outcome(self, run: dict[str, str], receipts: list[dict[str, Any]], *, verified: bool, success: bool, correction: bool = False, source: str = "runtime") -> list[str]: @@ -81,6 +82,13 @@ def _reconcile_artifact(self, skill_id: str) -> str | None: return f"Rolled back learned {skill['name']} after verified regression." successful_runs = {item["run_id"] for item in verified if item.get("success")} replay = (proposal or {}).get("validation", {}).get("shadow_replay") or {} + if replay and (replay.get("measurement_policy") != MEASURED_REPLAY_POLICY + or replay.get("measurements_complete") is not True): + replay = {**replay, "non_regressing": False, + "blocked_reason": "Replay evidence incomplete: measured paired cost, latency and regression evidence must be revalidated."} + self.store.update_proposal(proposal["id"], status=proposal["status"], validation={ + **proposal["validation"], "shadow_replay": replay, "revalidation_status": "incomplete", + }) verifier_ids = {item.get("source") for item in verified if item.get("success") and item.get("source")} verifiers_reliable = all( self.verifier_reliability(verifier)["reliability"] in {None, 1.0} @@ -88,7 +96,7 @@ def _reconcile_artifact(self, skill_id: str) -> str | None: for verifier in verifier_ids ) if (skill["status"] == "canary" and len(successful_runs) >= 2 and not any_failure and not corrected - and replay.get("non_regressing") is True and verifiers_reliable): + and _promotion_replay_ready(replay) and verifiers_reliable): if self.registry.activate_learned(skill_id): if proposal: self.store.update_proposal(proposal["id"], status="active", confidence=0.9, @@ -110,11 +118,13 @@ def validate_and_activate(self, proposal_id: str, *, validation: dict[str, Any]) if not proposal: return False skill_id = proposal.get("validation", {}).get("skill_id") - if skill_id: - return bool(self.registry and self.registry.activate_learned(skill_id)) - self.store.update_proposal(proposal_id, status="active", confidence=max(0.8, proposal["confidence"]), - validation=validation) - self.memory.add_log(proposal["content"], kind=proposal["kind"], status="active", - confidence=max(0.8, proposal["confidence"]), - metadata={"proposal_id": proposal_id, "validation": validation}) - return True + if not skill_id or self.registry is None: + return False + # Caller-supplied success is a claim, never a substitute for persisted promotion evidence. + self._reconcile_artifact(skill_id) + current = self._proposal_for_skill(skill_id) or {} + evidence = current.get("validation", {}) + return (current.get("status") == "active" and evidence.get("stage") == "active" + and evidence.get("verified_successes", 0) >= 2 + and _promotion_replay_ready(evidence.get("shadow_replay") or {}) + and any(item["id"] == skill_id and item["status"] == "active" for item in self.registry.list())) diff --git a/openkyrozen/memory/service.py b/openkyrozen/memory/service.py index d8e2a291..7dd73ecf 100644 --- a/openkyrozen/memory/service.py +++ b/openkyrozen/memory/service.py @@ -27,7 +27,8 @@ class MemoryBank: def __init__(self, path: str | os.PathLike[str] | None = None, *, user_id: str = "local", workspace_id: str = "default", session_id: str | None = None, - file_scope_id: str | None = None, store: MemoryStore, index: VectorIndex | None = None): + file_scope_id: str | None = None, store: MemoryStore, index: VectorIndex | None = None, + owns_store: bool = False): self._lock = threading.RLock() self.user_id = user_id self.workspace_id = workspace_id @@ -37,6 +38,8 @@ def __init__(self, path: str | os.PathLike[str] | None = None, *, user_id: str = self.session_id = session_id self.db_path = Path(path or store.path) self.store = store + self._owns_store = owns_store + self._owns_index = index is not None self._collection = index.memories if index is not None else None self._files_collection = index.files if index is not None else None self._client = index @@ -45,12 +48,25 @@ def scoped(self, *, user_id=None, workspace_id=None, session_id=None, file_scope """Share application storage/index while owning independent scope and locks.""" view = copy.copy(self) view._lock = threading.RLock() + view._owns_store = view._owns_index = False view.user_id = self.user_id if user_id is None else user_id view.workspace_id = self.workspace_id if workspace_id is None else workspace_id view.session_id = session_id view.file_scope_id = self.file_scope_id if file_scope_id is None else file_scope_id return view + def close(self) -> None: + try: + if self._owns_index: + close = getattr(self._client, "close", None) + if close is not None: + close() + self._owns_index = False + finally: + if self._owns_store: + self.store.close() + self._owns_store = False + @staticmethod def _kind_for_text(text: str) -> str: prefix = text.split(":", 1)[0].strip().upper() diff --git a/openkyrozen/memory/vector.py b/openkyrozen/memory/vector.py index 2d6c45f1..c8771358 100644 --- a/openkyrozen/memory/vector.py +++ b/openkyrozen/memory/vector.py @@ -11,3 +11,11 @@ def __init__(self, path): name="agent_logs", metadata={"description": "Derived index for durable OpenKyrozen memories"}) self.files = self.client.get_or_create_collection( name="agent_files", metadata={"description": "Derived index for workspace source files"}) + + def close(self): + self.client.close() + + def __del__(self): + client = getattr(self, "client", None) + if client is not None: + client.close() diff --git a/openkyrozen/persistence/artifacts.py b/openkyrozen/persistence/artifacts.py index 50472cf2..0c0011f7 100644 --- a/openkyrozen/persistence/artifacts.py +++ b/openkyrozen/persistence/artifacts.py @@ -21,6 +21,23 @@ def create(self, content: str, *, user_id: str, workspace_id: str, session_id: s ) return artifact_id + def get_or_create(self, content: str, *, user_id: str, workspace_id: str, session_id: str) -> str: + if not all(isinstance(value, str) and value for value in (user_id, workspace_id, session_id)): + raise ValueError("Tool artifacts require a complete session scope") + with self.database._lock, self.database.connection() as db: + row = db.execute( + "SELECT id FROM tool_artifacts WHERE user_id=? AND workspace_id=? AND session_id=? AND content=? LIMIT 1", + (user_id, workspace_id, session_id, content), + ).fetchone() + if row is not None: + return row["id"] + artifact_id = f"artifact_{uuid.uuid4().hex}" + db.execute( + "INSERT INTO tool_artifacts(id,user_id,workspace_id,session_id,content,created_at) VALUES(?,?,?,?,?,?)", + (artifact_id, user_id, workspace_id, session_id, content, utc_now()), + ) + return artifact_id + def read_page(self, artifact_id: str, offset: int, limit: int, *, user_id: str, workspace_id: str, session_id: str) -> dict | None: with self.database.connection() as db: diff --git a/openkyrozen/persistence/database.py b/openkyrozen/persistence/database.py index 368638c5..d057431c 100644 --- a/openkyrozen/persistence/database.py +++ b/openkyrozen/persistence/database.py @@ -8,6 +8,7 @@ import time import warnings from contextlib import contextmanager +from contextvars import ContextVar from pathlib import Path from typing import Any, Iterator @@ -197,27 +198,124 @@ "tool_artifacts": {"id", "user_id", "workspace_id", "session_id", "content", "created_at"}, } +class _DeadlineRLock: + """Preserve ordinary RLock behavior, with an opt-in cancellation deadline.""" + def __init__(self, remaining): + self._lock = threading.RLock() + self._remaining = remaining + + def acquire(self, blocking=True, timeout=-1): + remaining = self._remaining() + if remaining is None: + return self._lock.acquire(blocking, timeout) + if remaining <= 0: + return False + if not blocking: + return self._lock.acquire(False) + return self._lock.acquire(timeout=remaining if timeout < 0 else min(timeout, remaining)) + + def release(self): + return self._lock.release() + + def __enter__(self): + if not self.acquire(): + raise sqlite3.OperationalError("cancellation persistence deadline exceeded") + return self + + def __exit__(self, *args): + self.release() + + def __getattr__(self, name): + return getattr(self._lock, name) + + class SQLiteDatabase: SCHEMA_VERSION = _MIGRATIONS[-1][0] def __init__(self, path: str | os.PathLike[str] | None = None): configured = path or os.environ.get("KYROZEN_DB_PATH") self.path = Path(configured or Path.home() / ".kyrozen" / "v2" / "openkyrozen.sqlite3").expanduser() self.path.parent.mkdir(parents=True, exist_ok=True) - self._lock = threading.RLock() + self._control_deadline = ContextVar("sqlite_control_deadline", default=None) + self._lock = _DeadlineRLock(self._remaining_control_seconds) + self._lifecycle_lock = threading.Lock() + self._active_connections = 0 + self._closing = False + self._anchor = None self.last_backup_path: Path | None = None self._initialise() + anchor = sqlite3.connect(str(self.path), timeout=30, isolation_level=None, check_same_thread=False) + try: + anchor.execute("PRAGMA synchronous=NORMAL") + # Keep an idle WAL client so ordinary lease closes do not checkpoint the database. + anchor.execute("SELECT name FROM sqlite_schema LIMIT 1").fetchall() + except BaseException: + anchor.close() + raise + self._anchor = anchor + + def close(self): + with self._lifecycle_lock: + self._closing = True + anchor = self._anchor if self._active_connections == 0 else None + if anchor is not None: + self._anchor = None + if anchor is not None: + try: + anchor.close() + except BaseException: + with self._lifecycle_lock: + self._anchor = anchor + raise + def _remaining_control_seconds(self): + control = self._control_deadline.get() + if control is None or control[1] != threading.get_ident(): + return None + return max(0, control[0] - time.monotonic()) + + @contextmanager + def _cancellation_deadline(self): + remaining = self._remaining_control_seconds() + deadline = time.monotonic() + min(.3, remaining) if remaining is not None else time.monotonic() + .3 + token = self._control_deadline.set((deadline, threading.get_ident())) + try: + yield + finally: + self._control_deadline.reset(token) + @contextmanager def connection(self, *, timeout: float = 30, busy_timeout_ms: int | None = None) -> Iterator[sqlite3.Connection]: - connection = sqlite3.connect(str(self.path), timeout=timeout, isolation_level=None) - connection.row_factory = sqlite3.Row + with self._lifecycle_lock: + if self._closing: + raise RuntimeError("SQLite database is closed") + self._active_connections += 1 + connection = None try: + remaining = self._remaining_control_seconds() + if remaining is not None: + if remaining <= 0: + raise sqlite3.OperationalError("cancellation persistence deadline exceeded") + timeout = min(timeout, remaining) + busy_timeout_ms = min(30000 if busy_timeout_ms is None else busy_timeout_ms, + int(remaining * 1000)) + connection = sqlite3.connect(str(self.path), timeout=timeout, isolation_level=None) + connection.row_factory = sqlite3.Row connection.execute(f"PRAGMA busy_timeout={busy_timeout_ms if busy_timeout_ms is not None else 30000}") connection.execute("PRAGMA foreign_keys=ON") + # WAL mode persists, but synchronous is connection-local. + connection.execute("PRAGMA synchronous=NORMAL") yield connection finally: - connection.close() + try: + if connection is not None: + connection.close() + finally: + with self._lifecycle_lock: + self._active_connections -= 1 + finish_close = self._closing and self._active_connections == 0 + if finish_close: + self.close() def _initialise(self) -> None: diff --git a/openkyrozen/persistence/events.py b/openkyrozen/persistence/events.py index b2618f8f..65072d84 100644 --- a/openkyrozen/persistence/events.py +++ b/openkyrozen/persistence/events.py @@ -21,6 +21,26 @@ def append_event(self, event_type: str, payload: Any, *, user_id: str = "local", ) return event_id + def _append_control_audit(self, event_type: str, payload: Any, *, user_id: str, + workspace_id: str, session_id: str) -> str: + """Bound control-path audit contention; cleanup must not wait 30 seconds.""" + if event_type not in {"process.lifecycle", "turn.effect_uncertain"}: + raise ValueError("unsupported control audit event") + encoded = self.database._json(payload) + if not self.database._lock.acquire(timeout=.1): + raise RuntimeError("control audit journal is busy") + try: + event_id = f"evt_{uuid.uuid4().hex}" + with self.database.connection(timeout=.1, busy_timeout_ms=100) as db: + db.execute( + "INSERT INTO events(id,event_type,payload,user_id,workspace_id,session_id,task_id,created_at) " + "VALUES(?,?,?,?,?,?,?,?)", + (event_id, event_type, encoded, user_id, workspace_id, session_id, None, utc_now()), + ) + return event_id + finally: + self.database._lock.release() + def conversation_head(self, *, user_id: str, workspace_id: str, session_id: str) -> str | None: with self.database.connection() as db: row = db.execute( diff --git a/openkyrozen/persistence/migration.py b/openkyrozen/persistence/migration.py index 28d973eb..9ff4c6df 100644 --- a/openkyrozen/persistence/migration.py +++ b/openkyrozen/persistence/migration.py @@ -5,6 +5,7 @@ import argparse import json import shutil +from contextlib import closing from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -28,41 +29,41 @@ def migrate_v1_chroma(source: str | Path, target_db: str | Path, *, workspace_id if not backup.exists(): shutil.copytree(source_path, backup) client = chromadb.PersistentClient(path=str(source_path), settings=Settings(anonymized_telemetry=False)) - memory = MemoryBank(target_path, workspace_id=workspace_id) - imported_logs = 0 - imported_files = 0 - for name in ("agent_logs", "agent_files"): - try: - collection = client.get_collection(name) - except Exception: - continue - data = collection.get(include=["documents", "metadatas"]) - documents = data.get("documents", []) - metadatas = data.get("metadatas", []) - for document, metadata in zip(documents, metadatas): - if not document: + with closing(MemoryBank(target_path, workspace_id=workspace_id)) as memory: + imported_logs = 0 + imported_files = 0 + for name in ("agent_logs", "agent_files"): + try: + collection = client.get_collection(name) + except Exception: continue - metadata = metadata or {} - if name == "agent_files" or str(document).startswith("FILE:"): - rel_path = str(metadata.get("rel_path", "")) - if not rel_path: - rel_path = str(document).split("\n", 1)[0].removeprefix("FILE: ") - content = str(document).split("\n", 2)[-1] - content = content.removeprefix("```python\n").removesuffix("\n```") - memory.add_file(rel_path, content) - imported_files += 1 - else: - memory.add_log(str(document), kind=memory._kind_for_text(str(document)), - metadata={"migrated_from": str(source_path), "legacy_metadata": metadata}) - imported_logs += 1 - report = { - "source": str(source_path), "backup": str(backup), "target": str(target_path), - "workspace_id": workspace_id, "imported_logs": imported_logs, - "imported_files": imported_files, - "migrated_at": datetime.now(timezone.utc).isoformat(), - } - memory.store.append_event("migration.v1_completed", report, workspace_id=workspace_id) - return report + data = collection.get(include=["documents", "metadatas"]) + documents = data.get("documents", []) + metadatas = data.get("metadatas", []) + for document, metadata in zip(documents, metadatas): + if not document: + continue + metadata = metadata or {} + if name == "agent_files" or str(document).startswith("FILE:"): + rel_path = str(metadata.get("rel_path", "")) + if not rel_path: + rel_path = str(document).split("\n", 1)[0].removeprefix("FILE: ") + content = str(document).split("\n", 2)[-1] + content = content.removeprefix("```python\n").removesuffix("\n```") + memory.add_file(rel_path, content) + imported_files += 1 + else: + memory.add_log(str(document), kind=memory._kind_for_text(str(document)), + metadata={"migrated_from": str(source_path), "legacy_metadata": metadata}) + imported_logs += 1 + report = { + "source": str(source_path), "backup": str(backup), "target": str(target_path), + "workspace_id": workspace_id, "imported_logs": imported_logs, + "imported_files": imported_files, + "migrated_at": datetime.now(timezone.utc).isoformat(), + } + memory.store.append_event("migration.v1_completed", report, workspace_id=workspace_id) + return report def main() -> None: diff --git a/openkyrozen/persistence/store.py b/openkyrozen/persistence/store.py index ea9f1c5c..1500aae8 100644 --- a/openkyrozen/persistence/store.py +++ b/openkyrozen/persistence/store.py @@ -35,9 +35,20 @@ def __init__(self, path=None): _json = staticmethod(SQLiteDatabase._json) _loads = staticmethod(SQLiteDatabase._loads) + def close(self): + self.database.close() + def append_event(self, event_type: str, payload: Any, *, user_id: str='local', workspace_id: str='default', session_id: str | None=None, task_id: str | None=None): return self.events.append_event(event_type, payload, user_id=user_id, workspace_id=workspace_id, session_id=session_id, task_id=task_id) + def _cancellation_deadline(self): + return self.database._cancellation_deadline() + + def _append_control_audit(self, event_type: str, payload: Any, *, user_id: str, + workspace_id: str, session_id: str): + return self.events._append_control_audit(event_type, payload, user_id=user_id, + workspace_id=workspace_id, session_id=session_id) + def conversation_head(self, *, user_id: str, workspace_id: str, session_id: str): return self.events.conversation_head(user_id=user_id, workspace_id=workspace_id, session_id=session_id) diff --git a/openkyrozen/providers/calls.py b/openkyrozen/providers/calls.py index fa120aa5..9dd6e2ac 100644 --- a/openkyrozen/providers/calls.py +++ b/openkyrozen/providers/calls.py @@ -4,6 +4,8 @@ import math import sys import uuid +import hashlib +import json from typing import Any from openkyrozen.providers.usage import usage_scope from openkyrozen.providers.base import get_model_response, get_model_events @@ -119,6 +121,21 @@ def _get_model_response(self, messages: list[dict], model: str | None = None, *, self._last_model_response.set(None) self._turn_failed.set(False) selected_model = model or self.DEEPSEEK_MODEL + state = self._active_context_state.get() + if state is not None and not self._in_context_compaction.get(): + from openkyrozen.providers.models import provider_identity + state.request_provider_id = provider_identity(provider) + state.request_model = selected_model + if tool_names is None: + state.native_input_overhead_tokens = 0 + state.native_schema_fingerprint = "" + if tool_names is not None and not self._in_context_compaction.get(): + if state is not None: + from openkyrozen.agent.compaction import _text_tokens + schema_json = json.dumps(tools, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + state.native_input_overhead_tokens = _text_tokens(schema_json) + 32 * len(tools or ()) + 64 + state.native_schema_fingerprint = hashlib.sha256(schema_json.encode("utf-8")).hexdigest() + self._prepare_context_for_call(messages, selected_model) if self._active_context_state.get() is not None and not self._in_context_compaction.get(): self._record_context_history_metrics(messages) request_id = f"model_{uuid.uuid4().hex}" diff --git a/openkyrozen/providers/fallback.py b/openkyrozen/providers/fallback.py index fe396277..ed5c8327 100644 --- a/openkyrozen/providers/fallback.py +++ b/openkyrozen/providers/fallback.py @@ -63,6 +63,9 @@ def _model_for(self, provider: LLMProvider, requested_model: str | None) -> str: primary_config.model_complex, PROVIDER_DEFAULT_MODELS.get(primary_config.provider, ("", ""))[1], } + if primary_config.provider == "deepseek" and provider is not self._primary: + # Preserve the previous complex default when mapping a fallback. + primary_complex.add("deepseek-v4-pro") if requested in primary_complex and requested not in primary_simple: return target_config.model_complex if requested in primary_simple: diff --git a/openkyrozen/providers/registry.py b/openkyrozen/providers/registry.py index ed882502..d9e81fa6 100644 --- a/openkyrozen/providers/registry.py +++ b/openkyrozen/providers/registry.py @@ -21,7 +21,7 @@ class ProviderSpec: PROVIDER_REGISTRY: dict[str, ProviderSpec] = { # Defaults are taken from the providers' current official model catalogs. - "deepseek": ProviderSpec("deepseek", "DeepSeek", "openai_compat", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek-flash", "deepseek-v4-pro", 1_048_576, True, ("openai", "anthropic")), + "deepseek": ProviderSpec("deepseek", "DeepSeek", "openai_compat", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek-flash", "deepseek-flash", 1_048_576, True, ("openai", "anthropic")), "openai": ProviderSpec("openai", "OpenAI", "responses", "https://api.openai.com/v1", "OPENAI_API_KEY", "gpt-6-luna", "gpt-6-astra", 1_048_576, True, ("deepseek", "anthropic")), "anthropic": ProviderSpec("anthropic", "Anthropic", "messages", "https://api.anthropic.com", "ANTHROPIC_API_KEY", "claude-haiku-4-5", "claude-fable-5-1", 1_000_000, True, ("openai", "deepseek")), "google": ProviderSpec("google", "Google Gemini", "google_genai", "", "GEMINI_API_KEY", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", 1_048_576, True, ("openai", "deepseek")), diff --git a/openkyrozen/providers/usage.py b/openkyrozen/providers/usage.py index 1e6ea633..18b9facf 100644 --- a/openkyrozen/providers/usage.py +++ b/openkyrozen/providers/usage.py @@ -2,7 +2,7 @@ import os import uuid -from contextlib import contextmanager +from contextlib import closing, contextmanager from contextvars import ContextVar from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -146,7 +146,6 @@ def _track_cost(provider: str, usage: dict | None, *, model: str = "unknown", latency_ms: int | None = None, completion_state: str = "completed", occurred_at: datetime | None = None) -> str: """Write one immutable provider attempt; summaries always read this ledger.""" - scope = _current_usage_scope() prompt_tokens = _usage_integer(usage, "prompt_tokens") completion_tokens = _usage_integer(usage, "completion_tokens") cache_hit_tokens = _usage_integer(usage, "cache_hit_tokens") @@ -192,16 +191,22 @@ def _track_cost(provider: str, usage: dict | None, *, model: str = "unknown", else "estimated" ) attempt_id = f"usage_{uuid.uuid4().hex}" - scope.store.record_usage_attempt( - attempt_id=attempt_id, provider=provider, model=model, surface=scope.surface, - user_id=scope.user_id, workspace_id=scope.workspace_id, session_id=scope.session_id, - run_id=scope.run_id, cache_status=cache_status, prompt_tokens=prompt_tokens, - cache_hit_tokens=cache_hit_tokens, cache_miss_tokens=cache_miss_tokens, - completion_tokens=completion_tokens, reasoning_tokens=reasoning_tokens, - latency_ms=latency_ms, completion_state=completion_state, - usage_status=usage_status, - cost_picos=cost_picos, pricing_snapshot=pricing_snapshot, - ) + owns_store = _usage_scope.get() is None + scope = _current_usage_scope() + try: + scope.store.record_usage_attempt( + attempt_id=attempt_id, provider=provider, model=model, surface=scope.surface, + user_id=scope.user_id, workspace_id=scope.workspace_id, session_id=scope.session_id, + run_id=scope.run_id, cache_status=cache_status, prompt_tokens=prompt_tokens, + cache_hit_tokens=cache_hit_tokens, cache_miss_tokens=cache_miss_tokens, + completion_tokens=completion_tokens, reasoning_tokens=reasoning_tokens, + latency_ms=latency_ms, completion_state=completion_state, + usage_status=usage_status, + cost_picos=cost_picos, pricing_snapshot=pricing_snapshot, + ) + finally: + if owns_store: + scope.store.close() if scope.delegation_budget is not None and scope.delegation_budget_run_id: scope.delegation_budget.record_cost(scope.delegation_budget_run_id, cost_picos) return attempt_id @@ -250,7 +255,10 @@ def get_cost_report(*, store: EventStore | None = None, user_id: str = "local", raise ValueError("scope must be installation, workspace, or session") if scope == "session" and not session_id: raise ValueError("session scope requires a session_id") - store = store or EventStore() + if store is None: + with closing(EventStore()) as owned: + return get_cost_report(store=owned, user_id=user_id, workspace_id=workspace_id, + session_id=session_id, scope=scope) totals = { "installation": _scope_totals(store, scope="installation", user_id=user_id, workspace_id=workspace_id), @@ -272,6 +280,10 @@ def reset_cost_tracker(*, store: EventStore | None = None, user_id: str = "local workspace_id: str = "default", session_id: str | None = None, scope: str = "workspace") -> dict[str, str]: """Create an audited reporting-window reset without deleting usage attempts.""" - return (store or EventStore()).create_usage_reset( + if store is None: + with closing(EventStore()) as owned: + return reset_cost_tracker(store=owned, user_id=user_id, workspace_id=workspace_id, + session_id=session_id, scope=scope) + return store.create_usage_reset( scope=scope, user_id=user_id, workspace_id=workspace_id, session_id=session_id, ) diff --git a/openkyrozen/routing/router.py b/openkyrozen/routing/router.py index 04eb2392..65d76820 100644 --- a/openkyrozen/routing/router.py +++ b/openkyrozen/routing/router.py @@ -8,6 +8,12 @@ from openkyrozen.tasks.models import requires_working_plan +_MULTI_STEP_RE = re.compile( + r"\b(?:then|after that|step[- ]by[- ]step|step\s*\d+)\b|" + r"(?:^|[.!?;:,])\s*(?:first|next|finally)\b" +) + + @dataclass(frozen=True) class RoutingDecision: provider: str @@ -79,7 +85,7 @@ def _select_model(self, user_input: str) -> str: complexity_score += 1 # Multi-step / numbered instructions (English) - if re.search(r"\b(step|first|then|next|finally|after that)\b", text): + if _MULTI_STEP_RE.search(text): complexity_score += 2 # CJK multi-step indicators _cjk_step_markers = ["第一步", "第二步", "首先", "然后", "接着", "最后", "最終", @@ -91,17 +97,16 @@ def _select_model(self, user_input: str) -> str: # Code-related tasks (English) code_keywords = [ - "code", "debug", "fix", "refactor", "implement", "build a", - "write a program", "write a script", "function", "class", - "architecture", "design pattern", "optimize", "performance", + "debug", "fix", "refactor", "implement", "build a", + "write a program", "write a script", "optimize", "bug", "error", "traceback", "exception", "crash", "broken", "doesn't work", "not working", "fails", "failing" ] if any(kw in text for kw in code_keywords): - complexity_score += 3 # code tasks are inherently complex + complexity_score += 3 # CJK code-related tasks - _cjk_code_keywords = ["代码", "调试", "修复", "重构", "实现", "写一个程序", - "写脚本", "函数", "架构", "优化", "性能", "编程", + _cjk_code_keywords = ["调试", "修复", "重构", "实现", "写一个程序", + "写脚本", "优化", "编程", "bug", "报错", "错误", "出错了", "崩溃", "不行"] if any(kw in user_input for kw in _cjk_code_keywords): complexity_score += 3 @@ -150,7 +155,7 @@ def _select_model(self, user_input: str) -> str: if config.provider == "ollama": simple, complex_model = resolve_ollama_models(config) config.model_simple, config.model_complex = simple, complex_model - return model_for_complexity(config, complexity_score >= 3) + return model_for_complexity(config, complexity_score >= 3 or _classify_complexity(self, user_input) == "complex") def _classify_complexity(self, user_input: str) -> str: @@ -158,6 +163,24 @@ def _classify_complexity(self, user_input: str) -> str: Returns 'simple', 'medium', or 'complex'.""" text = user_input.lower().strip() + # Task signals take precedence over a greeting or question prefix. + complex_indicators = [ + r"\d+[.)]\s+\w", + r"\b(implement|refactor|migrate|audit|comprehensive)\b", + r"\b(write\s+(a|the)\s+(program|script|app|application|function|class))\b", + r"\b(analy[sz]e\s+(the|this|my|our)\s+(codebase|repo|project|architecture))\b", + r"\b(debug|fix\s+(the|this|a|my)\s+(bug|error|issue|problem|crash))\b", + r"\b(git\s+(merge|rebase|cherry-pick|bisect))\b", + ] + if _MULTI_STEP_RE.search(text) or any(re.search(p, text) for p in complex_indicators): + return "complex" + _cjk_complex = ["实现", "重构", "迁移", "审计", "审查代码", "写一个程序", "写脚本", + "分析代码", "分析架构", "第一步", "第二步", "全面的", + "调试", "修复bug", "修bug", "出错了", "报错", "错误", + "git操作", "git合并", "提交代码", "推送", "拉取"] + if any(ind in user_input for ind in _cjk_complex): + return "complex" + # Simple: short greetings, trivial questions, single actions (English) simple_patterns = [ r"^(hi|hey|hello|yo|sup|good morning|good evening)\b", @@ -177,6 +200,9 @@ def _classify_complexity(self, user_input: str) -> str: if any(re.search(p, user_input) for p in _cjk_simple_patterns): return "simple" + if re.search(r"\b(traceback|stack\s*trace|exception|typeerror|valueerror|attributeerror)\b", text): + return "complex" + # Short inputs without tool verbs are simple (CJK-aware) _tool_verbs = {"read","list","find","search","write","run","create","clone","fetch","open","edit","delete","move","copy"} _cjk_tool_verbs = {"读","读取","查","查找","搜索","写","运行","创建","克隆","获取","编辑","删除","移动","复制","列出","安装","读文件","写文件","搜索网页","执行"} @@ -189,26 +215,6 @@ def _classify_complexity(self, user_input: str) -> str: if len(user_input.split()) <= 3 and not (words & _tool_verbs): return "simple" - # Complex: multi-step, code generation, deep analysis, bug fixing, git operations - complex_indicators = [ - r"\b(step|first|then|next|finally|after that)\b", - r"\d+[.)]\s+\w", # numbered steps - r"\b(implement|refactor|migrate|audit|comprehensive)\b", - r"\b(write\s+(a|the)\s+(program|script|app|application|function|class))\b", - r"\b(analy[sz]e\s+(the|this|my|our)\s+(codebase|repo|project|architecture))\b", - r"\b(debug|fix\s+(the|this|a|my)\s+(bug|error|issue|problem|crash))\b", - r"\b(traceback|stack\s*trace|exception|TypeError|ValueError|AttributeError)\b", - r"\b(git\s+(merge|rebase|cherry-pick|bisect))\b", - ] - if any(re.search(p, text) for p in complex_indicators): - return "complex" - # CJK complex indicators - _cjk_complex = ["实现", "重构", "迁移", "审计", "审查代码", "写一个程序", "写脚本", - "分析代码", "分析架构", "第一步", "第二步", "全面的", - "调试", "修复bug", "修bug", "出错了", "报错", "错误", - "git操作", "git合并", "提交代码", "推送", "拉取"] - if any(ind in user_input for ind in _cjk_complex): - return "complex" # Long inputs with multiple sentences suggest complexity if len(user_input) > 250: return "complex" diff --git a/openkyrozen/security/permissions.py b/openkyrozen/security/permissions.py index ed788cb9..9b246a89 100644 --- a/openkyrozen/security/permissions.py +++ b/openkyrozen/security/permissions.py @@ -194,8 +194,9 @@ def _authorize_tool_action(self, action: str, args: object, *, approve=None) -> return True if mode == "full_jev" and not risk: return True - if mode == "full_jev" and risk and self._jev_permission_check(action, risk): - return True + if mode == "full_jev" and risk: + # Model advice cannot grant authority for a high-impact operation. + self._jev_permission_check(action, risk) if mode == "ask" and not requires_ask_approval(action, args, self.AVAILABLE_TOOLS): return True fallback_approval = approve is None and not callable(self._approval_callback.get()) diff --git a/openkyrozen/tasks/engine.py b/openkyrozen/tasks/engine.py index 7595d0ef..bca94712 100644 --- a/openkyrozen/tasks/engine.py +++ b/openkyrozen/tasks/engine.py @@ -12,6 +12,10 @@ from .models import CANONICAL_STATUSES, TERMINAL_STATUSES, VALID_STATUSES, WorkingPlan, _ORDERED_ACTION_FAMILIES, _ORDERED_ACTION_HINTS, _RECEIPT_ACTION_ALIASES, _ordered_action_compatible, _ordered_plan_id, _ordered_plan_info, _receipt_action, _receipt_args, _scoped_task_id, _task_key, canonical_status, is_complete, is_terminal, valid_task_transition from .ports import TaskStore +def _unknown_recovery_effect(task): + return ((task.get("checkpoint") or {}).get("recovery_effect") or {}).get("status") == "unknown" + + class TaskManager: """Compatibility facade backed by SQLite and guarded by evidence.""" @@ -22,7 +26,7 @@ def __init__(self, store: TaskStore, *, workspace_id: str = "default", self.session_id = session_id self.user_id = user_id self.tasks: list[dict[str, Any]] = [] - self._pending_completions: set[int] = set() + self._pending_completions: set[str] = set() self._evidence: list[dict[str, Any]] = [] self.working_plan: WorkingPlan | None = None self._runtime_event_callback: ContextVar[Any] = ContextVar("task_runtime_event_callback", default=None) @@ -73,6 +77,10 @@ def working_plan_snapshot(self) -> dict[str, Any] | None: "superseded_by": list(checkpoint.get("superseded_by", [])) if isinstance(checkpoint, dict) else [], "reason": str(block.get("reason", "")) if isinstance(block, dict) else "", }) + if isinstance(checkpoint, dict) and checkpoint.get("strategic_plan"): + tasks[-1]["role"] = "implementation" if checkpoint.get("strategy_anchor") else "approved_objective" + if checkpoint.get("strategy_anchor"): + tasks[-1]["strategy_anchor"] = checkpoint["strategy_anchor"] reason = "" events = self.store.list_events( "working_plan.updated", limit=20, user_id=self.user_id, @@ -144,6 +152,8 @@ def set_status(self, idx: int, status: str, *, evidence: dict[str, Any] | None = if not 0 <= idx < len(self.tasks): return False status = canonical_status(status) + if status in {"pending", "running"} and _unknown_recovery_effect(self.tasks[idx]): + return False current = canonical_status(self.tasks[idx].get("status", "pending")) if not valid_task_transition(current, status): return False @@ -152,7 +162,8 @@ def set_status(self, idx: int, status: str, *, evidence: dict[str, Any] | None = if status == "blocked" and self.tasks[idx].get("plan_id") and ( not isinstance(reason, str) or not reason.strip()): raise ValueError("blocking a Working Plan task requires a reason") - if status == "succeeded" and not self._has_evidence(idx, evidence): + if status == "succeeded" and (not self._has_evidence(idx, evidence) + or not self._dependencies_succeeded(self.tasks[idx], self.tasks)): return False task = deepcopy(self.tasks[idx]) expected_status = self.tasks[idx].get("status", "pending") @@ -242,6 +253,7 @@ def edit_plan(self, *, expected_revision: int, operations: list[dict[str, Any]], complexity: str | None = None, plan_id: str | None = None, strategic_bindings: dict[str, dict[str, Any]] | None = None, transaction_event: dict[str, Any] | None = None, + strategy_validator=None, reason: str | None = None) -> dict[str, Any]: """Apply a validated ordered operation batch as one revision-checked transaction.""" if type(expected_revision) is not int or expected_revision < 0: @@ -313,12 +325,51 @@ def edit_plan(self, *, expected_revision: int, operations: list[dict[str, Any]], and transaction_event.get("expected_proposal") ) references: dict[str, str] = {} + completion_requests: list[str] = [] used_strategic_bindings: set[str] = set() now = utc_now() scope_key = _task_key(f"{self.user_id}:{self.workspace_id}:{self.session_id or ''}") + def strategy_checkpoint(anchor_id: str, fields: dict[str, Any]) -> dict[str, Any]: + anchor = by_id[anchor_id] + binding = (anchor.get("checkpoint") or {}).get("strategic_plan") + if not binding or (anchor.get("checkpoint") or {}).get("strategy_anchor"): + raise ValueError("strategy anchor must identify an approved objective task") + checkpoint = fields["checkpoint"] + if checkpoint.get("action"): + execution = {"action": checkpoint["action"], "arguments": json.loads(checkpoint["args"])} + if callable(strategy_validator): + strategy_validator(binding, execution) + elif execution not in [binding.get("execution"), *binding.get("acceptance_verifications", [])]: + raise ValueError("strategy execution requires validation against accepted bounds") + return {**checkpoint, "strategic_plan": deepcopy(binding), "strategy_anchor": anchor_id} + + def add_strategy(anchor_id: str, ref: str, task_data: Any) -> str: + if not isinstance(ref, str) or not ref.strip() or ref in references or ref in by_id: + raise ValueError("task references must be unique nonempty strings") + anchor = by_id[anchor_id] + if anchor["status"] != "pending" or anchor.get("attempts") or anchor.get("evidence"): + raise ValueError("strategy refinement requires an unexecuted objective anchor") + fields = self._plan_task_fields(task_data) + checkpoint = strategy_checkpoint(anchor_id, fields) + task_id = f"task_{scope_key}_{uuid.uuid4().hex}" + task = {"id": task_id, **fields, "checkpoint": checkpoint, + "status": "pending", "attempts": 0, "evidence": [], + "created_at": now, "updated_at": now, "plan_id": plan_id, "parent_id": anchor_id} + references[ref] = task_id + tasks.append(task) + by_id[task_id] = task + anchor["dependencies"] = list(dict.fromkeys([*anchor.get("dependencies", []), task_id])) + return task_id + def supersede(source_ids: list[str], ref: str, task_data: Any) -> str: - if any((by_id[item].get("checkpoint") or {}).get("strategic_plan") for item in source_ids): + if any(_unknown_recovery_effect(by_id[item]) for item in source_ids): + raise ValueError("interrupted effects require trusted reconciliation before replacement") + strategic_sources = [by_id[item] for item in source_ids + if (by_id[item].get("checkpoint") or {}).get("strategic_plan")] + anchors = {(item.get("checkpoint") or {}).get("strategy_anchor") for item in strategic_sources} + if strategic_sources and (None in anchors or len(anchors) != 1 + or len(strategic_sources) != len(source_ids)): raise ValueError("Strategic Plan tasks cannot be superseded without approval") if not isinstance(ref, str) or not ref.strip() or ref in references or ref in by_id: raise ValueError("task references must be unique nonempty strings") @@ -341,6 +392,8 @@ def supersede(source_ids: list[str], ref: str, task_data: Any) -> str: task = {"id": task_id, **fields, "status": "pending", "attempts": 0, "evidence": [], "created_at": now, "updated_at": now, "plan_id": plan_id, "parent_id": source_ids[0], "checkpoint": {**fields["checkpoint"], "supersedes": source_ids}} + if strategic_sources: + task["checkpoint"].update(strategy_checkpoint(next(iter(anchors)), fields)) for source in sources: checkpoint = dict(source.get("checkpoint") or {}) checkpoint["superseded_by"] = [task_id] @@ -375,6 +428,7 @@ def resolve(value: Any) -> str: "split": {"op", "task", "children"}, "replace": {"op", "task", "replacement"}, "merge": {"op", "tasks", "replacement"}, "block": {"op", "task", "reason"}, "unblock": {"op", "task", "reason"}, + "start": {"op", "task"}, "suggest_complete": {"op", "task"}, } if saved is None and not any(isinstance(item, dict) and item.get("op") == "add" for item in operations): raise ValueError("a new Working Plan requires at least one add operation") @@ -382,9 +436,13 @@ def resolve(value: Any) -> str: if not isinstance(operation, dict) or operation.get("op") not in allowed_by_op: raise ValueError("each plan operation requires a supported op") kind = operation["op"] - if set(operation) != allowed_by_op[kind]: + if set(operation) != allowed_by_op[kind] and not ( + kind == "add" and set(operation) == allowed_by_op[kind] | {"anchor"}): raise ValueError(f"{kind} operation has missing or unsupported fields") if kind == "add": + if "anchor" in operation: + add_strategy(resolve(operation["anchor"]), operation["ref"], operation["task"]) + continue ref = operation["ref"] if not isinstance(ref, str) or not ref.strip() or ref in references or ref in by_id: raise ValueError("task references must be unique nonempty strings") @@ -464,6 +522,7 @@ def resolve(value: Any) -> str: current = by_id[task_id] if (has_strategic_tasks and isinstance(current.get("checkpoint"), dict) and isinstance(current["checkpoint"].get("strategic_plan"), dict) + and not current["checkpoint"].get("strategy_anchor") and set(fields) - {"priority", "dependencies"}): raise ValueError("Strategic Plan task intent cannot change without approval") validated = self._plan_task_fields({ @@ -483,6 +542,9 @@ def resolve(value: Any) -> str: } base = {key: value for key, value in checkpoint.items() if key not in excluded} normalized["checkpoint"] = {**base, **validated["checkpoint"]} + anchor_id = checkpoint.get("strategy_anchor") + if anchor_id: + normalized["checkpoint"].update(strategy_checkpoint(anchor_id, validated)) changed = any(current.get(key) != value for key, value in normalized.items()) if changed and (current["status"] != "pending" or current.get("attempts", 0) or current.get("evidence")): @@ -503,6 +565,14 @@ def resolve(value: Any) -> str: children = operation["children"] if not isinstance(children, list) or len(children) < 2: raise ValueError("split requires at least two children") + original = by_id[source_ids[0]] + if ((original.get("checkpoint") or {}).get("strategic_plan") + and not (original.get("checkpoint") or {}).get("strategy_anchor")): + for child in children: + if not isinstance(child, dict) or set(child) != {"ref", "task"}: + raise ValueError("each split child requires ref and task") + add_strategy(original["id"], child["ref"], child["task"]) + continue for child in children: if not isinstance(child, dict) or set(child) != {"ref", "task"}: raise ValueError("each split child requires ref and task") @@ -526,17 +596,35 @@ def resolve(value: Any) -> str: elif kind in {"block", "unblock"}: task_id = resolve(operation["task"]) current = by_id[task_id] + if kind == "unblock" and _unknown_recovery_effect(current): + raise ValueError("interrupted effects require trusted reconciliation before resuming") why = operation["reason"] if not isinstance(why, str) or not why.strip(): raise ValueError(f"{kind} requires a nonempty reason") - if has_strategic_tasks and (current.get("checkpoint") or {}).get("strategic_plan"): - raise ValueError("Strategic Plan task status requires approval") expected, target = ("pending", "blocked") if kind == "block" else ("blocked", "pending") if current["status"] != expected: raise ValueError(f"{kind} requires a {expected} task") checkpoint = dict(current.get("checkpoint") or {}) checkpoint["plan_block"] = {"reason": why.strip(), "action": kind} current.update(status=target, checkpoint=checkpoint, updated_at=now) + elif kind in {"start", "suggest_complete"}: + task_id = resolve(operation["task"]) + current = by_id[task_id] + if _unknown_recovery_effect(current): + raise ValueError("interrupted effects require trusted reconciliation before execution") + if (current["status"] not in {"pending", "running"} + or not self._dependencies_succeeded(current, tasks)): + raise ValueError("task must be active with all dependencies satisfied") + if kind == "start": + if any(task["id"] != task_id and task["status"] == "running" for task in tasks): + raise ValueError("another task is already running") + current.update(status="running", updated_at=now, + checkpoint={**current.get("checkpoint", {}), "native_execution_selected": True}) + else: + if self._task_has_evidence(current): + current.update(status="succeeded", updated_at=now) + else: + completion_requests.append(task_id) elif kind == "reorder": requested = operation["tasks"] if not isinstance(requested, list): @@ -630,6 +718,9 @@ def resolve(value: Any) -> str: ) self.working_plan = working_plan self.tasks = tasks + [task for task in self.tasks if task["id"] not in original_ids] + for task_id in completion_requests: + self.request_completion(next(index for index, task in enumerate(self.tasks) if task["id"] == task_id)) + self.reconcile_completions(set(completion_requests)) payload = {"plan_id": plan_id, "revision": revision, "task_ids": list(working_plan.task_ids), "status": plan_status} snapshot = self.working_plan_snapshot() or { @@ -647,19 +738,22 @@ def resolve(value: Any) -> str: callback(event_type, snapshot, tasks[-1]["id"] if tasks else None) except Exception: pass - return {**payload, "complexity": complexity, "tasks": deepcopy(tasks), + return {**payload, "status": self.working_plan.status, "complexity": complexity, + "tasks": deepcopy([task for task in self.tasks if task["id"] in working_plan.task_ids]), "references": dict(references)} def update_plan(self, *, complexity: str | None = None, tasks: list[dict[str, Any]] | None = None, plan_id: str | None = None, expected_revision: int | None = None, operations: list[dict[str, Any]] | None = None, + strategy_validator=None, reason: str | None = None) -> WorkingPlan | dict[str, Any]: """Apply canonical operations or adapt the historical complexity/tasks shape.""" if operations is not None: if expected_revision is None: raise ValueError("expected_revision is required for plan edits") return self.edit_plan(complexity=complexity, plan_id=plan_id, - expected_revision=expected_revision, operations=operations, reason=reason) + expected_revision=expected_revision, operations=operations, reason=reason, + strategy_validator=strategy_validator) if not isinstance(tasks, list) or not tasks: raise ValueError("a Working Plan requires at least one task") saved = self.store.get_working_plan( @@ -722,8 +816,14 @@ def adopt_legacy_plan(self, *, complexity: str = "complex") -> WorkingPlan: return self.working_plan def _has_evidence(self, idx: int, evidence: dict[str, Any] | None = None) -> bool: - evidence_items = ([evidence] if evidence else []) + self.tasks[idx].get("evidence", []) - strategic = ((self.tasks[idx].get("checkpoint") or {}).get("strategic_plan") or {}) + return self._task_has_evidence(self.tasks[idx], evidence) + + @staticmethod + def _task_has_evidence(task: dict[str, Any], evidence: dict[str, Any] | None = None) -> bool: + evidence_items = ([evidence] if evidence else []) + task.get("evidence", []) + strategic = ((task.get("checkpoint") or {}).get("strategic_plan") or {}) + if (task.get("checkpoint") or {}).get("strategy_anchor"): + strategic = {} required = set(strategic.get("acceptance_ids", ())) if required: covered = { @@ -973,12 +1073,21 @@ def record_evidence(self, *, task_id: str | None = None, action: str, result: st def request_completion(self, idx: int) -> None: if 0 <= idx < len(self.tasks): - self._pending_completions.add(idx) + self._pending_completions.add(self.tasks[idx]["id"]) + + @property + def execution_task_id(self) -> str | None: + """Only an explicitly started native task owns subsequent native receipts.""" + selected = [task for task in self.tasks if task.get("status") == "running" + and (task.get("checkpoint") or {}).get("native_execution_selected") + and self.active_working_task(task["id"]) is not None] + return selected[0]["id"] if len(selected) == 1 else None def active_task_id(self) -> str | None: """Return the next task that may receive an execution receipt.""" for task in self.tasks: if (task.get("status") in {"pending", "running"} + and not _unknown_recovery_effect(task) and self._dependencies_succeeded(task, self.tasks)): return task["id"] return None @@ -989,7 +1098,11 @@ def active_working_task(self, task_id: str) -> dict[str, Any] | None: if plan is None or plan.status != "active" or task_id not in plan.task_ids: return None task = next((item for item in self.tasks if item.get("id") == task_id), None) + stored = self._reload_task(task_id) if (task is None or task.get("plan_id") != plan.id + or stored is None or _unknown_recovery_effect(stored) + or canonical_status(stored.get("status", "pending")) not in {"pending", "running"} + or _unknown_recovery_effect(task) or canonical_status(task.get("status", "pending")) not in {"pending", "running"} or not self._dependencies_succeeded(task, self.tasks)): return None @@ -1045,6 +1158,8 @@ def resume(self, task_id: str) -> dict[str, Any] | None: if index is None: return None task = self.tasks[index] + if _unknown_recovery_effect(task): + return None if task.get("status") not in {"failed", "blocked"}: return task if task.get("status") == "pending" else None previous = task["status"] @@ -1062,13 +1177,17 @@ def resume(self, task_id: str) -> dict[str, Any] | None: return task def reconcile_completions(self, task_ids: set[str] | None = None) -> None: - for idx in list(self._pending_completions): - if task_ids is not None and self.tasks[idx]["id"] not in task_ids: + for task_id in list(self._pending_completions): + if task_ids is not None and task_id not in task_ids: + continue + idx = next((index for index, task in enumerate(self.tasks) if task["id"] == task_id), None) + if idx is None or is_terminal(self.tasks[idx]): + self._pending_completions.discard(task_id) continue if self._has_evidence(idx): self.set_status(idx, "succeeded") if is_complete(self.tasks[idx]): - self._pending_completions.discard(idx) + self._pending_completions.discard(task_id) def mark_done(self, idx: int, *, evidence: dict[str, Any] | None = None) -> bool: return self.set_status(idx, "succeeded", evidence=evidence) @@ -1193,18 +1312,34 @@ def mark_done_from_text(self, text: str) -> None: def recover(self) -> list[dict[str, Any]]: """Load unfinished tasks from SQLite and make running tasks recoverable.""" + def recover_running(task): + if task["status"] != "running": + return + from openkyrozen.tools.catalog import builtin_aliases, builtin_metadata + action = (task.get("checkpoint") or {}).get("action") + metadata = builtin_metadata(builtin_aliases().get(action, action)) if action else None + read_only = (metadata and metadata["capability"] == "read" + and metadata["side_effects"] in {"none", "read", "read_only"}) + uncertain = not read_only + task["status"] = "blocked" if uncertain else "pending" + if uncertain: + task["checkpoint"] = {**task.get("checkpoint", {}), "recovery_effect": { + "status": "unknown", "action": action, + "reason": "Runtime restarted after dispatch; inspect effects before replay.", + }} + task["updated_at"] = utc_now() + if self._persist(task, expected_status="running"): + self._event(task, "task.recovered", {"previous_status": "running", + "effect_status": "unknown" if uncertain else "read_only_or_unassigned"}) + else: + latest = self._reload_task(task["id"]) + if latest is not None: + task.update(latest) + unfinished = self.store.list_tasks(workspace_id=self.workspace_id, session_id=self.session_id, statuses={"pending", "running", "failed", "blocked"}, user_id=self.user_id) for task in unfinished: - if task["status"] == "running": - task["status"] = "pending" - task["updated_at"] = utc_now() - if self._persist(task, expected_status="running"): - self._event(task, "task.recovered", {"previous_status": "running"}) - else: - latest = self._reload_task(task["id"]) - if latest is not None: - task.update(latest) + recover_running(task) saved_plan = self.store.get_working_plan( user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id or "", ) @@ -1214,15 +1349,7 @@ def recover(self) -> list[dict[str, Any]]: session_id=self.session_id or "", ) for task in plan_tasks: - if task["status"] == "running": - task["status"] = "pending" - task["updated_at"] = utc_now() - if self._persist(task, expected_status="running"): - self._event(task, "task.recovered", {"previous_status": "running"}) - else: - latest = self._reload_task(task["id"]) - if latest is not None: - task.update(latest) + recover_running(task) terminal = bool(plan_tasks) and all( task["status"] in {"succeeded", "done", "cancelled"} for task in plan_tasks ) diff --git a/openkyrozen/tasks/planning.py b/openkyrozen/tasks/planning.py new file mode 100644 index 00000000..2fcc3c0d --- /dev/null +++ b/openkyrozen/tasks/planning.py @@ -0,0 +1,644 @@ +"""Feature-owned Strategic Plan validation, approval transitions and completion. + +TaskManager owns Working Plan edits; PlanningService coordinates approved +objectives with those edits through the same atomic storage contracts. +""" +from __future__ import annotations + +import hashlib +import json +import posixpath +import re +import uuid +from copy import deepcopy +from typing import Any + +from .ports import InteractionStore + +class InteractionError(ValueError): + """A bounded interaction payload failed validation.""" + + +def _text(value: Any, field: str, limit: int) -> str: + if not isinstance(value, str): + raise InteractionError(f"{field} must be text") + value = value.strip() + if not value or len(value) > limit: + raise InteractionError(f"{field} must be 1-{limit} characters") + return value + + +def validate_plan_proposal(value: Any, *, previous: dict[str, Any] | None = None, + original_input: str = "") -> dict[str, Any]: + if not isinstance(value, dict): + raise InteractionError("PlanProposal must contain a JSON object") + steps = value.get("steps") + if not isinstance(steps, list) or not 1 <= len(steps) <= 10: + raise InteractionError("PlanProposal requires 1-10 steps") + previous_version = int(previous.get("version", 0)) if previous else 0 + version = previous_version + 1 + normalised = [] + seen_ids: set[str] = set() + for index, step in enumerate(steps, 1): + if not isinstance(step, dict): + raise InteractionError("each plan step must be an object") + step_id = _text(step.get("id") or f"step-{index}", "step id", 64) + if step_id in seen_ids: + raise InteractionError("plan step ids must be unique") + seen_ids.add(step_id) + execution = step.get("execution") + if execution is not None: + action = execution.get("action") if isinstance(execution, dict) else None + arguments = execution.get("arguments") if isinstance(execution, dict) else None + if (not isinstance(execution, dict) or set(execution) != {"action", "arguments"} + or not isinstance(action, str) or action not in {"write_file", "edit_file", "apply_patch"} + or not isinstance(arguments, dict) + or not isinstance(arguments.get("path"), str) or not arguments["path"].strip()): + raise InteractionError("file execution contract must name write_file, edit_file, or apply_patch and an exact path") + required = ({"path", "content"} if action == "write_file" else + {"path", "expected_sha256", "hunks"} if action == "apply_patch" else + {"path", "old_text", "new_text", "expected_sha256"}) + if set(arguments) != required or (action != "apply_patch" and any(not isinstance(item, str) for item in arguments.values())): + raise InteractionError("file execution arguments must exactly match the selected file operation") + if action == "edit_file" and ( + not arguments["old_text"] or not re.fullmatch(r"[0-9a-fA-F]{64}", arguments["expected_sha256"])): + raise InteractionError("edit_file requires nonempty old_text and a SHA-256 read hash") + if action == "apply_patch" and ( + not isinstance(arguments["path"], str) or not arguments["path"].strip() + or not isinstance(arguments["expected_sha256"], str) + or not re.fullmatch(r"[0-9a-fA-F]{64}", arguments["expected_sha256"]) + or not isinstance(arguments["hunks"], list) or not arguments["hunks"] + or any(not isinstance(hunk, dict) or set(hunk) != {"old_text", "new_text"} + or not isinstance(hunk.get("old_text"), str) or not hunk["old_text"] + or not isinstance(hunk.get("new_text"), str) for hunk in arguments["hunks"])): + raise InteractionError("apply_patch requires a SHA-256 read hash and exact-text hunks") + try: + json.dumps(arguments, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise InteractionError("file execution arguments must be JSON-safe") from exc + execution = {"action": action, "arguments": deepcopy(arguments)} + acceptance = step.get("acceptance") + if isinstance(acceptance, str): + acceptance = [acceptance] + if not isinstance(acceptance, list) or not 1 <= len(acceptance) <= 5: + raise InteractionError("each plan step requires 1-5 acceptance criteria") + supplied_verifications = step.get("acceptance_verifications") + if supplied_verifications is not None and ( + not isinstance(supplied_verifications, list) or len(supplied_verifications) != len(acceptance)): + raise InteractionError("acceptance verifications must align with acceptance criteria") + acceptance_verifications = [] + normalized_acceptance = [] + for criterion_index, item in enumerate(acceptance): + verification = supplied_verifications[criterion_index] if supplied_verifications is not None else None + if isinstance(item, dict) and set(item) == {"criterion", "verification"}: + verification = item["verification"] + if verification is not None: + if (not isinstance(verification, dict) + or set(verification) not in ({"action", "arguments"}, {"action", "arguments", "expect"}) + or not isinstance(verification.get("action"), str) + or not verification["action"].strip() or not isinstance(verification.get("arguments"), dict)): + raise InteractionError("acceptance verification must contain an action and object arguments") + if verification["action"] == "read_file": + expect = verification.get("expect") + if (not isinstance(expect, dict) or len(expect) != 1 + or set(expect) not in ({"equals"}, {"contains"}) + or not isinstance(next(iter(expect.values())), str) + or ("contains" in expect and not expect["contains"])): + raise InteractionError("read_file verification requires a nonempty equals or contains predicate") + elif "expect" in verification: + raise InteractionError("expected-result predicates are currently supported only for read_file") + try: + json.dumps(verification["arguments"], ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise InteractionError("acceptance verification arguments must be JSON-safe") from exc + criterion = _text(item.get("criterion") if isinstance(item, dict) else item, + "acceptance criterion", 300) + normalized_acceptance.append(criterion) + acceptance_verifications.append(verification) + acceptance = normalized_acceptance + acceptance_ids = [] + for criterion, verification in zip(acceptance, acceptance_verifications): + identity = json.dumps({"criterion": criterion, "verification": verification}, sort_keys=True, + ensure_ascii=False, separators=(",", ":")) + acceptance_ids.append(f"{step_id}-ac-{hashlib.sha256(identity.encode('utf-8')).hexdigest()[:12]}") + if len(set(acceptance_ids)) != len(acceptance_ids): + raise InteractionError("acceptance criteria must be unique within each objective") + normalised_step = { + "id": step_id, + "title": _text(step.get("title") or f"Step {index}", "step title", 120), + "description": _text(step.get("description"), "step description", 800), + "acceptance": acceptance, + "acceptance_ids": acceptance_ids, + "acceptance_verifications": acceptance_verifications, + } + if execution is not None: + normalised_step["execution"] = execution + normalised.append(normalised_step) + assumptions = value.get("assumptions", []) + if assumptions is None: + assumptions = [] + if not isinstance(assumptions, list) or len(assumptions) > 10: + raise InteractionError("assumptions must contain at most 10 items") + raw_bounds = value.get("bounds") + bounds_source = "proposal" + if raw_bounds is None: + if "bounds" in value: + raise InteractionError("bounds must be an object") + raw_bounds = { + "workspace_paths": ["."], "capabilities": ["read"], + "effects": ["read_only"], "risk_ceiling": "normal", + } + bounds_source = "legacy-default" + if not isinstance(raw_bounds, dict) or set(raw_bounds) - { + "workspace_paths", "capabilities", "effects", "risk_ceiling", + }: + raise InteractionError("bounds must declare workspace paths, capabilities, effects, and risk ceiling") + + def values(field: str, allowed: set[str] | None = None) -> list[str]: + raw = raw_bounds.get(field, []) + if not isinstance(raw, list) or len(raw) > 32: + raise InteractionError(f"bounds.{field} must be an array with at most 32 values") + result = [] + for item in raw: + if not isinstance(item, str) or not item.strip(): + raise InteractionError(f"bounds.{field} entries must be nonempty strings") + item = item.strip() + if allowed is not None and item not in allowed: + raise InteractionError(f"unsupported bounds.{field} value: {item}") + result.append(item) + if len(set(result)) != len(result): + raise InteractionError(f"bounds.{field} values must be unique") + return result + + paths = values("workspace_paths") + normalised_paths = [] + for path in paths: + path = path.replace("\\", "/") + if path.startswith("/") or re.match(r"^[A-Za-z]:", path): + raise InteractionError("workspace paths must be relative to the active workspace") + path = posixpath.normpath(path) + if path == ".." or path.startswith("../"): + raise InteractionError("workspace paths cannot escape the active workspace") + normalised_paths.append(path) + if len(set(normalised_paths)) != len(normalised_paths): + raise InteractionError("workspace paths must be unique after normalization") + capabilities = values("capabilities", { + "read", "write", "shell", "network", "git", "browser", "destructive", + }) + effects = values("effects", { + "read_only", "filesystem", "process", "remote", "destructive", "internal", + }) + risk_ceiling = raw_bounds.get("risk_ceiling", "normal") + if not isinstance(risk_ceiling, str) or risk_ceiling not in {"normal", "high"}: + raise InteractionError("risk ceiling must be normal or high") + bounds = { + "workspace_paths": sorted(normalised_paths), "capabilities": sorted(capabilities), + "effects": sorted(effects), "risk_ceiling": risk_ceiling, + } + contract_policies = { + "read_file": ("read", "read_only"), + "write_file": ("write", "filesystem"), + "edit_file": ("write", "filesystem"), + "apply_patch": ("write", "filesystem"), + } + for step in normalised: + contracts = ([step["execution"]] if step.get("execution") else []) + [ + verification for verification in step["acceptance_verifications"] if verification + ] + for contract in contracts: + policy = contract_policies.get(contract["action"]) + if policy is None: + raise InteractionError(f"unsupported Strategic Plan contract action: {contract['action']}") + capability, effect = policy + if capability not in bounds["capabilities"] or effect not in bounds["effects"]: + raise InteractionError("Strategic Plan contract exceeds its approved capability or effect bounds") + path = contract["arguments"].get("path") + if not isinstance(path, str) or not path.strip(): + raise InteractionError("Strategic Plan file contracts require a path") + normalized_contract_path = posixpath.normpath(path.replace("\\", "/")) + if (normalized_contract_path.startswith("/") or normalized_contract_path == ".." + or normalized_contract_path.startswith("../")): + raise InteractionError("Strategic Plan contract path escapes the approved workspace") + if not any(root == "." or normalized_contract_path == root + or normalized_contract_path.startswith(root.rstrip("/") + "/") + for root in bounds["workspace_paths"]): + raise InteractionError("Strategic Plan contract path is outside its approved workspace bounds") + plan = { + "plan_id": _text( + (previous or {}).get("plan_id") or f"plan_{uuid.uuid4().hex}", + "plan_id", 100, + ), + "version": version, + "title": _text(value.get("title"), "plan title", 160), + "summary": _text(value.get("summary"), "plan summary", 1200), + "assumptions": [_text(item, "assumption", 300) for item in assumptions], + "steps": normalised, + "bounds": bounds, + "bounds_source": bounds_source, + "original_input": str(original_input).strip()[:12_000], + } + fingerprint_fields = {key: plan[key] for key in ( + "plan_id", "version", "title", "summary", "assumptions", "steps", "bounds", + )} + plan["fingerprint"] = hashlib.sha256(json.dumps( + fingerprint_fields, ensure_ascii=False, allow_nan=False, sort_keys=True, + separators=(",", ":"), + ).encode("utf-8")).hexdigest() + return plan + + +def _revision_materiality(accepted: dict[str, Any], proposal: dict[str, Any]) -> str: + """Allow unchanged objectives only when all revised bounds are provably narrower.""" + if any(accepted.get(field) != proposal.get(field) + for field in ("title", "summary", "assumptions")): + return "material" + objectives = lambda plan: { + step["id"]: (step["title"], step["description"], tuple(step["acceptance"]), + tuple(step["acceptance_verifications"]), step.get("execution")) + for step in plan.get("steps", ()) + } + if objectives(accepted) != objectives(proposal): + return "material" + old, new = accepted.get("bounds"), proposal.get("bounds") + if not isinstance(old, dict) or not isinstance(new, dict): + return "material" + if (not set(new.get("capabilities", ())) <= set(old.get("capabilities", ())) + or not set(new.get("effects", ())) <= set(old.get("effects", ())) + or (old.get("risk_ceiling") == "normal" and new.get("risk_ceiling") == "high")): + return "material" + roots = [posixpath.normpath(path) for path in old.get("workspace_paths", ())] + for path in map(posixpath.normpath, new.get("workspace_paths", ())): + if not any(root == "." or path == root or path.startswith(root.rstrip("/") + "/") + for root in roots): + return "material" + return "within_bounds" + + + +class PlanningService: + """Own Strategic Plan state and its binding to a Working Plan.""" + + def __init__(self, store: InteractionStore, *, user_id: str = "local", workspace_id: str = "default", + session_id: str): + self.store = store + self.user_id = user_id + self.workspace_id = workspace_id + self.session_id = session_id + + + def _append(self, event_type: str, payload: dict[str, Any]) -> None: + self.store.append_event( + event_type, payload, user_id=self.user_id, workspace_id=self.workspace_id, + session_id=self.session_id, + ) + + + def _append_auto_accepted(self, plan: dict[str, Any], previous: dict[str, Any]) -> None: + append = getattr(self.store, "append_plan_auto_accepted", None) + if not callable(append): + raise InteractionError("atomic Strategic Plan revision is unavailable") + try: + append(plan, expected_previous=previous, user_id=self.user_id, + workspace_id=self.workspace_id, session_id=self.session_id) + except ValueError as exc: + raise InteractionError(str(exc)) from exc + + + def _history(self) -> list[dict[str, Any]]: + if callable(getattr(self.store, "list_interaction_events", None)): + return self.store.list_interaction_events( + workspace_id=self.workspace_id, session_id=self.session_id, user_id=self.user_id, + ) + events = self.store.list_events( + limit=None, workspace_id=self.workspace_id, session_id=self.session_id, + user_id=self.user_id, + ) + return [event for event in reversed(events) + if str(event.get("event_type", "")).startswith("interaction.") + or event.get("event_type") == "history.restored"] + + + @staticmethod + def _plan_state(history: list[dict[str, Any]]) -> dict[str, Any]: + plan = None + executing = None + for event in history: + event_type, payload = event["event_type"], event.get("payload", {}) + if event_type == "history.restored": + restored = payload.get("interaction") if isinstance(payload, dict) else None + if isinstance(restored, dict): + plan = restored.get("pending_plan") + executing = restored.get("executing_plan") + elif event_type == "interaction.plan_proposed": + if payload.get("revision_materiality") != "within_bounds": + plan = payload + elif event_type == "interaction.plan_auto_accepted": + if executing and payload.get("plan_id") == executing.get("plan_id"): + executing = dict(payload) + plan = None + elif event_type == "interaction.plan_accepted": + if plan and payload.get("plan_id") == plan.get("plan_id") and payload.get("version") == plan.get("version"): + executing = dict(plan) + for field in ("working_plan_id", "working_plan_revision", "working_task_ids"): + if field in payload: + executing[field] = payload[field] + plan = None + elif event_type == "interaction.plan_cancelled": + if plan and payload.get("plan_id") == plan.get("plan_id"): + plan = None + elif event_type == "interaction.plan_completed": + if (executing and payload.get("plan_id") == executing.get("plan_id") + and payload.get("version") == executing.get("version") + and payload.get("fingerprint") == executing.get("fingerprint") + and payload.get("status") in {"completed", "cancelled"}): + executing = None + return {"pending_plan": plan, "executing_plan": executing} + + def state(self) -> dict[str, Any]: + """Reconstruct Strategic Plan authority from the scoped durable history.""" + return self._plan_state(self._history()) + + + def propose_plan(self, value: Any, *, original_input: str = "") -> dict[str, Any]: + state = self.state() + previous = state.get("pending_plan") or state.get("executing_plan") + if previous and state.get("pending_plan") is None: + latest = [int(event.get("payload", {}).get("version", 0)) for event in self._history() + if event.get("event_type") in {"interaction.plan_proposed", "interaction.plan_auto_accepted"} + and event.get("payload", {}).get("plan_id") == previous.get("plan_id")] + if latest: + previous = {**previous, "version": max(latest)} + plan = validate_plan_proposal(value, previous=previous, original_input=original_input) + executing = state.get("executing_plan") + if executing: + plan["revision_materiality"] = _revision_materiality(executing, plan) + working = self.store.get_working_plan( + user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id, + ) if callable(getattr(self.store, "get_working_plan", None)) else None + working_revision = (working.get("revision") if working + and working.get("id") == executing.get("working_plan_id") + else executing.get("working_plan_revision")) + plan.update({"working_plan_id": executing.get("working_plan_id"), + "working_plan_revision": working_revision, + "working_task_ids": executing.get("working_task_ids", [])}) + if plan["revision_materiality"] == "within_bounds": + self._append_auto_accepted(plan, executing) + return plan + else: + plan["revision_materiality"] = "initial" + self._append("interaction.plan_proposed", plan) + return plan + + + def cancel_plan(self, plan_id: str | None = None, version: int | None = None) -> dict[str, Any]: + plan = self.state().get("pending_plan") + if not plan or (plan_id and plan_id != plan.get("plan_id")) or (version is not None and version != plan.get("version")): + raise InteractionError("plan is not pending") + self._append("interaction.plan_cancelled", {"plan_id": plan["plan_id"], "version": plan["version"]}) + return plan + + + def accepted_plan(self, plan_id: str | None = None, version: int | None = None) -> dict[str, Any] | None: + proposals: dict[tuple[str, int], dict[str, Any]] = {} + accepted: dict[tuple[str, int], dict[str, Any]] = {} + for event in self._history(): + payload = event.get("payload", {}) + key = (str(payload.get("plan_id") or ""), int(payload.get("version") or 0)) + if event["event_type"] == "interaction.plan_proposed": + proposals[key] = payload + elif event["event_type"] == "interaction.plan_accepted": + accepted[key] = payload + elif event["event_type"] == "interaction.plan_auto_accepted": + proposals[key] = payload + accepted[key] = payload + matches = [proposal for key, proposal in proposals.items() if key in accepted + and (not plan_id or key[0] == plan_id) and (version is None or key[1] == version)] + if not matches: + return None + proposal = matches[-1] + key = (str(proposal.get("plan_id") or ""), int(proposal.get("version") or 0)) + return {**proposal, **{field: accepted[key][field] for field in ( + "working_plan_id", "working_plan_revision", "working_task_ids", + ) if field in accepted[key]}} + + + def accept_plan(self, task_manager: Any, *, plan_id: str | None = None, + version: int | None = None, fingerprint: str | None = None) -> tuple[dict[str, Any], bool]: + state = self.state() + plan = state.get("pending_plan") + executing = state.get("executing_plan") + if executing and not plan and (not plan_id or plan_id == executing.get("plan_id")): + if version is not None and version != executing.get("version"): + raise InteractionError("accepted plan version is stale") + if fingerprint is not None and fingerprint != executing.get("fingerprint"): + raise InteractionError("accepted plan fingerprint is stale") + return executing, False + if not plan: + if executing and plan_id and plan_id != executing.get("plan_id"): + raise InteractionError("accepted plan identity is stale") + accepted = self.accepted_plan(plan_id, version) + if accepted and (fingerprint is None or accepted.get("fingerprint") == fingerprint): + return accepted, False + if accepted: + raise InteractionError("accepted plan fingerprint is stale") + raise InteractionError("plan is not pending") + if ((plan_id and plan_id != plan.get("plan_id")) + or (version is not None and version != plan.get("version")) + or (fingerprint is not None and fingerprint != plan.get("fingerprint"))): + raise InteractionError("plan is not pending at the requested version") + if executing and task_manager.working_plan is None: + task_manager.recover() + elif not executing and task_manager.working_plan is None: + task_manager.recover() + if (not executing and task_manager.working_plan is not None + and task_manager.working_plan.status == "active"): + raise InteractionError("cannot accept a Strategic Plan while an unrelated Working Plan is active") + working_plan = task_manager.working_plan if executing else None + previous_tasks = [task for task in task_manager.tasks + if working_plan and task.get("id") in working_plan.task_ids] + if any(task.get("status") == "running" for task in previous_tasks): + raise InteractionError("cannot replace a Strategic Plan while a Working Plan task is running") + operations = [] + strategic_bindings = {} + active_ids = set(executing.get("working_task_ids", ())) if executing else set() + active_ids.update(task["id"] for task in previous_tasks + if (task.get("checkpoint") or {}).get("strategy_anchor") in active_ids) + if executing: + if (plan.get("revision_materiality") != "material" + or plan.get("plan_id") != executing.get("plan_id") + or plan.get("version") != executing.get("version", 0) + 1): + raise InteractionError("Strategic Plan revision is stale or does not require approval") + staged_revision = plan.get("working_plan_revision", executing.get("working_plan_revision")) + if (not working_plan or working_plan.id != executing.get("working_plan_id") + or working_plan.revision != staged_revision): + raise InteractionError("Working Plan revision changed; reload before accepting") + + prior = [task for task in previous_tasks if not executing or task.get("id") in active_ids] + if any(task.get("status") == "running" for task in prior): + raise InteractionError("cannot replace a Strategic Plan while a Working Plan task is running") + new_refs = [] + reused: set[str] = set() + for step in plan["steps"]: + description = f"{step['title']}: {step['description']}" + execution = step.get("execution") or {} + expected_args = (json.dumps(execution.get("arguments", {}), ensure_ascii=False, + allow_nan=False, sort_keys=True, separators=(",", ":")) + if execution else "") + existing = next((task for task in prior + if task.get("status") == "succeeded" + and (task.get("checkpoint") or {}).get("strategic_plan", {}).get("objective_id") == step["id"] + and (task.get("checkpoint") or {}).get("strategic_plan", {}).get("acceptance_ids") == step["acceptance_ids"] + and task.get("description") == description + and [item.get("criterion") if isinstance(item, dict) else item + for item in task.get("acceptance", [])] == step["acceptance"] + and (task.get("checkpoint") or {}).get("action") == execution.get("action") + and ((task.get("checkpoint") or {}).get("args") or "") == expected_args + and {criterion_id for item in task.get("evidence", []) + if isinstance(item, dict) and item.get("success") is True + for criterion_id in item.get("acceptance_ids", ())} >= set(step["acceptance_ids"])), None) + if existing: + new_refs.append(existing["id"]) + reused.add(existing["id"]) + continue + ref = f"revision_{plan['version']}_{step['id']}" if executing else step["id"] + new_refs.append(ref) + operations.append({"op": "add", "ref": ref, "task": { + "description": description, "acceptance": step["acceptance"], + **({"execution": step["execution"]} if step.get("execution") else {}), + }}) + strategic_bindings[ref] = { + "plan_id": plan["plan_id"], "version": plan["version"], + "fingerprint": plan["fingerprint"], "objective_id": step["id"], + "acceptance_ids": step["acceptance_ids"], + "acceptance_verifications": step["acceptance_verifications"], + "execution": step.get("execution"), + } + for task in prior: + if task["id"] not in reused and task.get("status") in {"pending", "failed", "blocked"}: + operations.insert(0, {"op": "cancel", "task": task["id"]}) + if not operations: + if not prior: + raise InteractionError("Strategic Plan has no Working Plan tasks to reuse") + operations.append({"op": "update", "task": prior[0]["id"], + "fields": {"priority": prior[0]["priority"]}}) + payload = { + "plan_id": plan["plan_id"], "version": plan["version"], + "fingerprint": plan["fingerprint"], "working_task_refs": new_refs, + **({"revision_of": {"version": executing["version"], + "fingerprint": executing["fingerprint"]}} if executing else {}), + } + try: + working = task_manager.edit_plan( + complexity=working_plan.complexity if working_plan else "complex", + plan_id=working_plan.id if working_plan else None, + expected_revision=working_plan.revision if working_plan else 0, + operations=operations, strategic_bindings=strategic_bindings, + transaction_event={"event_type": "interaction.plan_accepted", "payload": payload, + "expected_proposal": {"plan_id": plan["plan_id"], "version": plan["version"], + "fingerprint": plan["fingerprint"]}, + **({"expected_working_plan": { + "plan_id": executing["working_plan_id"], + "revision": executing["working_plan_revision"], + "strategic_plan_id": executing["plan_id"], + "version": executing["version"], "fingerprint": executing["fingerprint"], + }} if executing else {})}, + ) + except ValueError as exc: + accepted = self.accepted_plan(plan["plan_id"], plan["version"]) + current_state = self.state() + current_plan = current_state.get("pending_plan") or current_state.get("executing_plan") + if current_plan and not all( + current_plan.get(key) == plan.get(key) + for key in ("plan_id", "version", "fingerprint")): + message = str(exc) + if "changed" not in message.lower() and "stale" not in message.lower(): + message = f"Strategic Plan changed while acceptance was pending: {message}" + raise InteractionError(message) from exc + if accepted and accepted.get("fingerprint") == plan["fingerprint"]: + return accepted, False + raise InteractionError(str(exc)) from exc + bound_ids = [working["references"][ref] if ref in working["references"] else ref for ref in new_refs] + return {**plan, "working_plan_id": working["plan_id"], + "working_plan_revision": working["revision"], "working_task_ids": bound_ids}, True + + + def complete_plan(self, plan: dict[str, Any], *, status: str = "completed", + task_manager: Any | None = None) -> None: + if status not in {"completed", "failed", "cancelled"}: + raise InteractionError("Strategic Plan terminal status is invalid") + prior_completions = [event.get("payload", {}).get("status") for event in self._history() + if event["event_type"] == "interaction.plan_completed" + and event.get("payload", {}).get("plan_id") == plan.get("plan_id") + and event.get("payload", {}).get("version") == plan.get("version") + and event.get("payload", {}).get("fingerprint") == plan.get("fingerprint")] + if any(previous in {"completed", "cancelled"} or previous == status + for previous in prior_completions): + return + executing = self.state().get("executing_plan") + if not executing or any(executing.get(key) != plan.get(key) + for key in ("plan_id", "version", "fingerprint")): + raise InteractionError("Strategic Plan changed; reload before recording completion") + working = task_manager.working_plan if task_manager is not None else None + task_ids = plan.get("working_task_ids") + if task_ids is None and working and working.id == plan.get("working_plan_id"): + task_ids = working.task_ids + if status == "completed": + if task_manager is None or not isinstance(task_ids, list) or not task_ids: + raise InteractionError("Strategic Plan completion requires its linked Working Plan evidence") + if any((task.get("checkpoint") or {}).get("strategy_anchor") in task_ids + and task.get("status") not in {"succeeded", "cancelled"} + for task in task_manager.tasks + if working and task.get("id") in working.task_ids): + raise InteractionError("Strategic Plan refinement tasks are still unfinished") + tasks = {task.get("id"): task for task in task_manager.tasks} + objectives = {step["id"] for step in plan.get("steps", [])} + for task_id in task_ids: + binding = (tasks.get(task_id, {}).get("checkpoint") or {}).get("strategic_plan") or {} + if binding.get("plan_id") != plan.get("plan_id") or binding.get("objective_id") not in objectives: + raise InteractionError("Working Plan contains a task not linked to the accepted Strategic Plan") + if any(tasks.get(task_id, {}).get("status") != "succeeded" + or not any(isinstance(item, dict) and item.get("success") is True and item.get("acceptance") + for item in tasks[task_id].get("evidence", [])) + for task_id in task_ids): + raise InteractionError("Strategic Plan tasks have not passed their acceptance evidence gates") + for task_id in task_ids: + task = tasks[task_id] + binding = (task.get("checkpoint") or {}).get("strategic_plan") or {} + required = set(binding.get("acceptance_ids", ())) + if required: + covered = { + criterion_id + for item in task.get("evidence", []) + if isinstance(item, dict) and item.get("success") is True + for criterion_id in item.get("acceptance_ids", ()) + } + if not required <= covered: + raise InteractionError("Strategic Plan criteria have not all been verified") + payload = { + "plan_id": plan["plan_id"], "version": plan["version"], + "fingerprint": plan.get("fingerprint"), "working_plan_id": plan.get("working_plan_id"), + "working_task_ids": task_ids or [], "status": status, + } + append = getattr(self.store, "append_plan_completed", None) + if callable(append): + try: + append(payload, user_id=self.user_id, workspace_id=self.workspace_id, + session_id=self.session_id) + except ValueError as exc: + raise InteractionError(str(exc)) from exc + else: + self._append("interaction.plan_completed", payload) + + + def reconcile_plan_completion(self, task_manager: Any) -> None: + plan = self.state().get("executing_plan") + if not plan or not plan.get("working_plan_id"): + return + working = task_manager.working_plan + if working is None or working.id != plan["working_plan_id"]: + working = task_manager.restore_working_plan(plan["working_plan_id"]) + if working is not None and working.status in {"completed", "cancelled"}: + self.complete_plan( + plan, status=working.status, task_manager=task_manager, + ) + diff --git a/openkyrozen/tasks/ports.py b/openkyrozen/tasks/ports.py index 3a12ca41..37f6264e 100644 --- a/openkyrozen/tasks/ports.py +++ b/openkyrozen/tasks/ports.py @@ -13,3 +13,9 @@ def list_working_plan_tasks(self, plan_id: str, **scope) -> list[dict]: ... def write_working_plan(self, plan: dict, tasks: list[dict], **scope) -> None: ... def apply_working_plan_edit(self, plan: dict, tasks: list[dict], **scope) -> int: ... def append_event(self, event_type: str, payload: Any, **scope) -> str: ... + + +class InteractionStore(Protocol): + def append_event(self, event_type: str, payload: Any, **scope) -> str: ... + def list_events(self, event_type: str | None = None, **scope) -> list[dict]: ... + def list_interaction_events(self, **scope) -> list[dict]: ... diff --git a/openkyrozen/tasks/worker.py b/openkyrozen/tasks/worker.py index de971dd2..7d571107 100644 --- a/openkyrozen/tasks/worker.py +++ b/openkyrozen/tasks/worker.py @@ -23,6 +23,10 @@ def run_once(self) -> dict[str, Any] | None: if task is None: return None task_id = task["id"] + if (task.get("checkpoint", {}).get("recovery_effect") or {}).get("status") == "unknown": + index = next(i for i, item in enumerate(self.manager.tasks) if item["id"] == task_id) + self.manager.set_status(index, "blocked", reason="Inspect and reconcile the interrupted operation before resuming.") + return {"task": self.manager.tasks[index], "status": "blocked", "result": "Interrupted effect is unknown.", "evidence": {}} try: outcome = self.executor(task) if not isinstance(outcome, dict): diff --git a/openkyrozen/tools/catalog.py b/openkyrozen/tools/catalog.py index a486b44a..ef6e435a 100644 --- a/openkyrozen/tools/catalog.py +++ b/openkyrozen/tools/catalog.py @@ -5,6 +5,8 @@ # name, capability, side effects, owner, risk, parallel safety, aliases, conversion fields, argument aliases, confirmation surfaces _ROWS = ( ('update_plan', 'write', 'internal_write', 'runtime', 'normal', False, (), (), (), ()), + ('propose_plan', 'read', 'internal_write', 'runtime', 'normal', False, (), (), (), ()), + ('request_question', 'read', 'internal_write', 'runtime', 'normal', False, (), (), (), ()), ('spawn_agents', 'read', 'orchestration', 'orchestration', 'normal', False, (), (), (), ()), ('send_subagent', 'read', 'orchestration', 'orchestration', 'normal', False, (), (), (), ()), ('list_subagents', 'read', 'orchestration', 'orchestration', 'normal', False, (), (), (), ()), @@ -155,7 +157,8 @@ },'minProperties':1,'additionalProperties':False} _PLAN_OPERATION_SCHEMAS = [ {'type':'object','properties':{'op':{'type':'string','enum':['add']}, - 'ref':{'type':'string','minLength':1},'task':_PLAN_TASK_SCHEMA}, + 'ref':{'type':'string','minLength':1},'task':_PLAN_TASK_SCHEMA, + 'anchor':{'type':'string','minLength':1,'description':'Existing approved objective task ID for in-scope strategy work.'}}, 'required':['op','ref','task'],'additionalProperties':False}, {'type':'object','properties':{'op':{'type':'string','enum':['update']}, 'task':{'type':'string','minLength':1},'fields':_PLAN_UPDATE_FIELDS_SCHEMA}, @@ -189,6 +192,9 @@ *[{'type':'object','properties':{'op':{'type':'string','enum':[op]}, 'task':{'type':'string','minLength':1},'reason':{'type':'string','minLength':1}}, 'required':['op','task','reason'],'additionalProperties':False} for op in ('block','unblock')], + *[{'type':'object','properties':{'op':{'type':'string','enum':[op]}, + 'task':{'type':'string','minLength':1}}, + 'required':['op','task'],'additionalProperties':False} for op in ('start','suggest_complete')], ] _OBJECT_FIELDS = { @@ -276,6 +282,43 @@ 'required': ['complexity', 'tasks'], 'additionalProperties': False}, ] +_CONTROL_VERIFICATION = {'type':'object','properties':{ + 'action':{'type':'string','minLength':1}, + 'arguments':{'type':'object','properties':{},'additionalProperties':True}, + 'expect':{'type':'object','properties':{'equals':{'type':'string'},'contains':{'type':'string','minLength':1}}, + 'minProperties':1,'maxProperties':1,'additionalProperties':False}, +},'required':['action','arguments'],'additionalProperties':False} +_SCHEMAS['propose_plan'] = {'type':'object','properties':{ + 'title':{'type':'string','minLength':1,'maxLength':120}, + 'summary':{'type':'string','minLength':1,'maxLength':1000}, + 'assumptions':{'type':'array','maxItems':10,'items':{'type':'string'}}, + 'bounds':{'type':'object','properties':{ + 'workspace_paths':{'type':'array','minItems':1,'items':{'type':'string','minLength':1}}, + 'capabilities':{'type':'array','minItems':1,'items':{'type':'string'}}, + 'effects':{'type':'array','minItems':1,'items':{'type':'string'}}, + 'risk_ceiling':{'type':'string','enum':['normal','high']}, + },'required':['workspace_paths','capabilities','effects','risk_ceiling'],'additionalProperties':False}, + 'steps':{'type':'array','minItems':1,'maxItems':10,'items':{'type':'object','properties':{ + 'id':{'type':'string','minLength':1,'maxLength':64}, + 'title':{'type':'string','minLength':1},'description':{'type':'string','minLength':1}, + 'acceptance':{'type':'array','minItems':1,'maxItems':5,'items':{'anyOf':[ + {'type':'string','minLength':1}, + {'type':'object','properties':{'criterion':{'type':'string','minLength':1}, + 'verification':_CONTROL_VERIFICATION},'required':['criterion','verification'],'additionalProperties':False}, + ]}},'execution':_PLAN_EXECUTION_SCHEMA, + },'required':['id','title','description','acceptance'],'additionalProperties':False}}, +},'required':['title','summary','bounds','steps'],'additionalProperties':False} +_SCHEMAS['request_question'] = {'type':'object','properties':{ + 'questions':{'type':'array','minItems':1,'maxItems':3,'items':{'type':'object','properties':{ + 'id':{'type':'string','minLength':1},'header':{'type':'string','minLength':1}, + 'prompt':{'type':'string','minLength':1}, + 'choices':{'type':'array','minItems':2,'maxItems':3,'items':{'type':'object','properties':{ + 'id':{'type':'string','minLength':1},'label':{'type':'string','minLength':1}, + 'description':{'type':'string'}, + },'required':['id','label'],'additionalProperties':False}}, + },'required':['id','header','prompt','choices'],'additionalProperties':False}}, +},'required':['questions'],'additionalProperties':False} + def builtin_names(owner=None): @@ -298,7 +341,7 @@ def builtin_metadata(name): 'analyze_remote_repo', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_type', 'browser_close', 'github_status', 'github_read'}: effects = 'remote' - elif name in {'update_plan', 'graph_refresh', 'spawn_agents', 'send_subagent', 'list_subagents', + elif name in {'update_plan', 'propose_plan', 'request_question', 'graph_refresh', 'spawn_agents', 'send_subagent', 'list_subagents', 'wait_subagents', 'cancel_subagent'}: effects = 'internal' else: diff --git a/openkyrozen/tools/processes.py b/openkyrozen/tools/processes.py index b498e5f4..bf94220d 100644 --- a/openkyrozen/tools/processes.py +++ b/openkyrozen/tools/processes.py @@ -2,11 +2,13 @@ from __future__ import annotations import os +import json import signal import subprocess import threading import time import uuid +from datetime import datetime, timezone from dataclasses import dataclass, field from openkyrozen.tools.sandbox import sandbox_command @@ -36,6 +38,11 @@ class ProcessHandle: stderr_truncated: bool = False timed_out: bool = False finished_at: float | None = None + cancelled: bool = False + stop_reason: str | None = None + audit_error: bool = False + audit_exit_recorded: bool = False + expiry_thread: threading.Thread | None = None lock: threading.Lock = field(default_factory=threading.Lock) @@ -49,50 +56,202 @@ def __init__(self, owner_id: str, workspace_id: str, session_id: str): self._handles: dict[str, ProcessHandle] = {} self._lock = threading.Lock() self._closed = False + self._audit_store = None + self._audit_lock = threading.Lock() + self._audit_closed = False + self._audit_workspace_id = None + self._audit_manager_id = uuid.uuid4().hex + self._prior_process_audit = [] + self._prior_process_total = 0 + self._prior_audit_unavailable = False - def start(self, command: str, *, cwd: str, env: dict[str, str], timeout_seconds: float) -> ProcessHandle: - with self._lock: - if self._closed: - raise RuntimeError("session process manager is closed") - if len(self._handles) >= 32: - raise RuntimeError("session process handle limit reached") - invocation = sandbox_command( - command, workspace=self.workspace_id, cwd=cwd, - env=os.environ.copy() if env is None else env, - ) + def _bind_audit(self, store, workspace_id: str) -> None: + """Bind the existing session journal; filesystem roots are not DB scope IDs.""" + self._audit_store = store + self._audit_workspace_id = str(workspace_id) + try: + unresolved = [record for record in self._audit_records() + if record.get("manager_id") != self._audit_manager_id + and record["owned_process_state"] == "unknown"] + self._prior_process_total = len(unresolved) + self._prior_process_audit = [{ + "process_id": record["process_id"][:64], + "status": record.get("status") if record.get("status") in { + "launch_intent", "started", "stop_requested", "termination_failed", "launch_failed" + } else "unknown", + "pid_observation": record.get("pid") if type(record.get("pid")) is int else None, + } for record in unresolved[:5]] + except Exception: + self._prior_audit_unavailable = True + + def _recovery_context(self) -> str: + if self._prior_audit_unavailable: + return ("Prior process audit unavailable; previous process effects are unknown. " + "Do not replay, reattach or kill processes from historical PID metadata.") + if not self._prior_process_total: + return "" + return ("Prior process outcomes are unknown: " + f"total={self._prior_process_total}, omitted={self._prior_process_total - len(self._prior_process_audit)}. " + "Inspect or request trusted reconciliation before repeating effects. " + "PID observations are not authority to reattach or kill; do not replay these commands. " + + json.dumps(self._prior_process_audit, separators=(",", ":"))) + + def _audit_event(self, status, process_id, *, handle=None, **fields): + if self._audit_store is None: + return + payload = {"process_id": process_id, "manager_id": self._audit_manager_id, + "status": status, "observed_at": datetime.now(timezone.utc).isoformat(), + "owned_process_state": "unknown", "descendant_state": "unknown", **fields} + if handle is not None: + pid = getattr(handle.process, "pid", None) + payload.update(pid=pid if type(pid) is int else None, + sandbox_backend=handle.sandbox_backend) + try: + if self._audit_closed or not self._audit_lock.acquire(timeout=.1): + raise RuntimeError("process audit is closed or busy") + try: + if self._audit_closed: + raise RuntimeError("process audit is closed") + self._audit_store._append_control_audit("process.lifecycle", payload, user_id=self.owner_id, + workspace_id=self._audit_workspace_id, session_id=self.session_id) + finally: + self._audit_lock.release() + except Exception: + # Neither database errors nor command/environment values enter audit logs. + raise RuntimeError("Process audit could not be persisted.") from None + + def _audit_records(self) -> list[dict]: + """Enumerate scoped uncertainty, never reattach or signal an observed PID.""" + if self._audit_store is None: + return [] + events = self._audit_store.list_events("process.lifecycle", limit=None, + user_id=self.owner_id, workspace_id=self._audit_workspace_id, session_id=self.session_id) + records = {} + for event in reversed(events): + payload = event["payload"] + process_id = payload.get("process_id") + if not isinstance(process_id, str): + continue + records.setdefault(process_id, {}).update(payload) + for record in records.values(): + if record.get("owned_process_state") not in {"exited", "not_started"}: + record["owned_process_state"] = "unknown" + record["potentially_active"] = (record["owned_process_state"] == "unknown" + or record.get("descendant_state") == "unknown") + return list(records.values()) + + def _audit_started(self, handle): + try: + self._audit_event("started", handle.process_id, handle=handle, + owned_process_state="running", timeout_seconds=handle.timeout_seconds) + except RuntimeError: + handle.audit_error = True + handle.stop_reason = "launch_audit_failed" try: - process = subprocess.Popen( - invocation.command, shell=invocation.shell, cwd=cwd, env=invocation.env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=(os.name != "nt"), - creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0), + self.stop(handle.process_id) + finally: + if handle.process.poll() is not None: + for name in ("stdout", "stderr"): + getattr(handle.process, name).close() + if handle.temporary_directory is not None: + handle.temporary_directory.cleanup() + handle.temporary_directory = None + raise RuntimeError("Process audit failed after launch; owned cleanup was attempted.") from None + + def _audit_outcome(self, handle): + code = handle.process.poll() + if handle.audit_exit_recorded: + return + reason = handle.stop_reason or "exited" + try: + self._audit_event(reason if code is not None else "termination_failed", + handle.process_id, handle=handle, reason=reason, + owned_process_state="exited" if code is not None else "unknown", + exit_code=code if type(code) is int else None) + handle.audit_exit_recorded = code is not None + except RuntimeError: + # The prior intent/started record stays unresolved on restart. + handle.audit_error = True + + def start(self, command: str, *, cwd: str, env: dict[str, str], timeout_seconds: float, + cancelled=None) -> ProcessHandle: + process_id = uuid.uuid4().hex + self._audit_event("launch_intent", process_id, timeout_seconds=timeout_seconds) + try: + with self._lock: + if self._closed: + raise RuntimeError("session process manager is closed") + if cancelled is not None and cancelled.is_set(): + raise RuntimeError("turn was cancelled before process start") + if sum(handle.process.poll() is None for handle in self._handles.values()) >= 32: + raise RuntimeError("session process handle limit reached") + invocation = sandbox_command( + command, workspace=self.workspace_id, cwd=cwd, + env=os.environ.copy() if env is None else env, + ) + try: + process = subprocess.Popen( + invocation.command, shell=invocation.shell, cwd=cwd, env=invocation.env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=(os.name != "nt"), + creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0), + ) + except Exception: + invocation.close() + raise + handle = ProcessHandle( + process_id, process, self.owner_id, self.workspace_id, + self.session_id, timeout_seconds, invocation.backend, + invocation.temporary_directory, invocation.unavailable_reason, ) - except Exception: - invocation.close() - raise - handle = ProcessHandle( - uuid.uuid4().hex, process, self.owner_id, self.workspace_id, - self.session_id, timeout_seconds, invocation.backend, - invocation.temporary_directory, invocation.unavailable_reason, - ) - self._handles[handle.process_id] = handle + self._handles[handle.process_id] = handle + except BaseException: + try: + self._audit_event("launch_failed", process_id, reason="launch_not_confirmed") + except RuntimeError: + pass + raise + self._audit_started(handle) for name in ("stdout", "stderr"): threading.Thread(target=self._collect, args=(handle, name), daemon=True).start() - threading.Thread(target=self._expire, args=(handle,), daemon=True).start() + handle.expiry_thread = threading.Thread(target=self._expire, args=(handle, cancelled), daemon=True) + handle.expiry_thread.start() return handle - def _expire(self, handle: ProcessHandle) -> None: + def _expire(self, handle: ProcessHandle, cancelled=None) -> None: try: - handle.process.wait(timeout=min(handle.timeout_seconds, _MAX_PROCESS_SECONDS)) - except subprocess.TimeoutExpired: - if handle.process.poll() is None: - self.stop(handle.process_id) - handle.timed_out = True + deadline = handle.started_at + min(handle.timeout_seconds, _MAX_PROCESS_SECONDS) + while handle.process.poll() is None: + if cancelled is not None and cancelled.is_set(): + handle.stop_reason = "cancelled" + self.stop(handle.process_id) + handle.cancelled = True + break + remaining = deadline - time.monotonic() + if remaining <= 0: + handle.stop_reason = "timed_out" + self.stop(handle.process_id) + handle.timed_out = True + break + try: + handle.process.wait(timeout=min(.1, remaining)) + except subprocess.TimeoutExpired: + pass finally: - handle.finished_at = time.monotonic() - if handle.temporary_directory is not None: - handle.temporary_directory.cleanup() - handle.temporary_directory = None + # A failed termination must remain visibly running, with its + # sandbox retained while the process may still be using it. + self._audit_outcome(handle) + if handle.process.poll() is not None: + handle.finished_at = time.monotonic() + self._cleanup_sandbox(handle) + + @staticmethod + def _cleanup_sandbox(handle): + with handle.lock: + temporary = handle.temporary_directory + handle.temporary_directory = None + if temporary is not None: + temporary.cleanup() @staticmethod def _collect(handle: ProcessHandle, name: str) -> None: @@ -119,6 +278,7 @@ def read(self, process_id: str, stream: str = "stdout", offset: int = 0, return None if type(limit) is not int or not 1 <= limit <= 4000: return None + exit_code = handle.process.poll() with handle.lock: output = getattr(handle, stream) page = output[offset:offset + limit] @@ -128,12 +288,12 @@ def read(self, process_id: str, stream: str = "stdout", offset: int = 0, return { "process_id": process_id, "stream": stream, "offset": offset, "output": page, "next_offset": offset + len(page), - "output_truncated": truncated, "timed_out": timed_out, + "output_truncated": truncated, "timed_out": timed_out, "cancelled": handle.cancelled, "sandbox_backend": handle.sandbox_backend, "sandbox_reason": handle.sandbox_reason, "duration_seconds": duration, - "running": handle.process.poll() is None, - "exit_code": handle.process.poll(), + "running": exit_code is None, + "exit_code": exit_code, } def stop(self, process_id: str) -> bool | None: @@ -141,6 +301,12 @@ def stop(self, process_id: str) -> bool | None: if handle is None: return None if handle.process.poll() is None: + handle.stop_reason = handle.stop_reason or "stopped" + try: + self._audit_event("stop_requested", handle.process_id, handle=handle, + reason=handle.stop_reason) + except RuntimeError: + handle.audit_error = True try: if os.name == "nt": subprocess.run(["taskkill", "/PID", str(handle.process.pid), "/T", "/F"], @@ -162,11 +328,29 @@ def stop(self, process_id: str) -> bool | None: pass if handle.process.poll() is not None and handle.finished_at is None: handle.finished_at = time.monotonic() + self._audit_outcome(handle) + if handle.process.poll() is not None: + self._cleanup_sandbox(handle) return handle.process.poll() is not None def close(self) -> None: with self._lock: self._closed = True process_ids = tuple(self._handles) - for process_id in process_ids: - self.stop(process_id) + try: + for process_id in process_ids: + self.stop(process_id) + for process_id in process_ids: + thread = self._handles[process_id].expiry_thread + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=1) + finally: + # Late expiry can retain uncertainty, but cannot reopen a closed + # application's journal. Drain bounded in-flight audit appends. + self._audit_closed = True + if self._audit_lock.acquire(timeout=.3): + self._audit_lock.release() + else: + for handle in self._handles.values(): + handle.audit_error = True + raise RuntimeError("Process audit finalization did not complete.") diff --git a/openkyrozen/updates/service.py b/openkyrozen/updates/service.py index 2354b38c..bb60ee48 100644 --- a/openkyrozen/updates/service.py +++ b/openkyrozen/updates/service.py @@ -50,6 +50,31 @@ def _resolve_update_revision(self, ref: str = "refs/heads/main", timeout: int = return None +def _update_version_key(value: str) -> tuple[int, ...] | None: + """Order supported PEP 440 release, prerelease, and development versions.""" + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+))?(?:\.dev(\d+))?", value) + if not match: + return None + major, minor, patch, prerelease, prerelease_number, development = match.groups() + stage = {"a": 0, "b": 1, "rc": 2, None: 3}[prerelease] + if prerelease is None and development is not None: + stage = -1 + return (int(major), int(minor), int(patch), stage, int(prerelease_number or 0), + 0 if development is not None else 1, int(development or 0)) + + +def _read_update_version(revision: str) -> str: + request = urllib.request.Request( + f"https://raw.githubusercontent.com/EvanProgramming/OpenKyrozen/{revision}/pyproject.toml", + headers={"User-Agent": "OpenKyrozen updater"}, + ) + with urllib.request.urlopen(request, timeout=5) as response: + version = tomllib.loads(response.read(128 * 1024).decode("utf-8"))["project"]["version"] + if not isinstance(version, str): + raise ValueError("update version is not a string") + return version + + def _available_update(self) -> str | None: """Check the same immutable main source used by /update, without installing.""" try: @@ -63,9 +88,9 @@ def _available_update(self) -> str | None: with urllib.request.urlopen(request, timeout=5) as response: latest = tomllib.loads(response.read(128 * 1024).decode("utf-8"))["project"]["version"] current = self.__version__ - if not all(re.fullmatch(r"\d+\.\d+\.\d+", value) for value in (latest, current)): + latest_parts, current_parts = _update_version_key(latest), _update_version_key(current) + if latest_parts is None or current_parts is None: return None - latest_parts, current_parts = (tuple(map(int, value.split("."))) for value in (latest, current)) if latest_parts > current_parts: return latest if latest_parts < current_parts: @@ -331,6 +356,18 @@ def outcome(status, message, restart=False): requested_python = (f"{sys.version_info.major}.{sys.version_info.minor}" if sys.version_info[:2] in {(3, 12), (3, 13)} else "3.12") revision = self._resolve_update_revision() + if ".dev" in self.RELEASE_VERSION: + if not revision: + return outcome("failed", "Cannot verify the update source version; the existing installation was kept.") + try: + current_version = _update_version_key(self.RELEASE_VERSION) + target_version = _update_version_key(_read_update_version(revision)) + if current_version is None or target_version is None: + raise ValueError("unsupported update version") + except (OSError, ValueError, KeyError, TypeError, UnicodeError, tomllib.TOMLDecodeError): + return outcome("failed", "Cannot verify the update source version; the existing installation was kept.") + if target_version < current_version: + return outcome("failed", "The update source version would downgrade this preview; the existing installation was kept.") if not revision and not self._release_tui_asset_available(): return outcome("failed", "No verified update source was available; the existing installation was kept.") package_spec = f"git+{self.UPDATE_REPOSITORY_URL}@{revision}" if revision else self.RELEASE_WHEEL_URL diff --git a/openkyrozen/workspace/history.py b/openkyrozen/workspace/history.py index 48dfef17..d798a174 100644 --- a/openkyrozen/workspace/history.py +++ b/openkyrozen/workspace/history.py @@ -38,7 +38,14 @@ def __init__(self, store: HistoryStore, root: str | os.PathLike[str], state_root *, source_scope_id: str, user_id: str = "local", workspace_id: str = "default", session_id: str): self.store = store - self.root = Path(root).expanduser().resolve() + root_path = Path(root).expanduser().absolute() + self.root = root_path.resolve() + database = Path(store.path).expanduser().absolute() + if database.is_relative_to(root_path): + database = self.root / database.relative_to(root_path) + self.database_paths = {Path(str(path) + suffix) + for path in (database, database.resolve()) + for suffix in ("", "-wal", "-shm", "-journal")} self.state_root = Path(state_root).expanduser().resolve() self.config_path = self.state_root.parent.parent / ".kyrozen_config.json" if not re.fullmatch(r"source-[a-f0-9]{32}", str(source_scope_id)): @@ -67,7 +74,8 @@ def _excluded(self, path: Path) -> bool: relative = path.relative_to(self.root) except ValueError: return True - if ".git" in relative.parts: + if (".git" in relative.parts or path in self.database_paths + or (path.is_symlink() and any(db.is_relative_to(path) for db in self.database_paths))): return True try: if path == self.state_root or path.is_relative_to(self.state_root): @@ -314,11 +322,16 @@ def _restore(self, node: dict[str, Any]) -> None: manifest = self._manifest(node) if not snapshot_workspace.is_dir(): raise HistoryError("history workspace snapshot is missing") + for relative, item in manifest.items(): + destination = self.root / relative + if (not self._excluded(destination) and item.get("kind") != "dir" + and any(db.is_relative_to(destination) for db in self.database_paths)): + raise HistoryError("history restore would overwrite the active database directory") for base, directories, files in os.walk(self.root, topdown=False, followlinks=False): base_path = Path(base) for name in files + directories: path = base_path / name - if self._excluded(path): + if self._excluded(path) or any(db.is_relative_to(path) for db in self.database_paths): continue relative = path.relative_to(self.root).as_posix() if relative not in manifest: @@ -326,14 +339,17 @@ def _restore(self, node: dict[str, Any]) -> None: for relative, item in sorted(manifest.items(), key=lambda pair: (pair[1].get("kind") != "dir", pair[0])): destination = self.root / relative kind = item.get("kind") + if self._excluded(destination): + continue if kind == "dir": if destination.exists() and not destination.is_dir(): self._remove_path(destination) destination.mkdir(parents=True, exist_ok=True) - try: - os.chmod(destination, int(item.get("mode", 0o755)), follow_symlinks=False) - except (OSError, NotImplementedError): - pass + if not any(db.is_relative_to(destination) for db in self.database_paths): + try: + os.chmod(destination, int(item.get("mode", 0o755)), follow_symlinks=False) + except (OSError, NotImplementedError): + pass elif kind == "symlink": if destination.exists() or destination.is_symlink(): self._remove_path(destination) diff --git a/openkyrozen/workspace/history_port.py b/openkyrozen/workspace/history_port.py index c9421c4d..4105c1d7 100644 --- a/openkyrozen/workspace/history_port.py +++ b/openkyrozen/workspace/history_port.py @@ -1,6 +1,7 @@ """Repository operations consumed by HistoryStore.""" from __future__ import annotations from typing import Protocol, Any +from pathlib import Path from dataclasses import dataclass class HistoryError(RuntimeError): @@ -14,6 +15,7 @@ class TurnToken: class HistoryStore(Protocol): + path: Path def append_event(self, event_type: str, payload: Any, *, user_id: str='local', workspace_id: str='default', session_id: str | None=None, task_id: str | None=None): ... def history_head(self, *, user_id: str='local', workspace_id: str='default', session_id: str): diff --git a/pyproject.toml b/pyproject.toml index 8fe93a84..575dcc2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openkyrozen" -version = "2.0.8" +version = "3.0.0" description = "Self-learning AI Agent with broad LLM provider support" readme = "README.md" license = {text = "MIT"} diff --git a/scripts/agent_workflow_acceptance.py b/scripts/agent_workflow_acceptance.py index c9f134c8..eb1f9634 100644 --- a/scripts/agent_workflow_acceptance.py +++ b/scripts/agent_workflow_acceptance.py @@ -16,13 +16,9 @@ if os.environ.get("KYROZEN_ACCEPTANCE_INSTALLED") != "1": sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from openkyrozen.app.bootstrap import build_application - -_application = build_application(surface="cli") - -main = _application.runtime +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.tools import ToolAdapters from openkyrozen.security.capabilities import issue_capability_token -from openkyrozen.persistence.store import EventStore from openkyrozen.agent.modes import InteractionController from openkyrozen.tasks.engine import TaskManager @@ -139,13 +135,14 @@ def test_page_is_served(self): "TaskDone: 3\nDeployment verified.", ] - original = ( - main.tasks, main._interaction_controller, main.learning_engine, - main._get_workspace_root(), main._execution_capability_token, - ) with tempfile.TemporaryDirectory(prefix="openkyrozen-agent-acceptance-") as directory: root = Path(directory) - store = EventStore(root / "state.sqlite3") + application = build_application( + surface="cli", memory=build_memory(root / "state.sqlite3", workspace_id="acceptance"), + tools=ToolAdapters(root), + ) + main = application.runtime + store = main.memory_bank.store main.tasks = TaskManager(store, workspace_id="acceptance", session_id="release") main._interaction_controller = InteractionController( store, workspace_id="acceptance", session_id="release", @@ -203,9 +200,7 @@ def test_page_is_served(self): finally: main._approval_callback.reset(approval_token) main._stream_event_callback.reset(stream_token) - (main.tasks, main._interaction_controller, main.learning_engine, - previous_root, main._execution_capability_token) = original - main._set_workspace_root(previous_root) + application.close() if __name__ == "__main__": diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index b0913cc7..c7a5be4f 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -62,6 +62,12 @@ "builtin_skills/", "benchmarks/", ) +RELEASE_EVIDENCE_PATHS = { + ".github/workflows/ci.yml", + "benchmarks/v3_fixtures.py", + "benchmarks/v3_release_platforms.py", + "scripts/production_soak.py", +} def classify_paths(paths: set[str] | list[str] | tuple[str, ...]) -> set[str]: @@ -139,6 +145,11 @@ def classify_paths(paths: set[str] | list[str] | tuple[str, ...]) -> set[str]: return selected +def requires_release_evidence(paths: set[str] | None, release_candidate_sha: str = "") -> bool: + """Run the long production matrix only for its inputs or an explicit candidate.""" + return bool(release_candidate_sha) or (paths is not None and bool(paths & RELEASE_EVIDENCE_PATHS)) + + def changed_paths(root: Path, event: str, base: str, head: str) -> set[str] | None: """Read all changed paths, including both sides of renames; None means full CI.""" if not base or not head or set(base) == {"0"}: @@ -172,6 +183,7 @@ def main() -> int: parser.add_argument("--base", default="") parser.add_argument("--head", default="") parser.add_argument("--full", action="store_true") + parser.add_argument("--release-candidate-sha", default="") args = parser.parse_args() paths = None if args.full else changed_paths(Path.cwd(), args.event, args.base, args.head) @@ -180,6 +192,7 @@ def main() -> int: outputs["all"] = str("all" in checks).lower() outputs["core"] = str(bool(checks & {"python", "docs"})).lower() outputs["full"] = str(args.full or "all" in checks).lower() + outputs["release"] = str(requires_release_evidence(paths, args.release_candidate_sha)).lower() output_path = os.environ.get("GITHUB_OUTPUT") rendered = "".join(f"{name}={value}\n" for name, value in outputs.items()) diff --git a/scripts/generate_tool_inventory.py b/scripts/generate_tool_inventory.py index 7ce635eb..4b792fbe 100755 --- a/scripts/generate_tool_inventory.py +++ b/scripts/generate_tool_inventory.py @@ -22,11 +22,12 @@ def _load_runtime() -> tuple[dict[str, Any], Any, Any]: tools = ToolAdapters().AVAILABLE_TOOLS from openkyrozen.agent import delegation_runtime from openkyrozen.agent.prompts import _discover_tools - from openkyrozen.agent.plan_tools import _update_plan + from openkyrozen.agent.plan_tools import _propose_plan, _request_question, _update_plan from openkyrozen.agent.runtime import AgentRuntime from openkyrozen.tools.catalog import builtin_names runtime_functions = {'check_stored_data':_check_stored_data, 'search_memory':_search_memory, 'discover_tools':_discover_tools, 'update_plan':_update_plan, + 'propose_plan':_propose_plan, 'request_question':_request_question, 'read_tool_artifact':AgentRuntime._read_tool_artifact, 'read_process':AgentRuntime._read_process, 'stop_process':AgentRuntime._stop_process} diff --git a/scripts/production_soak.py b/scripts/production_soak.py index db637841..02850ada 100644 --- a/scripts/production_soak.py +++ b/scripts/production_soak.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse from concurrent.futures import ThreadPoolExecutor +from contextlib import closing import io import hashlib from importlib import metadata @@ -17,13 +18,229 @@ import tempfile import time import unittest -import psutil ROOT = Path(__file__).resolve().parents[1] sys.path[:0] = [str(ROOT), str(ROOT / 'tests')] from test_durable_tasks_gateway import DurableTaskGatewayTests +def _parse_process_table(output: str) -> dict[int, int]: + parents = {} + for line in output.splitlines(): + fields = line.split() + if len(fields) >= 2: + try: + parents[int(fields[0])] = int(fields[1]) + except ValueError: + continue + return parents + + +def _powershell(script: str) -> str: + shell = shutil.which('powershell') or shutil.which('pwsh') + if not shell: + raise RuntimeError('PowerShell is required for Windows process evidence') + return subprocess.run([shell, '-NoProfile', '-NonInteractive', '-Command', script], + capture_output=True, text=True, timeout=15, check=True).stdout + + +def _process_output(command: list[str]) -> tuple[str, int]: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + stdout, stderr = process.communicate(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + if process.returncode: + raise subprocess.CalledProcessError(process.returncode, command, stdout, stderr) + return stdout, process.pid + + +class _WindowsProcessTable(dict): + def __init__(self, rows, probe_pid): + self.probe_pid = probe_pid + self.probe = rows.pop(probe_pid, None) + super().__init__(rows) + + +def _process_table() -> dict: + if platform.system() == 'Windows': + shell = shutil.which('powershell') or shutil.which('pwsh') + if not shell: + raise RuntimeError('PowerShell is required for Windows process evidence') + output, probe_pid = _process_output([shell, '-NoProfile', '-NonInteractive', '-Command', + "Get-CimInstance Win32_Process | Select-Object " + "@{Name='pid';Expression={$_.ProcessId}},@{Name='ppid';Expression={$_.ParentProcessId}}," + "@{Name='CreationDate';Expression={if ($null -ne $_.CreationDate) {" + "$_.CreationDate.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)}}},Name " + "| ConvertTo-Json -Compress"]) + rows = json.loads(output) if output.strip() else [] + if isinstance(rows, dict): + rows = [rows] + parents = {} + for row in rows: + pid = int(row['pid']) + if pid in parents: + raise AssertionError(f'ambiguous Windows process identity for PID {pid}') + stamp = row.get('CreationDate') + parents[pid] = {'pid': pid, 'ppid': int(row['ppid']), + 'CreationDate': stamp[:64] if isinstance(stamp, str) else None, + 'Name': str(row.get('Name') or '')[:256]} + return _WindowsProcessTable(parents, probe_pid) + else: + output, probe_pid = _process_output(['ps', '-axo', 'pid=,ppid=']) + parents = _parse_process_table(output) + parents.pop(probe_pid, None) + return parents + + +def _process_identity(parents, pid): + row = parents[pid] + if not isinstance(row, dict): + return pid, None + import datetime + import re + stamp = row.get('CreationDate') + try: + if not isinstance(stamp, str) or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{7}Z', stamp): + raise ValueError('missing or malformed timestamp') + datetime.datetime.fromisoformat(stamp) + except ValueError as exc: + raise AssertionError(f'missing or invalid Windows creation identity for PID {pid}') from exc + # Preserve all seven fractional digits; parsing above validates the calendar only. + return pid, stamp + + +def _descendants(pid: int, parents: dict | None = None) -> set[int]: + parents = _process_table() if parents is None else parents + if isinstance(parents, _WindowsProcessTable): + if pid not in parents: + raise AssertionError(f'missing Windows creation identity for root PID {pid}') + _process_identity(parents, pid) + children = {} + for child, parent in parents.items(): + parent = parent['ppid'] if isinstance(parent, dict) else parent + children.setdefault(parent, []).append(child) + found, pending = set(), [(pid, child) for child in children.get(pid, ())] + while pending: + parent, child = pending.pop() + if child not in found: + if isinstance(parents.get(parent), dict): + # Win32 ParentProcessId can name a newer, unrelated reused PID. + if _process_identity(parents, child)[1] < _process_identity(parents, parent)[1]: + continue + found.add(child) + pending.extend((child, descendant) for descendant in children.get(child, ())) + return found + + +def _process_checkpoint(path, parents, relevant, **fields): + relevant = sorted(relevant) + rows = [parents[pid] if isinstance(parents.get(pid), dict) + else {'pid': pid, 'ppid': parents.get(pid), 'CreationDate': None} + for pid in relevant[:512]] + path.write_text(json.dumps({'schema_version': 1, 'rows': rows, 'row_count': len(relevant), + 'rows_truncated': len(relevant) > 512, + 'identity_scheme': 'windows_pid_creation_date' if isinstance(parents, _WindowsProcessTable) else 'posix_pid', + 'excluded_probe_pid': getattr(parents, 'probe_pid', None), + 'excluded_probe': getattr(parents, 'probe', None), **fields}, indent=2) + '\n') + + +def _read_process_table(checkpoint): + try: + return _process_table() + except Exception as exc: + _process_checkpoint(checkpoint, {}, set(), status='capture_failed', + identity_scheme='unavailable', error=f'{type(exc).__name__}: {str(exc)[:300]}') + raise + + +def _capture_process_tree(pid, checkpoint): + parents = _read_process_table(checkpoint) + raw = {key: row['ppid'] if isinstance(row, dict) else row for key, row in parents.items()} + candidates = _descendants(pid, raw) + fields = {'root_pid': pid, 'status': 'captured'} + try: + tracked = _descendants(pid, parents) + identities = {child: _process_identity(parents, child) for child in tracked} + except AssertionError as exc: + _process_checkpoint(checkpoint, parents, candidates | {pid}, root_pid=pid, + status='identity_unavailable', error=str(exc)) + raise + _process_checkpoint(checkpoint, parents, candidates | {pid}, **fields, + tracked_pids=sorted(tracked)[:512], tracked_count=len(tracked), + excluded_stale_parent_pids=sorted(candidates - tracked)[:512], + excluded_stale_parent_count=len(candidates - tracked)) + return {'parents': parents, 'tracked': tracked, 'identities': identities} + + +def _assert_processes_stopped(before, checkpoint): + after = _read_process_table(checkpoint) + present = before['tracked'] & set(after) + offending, reused = [], [] + try: + for pid in sorted(present): + if _process_identity(after, pid) == before['identities'][pid]: + offending.append(after[pid] if isinstance(after[pid], dict) + else {'pid': pid, 'ppid': after[pid], 'CreationDate': None}) + else: + reused.append(pid) + except AssertionError as exc: + _process_checkpoint(checkpoint, after, before['tracked'], status='identity_unavailable', + error=str(exc), offending=offending[:512], reused_pids=reused[:512]) + raise + _process_checkpoint(checkpoint, after, before['tracked'], + status='orphan' if offending else 'stopped', + offending=offending[:512], offending_count=len(offending), + reused_pids=reused[:512], reused_pid_count=len(reused)) + assert not offending, 'orphan gateway child' + + +def _assert_no_new_audit_children(pid, baseline, checkpoint): + current = _capture_process_tree(pid, checkpoint) + baseline_keys = set(baseline['identities'].values()) + extra = {child for child, identity in current['identities'].items() if identity not in baseline_keys} + if extra: + offending_pids = sorted(extra)[:512] + offending = [current['parents'][child] if isinstance(current['parents'][child], dict) + else {'pid': child, 'ppid': current['parents'][child], 'CreationDate': None} + for child in offending_pids] + report = json.loads(checkpoint.read_text()) + report.update(status='orphan', offending_pids=offending_pids, + offending=offending, offending_count=len(extra)) + checkpoint.write_text(json.dumps(report, indent=2) + '\n') + assert not extra, 'orphan audit child' + + +def _process_snapshot(pid: int) -> dict[str, int]: + if platform.system() == 'Windows': + script = (f"$p=Get-Process -Id {int(pid)} -ErrorAction Stop; " + "[pscustomobject]@{rssBytes=[long]$p.WorkingSet64;threads=[int]$p.Threads.Count;" + "openHandles=[int]$p.HandleCount}|ConvertTo-Json -Compress") + result = json.loads(_powershell(script)) + return {'rss_bytes': int(result['rssBytes']), 'threads': int(result['threads']), + 'open_handles': int(result['openHandles'])} + proc = Path('/proc') / str(pid) + if platform.system() == 'Linux' and proc.is_dir(): + status = dict(line.split(':', 1) for line in (proc / 'status').read_text().splitlines() + if ':' in line) + return {'rss_bytes': int(status['VmRSS'].split()[0]) * 1024, + 'threads': int(status['Threads']), 'open_handles': len(list((proc / 'fd').iterdir()))} + rss = int(subprocess.run(['ps', '-p', str(pid), '-o', 'rss='], capture_output=True, + text=True, timeout=15, check=True).stdout.strip()) * 1024 + threads = max(0, len(subprocess.run(['ps', '-M', '-p', str(pid)], capture_output=True, + text=True, timeout=15, check=True).stdout.splitlines()) - 1) + handles = max(0, len(subprocess.run(['lsof', '-nP', '-p', str(pid)], capture_output=True, + text=True, timeout=15, check=True).stdout.splitlines()) - 1) + return {'rss_bytes': rss, 'threads': threads, 'open_handles': handles} + + +def _handle_limit(initial_handles: int) -> int: + # Windows starts with more runtime handles; bound growth rather than its baseline. + return initial_handles + 100 if platform.system() == 'Windows' else 99 + + def candidate_identity() -> dict: tracked = subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).split(b"\0") tracked = sorted(set(tracked) | {b"tests/test_gateway_startup_logging.py", b"scripts/production_soak.py"}) @@ -36,7 +253,22 @@ def candidate_identity() -> dict: "candidate_source_sha256": digest.hexdigest(), "python": sys.version, "python_executable": sys.executable, "platform": platform.platform(), "dependencies": {name: metadata.version(name) for name in - ("psutil", "fastapi", "uvicorn", "pydantic", "chromadb")}} + ("fastapi", "uvicorn", "pydantic", "chromadb")}} + + +def _wait_for_task_completion(gateway, url, workspace: Path, session: str, + cycles: int, marker: str, task_id: str) -> None: + deadline = time.monotonic() + 40 + while time.monotonic() < deadline: + tasks = gateway._request(url, f'/api/v2/tasks?session_id={session}', timeout=35)['tasks'] + current = next(t for t in tasks if t['id'] == task_id) + if current['status'] in ('succeeded', 'failed', 'blocked'): + assert current['status'] == 'succeeded', current + assert (workspace / marker).read_text() == f'{session}:{cycles}' + assert all(t['session_id'] == session for t in tasks) + return + time.sleep(.1) + raise AssertionError('durable worker did not complete within 40 seconds') def run(seconds: float, log_dir: Path) -> dict: @@ -59,43 +291,39 @@ def start_gateway(workspace, db, skills, log_path): db = root / 'state.sqlite3' os.environ['KYROZEN_DB_PATH'] = str(root / 'test-driver.sqlite3') os.environ['KYROZEN_BROWSER_PROFILES'] = str(root / 'browser-profiles') + baseline_children = _capture_process_tree(os.getpid(), log_dir / 'process-audit-baseline.json') try: process, url = start_gateway(workspace, db, root / 'skills', log_path=log_dir / f'gateway-{restarts:03d}.log') - baseline_children = {p.pid for p in psutil.Process().children(recursive=True)} - initial_rss = psutil.Process(process.pid).memory_info().rss + initial = _process_snapshot(process.pid) + initial_rss = initial['rss_bytes'] generation_rss = initial_rss + generation_handles = initial['open_handles'] def task(session): marker = f'{session}-{cycles}.txt' + # Match SQLite's 30-second busy timeout, with a small HTTP margin. created = gateway._request(url, '/api/v2/tasks', method='POST', body={ 'description': f'soak {session} {cycles}', 'session_id': session, - 'action': 'write_file', 'args': f'{marker}|{session}:{cycles}'})['task'] - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - tasks = gateway._request(url, f'/api/v2/tasks?session_id={session}')['tasks'] - current = next(t for t in tasks if t['id'] == created['id']) - if current['status'] in ('succeeded', 'failed', 'blocked'): - assert current['status'] == 'succeeded', current - assert (workspace / marker).read_text() == f'{session}:{cycles}' - assert all(t['session_id'] == session for t in tasks) - return - time.sleep(.1) - raise AssertionError('durable worker did not complete within 15 seconds') + 'action': 'write_file', 'args': f'{marker}|{session}:{cycles}'}, timeout=35)['task'] + _wait_for_task_completion(gateway, url, workspace, session, cycles, marker, created['id']) while time.monotonic() - started < seconds: with ThreadPoolExecutor(max_workers=2) as pool: list(pool.map(task, ('soak-a', 'soak-b'))) - with sqlite3.connect(db) as connection: - count, unique = connection.execute('SELECT count(*), count(DISTINCT id) FROM tasks').fetchone() - assert count == unique == (cycles + 1) * 2, (count, unique, cycles) - assert connection.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' - assert not connection.execute('PRAGMA foreign_key_check').fetchall() - current = psutil.Process(process.pid) - rss = current.memory_info().rss + with closing(sqlite3.connect(db)) as connection: + with connection: + count, unique = connection.execute('SELECT count(*), count(DISTINCT id) FROM tasks').fetchone() + assert count == unique == (cycles + 1) * 2, (count, unique, cycles) + assert connection.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' + assert not connection.execute('PRAGMA foreign_key_check').fetchall() + current = _process_snapshot(process.pid) + rss = current['rss_bytes'] assert rss <= generation_rss + 256 * 1024 * 1024, 'gateway grew by more than 256 MiB in one generation' - measurements.append({'cycle': cycles, 'rss_bytes': rss, 'threads': current.num_threads(), - 'open_files': len(current.open_files())}) - assert current.num_threads() < 80, 'unbounded gateway thread growth' - assert len(current.open_files()) < 100, 'unbounded gateway file descriptor growth' + measurements.append({'cycle': cycles, 'handle_baseline': generation_handles, **current}) + assert current['threads'] < 80, 'unbounded gateway thread growth' + assert current['open_handles'] <= _handle_limit(generation_handles), ( + f"gateway handle growth exceeded its bound: baseline={generation_handles}, " + f"current={current['open_handles']}" + ) cycles += 1 if cycles % 6 == 0: suite = unittest.defaultTestLoader.loadTestsFromNames([ @@ -106,12 +334,14 @@ def task(session): result = unittest.TextTestRunner(stream=io.StringIO()).run(suite) assert result.wasSuccessful(), (result.errors, result.failures) if cycles % 12 == 0: - tracked = current.children(recursive=True) + tracked = _capture_process_tree(process.pid, log_dir / f'process-{restarts:03d}-before.json') gateway._stop_server(process) - assert all(not child.is_running() for child in tracked), 'orphan gateway child' + _assert_processes_stopped(tracked, log_dir / f'process-{restarts:03d}-after.json') process, url = start_gateway(workspace, db, root / 'skills', log_path=log_dir / f'gateway-{restarts + 1:03d}.log') - generation_rss = psutil.Process(process.pid).memory_info().rss + initial = _process_snapshot(process.pid) + generation_rss = initial['rss_bytes'] + generation_handles = initial['open_handles'] restarts += 1 for session in ('soak-a', 'soak-b'): tasks = gateway._request(url, f'/api/v2/tasks?session_id={session}')['tasks'] @@ -119,17 +349,33 @@ def task(session): print(json.dumps({'cycles': cycles, 'restarts': restarts, 'elapsed_seconds': round(time.monotonic()-started), 'rss_bytes': rss}), flush=True) time.sleep(min(5, max(0, seconds - (time.monotonic() - started)))) + tracked = _capture_process_tree(process.pid, log_dir / 'process-final-before.json') gateway._stop_server(process) process = None - children = psutil.Process().children(recursive=True) - assert not [p.pid for p in children if p.pid not in baseline_children], 'orphan audit child' + _assert_processes_stopped(tracked, log_dir / 'process-final-after.json') + _assert_no_new_audit_children(os.getpid(), baseline_children, log_dir / 'process-audit-final.json') except Exception as exc: # Retain disposable state and progress so a timeout is diagnosable. evidence = log_dir / 'failed-state' - shutil.copytree(root, evidence) + if process is not None: + try: + gateway._stop_server(process) + except Exception as cleanup_error: + exc.add_note(f'gateway cleanup failed: {type(cleanup_error).__name__}') + else: + process = None + failed_state = None + try: + shutil.copytree(root, evidence) + failed_state = str(evidence) + except OSError as copy_error: + exc.add_note(f'failed-state copy failed: {type(copy_error).__name__}') exc.soak_report = {'elapsed_seconds': round(time.monotonic()-started, 2), 'cycles': cycles, 'tasks': cycles * 2, 'restarts': restarts, - 'samples': measurements, 'startup_seconds': startup_seconds, 'startup_timeout_seconds': 60, 'failed_state': str(evidence)} + 'samples': measurements, 'startup_seconds': startup_seconds, + 'startup_timeout_seconds': 60} + if failed_state: + exc.soak_report['failed_state'] = failed_state raise finally: if process is not None: diff --git a/scripts/publish_v3_preview.py b/scripts/publish_v3_preview.py new file mode 100644 index 00000000..065615e3 --- /dev/null +++ b/scripts/publish_v3_preview.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Build and publish an exact-candidate V3 preview after signed evidence passes.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import yaml + +from benchmarks.v3_release_gates import ( + LIMITED_RELEASE_SCOPE, SIGNER_FINGERPRINT, _current_candidate, _gpg_verify, _read_signed_pair, + _require_outside_checkout, _write_signed_manifest, verify_signed_preview_manifest, +) + +REPOSITORY = "EvanProgramming/OpenKyrozen" +GATE_NAMES = { + "R259": "native protocol recovery", + "R262": "dynamic replanning quality", + "R263": "matched task success", + "R264": "matched request and token efficiency", +} + + +class PublicationUncertain(RuntimeError): + """GitHub may have published the release; inspection is required before retrying.""" + + +def _run(args: list[str], *, cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run(args, cwd=cwd, check=True, capture_output=True, text=True, timeout=600) + + +def _verify_git_signature(repo: Path, kind: str, revision: str) -> None: + result = _run(["git", f"verify-{kind}", "--raw", revision], cwd=repo) + for line in (result.stdout + "\n" + result.stderr).splitlines(): + if line.startswith("[GNUPG:] VALIDSIG "): + fields = line.split() + if len(fields) > 2 and SIGNER_FINGERPRINT in {fields[2], fields[11] if len(fields) > 11 else fields[2]}: + return + raise ValueError(f"{kind} signature does not match the pinned release-owner key") + + +def _verify_candidate_signature(repo: Path, candidate: str) -> None: + _verify_git_signature(repo, "commit", candidate) + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _verify_prepared_artifacts(files: list[Path], expected: dict, attestation: Path) -> None: + data, _, verified = _read_signed_pair(attestation, attestation.with_name(attestation.name + ".asc"), _gpg_verify) + try: + content = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError): + content = None + if (not verified or content != expected or + any(_digest(path) != expected["artifacts"].get(path.name) for path in files)): + raise ValueError("prepared preview artifacts or attestation changed") + + +def _require_safe_release_workflow(repo: Path, tag: str) -> None: + try: + workflow = yaml.load((repo / ".github/workflows/release.yml").read_text(encoding="utf-8"), + Loader=yaml.BaseLoader) + patterns = workflow["on"]["push"]["tags"] + condition = workflow["jobs"]["build-release"]["if"] + if (not tag.startswith("v3.") or patterns != ["v*", "!v3.*"] or + condition.strip() != "${{ !startsWith(inputs.release_tag || github.ref_name, 'v3.') }}"): + raise ValueError("ungated release workflow can publish the V3 tag") + except (OSError, KeyError, TypeError, AttributeError, yaml.YAMLError) as exc: + raise ValueError(f"cannot verify ungated release workflow protection: {exc}") from None + + +def _ensure_signed_remote_tag(repo: Path, tag: str, candidate: str) -> None: + local = subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{tag}"], cwd=repo, check=False) + if local.returncode not in (0, 1): + raise ValueError("preview tag could not be checked locally") + remote = _run(["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}"], cwd=repo).stdout.split() + if local.returncode == 1: + if remote: + raise ValueError("preview tag exists on the remote but is not available locally for verification") + _run(["git", "tag", "-s", "-u", SIGNER_FINGERPRINT, "-m", f"OpenKyrozen {tag}", tag, candidate], cwd=repo) + _verify_git_signature(repo, "tag", tag) + if _run(["git", "rev-parse", f"refs/tags/{tag}^{{}}"], cwd=repo).stdout.strip() != candidate: + raise ValueError("signed preview tag does not point to the candidate") + tag_object = _run(["git", "rev-parse", f"refs/tags/{tag}"], cwd=repo).stdout.strip() + if remote and remote[0] != tag_object: + raise ValueError("remote preview tag does not match the signed local tag") + if not remote: + _run(["git", "push", "origin", f"refs/tags/{tag}"], cwd=repo) + peeled = _run(["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}^{{}}"], cwd=repo).stdout.split() + if not peeled or peeled[0] != candidate: + raise ValueError("remote preview tag does not point to the candidate") + + +def _notes(candidate: str, manifest: dict, tag: str) -> str: + lines = [ + "# OpenKyrozen V3 limited-evidence preview", "", + f"Candidate: `{candidate}`.", + "Scope: Linux and macOS preview use with `KYROZEN_SANDBOX_REQUIRED=1`.", + "Windows distribution, production use, and general availability are outside this scope.", + "The matched V2/V3 evaluation is incomplete or inconclusive; the full V3 benchmark and release gates have not passed.", + "", "## Install and run", "", "```sh", + f"curl -fsSL https://raw.githubusercontent.com/{REPOSITORY}/{tag}/install.sh | sh", + "KYROZEN_SANDBOX_REQUIRED=1 kyrozen", "```", + "Set `KYROZEN_SANDBOX_REQUIRED=1` for every preview CLI or web launch. If the required sandbox is unavailable, execution is blocked.", + "", "## Waived gates", "", + ] + for gate in manifest["waived_gates"]: + waiver = manifest["gates"][gate]["waiver"] + lines.append(f"- {gate} ({GATE_NAMES[gate]}): inconclusive. {waiver['rationale']}") + lines.extend(f" - Limitation: {item}" for item in waiver["limitations"]) + lines.append(f" - Review or expiry: {waiver.get('review_condition') or waiver.get('expires_at')}") + return "\n".join(lines) + "\n" + + +def publish(repo: Path, evidence_dir: Path, manifest_path: Path, output_dir: Path | None = None) -> str: + repo = Path(repo).resolve(strict=True) + evidence_dir = Path(evidence_dir).resolve(strict=True) + manifest_path = Path(manifest_path).resolve(strict=True) + _require_outside_checkout(evidence_dir, repo, "evidence directory") + _require_outside_checkout(manifest_path, repo, "manifest") + candidate = _current_candidate(repo) + _verify_candidate_signature(repo, candidate) + manifest = verify_signed_preview_manifest(evidence_dir, candidate, manifest_path, verify_signature=_gpg_verify) + if output_dir is None: + raise ValueError("an outside-checkout output directory is required") + output_dir = Path(output_dir).resolve() + _require_outside_checkout(output_dir, repo, "output directory") + if output_dir.exists(): + raise ValueError("output directory already exists") + version = tomllib.loads((repo / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + if not re.fullmatch(r"3\.0\.0\.dev[1-9]\d*", version): + raise ValueError("candidate package version is not a V3 development preview") + tag = f"v{version}" + _require_safe_release_workflow(repo, tag) + origin = _run(["git", "remote", "get-url", "origin"], cwd=repo).stdout.strip() + if origin not in {f"https://github.com/{REPOSITORY}.git", f"https://github.com/{REPOSITORY}", + f"git@github.com:{REPOSITORY}.git"}: + raise ValueError("origin does not identify the expected GitHub repository") + output_dir.mkdir(parents=True) + staged_manifest = output_dir / "preview-manifest.json" + shutil.copyfile(manifest_path, staged_manifest) + shutil.copyfile(manifest_path.with_name(manifest_path.name + ".asc"), + staged_manifest.with_name(staged_manifest.name + ".asc")) + manifest = verify_signed_preview_manifest(evidence_dir, candidate, staged_manifest, verify_signature=_gpg_verify) + for gate in manifest["waived_gates"]: + waiver = manifest["gates"][gate]["waiver"] + for path_key, digest_key in (("record_path", "record_sha256"), ("signature_path", "signature_sha256")): + source = (evidence_dir / waiver[path_key]).resolve(strict=True) + source.relative_to(evidence_dir) + content = source.read_bytes() + if hashlib.sha256(content).hexdigest() != waiver[digest_key]: + raise ValueError(f"{gate}: waiver changed during publication preparation") + (output_dir / source.name).write_bytes(content) + source_tar = output_dir / "candidate.tar" + _run(["git", "archive", "--format=tar", f"--output={source_tar}", candidate], cwd=repo) + source = output_dir / "source" + source.mkdir() + with tarfile.open(source_tar) as archive: + archive.extractall(source, filter="data") + _run([sys.executable, "-m", "build", "--outdir", str(output_dir)], cwd=source) + source_tar.unlink() + shutil.rmtree(source) + wheel = output_dir / f"openkyrozen-{version}-py3-none-any.whl" + sdist = output_dir / f"openkyrozen-{version}.tar.gz" + tui = output_dir / f"openkyrozen-tui-{version}.tar.gz" + if not wheel.is_file() or not sdist.is_file(): + raise ValueError("candidate build did not produce the expected wheel and source archive") + _run(["git", "archive", "--format=tar.gz", f"--output={tui}", f"{candidate}:tui"], cwd=repo) + checksum = output_dir / f"{tui.name}.sha256" + checksum.write_text(f"{_digest(tui)} {tui.name}\n", encoding="utf-8") + notes = output_dir / "preview-release-notes.md" + notes.write_text(_notes(candidate, manifest, tag), encoding="utf-8") + files = [wheel, sdist, tui, checksum, notes, staged_manifest, + staged_manifest.with_name(staged_manifest.name + ".asc")] + for gate in manifest["waived_gates"]: + waiver = manifest["gates"][gate]["waiver"] + files.extend(output_dir / Path(waiver[key]).name for key in ("record_path", "signature_path")) + attestation = output_dir / "preview-artifacts.json" + artifact_hashes = {path.name: _digest(path) for path in files} + attestation_data = {"candidate_sha": candidate, "tag": tag, "release_scope": LIMITED_RELEASE_SCOPE, + "artifacts": artifact_hashes} + _write_signed_manifest(attestation_data, attestation) + files.extend([attestation, attestation.with_name(attestation.name + ".asc")]) + if _current_candidate(repo) != candidate: + raise ValueError("candidate changed during publication preparation") + verify_signed_preview_manifest(evidence_dir, candidate, staged_manifest, verify_signature=_gpg_verify) + _run(["gh", "auth", "status"], cwd=repo) + _verify_prepared_artifacts(files[:-2], attestation_data, attestation) + _ensure_signed_remote_tag(repo, tag, candidate) + _verify_prepared_artifacts(files[:-2], attestation_data, attestation) + _run(["gh", "release", "create", tag, *map(str, files), "--repo", REPOSITORY, + "--draft", "--prerelease", "--verify-tag", "--title", f"OpenKyrozen {tag} preview", + "--notes-file", str(notes)], cwd=repo) + fields = "tagName,isPrerelease,isDraft,url" + release = json.loads(_run(["gh", "release", "view", tag, "--repo", REPOSITORY, + "--json", fields], cwd=repo).stdout) + if release.get("tagName") != tag or release.get("isPrerelease") is not True or release.get("isDraft") is not True: + raise ValueError("GitHub did not confirm the requested draft prerelease") + expected_uploads = {path.name: _digest(path) for path in files} + with tempfile.TemporaryDirectory(prefix="v3-preview-download-") as directory: + download = Path(directory) + _run(["gh", "release", "download", tag, "--repo", REPOSITORY, "--dir", str(download)], cwd=repo) + if {path.name for path in download.iterdir()} != set(expected_uploads) or any( + _digest(download / name) != digest for name, digest in expected_uploads.items()): + raise ValueError("draft release assets do not match the signed local artifacts") + if _current_candidate(repo) != candidate: + raise ValueError("candidate changed before publication") + verify_signed_preview_manifest(evidence_dir, candidate, staged_manifest, verify_signature=_gpg_verify) + _verify_prepared_artifacts(files[:-2], attestation_data, attestation) + try: + _run(["gh", "release", "edit", tag, "--repo", REPOSITORY, "--draft=false", "--prerelease"], cwd=repo) + release = json.loads(_run(["gh", "release", "view", tag, "--repo", REPOSITORY, + "--json", fields], cwd=repo).stdout) + if release.get("tagName") != tag or release.get("isPrerelease") is not True or release.get("isDraft") is not False: + raise ValueError("GitHub did not confirm the published prerelease") + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as exc: + raise PublicationUncertain(f"inspect the GitHub release before retrying: {exc}") from None + return release["url"] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + try: + url = publish(args.repo, args.evidence_dir, args.manifest, args.output_dir) + except PublicationUncertain as exc: + print(f"preview publication outcome uncertain: {exc}", file=sys.stderr) + return 1 + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as exc: + print(f"preview publication blocked: {exc}", file=sys.stderr) + return 1 + print(f"Preview prerelease published: {url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tui_workflow_acceptance.py b/scripts/tui_workflow_acceptance.py index b1ad1f9b..8b664d41 100644 --- a/scripts/tui_workflow_acceptance.py +++ b/scripts/tui_workflow_acceptance.py @@ -198,10 +198,14 @@ def response_count(rows): return sum(r.get('event') == 'response' for r in rows) def submit(text): - count = response_count(events()) + rows = events() + count = response_count(rows) + ready_count = sum(r.get('event') == 'status' and r.get('state') == 'ready' for r in rows) send(text.encode() + b'\r') wait(lambda rows: response_count(rows) > count, 'chat response') - wait(lambda rows: rows[-1].get('event') != 'status' or not rows[-1].get('busy'), 'turn completion') + if not text.startswith('/'): + wait(lambda rows: sum(r.get('event') == 'status' and r.get('state') == 'ready' + for r in rows) > ready_count, 'turn completion') try: wait(prompt('onboarding'), 'onboarding') @@ -258,7 +262,9 @@ def submit(text): drain(0.3) send(b'q') drain(0.5) - count = response_count(events()) + rows = events() + count = response_count(rows) + ready_count = sum(r.get('event') == 'status' and r.get('state') == 'ready' for r in rows) send(b'cancel audit\r') wait(lambda rows: any(r.get('event') == 'subagent' and r.get('agent', {}).get('status') == 'running' and r.get('agent', {}).get('assignment', {}).get('objective') == 'wait-for-cancellation' for r in rows), 'cancellable running agent') @@ -268,6 +274,8 @@ def submit(text): wait(lambda rows: any(r.get('event') == 'subagent' and r.get('agent', {}).get('status') == 'cancelled' for r in rows), 'agent cancellation') send(b'q') wait(lambda rows: response_count(rows) > count, 'cancelled work retained in final report') + wait(lambda rows: sum(r.get('event') == 'status' and r.get('state') == 'ready' + for r in rows) > ready_count, 'cancelled turn completion') for width, height in ((60, 20), (160, 45), (80, 24)): previous = len(terminal) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', height, width, 0, 0)) @@ -277,6 +285,8 @@ def submit(text): assert 'APPROVAL REQUIRED' in plain and 'Choose a provider' in plain assert 'first conversation' in plain and 'second conversation' in plain assert '2.0.5 available' in plain and 'Run /update' in plain + send(b'\x1b') # Dismiss the expected error/agent dialog before the next command. + drain(0.3) send(b'/update\r') wait(lambda rows: any(r.get('event') == 'restart' for r in rows), 'update command requests restart') process.wait(timeout=10) @@ -285,7 +295,10 @@ def submit(text): print('TUI acceptance passed: startup update notice and command, onboarding, chat isolation, approvals, sub-agent updates/inspection/scrolling/cancellation, and 3 terminal sizes.') finally: if process.poll() is None: - process.kill() + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass process.wait() os.close(master) diff --git a/scripts/v3_windows_reliability.py b/scripts/v3_windows_reliability.py new file mode 100644 index 00000000..9087827c --- /dev/null +++ b/scripts/v3_windows_reliability.py @@ -0,0 +1,921 @@ +#!/usr/bin/env python3 +"""Trace the unchanged cancellation unittest; passing samples do not close R260.""" +from __future__ import annotations + +import argparse +from contextlib import closing +import faulthandler +import hashlib +from importlib import metadata +import json +import os +from pathlib import Path +import platform +import re +import signal +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import uuid +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +TEST = 'test_cancel_during_provider_call_prevents_late_write' +HISTORICAL_PRODUCT = '0f172f9fe692ff8fafcc0a15a72fe51eaf67e977' +sys.path[:0] = [str(ROOT), str(ROOT / 'tests')] + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8') + + +# Exact dependency versions from job 125106035551, including its four already-satisfied packages. +HISTORICAL_REQUIREMENTS = """ +aiohappyeyeballs==2.7.1 aiohttp==3.14.5 aiohttp-retry==2.9.1 aiosignal==1.4.0 annotated-doc==0.0.5 +annotated-types==0.8.0 anthropic==2.3.0 anyio==4.16.0 attrs==26.1.0 azure-core==1.41.0 +azure-identity==1.27.0 bcrypt==5.0.0 beautifulsoup4==4.15.0 boto3==1.43.123 botocore==1.43.123 +build==1.6.1 certifi==2026.10.23 cffi==2.1.1 charset-normalizer==3.5.2 chromadb==1.5.9 +cryptography==50.0.2 distro==1.9.0 docstring-parser==0.18.0 durationpy==0.11 fastapi==0.162.0 +flatbuffers==25.12.19 frozenlist==1.8.0 fsspec==2026.9.0 google-auth==2.62.0 google-genai==2.34.0 +googleapis-common-protos==1.76.0 googlesearch-python==1.3.0 graphifyy==0.9.64 greenlet==3.5.6 +grpcio==1.85.1 h11==0.16.0 hf-xet==1.8.0 httpcore==1.0.9 httpcore2==2.16.0 httptools==0.9.0 +httpx==0.28.1 httpx2==2.16.0 huggingface-hub==2.4.0 idna==3.20 importlib-resources==7.1.0 +jiter==0.18.0 jmespath==1.1.0 jsonschema==4.26.0 jsonschema-specifications==2025.9.1 +kubernetes==37.0.1 markdown-it-py==4.2.0 mdurl==0.1.2 mmh3==5.3.1 msal==1.39.0 +msal-extensions==1.3.1 multidict==7.2.1 networkx==3.7 numpy==2.5.4 oauthlib==4.0.1 +onnxruntime==1.31.1 openai==3.42.1 opentelemetry-api==1.45.2 +opentelemetry-exporter-otlp-common==0.66b2 opentelemetry-exporter-otlp-proto-common==1.45.2 +opentelemetry-exporter-otlp-proto-grpc==1.45.2 opentelemetry-proto==1.45.2 opentelemetry-sdk==1.45.2 +opentelemetry-semantic-conventions==0.66b2 orjson==3.13.0 overrides==7.7.0 perplexityai==0.46.1 +playwright==1.64.1 propcache==0.5.4 protobuf==7.36.2 pyasn1==0.6.5 pyasn1-modules==0.4.2 +pybase64==1.5.1 pycparser==3.12 pydantic==2.14.2 pydantic-core==2.50.2 pydantic-settings==2.16.2 +pyee==14.0.0 pygments==2.21.0 pyjwt==2.15.1 pypika==0.51.1 pyproject-hooks==1.3.3 +python-dateutil==2.9.0.post0 python-dotenv==1.2.4 pyyaml==6.0.3 rapidfuzz==3.14.6 +referencing==0.37.0 requests==2.34.2 requests-oauthlib==2.0.0 rich==15.0.0 rpds-py==2026.9.1 +s3transfer==0.19.3 send2trash==2.1.0 shellingham==1.5.4 six==1.17.0 sniffio==1.3.1 soupsieve==3.0.4 +starlette==1.10.0 tenacity==9.1.4 tokenizers==0.23.3 tqdm==4.70.1 tree-sitter==0.25.2 +tree-sitter-bash==0.25.1 tree-sitter-c==0.24.2 tree-sitter-c-sharp==0.23.5 tree-sitter-cpp==0.23.4 +tree-sitter-elixir==0.3.5 tree-sitter-fortran==0.6.0 tree-sitter-go==0.25.0 +tree-sitter-groovy==0.1.2 tree-sitter-java==0.23.5 tree-sitter-javascript==0.25.0 +tree-sitter-json==0.24.8 tree-sitter-julia==0.23.1 tree-sitter-kotlin==1.1.0 tree-sitter-lua==0.5.0 +tree-sitter-objc==3.0.2 tree-sitter-php==0.24.1 tree-sitter-powershell==0.26.4 +tree-sitter-python==0.25.0 tree-sitter-ruby==0.23.1 tree-sitter-rust==0.24.2 +tree-sitter-scala==0.26.4 tree-sitter-swift==0.7.4 tree-sitter-typescript==0.23.2 +tree-sitter-verilog==1.0.3 tree-sitter-zig==1.1.2 truststore==0.10.5 typer==0.27.3 +typing-extensions==4.16.0 typing-inspection==0.4.4 urllib3==2.8.0 uvicorn==0.54.0 watchfiles==1.3.0 +websocket-client==1.9.2 websockets==17.4 yarl==1.25.1 +packaging==26.3 colorama==0.4.6 click==8.5.0 filelock==4.2.4 +""" + + +def isolated_environment(home): + prefixes = ('KYROZEN_', 'OPENAI_', 'ANTHROPIC_', 'DEEPSEEK_', 'AZURE_', 'GEMINI_', + 'GOOGLE_', 'AWS_', 'BEDROCK_', 'OLLAMA_', 'PERPLEXITY_', 'MISTRAL_', + 'GROQ_', 'COHERE_', 'XAI_', 'TOGETHER_', 'OPENROUTER_', 'FIREWORKS_') + env = {key: value for key, value in os.environ.items() + if not key.upper().startswith(prefixes) and not any(term in key.upper() for term in + ('API_KEY', 'ACCESS_KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL', 'ENDPOINT', 'BASE_URL'))} + workspace, skills = home / '.kyrozen' / 'workspace', home / 'skills' + workspace.mkdir(parents=True, exist_ok=True) + skills.mkdir(exist_ok=True) + env.update(HOME=str(home), USERPROFILE=str(home), KYROZEN_WORKSPACE_ROOT=str(workspace), + KYROZEN_DB_PATH=str(home / 'runtime.sqlite3'), KYROZEN_SKILLS_PATH=str(skills), + KYROZEN_SKILLS_DIR=str(skills)) + return env + + +def historical_environment(): + mismatches = {} + for requirement in HISTORICAL_REQUIREMENTS.split(): + name, expected = requirement.split('==') + try: + installed = metadata.version(name) + except metadata.PackageNotFoundError: + installed = None + if installed != expected: + mismatches[name] = {'expected': expected, 'installed': installed} + runtime, system = platform.python_version(), platform.system() + return {'status': 'MATCH' if runtime == '3.12.10' and system == 'Windows' and not mismatches + else 'INFRASTRUCTURE_FAILURE', 'python': runtime, 'system': system, + 'expected_python': '3.12.10', 'dependency_mismatches': mismatches, + 'requirements_sha256': hashlib.sha256(HISTORICAL_REQUIREMENTS.encode()).hexdigest(), + 'source_job': 'https://github.com/EvanProgramming/OpenKyrozen/actions/runs/41392269312/job/125106035551', + 'qualification': 'The original runner image and host scheduling are not reproduced by matching Python and packages.'} + + +_CPU_LOAD = ''' +import json, math, sys, time +from pathlib import Path +ready, stop, result = map(Path, sys.argv[1:4]) +started, cpu = time.monotonic(), time.process_time() +deadline = started + float(sys.argv[4]) +ready.write_text('ready') +value = 0.5 +while not stop.exists() and time.monotonic() < deadline: + for _ in range(20000): + value = math.sin(value) + 0.5 +result.write_text(json.dumps({'cpu_seconds': time.process_time()-cpu, + 'wall_seconds': time.monotonic()-started, + 'stopped_by': 'parent' if stop.exists() else 'deadline'})) +''' + + +class LoadControl: + """Bounded diagnostic load; never writes fixture data or changes timeouts.""" + + def __init__(self, control, workers, case, trace, readonly_anchor=False): + self.control, self.workers, self.case, self.trace = control, workers, case, trace + self.stopped, self.sqlite_ready = threading.Event(), threading.Event() + self.cpu = [] + self.sqlite_thread = None + self.acquired_transactions = 0 + self.sqlite_fatal_error = None + self.closed = False + self.anchor = None + self.anchor_info = {'enabled': readonly_anchor, 'closed': not readonly_anchor} + + def start(self): + if self.anchor_info['enabled']: + database = self.case.root / 'state.sqlite3' + self.anchor = sqlite3.connect(database.as_uri() + '?mode=ro', uri=True, + timeout=.05, isolation_level=None) + # Opening alone is lazy; a finished schema read materializes the WAL client without a transaction. + self.anchor.execute('SELECT name FROM sqlite_schema LIMIT 1').fetchall() + mode = self.anchor.execute('PRAGMA journal_mode').fetchone()[0] + self.anchor_info.update(journal_mode=mode, total_changes=self.anchor.total_changes, + in_transaction=self.anchor.in_transaction) + if mode != 'wal' or self.anchor.total_changes or self.anchor.in_transaction: + raise RuntimeError('Read-only anchor did not materialize an idle WAL client') + self.trace.record('anchor.opened', **self.anchor_info) + if self.control in {'sqlite', 'combined'}: + self.sqlite_thread = threading.Thread(target=self._sqlite_load, + name='diagnostic-sqlite-control', daemon=True) + self.sqlite_thread.start() + if not self.sqlite_ready.wait(3): + raise RuntimeError('SQLite load control did not acquire its first bounded transaction') + if self.control in {'cpu', 'combined'}: + for index in range(self.workers): + prefix = self.trace.output / f'cpu-{index}-{uuid.uuid4().hex}' + ready, stop, result = (Path(str(prefix) + suffix) for suffix in ('.ready', '.stop', '.json')) + process = subprocess.Popen([sys.executable, '-c', _CPU_LOAD, + str(ready), str(stop), str(result), '30'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.cpu.append((process, stop, result)) + deadline = time.monotonic() + 3 + while not ready.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(.01) + if not ready.exists(): + raise RuntimeError('CPU load control did not become ready') + self.trace.record('load.started', control=self.control, cpu_pids=[item[0].pid for item in self.cpu]) + + def _sqlite_load(self): + try: + with closing(sqlite3.connect(self.case.root / 'state.sqlite3', timeout=.05, + isolation_level=None)) as connection: + while not self.stopped.is_set(): + acquired = False + started = time.monotonic_ns() + try: + connection.execute('BEGIN IMMEDIATE') + acquired = True + self.acquired_transactions += 1 + self.trace.record('sqlite.lock_acquired', wait_ns=time.monotonic_ns()-started) + self.sqlite_ready.set() + self.stopped.wait(.025) + except sqlite3.Error as error: + self.trace.record('sqlite.lock_error', error_type=type(error).__name__, + wait_ns=time.monotonic_ns()-started) + finally: + if acquired: + connection.execute('ROLLBACK') + self.trace.record('sqlite.lock_released') + self.stopped.wait(.01) + except sqlite3.Error as error: + self.sqlite_fatal_error = type(error).__name__ + self.trace.record('sqlite.control_error', error_type=type(error).__name__) + + def close(self): + if self.closed: + return + self.closed = True + self.stopped.set() + anchor_error = None + if self.anchor is not None: + self.trace.record('anchor.close.call') + started = time.monotonic_ns() + try: + self.anchor_info.update(total_changes=self.anchor.total_changes, + in_transaction=self.anchor.in_transaction) + self.anchor.close() + self.anchor_info['closed'] = True + self.anchor_info['wal_present_after_close'] = (self.case.root / 'state.sqlite3-wal').exists() + self.anchor = None + except Exception as error: + anchor_error = type(error).__name__ + self.anchor_info['close_error'] = anchor_error + finally: + self.anchor_info['close_elapsed_ns'] = time.monotonic_ns() - started + self.trace.record('anchor.close.return', **self.anchor_info) + cpu = [] + for process, stop, result in self.cpu: + stop.touch() + forced = False + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + forced = True + process.kill() + process.wait(timeout=3) + metrics = json.loads(result.read_text()) if result.is_file() else {} + cpu.append({'pid': process.pid, 'returncode': process.returncode, + 'forced_cleanup': forced, **metrics}) + if self.sqlite_thread is not None: + self.sqlite_thread.join(timeout=2) + sqlite_stopped = self.sqlite_thread is None or not self.sqlite_thread.is_alive() + write_json(self.trace.output / 'load-control.json', {'control': self.control, 'cpu': cpu, + 'readonly_anchor': self.anchor_info, + 'sqlite': {'acquired_transactions': self.acquired_transactions, 'thread_stopped': sqlite_stopped, + 'fatal_error': self.sqlite_fatal_error}}) + self.trace.record('load.stopped', control=self.control, cpu=cpu, sqlite_thread_stopped=sqlite_stopped) + if (anchor_error or not sqlite_stopped or self.sqlite_fatal_error + or any(item['forced_cleanup'] or item['returncode'] != 0 + or item.get('stopped_by') != 'parent' for item in cpu)): + raise RuntimeError('Load-control cleanup did not complete normally; inspect diagnostic evidence') + + +def stop_diagnostic_tree(process): + """Stop the still-owned diagnostic group before its ancestry disappears.""" + attempt = {'parent_pid': process.pid, 'descendant_cleanup_certified': False} + if os.name == 'nt': + result = subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'], + capture_output=True, timeout=5, check=False) + attempt.update(method='taskkill_tree', command_returncode=result.returncode) + else: + try: + os.killpg(process.pid, signal.SIGKILL) + attempt.update(method='owned_process_group', signal_sent=True) + except ProcessLookupError: + attempt.update(method='owned_process_group', signal_sent=False) + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + attempt['parent_returncode'] = process.returncode + return attempt + + +class Trace: + def __init__(self, output, native=False): + self.output = output + self.stream = (output / 'events.jsonl').open('w', encoding='utf-8') + self.lock = threading.Lock() + self.case = None + self.coordinator = None + self.active = True + self.native = native + if native: + clock = {'performance_counter_ns': time.perf_counter_ns(), 'unix_time_ns': time.time_ns()} + if os.name == 'nt': + import ctypes + counter, frequency = ctypes.c_longlong(), ctypes.c_longlong() + kernel = ctypes.WinDLL('kernel32', use_last_error=True) + if not kernel.QueryPerformanceCounter(ctypes.byref(counter)) or not kernel.QueryPerformanceFrequency(ctypes.byref(frequency)): + raise OSError('Windows performance-counter mapping unavailable') + clock.update(qpc_counter=counter.value, qpc_frequency=frequency.value) + self.record('clock.mapping', **clock) + + def record(self, event, **fields): + with self.lock: + if not self.active: + return + self.stream.write(json.dumps({'event': event, 'monotonic_ns': time.monotonic_ns(), + 'thread_id': threading.get_ident(), 'native_tid': threading.get_native_id(), + 'pid': os.getpid(), 'thread_cpu_ns': time.thread_time_ns(), **fields}) + '\n') + self.stream.flush() + + def wal_metadata(self): + try: + path = self.case.root / 'state.sqlite3-wal' + return {'exists': True, 'bytes': path.stat().st_size} + except FileNotFoundError: + return {'exists': False, 'bytes': 0} + except (OSError, AttributeError) as error: + return {'error': type(error).__name__} + + def profile(self, frame, event, arg): + if event in {'c_call', 'c_return', 'c_exception'}: + if isinstance(getattr(arg, '__self__', None), sqlite3.Connection): + details = ({'connection_id': id(arg.__self__), 'wal': self.wal_metadata()} + if self.native and arg.__name__ == 'close' else {}) + self.record('sqlite.' + arg.__name__ + '.' + event, + caller=frame.f_code.co_qualname, **details) + elif arg is sqlite3.connect: + self.record('sqlite.connect.' + event, caller=frame.f_code.co_qualname) + return + if event not in {'call', 'return'}: + return + module = frame.f_globals.get('__name__', '') + name = frame.f_code.co_name + label = None + if module == 'test_delegation' and name == 'chat' and 'SlowProvider' in frame.f_code.co_qualname: + label = 'provider' + elif module == 'openkyrozen.agent.delegation': + label = {'spawn': 'dispatch', 'cancel': 'cancel', '_work': 'worker', + '_publish': 'persist', '_invoke': 'invoke'}.get(name) + if label: + self.coordinator = frame.f_locals['self'] + elif module == 'threading' and name == 'set': + caller = frame.f_back + if caller and caller.f_code.co_name == TEST: + if frame.f_locals['self'] is caller.f_locals.get('release'): + label = 'release' + elif module == 'concurrent.futures._base' and name == 'result': + label = 'future_wait' + elif module == 'openkyrozen.app.container' and name == 'close': + label = 'application_close' + elif module == 'openkyrozen.providers.retry': + label = {'_next_event': 'provider_wait', 'check': 'provider_check', + 'bounded_call': 'provider_bounded_call'}.get(name) + elif module == 'openkyrozen.providers.usage' and name == '_track_cost': + label = 'usage_record' + elif module == 'openkyrozen.persistence.usage' and name == 'record_usage_attempt': + label = 'usage_persist' + elif module == 'openkyrozen.persistence.events' and name == 'append_event': + label = 'event_persist' + elif module == 'openkyrozen.agent.delegation_runtime' and name == '_invoke_delegated': + label = 'delegated_invoke' + elif module == 'openkyrozen.agent.subagent_runtime' and name == '_subagent_usage_metrics': + label = 'usage_summary' + elif module == 'openkyrozen.agent.runtime' and name == '_publish_runtime_event': + label = 'runtime_event' + if label: + self.record(label + '.' + event, function=frame.f_code.co_qualname) + if self.native and label == 'application_close' and event == 'return': + self.snapshot('state-after-completion.json') + + def snapshot(self, filename): + """Avoid application locks and raw copies of live SQLite/WAL files.""" + state = {'monotonic_ns': time.monotonic_ns(), 'persisted_states': [], 'runs': []} + case, coordinator = self.case, self.coordinator + if coordinator: + for run_id, run in list(coordinator.runs.items()): + future = coordinator.futures.get(run_id) + state['runs'].append({'run_id': run_id, 'status': run.get('status'), + 'error': run.get('error'), 'future_done': future.done() if future else None, + 'cancelled': coordinator.cancelled[run_id].is_set()}) + if case and hasattr(case, 'root'): + try: + state['marker'] = (case.root / 'marker.txt').read_text(encoding='utf-8') + database = case.root / 'state.sqlite3' + state['sqlite_files'] = {path.name: path.stat().st_size + for path in case.root.glob('state.sqlite3*') if path.is_file()} + state['database_snapshot_deferred'] = bool(self.native and + (not state['runs'] or not all(run['future_done'] for run in state['runs']))) + if not state['database_snapshot_deferred']: + with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True, timeout=0)) as connection: + rows = connection.execute("SELECT payload FROM events WHERE event_type='subagent.state' ORDER BY rowid").fetchall() + for row in rows: + run = json.loads(row[0]) + state['persisted_states'].append({key: run.get(key) for key in + ('run_id', 'status', 'version', 'error', 'progress')}) + except (OSError, sqlite3.Error) as exc: + state['snapshot_error'] = repr(exc) + write_json(self.output / filename, state) + + +def run_child(output, control='baseline', cpu_workers=2, readonly_anchor=False, native_trace=False): + # Import the fixture before starting the four-second cancellation watchdog. + from test_delegation import DelegationTests + + output.mkdir(parents=True, exist_ok=True) + trace = Trace(output, native=native_trace) + if native_trace: + import openkyrozen + if not Path(openkyrozen.__file__).resolve().is_relative_to(ROOT): + raise RuntimeError('Native comparison imported the wrong product source') + trace.record('product.source', path=str(Path(openkyrozen.__file__).resolve())) + + class DiagnosticCase(DelegationTests): + def setUp(self): + trace.case = self + trace.record('setup.call') + super().setUp() + trace.record('setup.return') + # Lazy SDK initialization must not consume the cancellation trace's profiling overhead. + sys.setprofile(trace.profile) + threading.setprofile(trace.profile) + self.load_control = LoadControl(control, cpu_workers, self, trace, readonly_anchor) + self.addCleanup(self.load_control.close) + self.load_control.start() + + def tearDown(self): + trace.record('cleanup.call') + trace.snapshot('state-before-cleanup.json') + try: + self.load_control.close() + finally: + try: + super().tearDown() + finally: + trace.record('cleanup.return') + + stopped = threading.Event() + + def watch(): + count = 0 + while not stopped.wait(4): + count += 1 + trace.record('watchdog', interval_seconds=4) + trace.snapshot(f'state-watchdog-{count}.json') + if stopped.is_set(): + return + stacks.write('Timeout (0:00:04)!\n') + stacks.flush() + # The C watchdog can spin walking profiled live frames on CPython 3.12. + faulthandler.dump_traceback(file=stacks, all_threads=True) + + previous = sys.getprofile() + previous_thread = threading.getprofile() + with (output / 'threads.log').open('w', encoding='utf-8') as stacks: + watcher = threading.Thread(target=watch, name='diagnostic-state-watchdog', daemon=True) + watcher.start() + try: + with (output / 'unittest.log').open('w', encoding='utf-8') as log: + result = unittest.TextTestRunner(stream=log, verbosity=2).run(DiagnosticCase(TEST)) + trace.record('result', successful=result.wasSuccessful(), failures=len(result.failures), errors=len(result.errors)) + finally: + sys.setprofile(previous) + threading.setprofile(previous_thread) + stopped.set() + watcher.join(timeout=1) + with trace.lock: + trace.active = False + trace.stream.close() + return 0 if result.wasSuccessful() else 1 + + +def run_process(command, log_path, env, *, timeout=45, abort=None): + started = time.monotonic_ns() + cleanup = None + abort_reason = None + if abort is not None and (abort_reason := abort()): + return {'returncode':124, 'elapsed_ns':time.monotonic_ns()-started, 'command':command, + 'timeout_seconds':timeout, 'timeout_cleanup':None, 'abort_reason':abort_reason} + with log_path.open('w', encoding='utf-8') as log: + process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, + start_new_session=os.name != 'nt', + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0) + try: + if abort is None: + returncode = process.wait(timeout=timeout) + else: + deadline = time.monotonic() + timeout + while True: + abort_reason = abort() + remaining = deadline - time.monotonic() + if abort_reason or remaining <= 0: + raise subprocess.TimeoutExpired(command, timeout) + try: + returncode = process.wait(timeout=min(.1, remaining)) + break + except subprocess.TimeoutExpired: + pass + except subprocess.TimeoutExpired: + try: + cleanup = stop_diagnostic_tree(process) + except Exception as error: + cleanup = {'parent_pid': process.pid, 'error_type': type(error).__name__, + 'descendant_cleanup_certified': False} + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + returncode = 124 + return {'returncode': returncode, 'elapsed_ns': time.monotonic_ns() - started, + **({'abort_reason': abort_reason} if abort else {}), + 'command': command, 'timeout_seconds': timeout, 'timeout_cleanup': cleanup} + + +def native_limit(directory, deadline): + if time.monotonic() >= deadline: + return 'active_time_limit' + size = 0 + try: + for path in directory.rglob('*.etl'): + try: + size += path.stat().st_size + except FileNotFoundError: + continue # WPR may merge/rename an owned temporary while it is inspected. + except OSError: + return 'trace_inventory_error' + if size > 512 * 1024 * 1024: + return 'trace_size_limit' + return None + + +_NATIVE_WAIT_PROFILE = 'KyrozenR260' +_NATIVE_WAIT_KEYWORDS = ('ProcessThread', 'Loader', 'ThreadPriority', 'CSwitch', 'ReadyThread', + 'DiskIO', 'DiskIOInit', 'FileIO', 'FileIOInit', 'Filename') + + +class NativeCapture: + """Only controls one uniquely named WPR instance; never cancels a global trace.""" + def __init__(self, output, env): + self.output, self.env = output, env + self.wpr, self.decoder = shutil.which('wpr.exe'), shutil.which('tracerpt.exe') + self.instance = 'KyrozenR260-' + uuid.uuid4().hex + self.case = None + self.active = False + self.cleanup_uncertified = False + self.profile = self.profile_sha256 = None + + def _tool(self, command, label, timeout=10, check=True, allowed_returncodes=(0,)): + path = (self.case or self.output) / (label + '.json') + if path.exists(): + path = path.with_stem(path.stem + '-' + uuid.uuid4().hex[:8]) + try: + result = subprocess.run(command, env=self.env, capture_output=True, text=True, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as error: + write_json(path, {'command': command, 'error': type(error).__name__}) + raise + write_json(path, {'command': command, 'returncode': result.returncode, + 'stdout': result.stdout[:65536], 'stderr': result.stderr[:65536]}) + if check and result.returncode not in allowed_returncodes: + raise RuntimeError(f'{label} failed with exit {result.returncode}') + return result.stdout + result.stderr + + def preflight(self): + self.profile = self.profile_sha256 = None + if platform.system() != 'Windows' or not self.wpr or not self.decoder: + raise RuntimeError('Windows WPR/tracerpt unavailable') + profiles = self._tool([self.wpr, '-profiles'], 'wpr-profiles') + advanced = self._tool([self.wpr, '-help', 'advanced'], 'wpr-help') + if not all(re.search(r'\b' + name + r'\b', profiles) for name in ('GeneralProfile', 'FileIO')): + raise RuntimeError('Required WPR GeneralProfile/FileIO profile unavailable') + if 'instancename' not in advanced.lower(): + raise RuntimeError('Owned WPR instance support unavailable') + exported = self.output / 'available-profile.wprp' + self._tool([self.wpr, '-exportprofile', 'GeneralProfile+FileIO', str(exported), '-filemode'], 'wpr-export') + try: + available = {node.get('Value') for node in ET.parse(exported).findall('.//SystemProvider/Keywords/Keyword')} + except ET.ParseError as error: + raise RuntimeError('Exported WPR profile is not valid XML') from error + # Filename is a documented kernel keyword not listed by this built-in + # export. The custom parser and Strict start must support it as well. + if not (set(_NATIVE_WAIT_KEYWORDS) - {'Filename'}) <= available: + raise RuntimeError('Exported WPR profile lacks native wait/I/O events') + document = ET.Element('WindowsPerformanceRecorder', Version='1.0') + definitions = ET.SubElement(document, 'Profiles') + collector = ET.SubElement(definitions, 'SystemCollector', Id='R260Collector', Name='KyrozenR260 System Collector') + # ETW recommends <=64 KiB for ordinary event rates; retain 40 buffers. + ET.SubElement(collector, 'BufferSize', Value='64') + ET.SubElement(collector, 'Buffers', Value='40') + provider = ET.SubElement(definitions, 'SystemProvider', Id='R260Provider') + keywords = ET.SubElement(provider, 'Keywords') + for keyword in _NATIVE_WAIT_KEYWORDS: + ET.SubElement(keywords, 'Keyword', Value=keyword, Strict='true') + for mode in ('File', 'Memory'): + profile = ET.SubElement(definitions, 'Profile', Id=f'{_NATIVE_WAIT_PROFILE}.Verbose.{mode}', + Name=_NATIVE_WAIT_PROFILE, DetailLevel='Verbose', LoggingMode=mode, + Description='R260 scheduling and file I/O without sampling or stacks') + collectors = ET.SubElement(profile, 'Collectors') + reference = ET.SubElement(collectors, 'SystemCollectorId', Value='R260Collector') + ET.SubElement(reference, 'SystemProviderId', Value='R260Provider') + selected = self.output / 'selected-profile.wprp' + ET.indent(document) + ET.ElementTree(document).write(selected, encoding='utf-8', xml_declaration=True) + listed = self._tool([self.wpr, '-profiles', str(selected)], 'wpr-custom-profiles') + if not re.search(r'\b' + _NATIVE_WAIT_PROFILE + r'\b', listed): + raise RuntimeError('Custom WPR native wait profile unavailable') + self.profile = selected + self.profile_sha256 = hashlib.sha256(selected.read_bytes()).hexdigest() + return {'path': str(selected), 'name': _NATIVE_WAIT_PROFILE, 'sha256': self.profile_sha256, + 'system_keywords': list(_NATIVE_WAIT_KEYWORDS), 'buffer_size_kb': 64, 'buffers': 40} + + def start(self, case): + if self.profile is None or hashlib.sha256(self.profile.read_bytes()).hexdigest() != self.profile_sha256: + raise RuntimeError('Custom WPR profile has not passed preflight or has changed') + self.case = case + temporary = self.output / 'traces' / 'temporary' + temporary.mkdir(parents=True, exist_ok=True) + # A timed-out start is uncertain; finally still attempts to save this unique instance. + self.active = True + self._tool([self.wpr, '-start', str(self.profile) + '!' + _NATIVE_WAIT_PROFILE, '-filemode', + '-recordtempto', str(temporary), '-instancename', self.instance], 'wpr-start') + + def stop(self): + if not self.active: + return None + trace = self.output / 'traces' / (self.case.name + '.etl') + trace.parent.mkdir(parents=True, exist_ok=True) + if trace.exists(): + trace = trace.with_stem(trace.stem + '-' + uuid.uuid4().hex[:8]) + self.cleanup_uncertified = True + failure = None + try: + status = self._tool([self.wpr, '-status', 'collectors', '-details', + '-instancename', self.instance], 'wpr-status') + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + failure = error + try: + # Saving is attempted even if status inspection failed. A failed + # save must not leave the owned file-mode recorder unmonitored. + self._tool([self.wpr, '-stop', str(trace), '-skipPdbGen', '-instancename', self.instance], + 'wpr-stop', timeout=30) + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + failure = failure or error + self._cancel_owned() + else: + try: + self._verify_inactive() + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + failure = failure or error + self._cancel_owned() + self.active = False + self.cleanup_uncertified = False + if failure is not None: + raise failure + if not trace.is_file() or not trace.stat().st_size: + raise RuntimeError('WPR did not save a nonempty ETL') + losses = [int(value) for value in re.findall(r'(?:Events Lost|Dropped event)\s*:\s*(\d+)', status, re.I)] + if not losses or sum(losses): + raise RuntimeError('WPR event-loss evidence missing or nonzero; saved ETL is incomplete evidence') + with trace.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + return {'status': 'CAPTURED', 'etl': str(trace), 'bytes': trace.stat().st_size, + 'sha256': digest, + 'event_loss': sum(losses) if losses else None} + + def _verify_inactive(self): + absent = 0xc5583000 + status = self._tool([self.wpr, '-status', '-instancename', self.instance], 'wpr-inactive', + allowed_returncodes=(0, absent, absent - 2**32)) + inactive = {'wpr is not recording.', 'wpr is not recording', + 'windows performance recorder is not recording.', + 'there are no trace profiles running.'} + lines = {line.strip().casefold() for line in status.splitlines()} + if (not lines.intersection(inactive) or 'recording is in progress' in status.casefold() + or 'actively recording collectors' in status.casefold()): + raise RuntimeError('owned WPR inactivity could not be verified') + + def _cancel_owned(self): + errors = [] + for operation in (lambda: self._tool([self.wpr, '-cancel', '-instancename', self.instance], + 'wpr-cancel', timeout=10), self._verify_inactive): + try: + operation() + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + errors.append(error) + if errors: + raise RuntimeError('owned WPR cleanup_uncertified: cancel/inactive verification failed') from errors[0] + self.active = False + self.cleanup_uncertified = False + + def decode(self, capture): + self._tool([self.decoder, capture['etl'], '-o', str(self.case / 'native-events.xml'), + '-of', 'XML', '-summary', str(self.case / 'native-summary.txt'), '-y'], + 'tracerpt', timeout=30) + + +def _product_identity(repository): + def git(*arguments): + return subprocess.run(['git', *arguments], cwd=repository, check=True, capture_output=True, + timeout=10).stdout + revision = git('rev-parse', 'HEAD').decode().strip() + if git('diff', 'HEAD', '--'): + raise RuntimeError('Native experiment product has tracked edits') + return {'candidate_sha': revision, 'tracked_product_edits': False, + 'fixture_sha256': hashlib.sha256((repository / 'tests/test_delegation.py').read_bytes()).hexdigest(), + 'database_sha256': hashlib.sha256((repository / 'openkyrozen/persistence/database.py').read_bytes()).hexdigest()} + + +def _native_timing(case): + events = [json.loads(line) for line in (case / 'events.jsonl').read_text(encoding='utf-8-sig').splitlines()] + cancel = next((row for row in events if row['event'] == 'cancel.return'), None) + start = next((row for row in events if row['event'] == 'future_wait.call' and + cancel and row['thread_id'] == cancel['thread_id'] and + row['monotonic_ns'] >= cancel['monotonic_ns']), None) + end = next((row for row in events if row['event'] == 'future_wait.return' and start and + row['thread_id'] == start['thread_id'] and row['monotonic_ns'] >= start['monotonic_ns']), None) + return (end['monotonic_ns'] - start['monotonic_ns']) / 1e9 if end else None + + +def run_native_comparison(output, current, historical): + output.mkdir(parents=True, exist_ok=True) + report = {'schema_version': 4, 'status': 'BLOCKED', 'historical_cause_established': False, + 'capture_scope': 'experiment', + 'active_limit_seconds': 300, 'trace_limit_bytes': 512*1024*1024, + 'test_timeouts_seconds': {'provider_wait':3, 'entry_wait':10, 'future_wait':5}, + 'child_limit_seconds':45, 'cpu_workers':2, 'iterations':[], 'execution_order':[], + 'qualification': 'Native traces require wait/phase analysis; all-fast samples are inconclusive.'} + capture = None + + def save_capture(): + saved = capture.stop() + if saved is not None: + report['capture'] = dict(saved, instance_name=capture.instance) + for row in report['iterations']: + row['capture'] = report['capture'] + # Saving/merging can first cross the sampled size bound. Cleanup + # time is outside the active budget, so only inspect the inventory. + if reason := native_limit(output / 'traces', float('inf')): + report.setdefault('limit_reached', reason) + + try: + report['historical_environment'] = historical_environment() + if report['historical_environment']['status'] != 'MATCH': + raise RuntimeError('Exact historical Windows Python/packages unavailable') + report['products'] = {'historical':_product_identity(historical), 'current':_product_identity(current)} + if report['products']['historical']['candidate_sha'] != HISTORICAL_PRODUCT: + raise RuntimeError('Historical product must be the pinned original source') + report['diagnostic_sha256'] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + write_json(output / 'report.json', report) + with tempfile.TemporaryDirectory(prefix='kyrozen-native-wait-') as home: + env = isolated_environment(Path(home)) + capture = NativeCapture(output, env) + report['capture_profile'] = capture.preflight() + capture_directory = output / 'native-capture' + capture_directory.mkdir() + report['capture_directory'] = str(capture_directory) + report['capture_instance'] = capture.instance + deadline = time.monotonic() + 300 + abort = lambda: native_limit(output / 'traces', deadline) + orders = (('baseline','anchor','current'), ('current','anchor','baseline'), + ('anchor','baseline','current'), ('current','baseline','anchor')) + slow = False + try: + capture.start(capture_directory) + for index, order in enumerate(orders, 1): + for variant in order: + if reason := abort(): + report['limit_reached'] = reason + break + if deadline - time.monotonic() <= 10: + report['limit_reached'] = 'active_time_limit' + break + case = output / f'{index}-{variant}' + case.mkdir() + repository = current if variant == 'current' else historical + row = {'triple':index, 'variant':variant, 'repository':str(repository), + 'native_events':str(case / 'events.jsonl')} + report['iterations'].append(row) + report['execution_order'].append('cancellation-' + case.name) + write_json(output / 'report.json', report) + command = [sys.executable, str(Path(__file__).resolve()), '--child', '--output', str(case), + '--repository', str(repository), '--control', 'cpu', '--cpu-workers', '2', '--native-trace'] + if variant == 'anchor': + command.append('--readonly-anchor') + row.update(run_process(command, case / 'process.log', env, abort=abort)) + if row.get('abort_reason') or abort(): + report['limit_reached'] = row.get('abort_reason') or abort() + break + row['future_wait_seconds'] = _native_timing(case) + slow |= row['returncode'] != 0 or (row['future_wait_seconds'] or 0) >= 1 + report['execution_order'].append('reviewer-' + case.name) + row['reviewer'] = run_process([sys.executable, str(historical / 'scripts/subagent_workflow_acceptance.py')], + case / 'reviewer.log', env, abort=abort) + if row['reviewer'].get('abort_reason'): + report['limit_reached'] = row['reviewer']['abort_reason'] + break + if row['reviewer']['returncode']: + raise RuntimeError('Pinned historical reviewer workload failed; inspect retained row') + write_json(output / 'report.json', report) + if report.get('limit_reached') or slow: + break + finally: + save_capture() + write_json(output / 'report.json', report) + report['status'] = 'PENDING_NATIVE_ANALYSIS' if slow else 'INCONCLUSIVE' + # All cell PID/TID/QPC events join to this one experiment trace. + if report['iterations']: + capture.decode(report['capture']) + except (OSError, RuntimeError, subprocess.SubprocessError, ValueError) as error: + report.update(status='BLOCKED', error=f'{type(error).__name__}: {error}') + finally: + if capture is not None and capture.active: + try: + save_capture() + except Exception as error: + report.update(status='BLOCKED', cleanup_error=f'{type(error).__name__}: {error}') + if capture is not None: + report['cleanup_uncertified'] = capture.active or getattr(capture, 'cleanup_uncertified', False) + write_json(output / 'report.json', report) + print('Native wait experiment: ' + report['status'], flush=True) + if report['status'] == 'BLOCKED': + return 2 + return int(bool(report.get('limit_reached')) or any( + row.get('returncode') or row.get('reviewer', {}).get('returncode') + for row in report['iterations'])) + + +def main(): + global ROOT + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path) + parser.add_argument('--repository', type=Path, default=ROOT, help='Exact product checkout to test; harness may live separately') + parser.add_argument('--iterations', type=int, default=20) + parser.add_argument('--control', choices=('baseline', 'cpu', 'sqlite', 'combined', 'paired-anchor', 'native-wait'), default='baseline') + parser.add_argument('--historical-repository', type=Path, help='Pinned old checkout for native Windows comparison') + parser.add_argument('--native-trace', action='store_true', help=argparse.SUPPRESS) + parser.add_argument('--cpu-workers', type=int, default=2, help='Bounded owned CPU load processes (1–4)') + parser.add_argument('--interleave-reviewer', action='store_true', help='Run fixture-only reviewer acceptance after each cancellation, matching the original order') + parser.add_argument('--historical-environment', action='store_true', help='Require Windows Python 3.12.10 and every original package version; mismatch is infrastructure failure') + parser.add_argument('--write-historical-requirements', type=Path, help='Write original package pins and exit without loading the product') + parser.add_argument('--child', action='store_true', help=argparse.SUPPRESS) + parser.add_argument('--readonly-anchor', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + if args.write_historical_requirements: + args.write_historical_requirements.parent.mkdir(parents=True, exist_ok=True) + args.write_historical_requirements.write_text('\n'.join(HISTORICAL_REQUIREMENTS.split()) + '\n', encoding='utf-8') + return 0 + if args.output is None: + parser.error('--output is required') + if args.iterations < 1: + parser.error('--iterations must be positive') + if not 1 <= args.cpu_workers <= 4: + parser.error('--cpu-workers must be between 1 and 4') + paired = args.control == 'paired-anchor' + if paired and (args.child or args.iterations > 10): + parser.error('paired-anchor is a parent campaign bounded at 10 pairs') + if paired: + args.interleave_reviewer = True + ROOT = args.repository.resolve() + sys.path[:0] = [str(ROOT), str(ROOT / 'tests')] + output = args.output.resolve() + if args.child: + return run_child(output, args.control, args.cpu_workers, args.readonly_anchor, args.native_trace) + if args.control == 'native-wait': + if not args.historical_repository or not args.historical_environment or args.iterations != 4: + parser.error('native-wait requires historical environment, historical repository and exactly four triples') + return run_native_comparison(output, ROOT, args.historical_repository.resolve()) + output.mkdir(parents=True, exist_ok=True) + if args.historical_environment: + environment = historical_environment() + write_json(output / 'historical-environment.json', environment) + if environment['status'] != 'MATCH': + print('INFRASTRUCTURE_FAILURE: historical environment differs; no fixture was executed.', flush=True) + return 2 + revision = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip() + tracked_diff = subprocess.run(['git', 'diff', 'HEAD', '--'], cwd=ROOT, capture_output=True, check=True).stdout + report = {'tracked_product_edits': bool(tracked_diff), + 'tracked_product_diff_sha256': hashlib.sha256(tracked_diff).hexdigest(), 'schema_version': 2 if paired else 1, 'candidate_sha': revision, 'platform': platform.platform(), + 'python': sys.version, 'sqlite_version': sqlite3.sqlite_version, 'cpu_count': os.cpu_count(), + 'fixture': 'tests.test_delegation.DelegationTests.' + TEST, + 'fixture_sha256': hashlib.sha256((ROOT / 'tests/test_delegation.py').read_bytes()).hexdigest(), + 'diagnostic_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'test_timeouts_seconds': {'provider_wait': 3, 'entry_wait': 10, 'future_wait': 5}, + 'watchdog_seconds': 4, 'historical_cause_established': False, + 'historical_environment_required': args.historical_environment, + 'interleave_reviewer': args.interleave_reviewer, 'execution_order': [], + 'private_home': True, + 'control': args.control, 'cpu_workers': args.cpu_workers if args.control in {'cpu', 'combined', 'paired-anchor'} else 0, + 'paired_readonly_anchor': paired, 'requested_pairs': args.iterations if paired else None, + 'qualification': 'Observational instrumentation changes scheduling; passing samples do not explain the historical timeout.', + 'iterations': []} + write_json(output / 'report.json', report) + with tempfile.TemporaryDirectory(prefix='kyrozen-windows-diagnostic-') as home: + env = isolated_environment(Path(home)) + for index in range(1, args.iterations + 1): + variants = [('baseline', False), ('anchor', True)] if paired else [('', args.readonly_anchor)] + if paired and index % 2 == 0: + variants.reverse() + for variant, anchor in variants: + directory = output / str(index) + tag = f'{index}-{variant}' if paired else str(index) + if paired: + directory = directory / variant + directory.mkdir(parents=True, exist_ok=True) + control = 'cpu' if paired else args.control + command = [sys.executable, str(Path(__file__).resolve()), '--child', '--output', str(directory), + '--repository', str(ROOT), '--control', control, '--cpu-workers', str(args.cpu_workers)] + if anchor: + command.append('--readonly-anchor') + report['execution_order'].append(f'cancellation-{tag}') + write_json(output / 'report.json', report) + result = {'iteration': len(report['iterations']) + 1, + **run_process(command, directory / 'process.log', env)} + if paired: + result.update(pair=index, variant=variant, control=control, readonly_anchor=anchor) + report['iterations'].append(result) + write_json(output / 'report.json', report) + print(f"Cancellation iteration {tag}: exit={result['returncode']}", flush=True) + if args.interleave_reviewer: + report['execution_order'].append(f'reviewer-{tag}') + write_json(output / 'report.json', report) + result['reviewer'] = run_process( + [sys.executable, str(ROOT / 'scripts/subagent_workflow_acceptance.py')], + directory / 'reviewer.log', env) + write_json(output / 'report.json', report) + print(f"Reviewer iteration {tag}: exit={result['reviewer']['returncode']}", flush=True) + return int(any(item['returncode'] != 0 or item.get('reviewer', {}).get('returncode', 0) != 0 + for item in report['iterations'])) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_acceptance_isolation.py b/tests/test_acceptance_isolation.py new file mode 100644 index 00000000..9ad3e841 --- /dev/null +++ b/tests/test_acceptance_isolation.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +class AcceptanceIsolationTests(unittest.TestCase): + def test_agent_workflow_does_not_open_the_configured_user_database(self): + root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / 'user-state.sqlite3' + original = b'User data must never be opened by deterministic acceptance.' + database.write_bytes(original) + env = {**os.environ, 'KYROZEN_DB_PATH': str(database), + 'KYROZEN_DISABLE_VECTOR_INDEX': '1'} + result = subprocess.run( + [sys.executable, str(root / 'scripts' / 'agent_workflow_acceptance.py')], + cwd=directory, env=env, capture_output=True, text=True, timeout=45, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('Agent workflow acceptance passed.', result.stdout) + self.assertEqual(database.read_bytes(), original) + diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index b5c18c66..55f401cb 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import os import subprocess import sys @@ -90,10 +91,10 @@ def test_schema_rejects_unknown_keys_bad_types_and_duplicate_yaml_keys(self): def test_custom_role_influences_prompt_and_capability_has_real_effect(self): original_memory = main.memory_bank original_root = main._get_workspace_root() - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) (root / "agent.yaml").write_text(_config_text("reviewer", "read"), encoding="utf-8") - main.memory_bank = MemoryBank(root / "state.sqlite3", workspace_id="config", session_id="test") + main.memory_bank = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", workspace_id="config", session_id="test"))) main._set_workspace_root(root) try: with patch.object(main, "_retrieve_failure", return_value=[]), \ diff --git a/tests/test_agent_engine.py b/tests/test_agent_engine.py index 69395ef0..bded2562 100644 --- a/tests/test_agent_engine.py +++ b/tests/test_agent_engine.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import contextvars import os import tempfile @@ -74,6 +75,156 @@ def bind_tool_batch(self, runtime, specs=None): runtime._operation_action = lambda name: name runtime._execute_tool_batch = lambda calls, **kwargs: _execute_tool_batch(runtime, calls, **kwargs) + def complex_native_runtime(self): + provider = SimpleNamespace(get_capabilities=lambda _model: ProviderCapabilities(native_tools=True)) + runtime = SimpleNamespace( + execution_context=SimpleNamespace(provider=provider, discovered_tools=frozenset()), + llm_provider=None, DEEPSEEK_MODEL="model", MAX_STEPS_PER_TURN=2, + _record_foreground_model=contextvars.ContextVar("record", default=False), + _conversation_turn_id=contextvars.ContextVar("turn", default=None), + _last_response_is_native=contextvars.ContextVar("native", default=False), + _last_prompt_tokens=0, _last_completion_tokens=0, + tasks=SimpleNamespace(working_plan=None), + ) + runtime._prepare_turn = lambda turn: ( + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "complex"), + setattr(turn, "learning_run", {"run_id": "run"}), setattr(turn, "learned_context", ""), + ) + runtime._prepare_context_for_call = lambda *_args: None + runtime._build_messages = lambda *args, **kwargs: [] + runtime._permitted_tool_names = lambda: {"read_file", "update_plan"} + runtime._prompt_profile = lambda: "compact" + runtime._get_model_response = Mock(return_value=ModelResponse( + tool_calls=(ToolCall("read-1", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST, + )) + runtime._execute_tool_batch = Mock() + runtime._deterministic_tool_summary = lambda records: "no tools" + runtime._complete_turn = lambda turn: turn.protocol_error_message or turn.final_answer + return runtime + + def test_complex_native_turn_cannot_run_tools_before_a_working_plan(self): + runtime = self.complex_native_runtime() + + result = AgentEngine(runtime).run(TurnContext("Complex task")) + + runtime._execute_tool_batch.assert_not_called() + self.assertIn("plan", result.lower()) + self.assertEqual(runtime._get_model_response.call_args.kwargs["tool_names"], {"update_plan"}) + + def test_complex_native_turn_blocks_when_planning_is_not_permitted(self): + runtime = self.complex_native_runtime() + runtime._permitted_tool_names = lambda: {"read_file"} + result = AgentEngine(runtime).run(TurnContext("Complex task")) + self.assertIn("planning is not permitted", result) + runtime._get_model_response.assert_not_called() + runtime._execute_tool_batch.assert_not_called() + + def test_complex_native_turn_rejects_plan_and_other_tool_in_same_batch(self): + runtime = self.complex_native_runtime() + runtime._get_model_response.return_value = ModelResponse(tool_calls=( + ToolCall("plan-1", "update_plan", {"complexity": "complex", "tasks": []}), + ToolCall("read-1", "read_file", {"path": "note.txt"}), + ), finish_reason=FinishReason.TOOL_REQUEST) + result = AgentEngine(runtime).run(TurnContext("Complex task")) + self.assertIn("plan", result.lower()) + runtime._execute_tool_batch.assert_not_called() + + def test_complex_native_turn_cannot_continue_after_failed_plan_update(self): + runtime = self.complex_native_runtime() + runtime._get_model_response.side_effect = [ + ModelResponse(tool_calls=(ToolCall("plan-1", "update_plan", {}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(tool_calls=(ToolCall("read-1", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST), + ] + result = AgentEngine(runtime).run(TurnContext("Complex task")) + self.assertIn("plan", result.lower()) + self.assertEqual(runtime._execute_tool_batch.call_count, 1) + self.assertEqual(runtime._execute_tool_batch.call_args.args[0][0]["action"], "update_plan") + + def test_complex_native_turn_retries_final_answer_without_plan_then_stops(self): + runtime = self.complex_native_runtime() + runtime._get_model_response.return_value = ModelResponse(text="Done", finish_reason=FinishReason.FINAL) + result = AgentEngine(runtime).run(TurnContext("Complex task")) + self.assertIn("not established", result) + self.assertEqual(runtime._get_model_response.call_count, 3) + runtime._execute_tool_batch.assert_not_called() + + def test_complex_native_turn_runs_other_tools_after_plan_succeeds(self): + runtime = self.complex_native_runtime() + runtime._get_model_response.side_effect = [ + ModelResponse(tool_calls=(ToolCall("plan-1", "update_plan", {"complexity": "complex", "tasks": []}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(tool_calls=(ToolCall("read-1", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(text="Done", finish_reason=FinishReason.FINAL), + ] + def execute(calls, **kwargs): + if calls[0]["action"] == "update_plan": + runtime.tasks.working_plan = SimpleNamespace(id="plan-1", status="active") + runtime._execute_tool_batch.side_effect = execute + self.assertEqual(AgentEngine(runtime).run(TurnContext("Complex task")), "Done") + self.assertEqual([call.args[0][0]["action"] for call in runtime._execute_tool_batch.call_args_list], + ["update_plan", "read_file"]) + self.assertEqual(runtime._get_model_response.call_args_list[0].kwargs["tool_names"], {"update_plan"}) + self.assertIn("read_file", runtime._get_model_response.call_args_list[1].kwargs["tool_names"]) + + def test_complex_native_turn_can_revise_plan_in_compact_mode(self): + runtime = self.complex_native_runtime() + runtime._permitted_tool_names = lambda: {"read_file", "discover_tools", "update_plan"} + runtime._get_model_response.side_effect = [ + ModelResponse(tool_calls=(ToolCall("plan-1", "update_plan", {"complexity": "complex", "tasks": []}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(tool_calls=(ToolCall("plan-2", "update_plan", {"expected_revision": 1, "operations": []}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(text="Done", finish_reason=FinishReason.FINAL), + ] + def execute(calls, **kwargs): + runtime.tasks.working_plan = SimpleNamespace(id="plan-1", status="active") + runtime._execute_tool_batch.side_effect = execute + self.assertEqual(AgentEngine(runtime).run(TurnContext("Complex task")), "Done") + self.assertIn("update_plan", runtime._get_model_response.call_args_list[1].kwargs["tool_names"]) + + def test_complex_native_turn_does_not_retain_revoked_plan_permission(self): + runtime = self.complex_native_runtime() + allowed = {"read_file", "discover_tools", "update_plan"} + runtime._permitted_tool_names = lambda: allowed.copy() + runtime._get_model_response.side_effect = [ + ModelResponse(tool_calls=(ToolCall("plan-1", "update_plan", {}),), finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(tool_calls=(ToolCall("plan-2", "update_plan", {}),), finish_reason=FinishReason.TOOL_REQUEST), + ] + def execute(calls, **kwargs): + runtime.tasks.working_plan = SimpleNamespace(id="plan-1", status="active") + allowed.remove("update_plan") + runtime._execute_tool_batch.side_effect = execute + result = AgentEngine(runtime).run(TurnContext("Complex task")) + self.assertIn("unknown tool", result) + self.assertEqual(runtime._execute_tool_batch.call_count, 1) + + def test_complex_native_turn_can_finish_after_its_plan_completes(self): + runtime = self.complex_native_runtime() + runtime.tasks.working_plan = SimpleNamespace(id="plan-1", status="active") + runtime._get_model_response.side_effect = [ + ModelResponse(tool_calls=(ToolCall("read-1", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST), + ModelResponse(text="Done", finish_reason=FinishReason.FINAL), + ] + runtime._execute_tool_batch.side_effect = lambda *args, **kwargs: setattr( + runtime.tasks.working_plan, "status", "completed") + self.assertEqual(AgentEngine(runtime).run(TurnContext("Complex task")), "Done") + + def test_compact_native_artifact_reader_respects_permission(self): + permitted = {"discover_tools", "read_tool_artifact"} + runtime = SimpleNamespace( + execution_context=SimpleNamespace(discovered_tools=frozenset()), + _prompt_profile=lambda: "compact", _permitted_tool_names=lambda: permitted, + ) + engine = AgentEngine(runtime) + self.assertIn("read_tool_artifact", engine._native_tool_names()) + permitted.remove("read_tool_artifact") + self.assertNotIn("read_tool_artifact", engine._native_tool_names()) + def test_compact_native_falls_back_to_permitted_inventory_without_discovery_access(self): runtime = SimpleNamespace( execution_context=SimpleNamespace(discovered_tools=frozenset()), @@ -107,9 +258,11 @@ def test_native_history_keeps_past_schema_but_rejects_revoked_new_calls(self): _last_prompt_tokens=0, _last_completion_tokens=0, ) runtime._prepare_turn = lambda turn: ( - setattr(turn, "interaction_mode", "agent"), setattr(turn, "learning_run", {"run_id": "run-1"}), + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "simple"), + setattr(turn, "learning_run", {"run_id": "run-1"}), setattr(turn, "learned_context", ""), ) + runtime._prepare_context_for_call = lambda *_args: None runtime._build_messages = lambda *args, **kwargs: [{"role": "user", "content": "Save this note"}] current_tools = {"write_file"} runtime._permitted_tool_names = lambda: current_tools.copy() @@ -166,9 +319,11 @@ def test_compact_native_discovery_adds_only_permitted_schemas_for_next_request(s _last_prompt_tokens=0, _last_completion_tokens=0, ) runtime._prepare_turn = lambda turn: ( - setattr(turn, "interaction_mode", "agent"), setattr(turn, "learning_run", {"run_id": "run-1"}), + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "simple"), + setattr(turn, "learning_run", {"run_id": "run-1"}), setattr(turn, "learned_context", ""), ) + runtime._prepare_context_for_call = lambda *_args: None runtime._build_messages = lambda *args, **kwargs: [{"role": "user", "content": "Review Git history"}] permitted = {"read_file", "list_dir", "discover_tools", "git_log", "github_read", "browser_open", "graph_query"} runtime._permitted_tool_names = lambda: permitted @@ -191,6 +346,11 @@ def execute(action, arguments, **kwargs): runtime._safe_fstring = str runtime._deterministic_tool_summary = lambda records: "tool summary" runtime.tasks = SimpleNamespace(tasks=[]) + runtime.current_session = SimpleNamespace() + runtime.memory_bank = SimpleNamespace( + user_id="user", workspace_id="workspace", session_id="session", + store=SimpleNamespace(artifacts=SimpleNamespace(get_or_create=lambda *args, **kwargs: "artifact-test")), + ) runtime._complete_turn = Mock(side_effect=lambda turn: turn.final_answer) result = AgentEngine(runtime).run(TurnContext("Review Git history")) @@ -219,9 +379,11 @@ def test_native_loop_stops_at_engine_step_limit(self): _last_prompt_tokens=0, _last_completion_tokens=0, ) runtime._prepare_turn = lambda turn: ( - setattr(turn, "interaction_mode", "agent"), setattr(turn, "learning_run", {"run_id": "run-1"}), + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "simple"), + setattr(turn, "learning_run", {"run_id": "run-1"}), setattr(turn, "learned_context", ""), ) + runtime._prepare_context_for_call = lambda *_args: None runtime._build_messages = lambda *args, **kwargs: [{"role": "user", "content": "Read README"}] runtime._permitted_tool_names = lambda: {"read_file"} runtime._prompt_profile = lambda: "classic" @@ -251,9 +413,11 @@ def test_native_loop_rejects_unknown_tool_without_execution(self): _last_prompt_tokens=0, _last_completion_tokens=0, ) runtime._prepare_turn = lambda turn: ( - setattr(turn, "interaction_mode", "agent"), setattr(turn, "learning_run", {"run_id": "run-1"}), + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "simple"), + setattr(turn, "learning_run", {"run_id": "run-1"}), setattr(turn, "learned_context", ""), ) + runtime._prepare_context_for_call = lambda *_args: None runtime._build_messages = lambda *args, **kwargs: [{"role": "user", "content": "Use missing tool"}] runtime._permitted_tool_names = lambda: {"read_file"} runtime._prompt_profile = lambda: "classic" @@ -287,9 +451,11 @@ def test_native_turn_uses_typed_calls_and_correlated_results_without_legacy_loop _last_prompt_tokens=2, _last_completion_tokens=1, ) runtime._prepare_turn = lambda turn: ( - setattr(turn, "interaction_mode", "agent"), setattr(turn, "learning_run", {"run_id": "run-1"}), + setattr(turn, "interaction_mode", "agent"), setattr(turn, "complexity", "simple"), + setattr(turn, "learning_run", {"run_id": "run-1"}), setattr(turn, "learned_context", ""), ) + runtime._prepare_context_for_call = lambda *_args: None runtime._build_messages = lambda *args, **kwargs: [{"role": "user", "content": "Read README"}] runtime._permitted_tool_names = lambda: {"read_file"} runtime._prompt_profile = lambda: "classic" @@ -330,6 +496,32 @@ def test_native_turn_uses_typed_calls_and_correlated_results_without_legacy_loop runtime._initial_turn_response.assert_not_called() runtime._execute_action_rounds.assert_not_called() + def test_archived_failed_result_keeps_error_identity_and_scoped_full_output(self): + import json + from openkyrozen.persistence.store import EventStore + + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) + memory = SimpleNamespace(store=store, user_id="alice", workspace_id="project", session_id="session") + runtime = SimpleNamespace(memory_bank=memory, current_session=SimpleNamespace(), tasks=None) + original = {"data": None, "text": "failed detail", "error": {"code": "denied"}, "metadata": {}} + messages = [ToolReply("call-1", "read_file", original, True)] + AgentEngine(runtime)._archive_replies(messages, [0]) + reply = messages[0] + self.assertTrue(reply.is_error) + self.assertEqual((reply.call_id, reply.name), ("call-1", "read_file")) + artifact_id = reply.output["data"]["artifact_id"] + page = store.artifacts.read_page(artifact_id, 0, 1000, user_id="alice", + workspace_id="project", session_id="session") + self.assertEqual(json.loads(page["content"]), original) + self.assertIsNone(store.artifacts.read_page(artifact_id, 0, 1000, user_id="alice", + workspace_id="other", session_id="session")) + reopened = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) + second = [ToolReply("call-1", "read_file", original, True)] + runtime.memory_bank.store = reopened + AgentEngine(runtime)._archive_replies(second, [0]) + self.assertEqual(second[0].output["data"]["artifact_id"], artifact_id) + def test_runtime_native_provider_executes_and_continues_without_text_parsing(self): from openkyrozen.app.bootstrap import build_application, build_memory from openkyrozen.tools import ToolAdapters @@ -360,6 +552,8 @@ def chat_response(self, messages, model=None, *, tools=None): raise AssertionError("native prompt includes legacy control syntax") if len(self.requests) == 1: self.assert_tool_schema(tools, "update_plan") + if {item["name"] for item in tools} != {"update_plan"}: + raise AssertionError("complex direct turn offered other tools before planning") return ModelResponse( tool_calls=(ToolCall("plan-1", "update_plan", { "complexity": "complex", @@ -379,7 +573,7 @@ def chat_response(self, messages, model=None, *, tools=None): continuation=provider_continuation(self, model, "fixture", {"id": "response-2"}), metadata={"provider": self.name, "model": model}, ) - if not isinstance(messages[-1], ToolReply) or messages[-1].call_id != "read-1": + if len(self.requests) == 3 and (not isinstance(messages[-1], ToolReply) or messages[-1].call_id != "read-1"): raise AssertionError("native read result was not correlated to its call") return ModelResponse( text="The file contains verified text.", finish_reason=FinishReason.FINAL, @@ -394,11 +588,11 @@ def assert_tool_schema(tools, name): with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", "KYROZEN_WORKSPACE_ROOT": directory, - }): + }), ExitStack() as sqlite_owners: root = Path(directory) (root / "note.txt").write_text("verified text", encoding="utf-8") app = build_application( - surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice", ) self.addCleanup(app.close) @@ -408,16 +602,48 @@ def assert_tool_schema(tools, name): runtime._default_turn.provider = provider runtime.set_interaction_mode("agent") packet = "frozen packet" + prepare_turn = runtime._prepare_turn + def prepare_complex(turn): + prepare_turn(turn) + turn.routing_decision = None + turn.complexity = "complex" with patch.object(runtime, "_schedule_post_turn_learning"), \ + patch.object(runtime, "_prepare_turn", side_effect=prepare_complex), \ patch.object(runtime, "_build_memory_context", return_value=packet) as retrieve_memory, \ patch.object(runtime, "_collect_tool_calls", side_effect=AssertionError("legacy parser used")), \ patch.object(runtime, "_collect_unwrapped_tool_calls", side_effect=AssertionError("legacy parser used")): answer = runtime.chat(runtime.current_session, "Read note.txt") + older = next(item for item in provider.requests[2][0] + if isinstance(item, ToolReply) and item.call_id == "plan-1") + original = provider.requests[1][0][-1].output + self.assertNotEqual(older.output, original) + artifact_id = older.output["data"]["artifact_id"] + page = runtime.tool_registry.get_spec("read_tool_artifact").executor({"artifact_id": artifact_id}) + import json + self.assertEqual(json.loads(page.data["content"]), original) + self.assertIsNone(runtime.memory_bank.store.artifacts.read_page( + artifact_id, 0, 1000, user_id="another-user", workspace_id=runtime.tasks.workspace_id, + session_id=runtime.tasks.session_id)) + with patch.object(runtime, "_schedule_post_turn_learning"), \ + patch.object(runtime, "_prepare_turn", side_effect=prepare_complex), \ + patch.object(runtime, "_build_memory_context", return_value=packet): + next_answer = runtime.chat(runtime.current_session, "Continue") + self.assertIn("verified text", next_answer) + historical = [item for item in provider.requests[3][0] if isinstance(item, ToolReply)] + self.assertTrue(historical) + self.assertTrue(all("artifact_id" in item.output["data"] for item in historical)) + self.assertEqual(historical[0].output["data"]["artifact_id"], artifact_id) + with patch.object(runtime.memory_bank.store.artifacts, "get_or_create", side_effect=OSError("offline")), \ + patch.object(runtime, "_schedule_post_turn_learning"): + failure = runtime.chat(runtime.current_session, "Continue again") + self.assertIn("Could not retain earlier tool results", failure) + self.assertEqual(provider.calls_attempted, 4) + self.assertIn("verified text", answer) retrieve_memory.assert_called_once() - self.assertEqual(provider.calls_attempted, 3) - self.assertEqual(len(provider.requests), 3) + self.assertEqual(provider.calls_attempted, 4) + self.assertEqual(len(provider.requests), 4) self.assertTrue(all(any(packet in item.get("content", "") for item in request[0] if isinstance(item, dict)) for request in provider.requests)) self.assertEqual(provider.requests[1][0][-1].call_id, "plan-1") @@ -426,5 +652,565 @@ def assert_tool_schema(tools, name): self.assertEqual(app.runtime.tasks.tasks[0]["description"], "Read note.txt") +class NativeLifecycleRegressionTests(unittest.TestCase): + def runtime(self, *, complexity="simple", child=False): + import time + from types import MethodType + from openkyrozen.agent.completion import _complete_turn + + runtime = AgentEngineTests().complex_native_runtime() + runtime.execution_context.child_run_id = "child-1" if child else None + runtime.tasks.tasks = [] + runtime._turn_failed = contextvars.ContextVar("failed", default=False) + runtime._turn_completion_status = contextvars.ContextVar("completion", default="completed") + runtime.subagent_manager = SimpleNamespace(coordinator=None) + runtime._clean_final_response = str + runtime._advance_fix_workflow = lambda state, _request, answer, *_args: (state, answer) + runtime._finish_learning_run = lambda _run, _receipts, _task, answer, *_args: answer + runtime._turn_cost_log = [] + runtime._complete_turn = MethodType(_complete_turn, runtime) + def prepare(turn): + turn.interaction_mode, turn.complexity = "agent", complexity + turn.learning_run, turn.turn_start = {"run_id": "fixture"}, time.time() + runtime._prepare_turn = prepare + return runtime + + def test_unsuccessful_native_finishes_cannot_complete_the_turn(self): + cases = [(FinishReason.BLOCKED, "blocked"), (FinishReason.ERROR, "incomplete"), + (FinishReason.LENGTH, "incomplete"), (FinishReason.UNKNOWN, "incomplete")] + for reason, expected_status in cases: + with self.subTest(reason=reason): + runtime = self.runtime() + runtime._get_model_response.return_value = ModelResponse( + text="A useful partial explanation.", finish_reason=reason) + turn = TurnContext("Explain the result") + answer = AgentEngine(runtime).run(turn) + self.assertEqual(turn.runtime_status, expected_status) + self.assertIn("partial explanation", answer) + self.assertTrue(turn.protocol_error_message) + + def test_unsuccessful_native_finish_never_dispatches_its_tool_calls(self): + runtime = self.runtime() + runtime._get_model_response.return_value = ModelResponse( + text="Provider failed while generating.", + tool_calls=(ToolCall("must-not-run", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.ERROR) + AgentEngine(runtime).run(TurnContext("Read a note")) + runtime._execute_tool_batch.assert_not_called() + + def test_native_cancellation_reaches_the_runtime_cancellation_handler(self): + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + for response in (ModelResponse(text="Cancelled by provider.", finish_reason=FinishReason.CANCELLED), + ProviderError(ProviderErrorKind.CANCELLED, terminal=True)): + with self.subTest(response=type(response).__name__): + runtime = self.runtime() + if isinstance(response, Exception): + runtime._get_model_response.side_effect = response + else: + runtime._get_model_response.return_value = response + with self.assertRaises(ProviderError) as caught: + AgentEngine(runtime).run(TurnContext("Read a note")) + self.assertEqual(caught.exception.kind, ProviderErrorKind.CANCELLED) + + def test_default_native_inventory_defers_specialized_tools_in_classic_profile(self): + runtime = self.runtime() + runtime._prompt_profile = lambda: "classic" + runtime._permitted_tool_names = lambda: {"read_file", "discover_tools", "github_read", "browser_open", "graph_query"} + self.assertEqual(AgentEngine(runtime)._native_tool_names(), {"read_file", "discover_tools"}) + runtime.execution_context.discovered_tools = frozenset({"github_read"}) + self.assertEqual(AgentEngine(runtime)._native_tool_names(), {"read_file", "discover_tools", "github_read"}) + + def test_complex_child_cannot_execute_before_establishing_its_working_plan(self): + runtime = self.runtime(complexity="complex", child=True) + answer = AgentEngine(runtime).run(TurnContext("Investigate and fix the repository")) + runtime._execute_tool_batch.assert_not_called() + self.assertIn("plan", answer.lower()) + + def test_complex_child_completion_reports_its_outstanding_required_tasks(self): + runtime = self.runtime(complexity="complex", child=True) + runtime.tasks.working_plan = SimpleNamespace(id="child-plan", status="active", task_ids=["child-task"]) + runtime.tasks.tasks = [{"id": "child-task", "status": "pending", "description": "Verify the change"}] + runtime._get_model_response.return_value = ModelResponse(text="The investigation is complete.", finish_reason=FinishReason.FINAL) + turn = TurnContext("Investigate and fix the repository") + answer = AgentEngine(runtime).run(turn) + self.assertEqual(turn.runtime_status, "incomplete") + self.assertIn("Verify the change", answer) + + def test_failed_effect_verification_marks_final_status_incomplete(self): + runtime = self.runtime() + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + turn.final_answer, turn.response_text = "The file was written successfully.", "" + turn.tool_calls = [] + turn.tool_records = [{"action": "write_file", "success": True, + "verification": {"status": "failed", "method": "file_content_match"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "incomplete") + self.assertIn("not verified", answer) + self.assertNotIn("file was written successfully", answer.lower()) + + def test_failed_write_without_effect_verification_cannot_keep_success_claim(self): + for stage in (None, "verified"): + for retried in (False, True): + with self.subTest(stage=stage, retried=retried): + runtime = self.runtime() + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": stage} if stage else None + turn.final_answer, turn.response_text = "The file was written successfully.", "" + turn.tool_calls = [] + first = {"action": "write_file", "success": False, "operation_id": "same-write", + "args": "note.txt|requested", "task_id": "task", "plan_id": "plan", + "verification": {"status": "not_applicable"}} + turn.tool_records = [first] + if retried: + turn.tool_records.append({**first, "success": True, + "verification": {"status": "verified", "method": "file_content_match"}}) + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "completed" if retried else "incomplete") + if retried: + self.assertIn("file was written successfully", answer) + else: + self.assertNotIn("file was written successfully", answer) + self.assertIn("write_file", answer) + + def test_typed_remote_effect_failure_cannot_keep_success_claim(self): + runtime = self.runtime() + turn = TurnContext("Publish the change") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": "verified"} + turn.final_answer, turn.response_text = "The remote change was published successfully.", "" + turn.tool_calls = [] + turn.tool_records = [{"action": "git_push", "side_effects": "remote", "success": False, + "verification": {"status": "not_applicable"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "incomplete") + self.assertNotIn("published successfully", answer) + self.assertIn("git_push", answer) + self.assertIn("not verified", answer) + + def test_only_trusted_duplicate_refusal_can_preserve_prior_verified_claim(self): + for kind in ("duplicate", "conflict", "permission_denied", "failed_verification", "different_task"): + with self.subTest(kind=kind): + runtime = self.runtime() + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": "verified"} + turn.final_answer, turn.response_text = "The file was written successfully.", "" + turn.tool_calls = [] + first = {"action": "write_file", "success": True, "operation_id": "write", "args": "note.txt|requested", + "task_id": "task", "plan_id": "plan", "verification": {"status": "verified", "method": "file_content_match"}} + later = {**first, "success": False, "failure": "conflict", + "verification": {"status": "not_applicable", "method": "duplicate_success_refusal"}} + if kind == "conflict": later["verification"] = {"status": "unavailable", "method": "none"} + elif kind == "permission_denied": later.update(failure="permission_denied", verification={"status": "unavailable"}) + elif kind == "failed_verification": later["verification"] = {"status": "failed", "method": "file_content_match"} + elif kind == "different_task": later["task_id"] = "other-task" + turn.tool_records = [first, later] + answer = runtime._complete_turn(turn) + if kind == "duplicate": + self.assertIn("file was written successfully", answer) + else: + self.assertNotIn("file was written successfully", answer) + self.assertEqual(turn.runtime_status, "blocked" if kind == "permission_denied" else "incomplete") + + def test_trusted_duplicate_refusal_completes_without_a_fix_workflow(self): + runtime = self.runtime() + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + turn.final_answer, turn.response_text = "The file was written successfully.", "" + turn.tool_calls = [] + first = {"action": "write_file", "success": True, "operation_id": "write", "args": "note.txt|requested", + "task_id": "task", "plan_id": "plan", "verification": {"status": "verified", "method": "file_content_match"}} + turn.tool_records = [first, {**first, "success": False, "failure": "conflict", + "verification": {"status": "not_applicable", "method": "duplicate_success_refusal"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "completed") + self.assertEqual(answer, "The file was written successfully.") + + def test_delegated_failure_reply_remains_valid_worker_or_review_json(self): + import json + from openkyrozen.agent.delegation import REPORT_LISTS, report + for review in (False, True): + with self.subTest(review=review): + runtime = self.runtime(complexity="complex", child=True) + runtime.execution_context.coordinator = object() + runtime.tasks.working_plan = SimpleNamespace(id="child-plan", status="active", task_ids=["child-task"]) + runtime.tasks.tasks = [{"id": "child-task", "status": "pending", "description": "Verify the change"}] + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + payload = ({"verdict": "verified", "findings": [], "evidence": []} if review else + {**{key: [] for key in REPORT_LISTS}, "status": "completed"}) + payload["summary"] = "The file was written successfully." + raw = json.dumps(payload) + turn.final_answer = "```json\n" + raw + "\n```" if review else raw + turn.response_text, turn.tool_calls = "", [] + turn.tool_records = [{"action": "write_file", "success": False, "failure": "permission_denied", + "verification": {"status": "unavailable"}, "tool_error": {"code": "permission_denied"}}] + answer = runtime._complete_turn(turn) + parsed = report(answer, review=review) + self.assertEqual(json.loads(answer), parsed) + self.assertEqual(parsed["verdict"] if review else parsed["status"], "unverifiable" if review else "blocked") + self.assertTrue(any("write_file" in finding for finding in parsed["findings"])) + self.assertNotIn("written successfully", answer) + self.assertEqual(turn.runtime_status, "blocked") + + def test_complex_child_plan_status_keeps_valid_worker_json_and_cannot_complete_active_plan(self): + import json + from openkyrozen.agent.delegation import REPORT_LISTS, report + for plan_status, task_status, expected_runtime, expected_report in ( + ("completed", "succeeded", "completed", "completed"), + ("active", "pending", "incomplete", "partial"), + ("active", "blocked", "blocked", "blocked")): + with self.subTest(plan_status=plan_status, task_status=task_status): + runtime = self.runtime(complexity="complex", child=True) + runtime.execution_context.coordinator = object() + runtime.tasks.working_plan = SimpleNamespace(id="child-plan", status=plan_status, task_ids=["child-task"]) + runtime.tasks.tasks = [{"id": "child-task", "status": task_status, "description": "Verify the change"}] + turn = TurnContext("Investigate the result") + runtime._prepare_turn(turn) + payload = {**{key: [] for key in REPORT_LISTS}, "status": "completed", "summary": "The requested result."} + turn.final_answer = json.dumps(payload) + turn.response_text, turn.tool_calls, turn.tool_records = "", [], [] + answer = runtime._complete_turn(turn) + parsed = report(answer) + self.assertEqual(json.loads(answer), parsed) + self.assertEqual(turn.runtime_status, expected_runtime) + self.assertEqual(parsed["status"], expected_report) + if expected_report == "completed": + self.assertEqual(parsed, payload) + else: + self.assertTrue(any("Plan status:" in item for item in parsed["uncertainties"])) + + def test_native_final_claim_is_replaced_after_actual_failed_write_verification(self): + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.manifest import ToolSpec + class Provider(LLMProvider): + calls = 0 + def get_capabilities(self, model=None): return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): raise AssertionError("legacy provider boundary used") + def chat_response(self, messages, model=None, *, tools=None): + self.calls += 1 + if self.calls == 1: + return ModelResponse(tool_calls=(ToolCall("failed-write", "write_file", { + "path": "note.txt", "content": "requested content"}),), finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"call": "failed-write"})) + return ModelResponse(text="The file was written successfully.", finish_reason=FinishReason.FINAL) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + }), ExitStack() as sqlite_owners: + root = Path(directory) + (root / "note.txt").write_text("unchanged old content") + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + runtime._set_workspace_root(root) + runtime.set_interaction_mode("agent") + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture") + runtime._provider_config = config + provider = Provider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + spec = runtime.tool_registry.get_spec("write_file") + runtime.tool_registry.register_spec(ToolSpec("write_file", lambda _arguments: ToolResult(text="Tool reported success"), + description=spec.description, input_schema=spec.input_schema, capability=spec.capability, + side_effects=spec.side_effects), replace=True) + events = [] + with patch.object(runtime, "_schedule_post_turn_learning"), patch.object(runtime, "_finish_learning_run", + side_effect=lambda _run, _receipts, _task, answer, *_args: answer): + answer = runtime.chat(runtime.current_session, "Write note.txt", on_runtime_event=events.append) + receipts = runtime.memory_bank.store.list_events("execution.receipt", user_id=runtime.tasks.user_id, + workspace_id=runtime.tasks.workspace_id, session_id=runtime.tasks.session_id) + receipt = next(event["payload"] for event in receipts if event["payload"].get("call_id") == "failed-write") + self.assertTrue(receipt["success"]) + self.assertEqual(receipt["verification"]["status"], "failed") + self.assertEqual((root / "note.txt").read_text(), "unchanged old content") + self.assertEqual(provider.calls, 2) + self.assertNotIn("file was written successfully", answer.lower()) + self.assertIn("write_file", answer) + self.assertIn("not verified", answer) + terminal = next(event for event in events if event.kind == "turn.completed") + self.assertEqual(terminal.payload["status"], "incomplete") + # CLI/TUI/Web consume the returned native reply through these literal projections. + self.assertEqual(runtime._clean_final_response(answer), answer) + self.assertEqual(runtime._split_reply(answer)[1], answer) + + def test_failed_effect_summary_preserves_provider_block_reason(self): + runtime = self.runtime() + turn = TurnContext("Write a note") + runtime._prepare_turn(turn) + turn.runtime_status = "blocked" + turn.protocol_error_message = "The provider blocked this response; no further tools were run." + turn.final_answer, turn.response_text = "The file was written successfully.", "" + turn.tool_calls = [] + turn.tool_records = [{"action": "write_file", "success": True, + "verification": {"status": "failed", "method": "file_content_match"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "blocked") + self.assertIn("provider blocked", answer) + self.assertIn("not verified", answer) + self.assertNotIn("written successfully", answer) + + def test_verified_reproduction_failure_preserves_literal_final_reply(self): + runtime = self.runtime() + turn = TurnContext("Fix the page") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": "verified"} + turn.final_answer = "The verified repair passed. Action: read_file is a literal example." + turn.response_text, turn.tool_calls = "", [] + turn.tool_records = [{"action": "run_cmd", "success": False, + "verification": {"status": "failed", "method": "command_exit_status"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "completed") + self.assertEqual(answer, turn.final_answer) + self.assertIn("Action: read_file is a literal example.", answer) + + def test_verified_fix_cannot_hide_latest_failed_file_verification(self): + for stage in ("verified", "explained"): + with self.subTest(stage=stage): + runtime = self.runtime() + turn = TurnContext("Fix the page") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": stage} + turn.final_answer, turn.response_text = "The repair was completed successfully.", "" + turn.tool_calls = [] + turn.tool_records = [{"action": "write_file", "success": True, "operation_id": "write-1", + "args": "page.txt|expected", "task_id": "task-1", "plan_id": "plan-1", + "verification": {"status": "failed", "method": "file_content_match"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, "incomplete", answer) + self.assertIn("not verified", answer) + + def test_only_later_verified_retry_of_same_bound_operation_resolves_file_failure(self): + first = {"action": "write_file", "success": True, "operation_id": "write-1", "args": "page.txt|expected", + "task_id": "task-1", "plan_id": "plan-1", "verification": {"status": "failed", "method": "file_content_match"}} + for changes, expected in (({}, "completed"), ({"operation_id": "different"}, "incomplete"), + ({"task_id": "other-task"}, "incomplete")): + with self.subTest(changes=changes): + runtime = self.runtime() + turn = TurnContext("Fix the page") + runtime._prepare_turn(turn) + turn.fix_workflow = {"stage": "verified"} + turn.final_answer, turn.response_text = "The repair was independently verified.", "" + turn.tool_calls = [] + turn.tool_records = [first, {**first, **changes, "verification": {"status": "verified", "method": "file_content_match"}}] + answer = runtime._complete_turn(turn) + self.assertEqual(turn.runtime_status, expected) + if expected == "completed": + self.assertIn("independently verified", answer) + else: + self.assertNotIn("independently verified", answer) + + def test_learning_bookkeeping_failure_preserves_the_completed_answer(self): + runtime = self.runtime() + runtime._record_learning_event = Mock() + runtime._finish_learning_run = Mock(side_effect=RuntimeError("database unavailable")) + runtime._get_model_response.return_value = ModelResponse(text="The verified direct answer.", finish_reason=FinishReason.FINAL) + turn = TurnContext("Explain the result") + answer = AgentEngine(runtime).run(turn) + self.assertEqual(answer, "The verified direct answer.") + self.assertEqual(turn.runtime_status, "completed") + + def test_deterministic_summary_does_not_call_failed_verification_completed(self): + from openkyrozen.agent.protocol import _deterministic_tool_summary + runtime = self.runtime() + answer = _deterministic_tool_summary(runtime, [{ + "action": "write_file", "success": True, "result": "File content differs from requested content.", + "verification": {"status": "failed", "method": "file_content_match"}, + }]) + self.assertIn("Could not complete", answer) + self.assertNotIn("- Completed", answer) + + def test_plan_edits_cannot_share_a_batch_with_calls_using_the_old_task_selection(self): + runtime = self.runtime(complexity="complex") + runtime.tasks.working_plan = SimpleNamespace(id="plan", status="active", task_ids=[]) + runtime._permitted_tool_names = lambda: {"read_file", "update_plan"} + runtime._get_model_response.return_value = ModelResponse(tool_calls=( + ToolCall("select", "update_plan", {"expected_revision": 1, "operations": [{"op": "start", "task": "new"}]}), + ToolCall("read", "read_file", {"path": "note.txt"}), + ), finish_reason=FinishReason.TOOL_REQUEST) + answer = AgentEngine(runtime).run(TurnContext("Inspect the task")) + runtime._execute_tool_batch.assert_not_called() + self.assertIn("separate", answer.lower()) + + def test_native_plan_start_and_completion_suggestion_bind_verified_receipts(self): + import time + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + command = "python -m unittest test_note" + class PlanningProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("legacy boundary used") + def chat_response(self, messages, model=None, *, tools=None): + previous = [message for message in messages if isinstance(message, ToolReply)] + if not previous: + call = ToolCall("plan", "update_plan", {"complexity": "complex", "expected_revision": 0, "operations": [ + {"op": "add", "ref": "verify", "task": {"description": "Verify note.txt contents", "acceptance": [command], + "execution": {"action": "run_cmd", "arguments": {"command": command}}}}, + {"op": "start", "task": "verify"}, {"op": "suggest_complete", "task": "verify"}, + ]}) + elif not any(message.call_id == "verify-command" for message in previous): + call = ToolCall("verify-command", "run_cmd", {"command": command}) + else: + return ModelResponse(text="The requested file contents passed the check.", finish_reason=FinishReason.FINAL) + return ModelResponse(tool_calls=(call,), finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"call": call.id})) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "PATH": str(Path(__import__("sys").executable).parent) + os.pathsep + os.environ["PATH"], + }), ExitStack() as sqlite_owners: + root = Path(directory) + (root / "note.txt").write_text("verified content", encoding="utf-8") + (root / "test_note.py").write_text( + "import unittest\nfrom pathlib import Path\nclass NoteTest(unittest.TestCase):\n" + " def test_content(self):\n self.assertEqual(Path('note.txt').read_text(), 'verified content')\n", + encoding="utf-8") + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + provider = PlanningProvider(ProviderConfig(provider="custom", model_simple="fixture")) + runtime._set_workspace_root(root) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime.set_interaction_mode("agent") + def prepare(turn): + turn.interaction_mode, turn.complexity = "agent", "complex" + turn.learning_run, turn.turn_start = {"run_id": "fixture"}, time.time() + turn.learned_context = "" + with patch.object(runtime, "_prepare_turn", side_effect=prepare), \ + patch.object(runtime, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, answer, *_args: answer), \ + patch.object(runtime, "_schedule_post_turn_learning"): + answer = runtime.chat(runtime.current_session, "Verify the requested file contents") + task = runtime.tasks.tasks[0] + self.assertEqual(task["status"], "succeeded", answer) + self.assertEqual(runtime.tasks.working_plan.status, "completed") + self.assertTrue(task["evidence"]) + receipts = runtime.memory_bank.store.list_events("execution.receipt", user_id=runtime.tasks.user_id, + workspace_id=runtime.tasks.workspace_id, session_id=runtime.tasks.session_id) + verification = next(event["payload"] for event in receipts if event["payload"]["call_id"] == "verify-command") + self.assertEqual(verification["task_id"], task["id"]) + self.assertEqual(verification["verification"]["status"], "verified") + self.assertIsNone(next(event["payload"] for event in receipts if event["payload"]["call_id"] == "plan")["task_id"]) + + def test_native_verified_write_receipts_stay_local_without_certifying_prose_acceptance(self): + import copy + import time + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + for delegated in (False, True): + with self.subTest(delegated=delegated), tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + }), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + runtime._set_workspace_root(root) + parent = runtime.current_session + parent.tasks.update_plan(complexity="complex", tasks=[{ + "description": "Independently verify the parent objective", "acceptance": ["Independent review passed"], + }]) + target = runtime.open_session("child-session") if delegated else parent + if delegated: + target._delegation_parent, target._delegation_task_id = parent, parent.tasks.tasks[0]["id"] + else: + parent.tasks.clear() + target.interaction.set_mode("agent") + class WriteProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("legacy boundary used") + def chat_response(self, messages, model=None, *, tools=None): + replies = [item for item in messages if isinstance(item, ToolReply)] + arguments = {"path": "child.txt", "content": "precisely verified content"} + if not replies: + call = ToolCall("plan", "update_plan", {"complexity": "complex", "expected_revision": 0, "operations": [ + {"op": "add", "ref": "write", "task": {"description": "Write the exact requested child.txt content", + "acceptance": ["Exact requested contents were written"], + "execution": {"action": "write_file", "arguments": arguments}}}, + {"op": "start", "task": "write"}, {"op": "suggest_complete", "task": "write"}, + ]}) + elif not any(item.call_id == "write" for item in replies): + call = ToolCall("write", "write_file", arguments) + else: + return ModelResponse(text="The requested file contents were verified.", finish_reason=FinishReason.FINAL) + return ModelResponse(tool_calls=(call,), finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"call": call.id})) + provider = WriteProvider(ProviderConfig(provider="custom", model_simple="fixture")) + context = copy.copy(runtime.execution_context) + context.provider = provider + context.child_run_id = "child-run" if delegated else None + token = runtime._turn_context.set(context) + self.addCleanup(runtime._turn_context.reset, token) + def prepare(turn): + turn.interaction_mode, turn.complexity = "agent", "complex" + turn.learning_run, turn.turn_start = {"run_id": "fixture"}, time.time() + turn.learned_context = "" + with patch.object(runtime, "_prepare_turn", side_effect=prepare), \ + patch.object(runtime, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, answer, *_args: answer), \ + patch.object(runtime, "_schedule_post_turn_learning"): + answer = runtime.chat(target, "Write and verify the exact requested content") + self.assertEqual((root / "child.txt").read_text(), "precisely verified content") + self.assertEqual(target.tasks.tasks[0]["status"], "running", answer) + self.assertEqual(target.tasks.working_plan.status, "active") + self.assertTrue(target.tasks.tasks[0]["evidence"]) + self.assertIsNone(target.tasks.tasks[0]["evidence"][-1].get("acceptance")) + receipts = target.memory.store.list_events("execution.receipt", user_id=target.tasks.user_id, + workspace_id=target.tasks.workspace_id, session_id=target.session_id) + write = next(event["payload"] for event in receipts if event["payload"]["call_id"] == "write") + self.assertEqual((write["plan_id"], write["task_id"]), (target.tasks.working_plan.id, target.tasks.tasks[0]["id"])) + self.assertEqual(write["verification"]["status"], "verified") + if delegated: + self.assertEqual(parent.tasks.tasks[0]["status"], "pending") + self.assertEqual(parent.tasks.tasks[0]["evidence"], []) + + def test_native_foreground_stream_delivers_text_before_generation_finishes(self): + import time + import threading + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + from openkyrozen.providers.models import TextDelta, ResponseComplete + events = [] + delta_delivered = threading.Event() + def observe(event): + events.append(event) + if event.kind == "model.text_delta": + delta_delivered.set() + class StreamingProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True, text_streaming=True, streaming_tool_calls=True) + def chat(self, messages, model=None): + raise AssertionError("text provider boundary used") + def chat_response(self, messages, model=None, *, tools=None): + raise AssertionError("native foreground response was buffered") + def chat_response_stream(self, messages, model=None, *, tools=None): + yield TextDelta("First useful text. ") + if not delta_delivered.wait(timeout=1): + raise AssertionError("first text was withheld until completion") + yield ResponseComplete(ModelResponse(text="First useful text. Finished.", finish_reason=FinishReason.FINAL)) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + provider = StreamingProvider(ProviderConfig(provider="custom", model_simple="fixture")) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime.set_interaction_mode("agent") + def prepare(turn): + turn.interaction_mode, turn.complexity = "agent", "simple" + turn.learning_run, turn.turn_start = {"run_id": "fixture"}, time.time() + turn.learned_context = "" + with patch.object(runtime, "_prepare_turn", side_effect=prepare), \ + patch.object(runtime, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, answer, *_args: answer), \ + patch.object(runtime, "_schedule_post_turn_learning"): + answer = runtime.chat(runtime.current_session, "Explain the result", on_runtime_event=observe) + self.assertEqual(answer, "First useful text. Finished.") + self.assertTrue(any(event.kind == "model.text_delta" for event in events)) + self.assertLess(next(event.sequence for event in events if event.kind == "model.text_delta"), + next(event.sequence for event in events if event.kind == "model.completed")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_approval_request.py b/tests/test_approval_request.py index 9de8687c..d82cf882 100644 --- a/tests/test_approval_request.py +++ b/tests/test_approval_request.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import unittest from openkyrozen.security.approvals import ApprovalRequest @@ -16,8 +17,8 @@ def test_execute_call_preserves_inherited_surface_capability_bound(self): with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full", "KYROZEN_APPROVAL_MODE": "never", - }): - app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), + }), ExitStack() as sqlite_owners: + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory)) calls = [] spec = ToolSpec( @@ -50,9 +51,9 @@ def test_ask_permission_mode_keeps_destructive_capability_for_explicit_approval( with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_TUI_CAPABILITIES": "full"} - ): + ), ExitStack() as sqlite_owners: app = build_application( - surface="tui", memory=build_memory(Path(directory) / "state.sqlite3"), + surface="tui", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory), ) try: @@ -155,9 +156,9 @@ def test_runtime_requested_capabilities_cannot_widen_surface_token(self): with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "workspace"} - ): + ), ExitStack() as sqlite_owners: adapters = ToolAdapters(directory) - app = build_application(surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), tools=adapters) + app = build_application(surface="web", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=adapters) calls = [] try: app.runtime.tool_registry.register_spec(ToolSpec( @@ -184,9 +185,9 @@ def test_empty_execute_call_capabilities_deny_read_tool(self): with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "full"} - ): + ), ExitStack() as sqlite_owners: app = build_application( - surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), + surface="web", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory), ) calls = [] @@ -216,9 +217,9 @@ def test_explicit_mcp_surface_bound_preserves_configured_dangerous_opt_in(self): with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WEB_CAPABILITIES": "workspace"} - ): + ), ExitStack() as sqlite_owners: app = build_application( - surface="web", memory=build_memory(Path(directory) / "state.sqlite3"), + surface="web", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory), ) try: @@ -271,8 +272,8 @@ def test_execute_call_approval_is_bound_to_the_structured_operation_and_spec(sel with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} - ): - app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + ), ExitStack() as sqlite_owners: + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory)) calls = [] spec = ToolSpec( "approval_probe", lambda value: calls.append(value) or ToolResult(text="ran"), @@ -332,8 +333,8 @@ def test_execute_call_rejects_arguments_mutated_while_approval_waits(self): with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} - ): - app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + ), ExitStack() as sqlite_owners: + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory)) calls = [] spec = ToolSpec( "approval_mutation_probe", lambda value: calls.append(value) or ToolResult(text="ran"), @@ -365,10 +366,10 @@ def test_structured_file_arguments_preserve_pipe_characters(self): with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} - ): + ), ExitStack() as sqlite_owners: root = Path(directory) / "workspace" root.mkdir() - app = build_application(surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(root)) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(root)) try: app.runtime._set_workspace_root(root) receipt = app.runtime.execute_call( @@ -391,9 +392,9 @@ def test_legacy_runtime_entry_rejects_registry_replacement_during_approval(self) with tempfile.TemporaryDirectory() as directory, patch.dict( "os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_CLI_CAPABILITIES": "full"} - ): + ), ExitStack() as sqlite_owners: app = build_application( - surface="cli", memory=build_memory(Path(directory) / "state.sqlite3"), + surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory), ) executed = [] diff --git a/tests/test_architecture.py b/tests/test_architecture.py index da82fcff..04e43b84 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import concurrent.futures import json import os @@ -53,12 +54,12 @@ def test_core_and_launch_imports_create_no_application_resources(self): self.assertFalse((Path(directory) / ".kyrozen").exists()) def test_actor_session_and_workspace_ownership_survives_switching(self): - with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"HOME": directory}): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"HOME": directory}), ExitStack() as sqlite_owners: root = Path(directory) first, second = root / "first", root / "second" first.mkdir() second.mkdir() - memory = build_memory(root / "state.sqlite3", index=_VectorIndex()) + memory = sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=_VectorIndex()))) application = build_application(memory=memory) runtime = application.runtime try: @@ -88,8 +89,8 @@ def test_actor_session_and_workspace_ownership_survives_switching(self): application.close() def test_parallel_execution_keeps_turn_tokens_and_sessions_local(self): - with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"HOME": directory}): - application = build_application(memory=build_memory(Path(directory) / "state.sqlite3", index=_VectorIndex())) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"HOME": directory}), ExitStack() as sqlite_owners: + application = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3", index=_VectorIndex())))) runtime = application.runtime runtime.configure_launch_context(project_path=directory) sessions = [runtime.open_session(name) for name in ("a", "b")] @@ -120,15 +121,15 @@ def read(_args): application.close() def test_vector_failure_falls_back_to_authoritative_store_and_rebuilds(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) index = _VectorIndex() - memory = MemoryBank(store.path, store=store, index=index) + memory = sqlite_owners.enter_context(closing(MemoryBank(store.path, store=store, index=index))) memory.add_log("FACT: modular architecture", kind="fact") self.assertIn("FACT: modular architecture", memory.recall("modular architecture")) self.assertEqual(memory.rebuild_index(), 1) self.assertEqual(len(index.memories.documents), 1) - reopened = MemoryBank(store.path, store=EventStore(store.path)) + reopened = sqlite_owners.enter_context(closing(MemoryBank(store.path, store=sqlite_owners.enter_context(closing(EventStore(store.path)))))) self.assertIn("FACT: modular architecture", reopened.recall("modular architecture")) diff --git a/tests/test_bugfix_workflow.py b/tests/test_bugfix_workflow.py index 0a0fbf39..dfae63e3 100644 --- a/tests/test_bugfix_workflow.py +++ b/tests/test_bugfix_workflow.py @@ -1,4 +1,5 @@ import json +from contextlib import ExitStack, closing import tempfile import unittest from pathlib import Path @@ -52,14 +53,19 @@ def setUp(self): self.original_root = main._get_workspace_root() self.original_fix_outcomes = list(main._fix_outcomes) self.tempdirs = [] + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) def tearDown(self): + if main.memory_bank is not self.original_memory: + main.memory_bank.close() main.memory_bank = self.original_memory main.tasks = self.original_tasks main.learning_engine = self.original_learning main._interaction_controller = self.original_interaction main._fix_outcomes[:] = self.original_fix_outcomes main._set_workspace_root(self.original_root) + self._sqlite_owners.close() for directory in self.tempdirs: directory.cleanup() @@ -67,7 +73,9 @@ def _run_fake_bug_turn(self, responses): directory = tempfile.TemporaryDirectory() self.tempdirs.append(directory) root = Path(directory.name) - main.memory_bank = MemoryBank(root / "state.sqlite3", workspace_id="bugfix", session_id="turn") + with patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): + main.memory_bank = self._sqlite_owners.enter_context(closing( + MemoryBank(root / "state.sqlite3", workspace_id="bugfix", session_id="turn"))) main.tasks = TaskManager(main.memory_bank.store, workspace_id="bugfix", session_id="turn") main._interaction_controller = InteractionController( main.memory_bank.store, workspace_id="bugfix", session_id="turn", @@ -82,11 +90,17 @@ def _run_fake_bug_turn(self, responses): patch.object(main, "_build_memory_context", return_value=""), \ patch.object(main, "_call_llm_with_spinner", side_effect=responses), \ patch.object(main, "_get_llm_response", return_value=""), \ + patch.object(main, "_schedule_post_turn_learning"), \ patch.object(main, "_finish_learning_run", side_effect=lambda run, receipts, task, result, records, tokens, started: result): reply = main._chat_turn("bug: the command fails", clear_tasks=True) return root, reply, main.memory_bank.store + def test_bugfix_fixture_does_not_schedule_unrelated_background_learning(self): + with patch.object(main, "_schedule_post_turn_learning") as schedule: + self._run_fake_bug_turn(["The bug is fixed now."] * 4) + schedule.assert_not_called() + def test_fake_provider_requires_real_reproduce_fix_and_verify_evidence(self): responses = [ ( diff --git a/tests/test_causal_selection.py b/tests/test_causal_selection.py index d7b76236..7928ee17 100644 --- a/tests/test_causal_selection.py +++ b/tests/test_causal_selection.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import tempfile import unittest from pathlib import Path @@ -24,11 +25,14 @@ class CausalSelectionTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() root = Path(self.directory.name) - self.memory = MemoryBank(root / "state.sqlite3", workspace_id="project") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", workspace_id="project"))) self.registry = SkillRegistry(self.memory.store, workspace_id="project", root=root / "skills") self.engine = LearningEngine(self.memory, registry=self.registry) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def _artifact(self, name): diff --git a/tests/test_context_compaction.py b/tests/test_context_compaction.py index 13cf1b9f..f7fcc8ac 100644 --- a/tests/test_context_compaction.py +++ b/tests/test_context_compaction.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import asyncio import os import tempfile @@ -114,6 +115,74 @@ def test_accepted_plan_summary_is_pinned_in_fixed_prompt(self): self.assertIn("Upgrade the service safely", pinned) self.assertIn("Inspect service", pinned) + def test_standalone_working_plan_state_survives_compaction(self): + from openkyrozen.persistence.store import EventStore + from openkyrozen.tasks.engine import TaskManager + + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), session_id="pinned") + created = manager.edit_plan(expected_revision=0, operations=[ + {"op": "add", "ref": "inspect", "task": { + "description": "Inspect current source", "acceptance": ["Source compared"], + }}, + {"op": "add", "ref": "verify", "task": { + "description": "Verify final result", "acceptance": ["Regression passes"], + "dependencies": ["inspect"], + }}, + ]) + mode = main._active_interaction_mode.set("agent") + try: + with patch.object(main.current_session, "tasks", manager), patch.object( + main._interaction_controller, "state", return_value={}): + messages = main._build_messages("continue", memory_packet="") + finally: + main._active_interaction_mode.reset(mode) + pinned = [item for item in messages if isinstance(item, dict) + and item.get("role") == "system" + and "Pinned Working Plan state" in item.get("content", "")] + self.assertEqual(len(pinned), 1) + for text in (created["plan_id"], created["references"]["inspect"], + created["references"]["verify"], "Regression passes", "dependencies", '"revision": 1'): + self.assertIn(text, pinned[0]["content"]) + pressured = messages[:-1] + [{"role": "user", "content": "old" * 5000}] * 8 + messages[-1:] + compacted = compact_for_pressure( + pressured, ContextState("custom", configured_window=5000, reserve_tokens=50), + lambda _text, _limit: "old history summarized", + ) + self.assertTrue(compacted.compacted) + self.assertIn(pinned[0], compacted.messages) + + def test_oversized_pinned_plan_retains_full_scoped_artifact_and_refreshes_revision(self): + import json + from openkyrozen.agent.prompts import _refresh_working_plan_context + + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", index=None)))) + self.addCleanup(app.close) + runtime = app.runtime + plan = runtime.tasks.edit_plan(expected_revision=0, reason="why" * 8000, operations=[ + {"op": "add", "ref": "large", "task": { + "description": "source " * 3000, "acceptance": ["full criterion retained"], + }}, + ]) + messages = [{"role": "system", "content": "fixed"}] + _refresh_working_plan_context(runtime, messages) + self.assertLessEqual(len(messages[1]["content"]), 12000) + state = json.loads(messages[1]["content"].split("\n", 2)[2]) + artifact = runtime.memory_bank.store.artifacts.read_page( + state["artifact_id"], user_id=runtime.tasks.user_id, + workspace_id=runtime.tasks.workspace_id, session_id=runtime.tasks.session_id, offset=0, limit=4000, + ) + self.assertIsNotNone(artifact) + self.assertEqual(state["revision"], 1) + runtime.tasks.edit_plan(plan_id=plan["plan_id"], expected_revision=1, operations=[ + {"op": "update", "task": plan["task_ids"][0], "fields": {"description": "Updated inspection"}}, + ]) + _refresh_working_plan_context(runtime, messages) + self.assertEqual(sum("Pinned Working Plan state" in item["content"] for item in messages), 1) + self.assertIn('"revision": 2', messages[1]["content"]) + self.assertIn("Updated inspection", messages[1]["content"]) + def test_compaction_can_summarize_legacy_prefix_before_native_history(self): from openkyrozen.providers.models import FinishReason, ModelResponse, ToolCall, ToolReply @@ -306,7 +375,9 @@ class ContextApiTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory(prefix="openkyrozen-context-api-") self.addCleanup(self.directory.cleanup) - self.memory = MemoryBank(Path(self.directory.name) / "state.sqlite3", workspace_id="context-api") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(Path(self.directory.name) / "state.sqlite3", workspace_id="context-api"))) self.original_sessions = server._sessions server._sessions = {} self.addCleanup(lambda: setattr(server, "_sessions", self.original_sessions)) diff --git a/tests/test_conversation_history.py b/tests/test_conversation_history.py index 5109642a..518acb14 100644 --- a/tests/test_conversation_history.py +++ b/tests/test_conversation_history.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import tempfile import unittest from pathlib import Path @@ -15,12 +16,15 @@ class ConversationHistoryTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() - self.store = EventStore(Path(self.directory.name) / "events.sqlite3") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.store = self._sqlite_owners.enter_context(closing(EventStore(Path(self.directory.name) / "events.sqlite3"))) self.history = ConversationHistory( self.store, user_id="alice", workspace_id="project", session_id="session-a", ) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def test_append_is_idempotent_and_private_from_generic_event_api(self): @@ -112,6 +116,31 @@ def fail_second_entry(value): self.assertEqual(self.history.head, original_head) self.assertEqual(self.history.display_messages(), [{"role": "user", "content": "old"}]) + def test_context_metrics_count_archived_reply_sent_to_provider(self): + from contextvars import ContextVar + from types import SimpleNamespace + from openkyrozen.agent.context import _record_context_history_metrics + from openkyrozen.providers.models import ToolReply + + entry = self.history.entry("tool.result", { + "call_id": "call-1", "name": "read_file", "output": {"text": "x" * 6000}, "is_error": False, + }) + self.history.append(entry) + messages = [ToolReply("call-1", "read_file", {"data": {"artifact_id": "artifact-1"}, + "text": "archived"})] + runtime = SimpleNamespace( + conversation_history=self.history, _conversation_turn_id=ContextVar("turn", default=None), + _context_history_projection_ids=ContextVar("projection", default=None), + _context_history_seen=ContextVar("seen", default=set()), + _context_history_metrics=ContextVar("metrics", default={}), + ) + runtime._context_history_projection_ids.set((id(messages), {0: entry.id}, (0, 1))) + _record_context_history_metrics(runtime, messages) + self.assertEqual(runtime._context_history_metrics.get()["new_entry_count"], 1) + self.assertLess(runtime._context_history_metrics.get()["new_context_tokens"], 500) + _record_context_history_metrics(runtime, messages) + self.assertLess(runtime._context_history_metrics.get()["retransmitted_history_tokens"], 500) + def test_context_metrics_drop_compacted_native_history_entries(self): from types import SimpleNamespace from openkyrozen.agent.compaction import ContextState @@ -301,9 +330,9 @@ def test_prompt_assembly_uses_correlated_native_history(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -343,9 +372,9 @@ def test_prompt_assembly_preserves_selected_continuation_model_and_pauses_on_mis with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -386,9 +415,9 @@ def test_provider_error_is_not_journaled_as_a_completed_turn(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -411,9 +440,9 @@ def test_failed_turn_refreshes_display_cache_before_next_turn(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -435,9 +464,9 @@ def test_truncated_display_projection_does_not_replace_canonical_history(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -459,10 +488,10 @@ def test_session_restart_restores_private_journal_without_replaying_tools(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) database = root / "state.sqlite3" - first = build_application(surface="cli", memory=build_memory(database, user_id="alice", index=None), + first = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(database, user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = first.runtime @@ -476,7 +505,7 @@ def test_session_restart_restores_private_journal_without_replaying_tools(self): finally: first.close() - second = build_application(surface="cli", memory=build_memory(database, user_id="alice", index=None), + second = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(database, user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: restored = second.runtime.open_session("surface:cli") @@ -496,9 +525,9 @@ def test_tool_receipt_records_correlated_typed_result_once(self): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, "KYROZEN_CLI_CAPABILITIES": "full", "KYROZEN_APPROVAL_MODE": "never", - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -519,14 +548,52 @@ def test_tool_receipt_records_correlated_typed_result_once(self): finally: app.close() + def test_parallel_tool_results_serialize_conversation_head_updates(self): + from concurrent.futures import ThreadPoolExecutor + from contextvars import copy_context + from threading import Barrier + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + + with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, + }), ExitStack() as sqlite_owners: + root = Path(temp) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + try: + runtime = app.runtime + turn = runtime._conversation_turn_id.set("turn-parallel") + head = runtime._conversation_expected_head.set(None) + barrier = Barrier(2) + + def append(call_id): + barrier.wait() + return runtime._record_conversation_entry("tool.result", {"call_id": call_id}) + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(copy_context().run, append, call_id) + for call_id in ("call-a", "call-b")] + entries = [future.result() for future in futures] + finally: + runtime._conversation_expected_head.reset(head) + runtime._conversation_turn_id.reset(turn) + history = runtime.conversation_history.entries() + self.assertEqual({entry.payload["call_id"] for entry in entries}, {"call-a", "call-b"}) + self.assertEqual({entry.id for entry in history}, {entry.id for entry in entries}) + self.assertEqual(history[1].parent_id, history[0].id) + finally: + app.close() + def test_repeated_equal_inputs_are_distinct_runtime_turns_and_interfaces_do_not_duplicate(self): from openkyrozen.app.bootstrap import build_application, build_memory from openkyrozen.tools import ToolAdapters with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -555,9 +622,9 @@ def test_model_response_is_journaled_before_next_dispatch_and_write_failure_is_t from openkyrozen.tools import ToolAdapters with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -593,9 +660,9 @@ def test_context_uses_stable_prefix_metrics_and_does_not_inject_graph_context(se from openkyrozen.tools import ToolAdapters with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime @@ -637,9 +704,9 @@ def chat_response(self, _messages, model=None): with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, { "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_WORKSPACE_ROOT": temp, - }): + }), ExitStack() as sqlite_owners: root = Path(temp) - app = build_application(surface="cli", memory=build_memory(root / "state.sqlite3", user_id="alice", index=None), + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), tools=ToolAdapters(root), user_id="alice") try: runtime = app.runtime diff --git a/tests/test_delegated_planning.py b/tests/test_delegated_planning.py new file mode 100644 index 00000000..ae5dda9d --- /dev/null +++ b/tests/test_delegated_planning.py @@ -0,0 +1,290 @@ +import copy +import os +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.providers.config import ProviderConfig +from openkyrozen.providers.models import ToolCall +from openkyrozen.security.capabilities import issue_capability_token +from openkyrozen.tools import ToolAdapters + + +class DelegatedPlanningTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.environment = patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1', 'KYROZEN_APPROVAL_MODE': 'never', + 'KYROZEN_WORKSPACE_ROOT': str(self.root)}) + self.environment.start() + self.addCleanup(self.environment.stop) + self.app = build_application(memory=build_memory(self.root / 'state.sqlite3'), tools=ToolAdapters(self.root)) + self.addCleanup(self.app.close) + self.runtime = self.app.runtime + self.runtime._provider_config = ProviderConfig(provider='deepseek', api_key='fixture-only', + model_simple='fixture', model_complex='fixture') + self.runtime.get_provider = lambda config: object() + self.runtime._execution_capability_token = issue_capability_token('parent', {'read', 'write'}) + self.coordinator = self.runtime.delegation() + self.addCleanup(self.coordinator.close) + self.run = {'run_id': 'child-local', 'budget_id': 'child-local', 'profile': 'coder', 'name': 'worker', + 'reviewer': 'reviewer', 'review_agent': {}, 'status': 'running', 'assignment': { + 'objective': 'Inspect and update marker.txt', 'context': 'Only assigned file', + 'scope': ['marker.txt'], 'acceptance': ['Marker verified'], 'deliverables': ['marker.txt'], + 'dependencies': [], 'reason': 'Independent work', + }} + self.coordinator.runs[self.run['run_id']] = self.run + self.coordinator.cancelled[self.run['run_id']] = threading.Event() + + def invoke(self, callback, *, review=False): + with patch.object(self.runtime, 'chat', side_effect=callback): + return self.runtime._invoke_delegated(self.run, review=review, feedback=None, coordinator=self.coordinator) + + def test_real_delegated_boundary_exposes_and_executes_only_child_local_plan(self): + runtime = self.runtime + parent = runtime.current_session + proposal = parent.interaction.propose_plan({ + 'title': 'Marker', 'summary': 'Update assigned marker', 'bounds': { + 'workspace_paths': ['marker.txt'], 'capabilities': ['read', 'write'], + 'effects': ['read_only', 'filesystem', 'internal'], 'risk_ceiling': 'normal'}, + 'steps': [{'id': 'marker', 'title': 'Marker', 'description': 'Write marker.txt', + 'acceptance': ['Marker verified']}], + }) + parent.interaction.accept_plan(parent.tasks, plan_id=proposal['plan_id'], version=proposal['version']) + parent_snapshot = copy.deepcopy(parent.tasks.working_plan_snapshot()) + parent_interaction = copy.deepcopy(parent.interaction.state()) + other = runtime.open_session('other-child') + other.tasks.update_plan(complexity='complex', tasks=[{'description': 'Other plan', 'acceptance': ['Preserved']}]) + other_snapshot = copy.deepcopy(other.tasks.working_plan_snapshot()) + child_sessions = [] + + def child_turn(child, message, **kwargs): + child_sessions.append(child) + self.assertIsNot(child.tasks, parent.tasks) + self.assertIn('update_plan', runtime._permitted_tool_names()) + created = runtime.execute_call(child, ToolCall('child-plan', 'update_plan', { + 'expected_revision': 0, 'operations': [{'op': 'add', 'ref': 'inspect', 'task': { + 'description': 'Inspect assigned marker', 'acceptance': ['Marker checked']}}], + })) + self.assertTrue(created.success, created.result) + for foreign in (parent.tasks.working_plan.id, other.tasks.working_plan.id): + denied = runtime.execute_call(child, ToolCall('foreign-plan', 'update_plan', { + 'plan_id': foreign, 'expected_revision': 1, + 'operations': [{'op': 'cancel', 'task': parent.tasks.tasks[0]['id']}], + })) + self.assertFalse(denied.success) + denied_write = runtime.execute_call(child, ToolCall('outside', 'write_file', { + 'path': 'outside.txt', 'content': 'unauthorized'})) + self.assertFalse(denied_write.success) + self.assertFalse((self.root / 'outside.txt').exists()) + self.assertEqual(parent.tasks.working_plan_snapshot(), parent_snapshot) + self.assertEqual(other.tasks.working_plan_snapshot(), other_snapshot) + self.assertEqual(parent.interaction.state(), parent_interaction) + return 'Child planning checked.' + + self.invoke(child_turn) + events = runtime.memory_bank.store.list_events('execution.receipt', limit=None, + workspace_id=parent.tasks.workspace_id, user_id=parent.memory.user_id, + session_id=child_sessions[0].session_id) + self.assertTrue(any(event['payload']['call_id'] == 'child-plan' for event in events)) + self.assertEqual(parent.tasks.working_plan_snapshot(), parent_snapshot) + self.assertEqual(parent.tasks.tasks[0].get('evidence', []), []) + + def test_readonly_worker_can_plan_locally_without_staging_or_granting_writes(self): + self.run['profile'] = 'researcher' + parent = self.runtime.current_session + parent_spec = self.runtime.tool_registry.get_spec('update_plan') + def child_turn(child, message, **kwargs): + runtime = self.runtime + self.assertIn('update_plan', runtime._permitted_tool_names()) + spec = runtime.tool_registry.get_spec('update_plan') + self.assertEqual(spec.capability, 'read') + self.assertFalse(spec.parallel_safe) + self.assertEqual(parent_spec.capability, 'write') + self.assertNotIn('write', runtime.execution_context.capability_token.capabilities) + created = runtime.execute_call(child, ToolCall('readonly-plan', 'update_plan', { + 'expected_revision': 0, 'operations': [{'op': 'add', 'ref': 'inspect', 'task': { + 'description': 'Inspect assigned file', 'acceptance': ['Inspected'], + 'execution': {'action': 'read_file', 'arguments': {'path': 'marker.txt'}}}}], + })) + self.assertTrue(created.success, created.result) + revision = child.tasks.working_plan.revision + for action, arguments in (('write_file', {'path': 'marker.txt', 'content': 'forbidden'}), + ('run_cmd', {'command': 'echo unsafe'}), + ('read_file', {'path': 'outside.txt'})): + denied = runtime.execute_call(child, ToolCall('staged-denial', 'update_plan', { + 'plan_id': child.tasks.working_plan.id, 'expected_revision': revision, + 'operations': [{'op': 'add', 'ref': 'bad', 'task': { + 'description': 'Forbidden execution', 'acceptance': ['Never'], + 'execution': {'action': action, 'arguments': arguments}}}], + })) + self.assertFalse(denied.success, action) + self.assertEqual(child.tasks.working_plan.revision, revision) + denied = runtime.execute_call(child, ToolCall('write-denial', 'write_file', { + 'path': 'marker.txt', 'content': 'forbidden'}), capabilities='write') + self.assertFalse(denied.success) + self.assertFalse((self.root / 'marker.txt').exists()) + return 'Readonly planning validated.' + self.invoke(child_turn) + self.assertIs(self.runtime.tool_registry.get_spec('update_plan'), parent_spec) + + def test_simple_child_classification_ignores_runtime_role_and_report_boilerplate(self): + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason, ToolReply + self.run['profile'] = 'researcher' + self.run['assignment']['objective'] = 'Read marker.txt' + self.run['assignment']['context'] = 'Report its exact contents' + (self.root / 'marker.txt').write_text('simple source', encoding='utf-8') + observed, runtime = [], self.runtime + class NativeRead(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError('legacy path used') + def chat_response(self, messages, model=None, *, tools=None): + observed.append(set(item['name'] for item in tools)) + if not any(isinstance(item, ToolReply) for item in messages): + return ModelResponse(tool_calls=(ToolCall('read', 'read_file', {'path': 'marker.txt'}),), + finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text='The assigned file contains simple source.', finish_reason=FinishReason.FINAL) + provider = NativeRead(runtime._provider_config) + runtime.get_provider = lambda config: provider + result = runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + self.assertIn('read_file', observed[0]) + self.assertIn('simple source', result['result']) + self.assertTrue(any(receipt['call_id'] == 'read' and receipt['success'] for receipt in result['tool_records'])) + + def test_actual_complex_readonly_native_child_establishes_own_plan_without_write_authority(self): + import json + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason, ToolReply + self.run['profile'] = 'researcher' + self.run['assignment']['objective'] = 'Audit marker.txt comprehensively and inspect its evidence' + (self.root / 'marker.txt').write_text('independent source', encoding='utf-8') + parent = self.runtime.current_session + parent_spec = self.runtime.tool_registry.get_spec('update_plan') + runtime, observed, children = self.runtime, [], [] + class NativeAudit(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError('legacy provider path used') + def chat_response(self, messages, model=None, *, tools=None): + children.append(runtime.current_session) + replies = [item for item in messages if isinstance(item, ToolReply)] + observed.append(set(item['name'] for item in tools)) + if not replies: + self_case.assertEqual(observed[-1], {'update_plan'}) + call = ToolCall('local-plan', 'update_plan', {'expected_revision': 0, 'operations': [ + {'op': 'add', 'ref': 'inspect', 'task': {'description': 'Inspect assigned marker source', + 'acceptance': ['Report exact checked source'], + 'execution': {'action': 'read_file', 'arguments': {'path': 'marker.txt'}}}}, + {'op': 'start', 'task': 'inspect'}, + ]}) + elif not any(item.call_id == 'inspect' for item in replies): + call = ToolCall('inspect', 'read_file', {'path': 'marker.txt'}) + else: + return ModelResponse(text=json.dumps({'status': 'partial', 'summary': 'Source inspected; independent acceptance still pending', + 'findings': [], 'evidence': ['marker.txt'], 'artifacts': [], 'checks': ['Source read'], + 'uncertainties': ['Independent acceptance pending'], 'suggested_followups': []}), finish_reason=FinishReason.FINAL) + return ModelResponse(tool_calls=(call,), finish_reason=FinishReason.TOOL_REQUEST) + self_case = self + provider = NativeAudit(self.runtime._provider_config) + self.runtime.get_provider = lambda config: provider + result = self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + self.assertIn('Source inspected', result['result']) + child = children[0] + self.assertIsNotNone(child.tasks.working_plan) + self.assertEqual(len(child.tasks.tasks), 1) + read = next(receipt for receipt in result['tool_records'] if receipt['call_id'] == 'inspect') + self.assertTrue(read['success']) + self.assertEqual(read['plan_id'], child.tasks.working_plan.id) + self.assertEqual(read['task_id'], child.tasks.tasks[0]['id']) + self.assertEqual(read['session_id'], child.session_id) + self.assertEqual(parent.tasks.tasks, []) + self.assertIs(runtime.tool_registry.get_spec('update_plan'), parent_spec) + self.assertEqual(parent_spec.capability, 'write') + self.assertEqual((self.root / 'marker.txt').read_text(), 'independent source') + + def test_children_and_reviewers_retrieve_only_their_own_tool_artifacts(self): + (self.root / 'marker.txt').write_text('own output ' * 1400 + 'END_OF_OWN_OUTPUT', encoding='utf-8') + parent = self.runtime.current_session + parent_scope = {'user_id': parent.tasks.user_id, 'workspace_id': parent.tasks.workspace_id, + 'session_id': parent.tasks.session_id} + parent_artifact = parent.memory.store.artifacts.create('PRIVATE_PARENT_OUTPUT', **parent_scope) + for reviewing in (False, True): + with self.subTest(reviewing=reviewing): + def child_turn(child, message, **kwargs): + runtime = self.runtime + self.assertIn('read_tool_artifact', runtime._permitted_tool_names()) + scope = {'user_id': child.tasks.user_id, 'workspace_id': child.tasks.workspace_id, + 'session_id': child.tasks.session_id} + archived = runtime._agent_engine._store_tool_output('OWN_ARCHIVED_OUTPUT') + fetched = runtime.execute_call(child, ToolCall('own-archive', 'read_tool_artifact', {'artifact_id': archived})) + self.assertTrue(fetched.success, fetched.result) + self.assertEqual(fetched.tool_result.data['content'], 'OWN_ARCHIVED_OUTPUT') + read = runtime.execute_call(child, ToolCall('large-read', 'read_file', {'path': 'marker.txt'})) + self.assertTrue(read.success) + artifact = read.tool_result.data['artifact_id'] + size = read.tool_result.data['total_chars'] + last = runtime.execute_call(child, ToolCall('last-page', 'read_tool_artifact', { + 'artifact_id': artifact, 'offset': max(0, size - 1000), 'limit': 1000})) + self.assertTrue(last.success, last.result) + self.assertIn('END_OF_OWN_OUTPUT', last.tool_result.data['content']) + foreign = [parent_artifact] + for alternate in ({**scope, 'user_id': 'another-user'}, + {**scope, 'workspace_id': 'another-workspace'}, + {**scope, 'session_id': 'another-child'}): + foreign.append(child.memory.store.artifacts.create('PRIVATE_FOREIGN_OUTPUT', **alternate)) + for artifact_id in foreign: + denied = runtime.execute_call(child, ToolCall('denied', 'read_tool_artifact', {'artifact_id': artifact_id})) + self.assertFalse(denied.success) + self.assertEqual(denied.failure, 'not_found') + self.assertNotIn('PRIVATE_', denied.result) + self.assertIsNone(child.memory.store.artifacts.read_page(archived, 0, 100, **parent_scope)) + return 'Own artifacts checked.' + self.invoke(child_turn, review=reviewing) + + def test_parent_material_reapproval_still_blocks_child_plan_mutation(self): + parent = self.runtime.current_session + proposal = parent.interaction.propose_plan({ + 'title': 'Marker', 'summary': 'Update marker', 'bounds': { + 'workspace_paths': ['marker.txt'], 'capabilities': ['read', 'write'], + 'effects': ['read_only', 'filesystem', 'internal'], 'risk_ceiling': 'normal'}, + 'steps': [{'id': 'marker', 'title': 'Marker', 'description': 'Write marker.txt', + 'acceptance': ['Marker verified']}], + }) + parent.interaction.accept_plan(parent.tasks, plan_id=proposal['plan_id'], version=proposal['version']) + parent.interaction.propose_plan({ + 'title': 'Expand work', 'summary': 'Add an unrelated output', 'bounds': proposal['bounds'], + 'steps': [{'id': 'extra', 'title': 'Extra', 'description': 'Write another.txt', + 'acceptance': ['Another output created']}], + }) + self.assertIsNotNone(parent.interaction.state()['pending_plan']) + before = copy.deepcopy(parent.tasks.working_plan_snapshot()) + def child_turn(child, message, **kwargs): + denied = self.runtime.execute_call(child, ToolCall('must-wait', 'update_plan', { + 'expected_revision': 0, 'operations': [{'op': 'add', 'ref': 'new', 'task': { + 'description': 'Must wait', 'acceptance': ['Never created']}}], + })) + self.assertFalse(denied.success) + self.assertIsNone(child.tasks.working_plan) + self.assertEqual(parent.tasks.working_plan_snapshot(), before) + return 'Child waits for renewed approval.' + self.invoke(child_turn) + + def test_readonly_reviewer_cannot_acquire_plan_write_permission(self): + def review_turn(child, message, **kwargs): + self.assertNotIn('update_plan', self.runtime._permitted_tool_names()) + denied = self.runtime.execute_call(child, ToolCall('forbidden-plan', 'update_plan', { + 'expected_revision': 0, 'operations': [{'op': 'add', 'ref': 'new', 'task': { + 'description': 'Must not create', 'acceptance': ['Never']}}], + })) + self.assertFalse(denied.success) + self.assertIsNone(child.tasks.working_plan) + return 'Review remains read only.' + self.invoke(review_turn, review=True) diff --git a/tests/test_delegation.py b/tests/test_delegation.py index 48c474f0..3a2dd27c 100644 --- a/tests/test_delegation.py +++ b/tests/test_delegation.py @@ -1,9 +1,11 @@ import copy import json import os +import sys import tempfile import threading import time +import traceback import unittest from pathlib import Path from unittest.mock import patch @@ -399,10 +401,17 @@ def invoke(run, **kwargs): return {"result": result()} coordinator = self.manager(invoke, 1) run = coordinator.spawn([brief()])[0] - self.assertTrue(entered.wait(2)) + self.assertTrue(entered.wait(10)) coordinator.cancel(run["run_id"]) release.set() - coordinator.futures[run["run_id"]].result(timeout=5) + try: + coordinator.futures[run["run_id"]].result(timeout=5) + except TimeoutError: + frames = sys._current_frames() + stacks = [f"{thread.name}:\n{''.join(traceback.format_stack(frames[thread.ident]))}" + for thread in threading.enumerate() + if thread.name.startswith("kyrozen-agent") and thread.ident in frames] + self.fail("Cancelled worker did not finish within 5 seconds:\n" + "\n".join(stacks)) self.assertEqual(coordinator.wait(timeout=3)[0]["status"], "cancelled") coordinator.memory.store.append_event("subagent.state", {**run, "status": "running"}, user_id=coordinator.memory.user_id, workspace_id=coordinator.memory.file_scope_id, @@ -471,6 +480,101 @@ def test_configuration_strictness_and_scoped_runs(self): with self.assertRaises(ValueError): self.runtime.delegation().detail(run["run_id"]) + def test_original_native_detail_uses_the_journal_scope_for_author_and_reviewer(self): + from openkyrozen.agent.conversation import ConversationHistory + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + + class Provider(LLMProvider): + def chat(self, messages, model=None): + raise AssertionError("native delegation must not use the legacy provider") + + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + + def chat_response(self, messages, model=None, *, tools=None): + if not any(isinstance(item, ToolReply) for item in messages): + return ModelResponse(tool_calls=(ToolCall("native-read", "read_file", {"path": "marker.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + review = any(isinstance(item, dict) and "Independently verify" in str(item.get("content", "")) + for item in messages) + return ModelResponse(text=json.dumps(reviewed() if review else result()), + finish_reason=FinishReason.FINAL) + + self.runtime.get_provider = Provider + for bound in (False, True): + with self.subTest(project_bound=bound): + if bound: + self.runtime.configure_launch_context(project_path=self.root) + self.runtime.bind_interaction_scope("project-parent") + # The task scope is independent of the source and conversation scopes. + self.runtime.tasks.workspace_id = "parent-tasks-only" + scope = self.runtime.interaction_workspace_id() + coordinator = self.runtime.delegation() + self.addCleanup(coordinator.close) + agent = coordinator.spawn([brief()])[0] + run = coordinator.wait([agent["run_id"]], timeout=10)[0] + self.assertEqual(run["status"], "succeeded", run.get("error")) + detail = coordinator.detail(agent["run_id"]) + for child_id, displayed in ((agent["run_id"], detail["messages"]), + (run["reviews"][0]["session_id"], detail["review_messages"][run["reviews"][0]["session_id"]])): + history = ConversationHistory(self.app.store, user_id="local", workspace_id=scope, + session_id=child_id) + entries = history.entries() + self.assertTrue(any(entry.kind == "tool.result" and entry.payload["call_id"] == "native-read" + for entry in entries)) + self.assertEqual([item["role"] for item in displayed], ["user", "assistant"]) + self.assertEqual(displayed, history.display_messages()) + self.assertEqual(detail.get("conversation_workspace_id"), scope) + + def test_detail_old_runs_fall_back_only_when_the_source_journal_is_absent(self): + from openkyrozen.agent.conversation import ConversationHistory + + coordinator = self.manager(lambda *_args, **_kwargs: None) + coordinator.memory.file_scope_id = "source-current" + store = coordinator.memory.store + for index, source_message in enumerate((None, "source history", "")): + with self.subTest(source_message=source_message): + run_id, review_id = f"old-author-{index}", f"old-review-{index}" + coordinator.runs[run_id] = {"run_id": run_id, "status": "succeeded", + "reviews": [{"session_id": review_id}]} + for session_id in (run_id, review_id): + global_history = ConversationHistory(store, user_id="local", workspace_id="default", + session_id=session_id) + global_history.import_legacy([{"role": "assistant", "content": "global history"}]) + if source_message is not None: + history = ConversationHistory(store, user_id="local", workspace_id="source-current", + session_id=session_id) + history.append(history.entry("turn.outcome", {"text": source_message})) + detail = coordinator.detail(run_id) + expected = ([{"role": "assistant", "content": "global history"}] if source_message is None else + [{"role": "assistant", "content": "source history"}] if source_message else []) + self.assertEqual(detail["messages"], expected) + self.assertEqual(detail["review_messages"][review_id], expected) + self.assertNotIn("conversation_workspace_id", coordinator.runs[run_id]) + + def test_detail_explicit_journal_scope_never_falls_back_to_other_scopes(self): + from openkyrozen.agent.conversation import ConversationHistory + + coordinator = self.manager(lambda *_args, **_kwargs: None) + coordinator.memory.file_scope_id = "source-current" + coordinator.runs["owned-author"] = {"run_id": "owned-author", "status": "succeeded", "reviews": [], + "conversation_workspace_id": "project-current"} + for user, workspace, session in (("other-user", "project-current", "owned-author"), + ("local", "other-project", "owned-author"), ("local", "default", "owned-author"), + ("local", "source-current", "owned-author"), ("local", "project-current", "other-session")): + history = ConversationHistory(coordinator.memory.store, user_id=user, workspace_id=workspace, + session_id=session) + history.import_legacy([{"role": "assistant", "content": "private foreign history"}]) + self.assertEqual(coordinator.detail("owned-author")["messages"], []) + coordinator.runs["old-owned-author"] = {"run_id": "old-owned-author", "status": "succeeded", "reviews": []} + for user, workspace, session in (("other-user", "default", "old-owned-author"), + ("local", "other-project", "old-owned-author"), ("local", "default", "old-other-session")): + history = ConversationHistory(coordinator.memory.store, user_id=user, workspace_id=workspace, + session_id=session) + history.import_legacy([{"role": "assistant", "content": "private foreign history"}]) + self.assertEqual(coordinator.detail("old-owned-author")["messages"], []) + def test_main_automatically_delegates_and_waits_before_claiming_success(self): runtime = self.runtime runtime.set_interaction_mode("agent") @@ -486,8 +590,10 @@ def chat(inner, messages, model): return ('Plan:\n1. Inspect workspace structure.\n2. Spawn two specialist agents.\n3. Wait for verified results and synthesize.\n' 'Action: {"action":"list_dir","args":"."}'), None runtime.llm_provider = MainProvider() + original_classifier = runtime._classify_complexity with (patch.object(runtime, "dispatch_learning_cycle", return_value={}), - patch.object(runtime, "_classify_complexity", return_value="complex"), + patch.object(runtime, "_classify_complexity", side_effect=lambda request: + "complex" if request == "Inspect two independent components" else original_classifier(request)), patch.object(runtime, "_touch_detached_learning_heartbeat"), patch.object(runtime, "_render_tool_result"), patch.object(runtime, "_update_tasks_panel")): @@ -638,6 +744,65 @@ def chat(inner, messages, model): self.assertIn("unsupported tool-call wrapper", reply) self.assertIsNone(self.runtime._interaction_controller.state().get("pending_plan")) + def test_concurrent_calls_use_only_one_terminal_spinner(self): + import io + from contextlib import redirect_stdout + + second_app = build_application(memory=build_memory(self.root / "state-second.sqlite3"), + tools=ToolAdapters(self.root)) + second_runtime = second_app.runtime + second_entered = threading.Event() + release_second = threading.Event() + spinner_starts = [] + errors = [] + answers = [] + original_start = threading.Thread.start + + def controlled_start(thread): + target = getattr(thread, "_target", None) + if (getattr(target, "__self__", None) in {self.runtime, second_runtime} + and getattr(target, "__name__", "") == "_spinner_worker"): + spinner_starts.append(thread) + return original_start(thread) + + def response(_messages, model=None): + if threading.current_thread().name == "spinner-first": + if not second_entered.wait(5): + raise TimeoutError("second model call did not start concurrently") + else: + second_entered.set() + if not release_second.wait(5): + raise TimeoutError("second model call was not released") + return "done" + + def call(): + try: + runtime = second_runtime if threading.current_thread().name == "spinner-second" else self.runtime + answers.append(runtime._call_llm_with_spinner([{"role": "user", "content": "test"}])) + except BaseException as exc: + errors.append(exc) + + with (patch.object(self.runtime, "_prepare_context_for_call"), + patch.object(self.runtime, "_get_llm_response", side_effect=response), + patch.object(second_runtime, "_prepare_context_for_call"), + patch.object(second_runtime, "_get_llm_response", side_effect=response), + patch.object(threading.Thread, "start", controlled_start), + redirect_stdout(io.StringIO())): + first = threading.Thread(target=call, name="spinner-first") + second = threading.Thread(target=call, name="spinner-second") + first.start() + second.start() + first.join(5) + release_second.set() + second.join(5) + second_app.close() + + self.assertFalse(first.is_alive()) + self.assertFalse(second.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(answers, ["done", "done"]) + self.assertEqual(len(spinner_starts), 1) + def test_parent_cannot_turn_unverified_findings_into_success(self): class MainProvider: def chat(inner, messages, model): @@ -754,7 +919,14 @@ def chat(inner, messages, model): self.runtime.get_provider = lambda _: SlowProvider() coordinator = self.runtime.delegation() run = coordinator.spawn([brief(profile="coder")])[0] - self.assertTrue(entered.wait(2)) + if not entered.wait(10): + with coordinator.lock: + future = coordinator.futures[run["run_id"]] + state = coordinator.summary(coordinator.runs[run["run_id"]]) + future_done = future.done() + future_error = repr(future.exception()) if future_done else None + self.fail(f"Provider call did not start within 10 seconds: state={state}, " + f"future_done={future_done}, future_error={future_error}") coordinator.cancel(run["run_id"]) release.set() coordinator.futures[run["run_id"]].result(timeout=5) @@ -808,7 +980,7 @@ def chat(inner, messages, model): runtime.get_provider = lambda _: Provider() coordinator = runtime.delegation() coordinator.spawn([brief(profile="coder")]) - run = coordinator.wait(timeout=5)[0] + run = coordinator.wait(timeout=15)[0] self.assertEqual(run["status"], "succeeded") self.assertEqual(run["corrections"], 1) self.assertEqual((self.root / "marker.txt").read_text(), "ok") @@ -927,7 +1099,15 @@ def handle(request): self.runtime._provider_config = ProviderConfig(provider="openai", api_key="fixture-openai", base_url="https://openai.fixture/v1", model_simple="gpt-fixture", model_complex="gpt-fixture") self.runtime.DEEPSEEK_MODEL = "gpt-fixture" - self.runtime.get_provider = get_provider + # This fixture checks nonstreaming SDK request shapes; live streaming has separate coverage. + from dataclasses import replace + def fixture_provider(config): + provider = get_provider(config) + capabilities = provider.get_capabilities + provider.get_capabilities = lambda model=None: replace( + capabilities(model), text_streaming=False, streaming_tool_calls=False) + return provider + self.runtime.get_provider = fixture_provider (self.root / "agent.yaml").write_text("subagents:\n roles:\n researcher:\n provider: anthropic\n model: claude-fixture\n") with (patch.dict(os.environ, {"ANTHROPIC_API_KEY": "fixture-claude", "KYROZEN_API_KEY": "wrong-main"}), patch("openai.OpenAI", side_effect=lambda **kw: real_openai(**kw, http_client=httpx.Client(transport=transport))), @@ -943,6 +1123,7 @@ def handle(request): self.assertIsNotNone(runs[1]["result"]["metrics"]["cost_picos"]) self.assertTrue(all(run["reviews"][0]["metrics"]["provider_model"] == "openai:gpt-fixture-actual" for run in runs)) for url, headers, body in requests: + headers = {name.lower(): value for name, value in headers.items()} if url.path == "/v1/messages": self.assertEqual(url.host, "api.anthropic.com") self.assertIn(headers.get("x-api-key") or headers.get("authorization"), @@ -1045,7 +1226,7 @@ def chat(inner, messages, model): self.assertFalse((self.root/"other/first.txt").exists()) agents = coordinator.spawn([brief(0, profile="coder", scope=["first.txt"]), brief(1, profile="coder", scope=["second.txt"], dependencies=["0"])]) - finished = coordinator.wait([run["run_id"] for run in agents],5) + finished = coordinator.wait([run["run_id"] for run in agents],15) finally: runtime._active_interaction_mode.reset(token) self.assertEqual([run["status"] for run in finished], ["succeeded", "blocked"], finished) diff --git a/tests/test_deletion.py b/tests/test_deletion.py index b41654a8..4e85c997 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -1,4 +1,5 @@ from __future__ import annotations +from contextlib import ExitStack, closing import tempfile import unittest @@ -10,9 +11,12 @@ class DeletionPersistenceTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() - self.store = EventStore(Path(self.temp.name) / "state.sqlite3") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.store = self._sqlite_owners.enter_context(closing(EventStore(Path(self.temp.name) / "state.sqlite3"))) def tearDown(self): + self._sqlite_owners.close() self.temp.cleanup() def add_history(self, *, user_id: str, workspace_id: str, session_id: str): diff --git a/tests/test_distribution.py b/tests/test_distribution.py index df1889ba..97fcbc7d 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -17,6 +17,27 @@ class DistributionTests(unittest.TestCase): + def test_v3_tag_cannot_trigger_the_ungated_stable_release_workflow(self): + import fnmatch + import yaml + + workflow = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader) + patterns = workflow["on"]["push"]["tags"] + + def selected(tag): + result = False + for pattern in patterns: + negative = pattern.startswith("!") + if fnmatch.fnmatchcase(tag, pattern[1:] if negative else pattern): + result = not negative + return result + + self.assertTrue(selected("v2.0.8")) + self.assertFalse(selected("v3.0.0")) + self.assertFalse(selected("v3.0.0.dev1")) + self.assertIn("!startsWith(inputs.release_tag || github.ref_name, 'v3.')", + workflow["jobs"]["build-release"]["if"]) + def test_ci_change_routing_covers_known_inputs_and_fails_open(self): from scripts.ci_changes import classify_paths @@ -38,6 +59,14 @@ def test_ci_change_routing_covers_known_inputs_and_fails_open(self): with self.subTest(label=label): self.assertEqual(classify_paths(paths), expected) + def test_release_evidence_matrix_is_selected_only_for_its_inputs_or_explicit_sha(self): + from scripts.ci_changes import requires_release_evidence + + self.assertTrue(requires_release_evidence({".github/workflows/ci.yml"})) + self.assertTrue(requires_release_evidence(set(), "a" * 40)) + self.assertFalse(requires_release_evidence({"docs/development.md"})) + self.assertFalse(requires_release_evidence(None)) + def test_ci_change_classifier_uses_complete_git_ranges_and_fails_open(self): from scripts.ci_changes import changed_paths @@ -70,7 +99,10 @@ def test_pyproject_exposes_both_entry_points_and_launch_module(self): with (ROOT / "pyproject.toml").open("rb") as handle: document = tomllib.load(handle) project = document["project"] - self.assertEqual(project["version"], "2.0.8") + self.assertEqual(project["version"], "3.0.0") + self.assertIn('var version = "3.0.0"', (ROOT / "tui" / "model.go").read_text()) + self.assertIn("Development Status :: 5 - Production/Stable", project["classifiers"]) + self.assertNotIn("Development Status :: 3 - Alpha", project["classifiers"]) self.assertEqual(project["requires-python"], ">=3.12,<3.14") self.assertEqual(project["scripts"]["kyrozen"], "openkyrozen.interfaces.cli.launcher:main") self.assertEqual(project["scripts"]["kyrozen-backend"], "openkyrozen.interfaces.tui.backend:main") @@ -112,6 +144,10 @@ def test_ci_keeps_core_and_full_python_coverage_without_duplicate_suites(self): self.assertIn("workflow_dispatch:", workflow) self.assertIn("name: CI result", workflow) self.assertIn("scripts/ci_changes.py", workflow) + self.assertIn("v3_release_candidate_sha", workflow) + self.assertIn("v3-platform-validation:", workflow) + self.assertIn("collect-r266:", workflow) + self.assertIn("PYTHONPATH=tests python -m unittest tests.test_durable_tasks_gateway", workflow) self.assertNotIn("matrix:\n python-version: [\"3.12\", \"3.13\"]", workflow) def test_one_shot_release_workflow_is_retired_after_v2_release(self): @@ -168,7 +204,7 @@ def test_posix_installer_has_valid_syntax_and_idempotent_user_path_logic(self): self.assertIn('PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"', text) self.assertIn("grep -Fq '$HOME/.local/bin'", text) self.assertIn('tool install --python', text) - self.assertIn("release_version='2.0.8'", text) + self.assertIn("release_version='3.0.0'", text) self.assertIn("-X main.revision=$release_tag", text) self.assertIn("releases/download", text) self.assertIn("--with fastapi --with uvicorn", text) @@ -203,7 +239,8 @@ def test_powershell_installer_is_static_safe_and_parses_when_available(self): self.assertIn("[Environment]::GetEnvironmentVariable(\"Path\", \"User\")", text) self.assertIn("SetEnvironmentVariable(\"Path\"", text) self.assertIn("tool install --python", text) - self.assertIn("v2.0.8", text) + self.assertIn("v3.0.0", text) + self.assertIn('$releaseVersion = "3.0.0"', text) self.assertIn("-X main.revision=$releaseTag", text) self.assertIn("releases/download", text) self.assertIn("--with fastapi --with uvicorn", text) @@ -248,7 +285,7 @@ def test_posix_installer_retries_with_uncached_uv_install(self): fi if [ "$1" = "--no-cache" ] && [ "$2" = "tool" ] && [ "$3" = "install" ]; then mkdir -p "$HOME/.local/bin" - printf '%s\n' '#!/bin/sh' 'if [ "$1" = "--version" ]; then printf "%s\n" "OpenKyrozen 2.0.8"; fi' > "$HOME/.local/bin/kyrozen" + printf '%s\n' '#!/bin/sh' 'if [ "$1" = "--version" ]; then printf "%s\n" "OpenKyrozen 3.0.0"; fi' > "$HOME/.local/bin/kyrozen" chmod +x "$HOME/.local/bin/kyrozen" exit 0 fi @@ -290,7 +327,7 @@ def test_cli_and_web_help_version_flags_work_without_provider_setup(self): }) commands = [ ([sys.executable, str(ROOT / "main.py"), "--help"], "--project"), - ([sys.executable, str(ROOT / "main.py"), "--version"], "OpenKyrozen"), + ([sys.executable, str(ROOT / "main.py"), "--version"], "OpenKyrozen 3.0.0"), ([sys.executable, str(ROOT / "server.py"), "--help"], "--reload"), ] for command, marker in commands: @@ -304,7 +341,7 @@ def test_cli_and_web_help_version_flags_work_without_provider_setup(self): from openkyrozen.interfaces.web.service import WebService server = WebService(None) - self.assertEqual(server.app.version, "2.0.8") + self.assertEqual(server.app.version, "3.0.0") def test_web_parser_accepts_project_and_server_flags(self): from openkyrozen.interfaces.web.service import WebService @@ -337,7 +374,8 @@ def test_update_uses_the_package_manager_instead_of_git_pull(self): ) with patch("openkyrozen.updates.service.shutil.which", return_value="/usr/local/bin/uv"), \ patch.object(main, "_release_tui_asset_available", return_value=True), \ - patch.object(main, "_resolve_update_revision", return_value=None), \ + patch.object(main, "_resolve_update_revision", return_value="a" * 40), \ + patch("openkyrozen.updates.service._read_update_version", return_value="3.0.0"), \ patch.object(main, "_stage_update_tui", return_value=Path("/fixture-tui")), \ patch.object(main, "_prepare_update_browser", return_value=None), \ patch.object(main, "_verify_update_package", return_value=True), \ @@ -356,7 +394,7 @@ def test_update_uses_the_package_manager_instead_of_git_pull(self): self.assertIn("--with", command) self.assertIn("fastapi", command) self.assertIn("uvicorn", command) - self.assertTrue(command[-1].endswith("/v2.0.8/openkyrozen-2.0.8-py3-none-any.whl")) + self.assertTrue(command[-1].endswith("@" + "a" * 40)) def test_update_retries_without_uv_cache_after_install_failure(self): from openkyrozen.app.bootstrap import build_application @@ -370,7 +408,8 @@ def test_update_retries_without_uv_cache_after_install_failure(self): ) with patch("openkyrozen.updates.service.shutil.which", return_value="/usr/local/bin/uv"), \ patch.object(main, "_release_tui_asset_available", return_value=True), \ - patch.object(main, "_resolve_update_revision", return_value=None), \ + patch.object(main, "_resolve_update_revision", return_value="a" * 40), \ + patch("openkyrozen.updates.service._read_update_version", return_value="3.0.0"), \ patch.object(main, "_stage_update_tui", return_value=Path("/fixture-tui")), \ patch.object(main, "_prepare_update_browser", return_value=None), \ patch.object(main, "_verify_update_package", return_value=True), \ @@ -400,7 +439,8 @@ def test_update_preserves_stdout_only_failure_diagnostics(self): ) with patch("openkyrozen.updates.service.shutil.which", return_value="/usr/local/bin/uv"), \ patch.object(main, "_release_tui_asset_available", return_value=True), \ - patch.object(main, "_resolve_update_revision", return_value=None), \ + patch.object(main, "_resolve_update_revision", return_value="a" * 40), \ + patch("openkyrozen.updates.service._read_update_version", return_value="3.0.0"), \ patch.object(main, "_stage_update_tui", return_value=Path("/fixture-tui")), \ patch.object(main, "_prepare_update_browser", return_value=None), \ patch.object(main, "_verify_update_package", return_value=True), \ @@ -421,6 +461,7 @@ def test_update_prefers_current_main_revision_over_stale_release_assets(self): with patch("openkyrozen.updates.service.shutil.which", return_value="/usr/local/bin/uv"), \ patch.object(main, "_release_tui_asset_available", return_value=True), \ patch.object(main, "_resolve_update_revision", return_value=revision), \ + patch("openkyrozen.updates.service._read_update_version", return_value="3.0.0"), \ patch.object(main, "_stage_update_tui", return_value=Path("/fixture-tui")), \ patch.object(main, "_prepare_update_browser", return_value=None), \ patch.object(main, "_verify_update_package", return_value=True), \ @@ -444,6 +485,7 @@ def test_update_bootstraps_main_revision_when_release_predates_tui(self): with patch("openkyrozen.updates.service.shutil.which", return_value="/usr/local/bin/uv"), \ patch.object(main, "_release_tui_asset_available", return_value=False), \ patch.object(main, "_resolve_update_revision", return_value=revision), \ + patch("openkyrozen.updates.service._read_update_version", return_value="3.0.0"), \ patch.object(main, "_stage_update_tui", return_value=Path("/fixture-tui")), \ patch.object(main, "_prepare_update_browser", return_value=None), \ patch.object(main, "_verify_update_package", return_value=True), \ diff --git a/tests/test_docs.py b/tests/test_docs.py index 51a18de7..5a35ffd4 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -60,7 +60,7 @@ def test_documented_environment_defaults_match_the_provider_registry(self): errors = _check_provider_defaults( Path("configuration.md"), "export KYROZEN_MODEL_SIMPLE=deepseek-flash\n" - "export KYROZEN_MODEL_COMPLEX=deepseek-v4-pro\n", + "export KYROZEN_MODEL_COMPLEX=deepseek-flash\n", PROVIDER_DEFAULT_MODELS, ) self.assertEqual(errors, []) diff --git a/tests/test_durable_tasks_gateway.py b/tests/test_durable_tasks_gateway.py index 5f4b53b1..391eb380 100644 --- a/tests/test_durable_tasks_gateway.py +++ b/tests/test_durable_tasks_gateway.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import socket @@ -23,7 +24,8 @@ def _free_port() -> int: return int(sock.getsockname()[1]) @staticmethod - def _request(base_url: str, path: str, *, method: str = "GET", body: dict | None = None) -> dict: + def _request(base_url: str, path: str, *, method: str = "GET", body: dict | None = None, + timeout: float = 5) -> dict: data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request( base_url + path, @@ -31,7 +33,8 @@ def _request(base_url: str, path: str, *, method: str = "GET", body: dict | None method=method, headers={"Content-Type": "application/json", "Authorization": "Bearer durable-test-token"}, ) - with urllib.request.urlopen(request, timeout=5) as response: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def _start_server(self, workspace: Path, db_path: Path, skills_path: Path, *, log_path: Path | None = None, startup_timeout: float = 20) -> tuple[subprocess.Popen, str]: @@ -87,15 +90,15 @@ def _stop_server(process: subprocess.Popen) -> None: if process.stdout: process.stdout.close() - def test_gateway_recovers_scoped_running_task_and_executes_api_task(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-task-gateway-") as directory: + def test_gateway_blocks_interrupted_mutations_and_executes_new_api_task(self): + with tempfile.TemporaryDirectory(prefix="openkyrozen-task-gateway-") as directory, ExitStack() as sqlite_owners: root = Path(directory) workspace = root / "workspace" workspace.mkdir() (workspace / "home").mkdir() db_path = root / "state.sqlite3" skills_path = root / "skills" - store = EventStore(db_path) + store = sqlite_owners.enter_context(closing(EventStore(db_path))) workspace_id = source_scope_id(workspace) recovery_manager = TaskManager( store, user_id="local", workspace_id=workspace_id, session_id="restart-session" @@ -125,30 +128,16 @@ def test_gateway_recovers_scoped_running_task_and_executes_api_task(self): process, base_url = self._start_server(workspace, db_path, skills_path) try: - deadline = time.monotonic() + 10 recovered = self._request(base_url, "/api/v2/tasks?session_id=restart-session") - while time.monotonic() < deadline: - recovered = self._request(base_url, "/api/v2/tasks?session_id=restart-session") - if recovered["tasks"][0]["status"] == "succeeded": - break - time.sleep(0.1) - self.assertEqual(recovered["tasks"][0]["status"], "succeeded") - self.assertEqual((workspace / "recovered.txt").read_text(encoding="utf-8"), "after restart") - - deadline = time.monotonic() + 10 - recovered_command = self._request( - base_url, "/api/v2/tasks?session_id=restart-command-session" - ) - while time.monotonic() < deadline: - recovered_command = self._request( - base_url, "/api/v2/tasks?session_id=restart-command-session" - ) - if recovered_command["tasks"][0]["status"] == "failed": - break - time.sleep(0.1) - self.assertEqual(recovered_command["tasks"][0]["status"], "failed") - self.assertTrue(recovered_command["tasks"][0]["evidence"]) - self.assertFalse(recovered_command["tasks"][0]["evidence"][-1]["success"]) + self.assertEqual(recovered["tasks"][0]["status"], "blocked") + self.assertFalse((workspace / "recovered.txt").exists()) + recovered_command = self._request(base_url, "/api/v2/tasks?session_id=restart-command-session") + self.assertEqual(recovered_command["tasks"][0]["status"], "blocked") + self.assertEqual(recovered_command["tasks"][0]["evidence"], []) + with self.assertRaises(urllib.error.HTTPError) as denied: + self._request(base_url, f"/api/v2/tasks/{recovered['tasks'][0]['id']}/resume", + method="POST", body={}) + self.assertEqual(denied.exception.code, 409) created = self._request( base_url, @@ -210,7 +199,7 @@ def test_gateway_recovers_scoped_running_task_and_executes_api_task(self): events = self._request(base_url, "/api/v2/events?session_id=restart-session")["events"] event_types = {event["event_type"] for event in events} self.assertIn("task.recovered", event_types) - self.assertIn("task.execution_completed", event_types) + self.assertNotIn("task.execution_completed", event_types) finally: self._stop_server(process) diff --git a/tests/test_dynamic_tools.py b/tests/test_dynamic_tools.py index b5aa0ecf..e0a6fdfe 100644 --- a/tests/test_dynamic_tools.py +++ b/tests/test_dynamic_tools.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import unittest import tempfile from pathlib import Path @@ -8,9 +9,9 @@ class DynamicToolRemovalTests(unittest.TestCase): def test_define_tool_text_is_rejected_without_registration(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: runtime = build_application( - memory=build_memory(Path(directory) / "state.sqlite3"), surface="cli", + memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), surface="cli", ).runtime before = set(runtime.tool_registry.specs) response = ( diff --git a/tests/test_evolution.py b/tests/test_evolution.py index 2c37bc62..7260bf2d 100644 --- a/tests/test_evolution.py +++ b/tests/test_evolution.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import tempfile import unittest @@ -29,11 +30,14 @@ class EvolutionTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() root = Path(self.directory.name) - self.memory = MemoryBank(root / "state.sqlite3", workspace_id="project") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", workspace_id="project"))) self.registry = SkillRegistry(self.memory.store, workspace_id="project", root=root / "skills") self.engine = LearningEngine(self.memory, registry=self.registry) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def test_post_turn_dispatch_uses_background_executor_and_records_failure(self): @@ -97,10 +101,9 @@ def _candidate(self): }) def _pass_replay(self, candidate): - return self.engine.record_shadow_replay( - candidate["proposal_id"], [{"case_id": "case-1", "verified_success": True}], - [{"case_id": "case-1", "verified_success": True}], - ) + measured = {"case_id": "case-1", "verified_success": True, "tokens": 100, + "latency": 0.5, "cost_rmb": 0.002, "corrections": 0, "repeated_errors": 0} + return self.engine.record_shadow_replay(candidate["proposal_id"], [measured], [measured]) def test_profile_isolation_canary_promotion_and_correction_rollback(self): candidate = self._candidate() @@ -265,6 +268,120 @@ def test_unverified_correction_does_not_arm_regression_preflight(self): self.assertEqual(self.memory.store.list_events( "learning.regression_case_created", workspace_id="project"), []) + def test_direct_activation_cannot_bypass_live_evidence_or_shadow_replay(self): + candidate = self._candidate() + self.assertFalse(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + self.assertEqual(self.registry.list()[0]["status"], "canary") + for _ in range(2): + run = self.engine.begin_run("coder", "run pytest tests") + _, receipts = self.engine.artifact_context(run) + self.engine.record_outcome(run, receipts, verified=True, success=True) + self.assertFalse(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + self._pass_replay(candidate) + self.assertTrue(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + self.assertEqual(self.registry.list()[0]["status"], "active") + + def test_incomplete_measured_replay_cannot_promote_even_after_verified_uses(self): + candidate = self._candidate() + for _ in range(2): + run = self.engine.begin_run("coder", "run pytest tests") + _, receipts = self.engine.artifact_context(run) + self.engine.record_outcome(run, receipts, verified=True, success=True) + measured = {"case_id": "measured-case", "verified_success": True, "tokens": 100, + "latency": 0.5, "cost_rmb": 0.002, "corrections": 0, "repeated_errors": 0} + for missing in (("cost_rmb",), ("latency",), ("corrections",), ("repeated_errors",), + ("cost_rmb", "latency", "corrections", "repeated_errors")): + with self.subTest(missing=missing): + incomplete = {key: value for key, value in measured.items() if key not in missing} + replay = self.engine.record_shadow_replay(candidate["proposal_id"], [incomplete], [incomplete]) + self.assertFalse(replay["non_regressing"]) + self.assertFalse(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + card = self.engine.evidence_card(candidate["proposal_id"]) + self.assertEqual(card["artifact"]["status"], "canary") + self.assertEqual(card["revalidation_status"], "incomplete") + self.assertIn("incomplete", card["shadow_replay"]["blocked_reason"].lower()) + replay = self.engine.record_shadow_replay(candidate["proposal_id"], [measured], [measured]) + self.assertTrue(replay["non_regressing"]) + self.assertTrue(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + self.assertEqual(self.engine.evidence_card(candidate["proposal_id"])["artifact"]["status"], "active") + + def test_replay_requires_a_consistent_explicit_cost_unit(self): + candidate = self._candidate() + measured = {"case_id": "cost-case", "verified_success": True, "latency": 0.5, + "cost": 0.002, "corrections": 0, "repeated_errors": 0} + self.assertFalse(self.engine.record_shadow_replay(candidate["proposal_id"], [measured], [measured])["non_regressing"]) + dollars = {**measured, "cost_unit": "USD"} + euros = {**measured, "cost_unit": "EUR"} + self.assertFalse(self.engine.record_shadow_replay(candidate["proposal_id"], [euros], [dollars])["non_regressing"]) + self.assertTrue(self.engine.record_shadow_replay(candidate["proposal_id"], [dollars], [dollars])["non_regressing"]) + + def test_persisted_legacy_success_only_replay_cannot_activate(self): + candidate = self._candidate() + for _ in range(2): + run = self.engine.begin_run("coder", "run pytest tests") + _, receipts = self.engine.artifact_context(run) + self.engine.record_outcome(run, receipts, verified=True, success=True) + proposal = self.engine._proposal_for_skill(candidate["skill_id"]) + self.memory.store.update_proposal(candidate["proposal_id"], status="canary", validation={ + **proposal["validation"], "shadow_replay": {"non_regressing": True, + "case_ids": ["legacy"], "candidate_successes": 1, "predecessor_successes": 1}, + }) + self.assertFalse(self.engine.validate_and_activate(candidate["proposal_id"], validation={"success": True})) + run = self.engine.begin_run("coder", "run pytest tests") + _, receipts = self.engine.artifact_context(run) + self.assertEqual(self.engine.record_outcome(run, receipts, verified=True, success=True), []) + self.assertEqual(self.engine.evidence_card(candidate["proposal_id"])["artifact"]["status"], "canary") + + def test_shadow_replay_rejects_individual_regression_hidden_by_aggregate_success(self): + candidate = self._candidate() + measurements = {"latency": 0.5, "cost_rmb": 0.002, "corrections": 0, "repeated_errors": 0} + replay = self.engine.record_shadow_replay(candidate["proposal_id"], [ + {**measurements, "case_id": "old", "verified_success": False}, + {**measurements, "case_id": "new", "verified_success": True}, + ], [ + {**measurements, "case_id": "old", "verified_success": True}, + {**measurements, "case_id": "new", "verified_success": False}, + ]) + self.assertFalse(replay["non_regressing"]) + + def test_shadow_replay_blocks_measured_cost_latency_and_error_regressions(self): + candidate = self._candidate() + baseline = {"case_id": "case", "verified_success": True, "tokens": 100, + "latency": 1.0, "cost_rmb": 0.002, "corrections": 0, "repeated_errors": 0} + for field, value in (("tokens", 101), ("latency", 1.1), ("cost_rmb", 0.003), + ("corrections", 1), ("repeated_errors", 1)): + with self.subTest(field=field): + changed = {**baseline, field: value} + result = self.engine.record_shadow_replay(candidate["proposal_id"], [changed], [baseline]) + self.assertFalse(result["non_regressing"]) + result = self.engine.record_shadow_replay(candidate["proposal_id"], [baseline], [baseline]) + self.assertTrue(result["non_regressing"]) + self.assertEqual(result["metric_comparisons"]["tokens"], {"candidate": 100, "predecessor": 100, "non_regressing": True}) + + def test_measured_replay_rejects_invalid_measurements_and_overflowing_totals(self): + candidate = self._candidate() + measured = {"case_id": "case-1", "verified_success": True, "latency": 0.5, + "cost_rmb": 0.002, "corrections": 0, "repeated_errors": 0} + for field in ("latency", "cost_rmb", "corrections", "repeated_errors"): + for value in (None, True, -1, float("inf"), float("nan")): + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + self.engine.record_shadow_replay(candidate["proposal_id"], [{**measured, field: value}], [measured]) + huge = [{**measured, "case_id": f"case-{index}", "cost_rmb": 1e308} for index in range(2)] + with self.assertRaises(ValueError): + self.engine.record_shadow_replay(candidate["proposal_id"], huge, huge) + + def test_shadow_replay_cannot_drop_baseline_measurement_or_use_truthy_success(self): + candidate = self._candidate() + for current, previous in (( + {"case_id": "case", "verified_success": True}, + {"case_id": "case", "verified_success": True, "latency": 1.0}, + ), ( + {"case_id": "case", "verified_success": "false"}, + {"case_id": "case", "verified_success": True}, + )): + with self.subTest(current=current), self.assertRaises(ValueError): + self.engine.record_shadow_replay(candidate["proposal_id"], [current], [previous]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_evolution_robustness.py b/tests/test_evolution_robustness.py index 30021632..05c27f03 100644 --- a/tests/test_evolution_robustness.py +++ b/tests/test_evolution_robustness.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import tempfile @@ -27,11 +28,14 @@ class EvolutionRobustnessTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() root = Path(self.directory.name) - self.memory = MemoryBank(root / "state.sqlite3", workspace_id="project") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", workspace_id="project"))) self.registry = SkillRegistry(self.memory.store, workspace_id="project", root=root / "skills") self.engine = LearningEngine(self.memory, registry=self.registry) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def _candidate(self, run_id="source"): @@ -47,9 +51,11 @@ def test_model_bound_artifact_requires_matching_model_or_cross_model_proof(self) other = self.engine.begin_run("coder", "pytest", provider_model="provider:model-b") self.assertEqual(self.engine.artifact_context(other), ("", [])) self.registry.set_learned_status(candidate["skill_id"], "active") + measurements = {"verified_success": True, "latency": 0.5, "cost_rmb": 0.002, + "corrections": 0, "repeated_errors": 0} paired = [ - {"case_id": "a", "verified_success": True, "provider_model": "provider:model-a"}, - {"case_id": "b", "verified_success": True, "provider_model": "provider:model-b"}, + {**measurements, "case_id": "a", "provider_model": "provider:model-a"}, + {**measurements, "case_id": "b", "provider_model": "provider:model-b"}, ] self.engine.record_shadow_replay(candidate["proposal_id"], paired, paired) context, receipts = self.engine.artifact_context(other) diff --git a/tests/test_execution_receipts.py b/tests/test_execution_receipts.py index 6060f78a..af3ea64b 100644 --- a/tests/test_execution_receipts.py +++ b/tests/test_execution_receipts.py @@ -938,6 +938,68 @@ def test_file_effect_verification_compares_requested_contents(self): self.assertIsNone(receipt.verified_effect) self.assertIsNone(receipt.acceptance) + def test_exact_operation_acceptance_does_not_cover_extra_criteria_or_changed_arguments(self): + runtime = self.app.runtime + cases = [ + ("unrelated-criterion", ["All unit tests pass"], "requested", "requested"), + ("extra-criteria", ["Requested contents exist", "Independent second condition"], "requested", "requested"), + ("changed-arguments", ["Requested contents exist"], "requested", "different"), + ] + for name, criteria, declared, actual in cases: + with self.subTest(name=name): + session = runtime.open_session(name) + with runtime.use_session(session): + plan = runtime.tasks.edit_plan(complexity="complex", expected_revision=0, operations=[ + {"op": "add", "ref": "write", "task": {"description": "Write exact contents", "acceptance": criteria, + "execution": {"action": "write_file", "arguments": {"path": name + ".txt", "content": declared}}}}, + {"op": "start", "task": "write"}, {"op": "suggest_complete", "task": "write"}, + ]) + receipt = runtime._execute_turn_action("write_file", {"path": name + ".txt", "content": actual}, + operation_scope=name, successful_operations=set(), call_id=name, call_id_source="native", + task_id=plan["task_ids"][0], task_plan_id=plan["plan_id"]) + self.assertEqual(receipt.verification["status"], "verified") + record = runtime._record_turn_receipt(receipt) + self.assertIsNone(record["acceptance"]) + self.assertEqual(runtime.tasks.tasks[0]["status"], "running") + + def test_child_local_receipt_cannot_fall_back_to_a_parent_task_id(self): + runtime = self.app.runtime + self.plan() + parent = runtime.current_session + parent_task_id = parent.tasks.tasks[0]["id"] + child = runtime.open_session("isolated-child") + child._delegation_parent, child._delegation_task_id = parent, parent_task_id + with runtime.use_session(child): + plan = runtime.tasks.edit_plan(complexity="complex", expected_revision=0, operations=[ + {"op": "add", "ref": "local", "task": {"description": "Check a value", "acceptance": ["Value checked"], + "execution": {"action": "calculate", "arguments": {"expression": "2+2"}}}}, + {"op": "start", "task": "local"}, + ]) + receipt = runtime._execute_turn_action("calculate", {"expression": "2+2"}, + operation_scope="isolated-child", successful_operations=set(), call_id="local-call", call_id_source="native", + task_id=plan["task_ids"][0], task_plan_id=plan["plan_id"]) + self.assertEqual(receipt.plan_id, plan["plan_id"]) + forged_binding = ExecutionReceipt.from_dict({**receipt.as_dict(), "task_id": parent_task_id}) + record = runtime._record_turn_receipt(forged_binding) + self.assertIsNone(record["task_id"]) + self.assertEqual(child.tasks.tasks[0]["evidence"], []) + runtime._record_turn_receipt(forged_binding) + self.assertEqual(parent.tasks.tasks[0]["evidence"], []) + self.assertEqual(parent.tasks.tasks[0]["status"], "pending") + + def test_native_selected_task_identity_survives_argument_rejection(self): + runtime = self.app.runtime + plan = self.plan("write_file", {"path": "invalid.txt", "content": "requested"}) + task_id = runtime.tasks.tasks[0]["id"] + runtime.tasks.edit_plan(plan_id=plan.id, expected_revision=1, operations=[{"op": "start", "task": task_id}]) + receipt = runtime._execute_turn_action("write_file", {"path": "invalid.txt"}, + operation_scope="invalid-selected", successful_operations=set(), call_id="invalid-selected", call_id_source="native", + task_id=task_id, task_plan_id=plan.id) + self.assertFalse(receipt.success) + self.assertEqual(receipt.task_id, task_id) + self.assertEqual(receipt.plan_id, plan.id) + self.assertFalse((self.root / "invalid.txt").exists()) + def test_command_verification_uses_exit_status_not_output_words(self): from openkyrozen.providers.models import ToolCall diff --git a/tests/test_fast_mode.py b/tests/test_fast_mode.py index 21fa2256..63d1fd5b 100644 --- a/tests/test_fast_mode.py +++ b/tests/test_fast_mode.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing from openkyrozen.routing import policy as system_one_policy from openkyrozen.routing import transport as routing_transport, kev as routing_kev import json @@ -30,8 +31,8 @@ def _answer(choice, options, probability=0.95): class FastModeTests(unittest.TestCase): def test_system_one_state_and_fast_compatibility_alias(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "events.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "events.sqlite3"))) controller = InteractionController(store, workspace_id="project", session_id="system-one") controller.set_system_one_backend("kev") state = controller.state() @@ -41,8 +42,8 @@ def test_system_one_state_and_fast_compatibility_alias(self): self.assertEqual(split_inline_command("Do it /fast kev"), ("Do it", "/fast kev")) def test_fast_preference_is_separate_from_interaction_mode_and_scoped_to_session(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "events.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "events.sqlite3"))) first = InteractionController(store, workspace_id="project", session_id="first") first.set_mode("plan") first.set_fast_backend("kev") @@ -138,8 +139,8 @@ def test_private_question_text_is_screened_before_jev_call(self): request.assert_not_called() def test_web_and_tui_controls_keep_key_out_of_replies(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="fast-web") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="fast-web"))) client = TestClient(server.app) original_sessions = server._sessions server._sessions = {} @@ -184,8 +185,8 @@ def test_web_and_tui_controls_keep_key_out_of_replies(self): server._sessions = original_sessions def test_kev_setup_failure_does_not_activate_fast(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "events.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "events.sqlite3"))) controller = InteractionController(store, workspace_id="local", session_id="test") previous = main._interaction_controller main._interaction_controller = controller diff --git a/tests/test_gateway_startup_logging.py b/tests/test_gateway_startup_logging.py index 043d2a1f..805325c1 100644 --- a/tests/test_gateway_startup_logging.py +++ b/tests/test_gateway_startup_logging.py @@ -45,12 +45,20 @@ def noisy_server(command, **kwargs): with tempfile.TemporaryDirectory() as directory: root = Path(directory) helper = gateway_tests.DurableTaskGatewayTests() + log_path = root / 'gateway.log' started = time.monotonic() - with patch('test_durable_tasks_gateway.subprocess.Popen', side_effect=noisy_server): - process, url = helper._start_server(root, root / 'state.db', root / 'skills') + def ready_after_output(_url, path): + self.assertEqual(path, '/api/health') + if 'startup-marker' not in log_path.read_text(encoding='utf-8'): + raise OSError('startup output is incomplete') + return {'status': 'ok'} + with patch('test_durable_tasks_gateway.subprocess.Popen', side_effect=noisy_server), \ + patch.object(helper, '_request', side_effect=ready_after_output): + process, _url = helper._start_server( + root, root / 'state.db', root / 'skills', log_path=log_path) try: self.assertLess(time.monotonic() - started, 5) - self.assertEqual(helper._request(url, '/api/health')['status'], 'ok') - self.assertIn('startup-marker', (root / 'gateway.log').read_text()) + self.assertIsNone(process.poll()) + self.assertIn('startup-marker', log_path.read_text(encoding='utf-8')) finally: helper._stop_server(process) diff --git a/tests/test_history.py b/tests/test_history.py index 132462b0..999ab0e9 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import os import stat import tempfile @@ -18,13 +19,16 @@ def setUp(self): self.state = Path(self.directory.name) / "state" self.root.mkdir() self.state.mkdir() - self.store = EventStore(self.state / "history.sqlite3") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.store = self._sqlite_owners.enter_context(closing(EventStore(self.state / "history.sqlite3"))) self.manager = HistoryManager( self.store, self.root, self.state, source_scope_id=source_scope_id(self.root), user_id="alice", workspace_id="project", session_id="conversation", ) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def test_branching_restore_preserves_files_descendants_memory_tasks_and_git(self): @@ -111,12 +115,87 @@ def test_branching_restore_preserves_files_descendants_memory_tasks_and_git(self ) reopened = HistoryManager( - EventStore(self.state / "history.sqlite3"), self.root, self.state, + self._sqlite_owners.enter_context(closing(EventStore(self.state / "history.sqlite3"))), self.root, self.state, source_scope_id=source_scope_id(self.root), user_id="alice", workspace_id="project", session_id="conversation", ) self.assertEqual(reopened.current()["id"], n1["id"]) + def test_snapshot_excludes_active_database_and_sidecars_inside_workspace(self): + db_dir = self.root / "database" + db_dir.mkdir() + store = self._sqlite_owners.enter_context(closing(EventStore(db_dir / "state.sqlite3"))) + manager = HistoryManager(store, self.root, self.state, + source_scope_id=source_scope_id(self.root), session_id="inside") + (db_dir / "state.sqlite3-unrelated").write_text("user file") + with store.database.connection() as connection: + connection.execute("BEGIN IMMEDIATE") + _, manifest, _ = manager._snapshot("hist_" + "a" * 32) + connection.rollback() + self.assertNotIn("database/state.sqlite3", manifest) + self.assertNotIn("database/state.sqlite3-wal", manifest) + self.assertNotIn("database/state.sqlite3-shm", manifest) + self.assertIn("database/state.sqlite3-unrelated", manifest) + + def test_restore_ignores_database_in_legacy_snapshot_and_preserves_new_db_directory(self): + before_db = self.manager.ensure_root() + db_dir = self.root / "database" + db_dir.mkdir() + store = self._sqlite_owners.enter_context(closing(EventStore(db_dir / "state.sqlite3"))) + manager = HistoryManager(store, self.root, self.state, + source_scope_id=source_scope_id(self.root), session_id="inside") + # Construct a legacy snapshot that includes the live database. + with patch.object(manager, "_excluded", side_effect=lambda path: False): + legacy = manager.ensure_root() + store.append_event("memory.test", {"content": "keep"}) + manager._restore(legacy) + self.assertEqual(len(store.list_events("memory.test")), 1) + # A snapshot from before the database directory existed must not remove it. + manager._restore(before_db) + self.assertEqual(len(store.list_events("memory.test")), 1) + + @unittest.skipIf(os.name == "nt", "Windows symlinks may require extra privileges") + def test_snapshot_and_restore_preserve_database_directory_symlink(self): + actual = Path(self.directory.name) / "external-database" + actual.mkdir() + link = self.root / "database-link" + link.symlink_to(actual, target_is_directory=True) + store = self._sqlite_owners.enter_context(closing(EventStore(link / "state.sqlite3"))) + manager = HistoryManager(store, self.root, self.state, + source_scope_id=source_scope_id(self.root), session_id="inside") + baseline = manager.ensure_root() + self.assertNotIn("database-link", manager._manifest(baseline)) + manager._restore(baseline) + self.assertTrue(link.is_symlink()) + store.append_event("memory.test", {"content": "keep"}) + self.assertEqual(len(store.list_events("memory.test")), 1) + + @unittest.skipIf(os.name == "nt", "POSIX directory modes are not supported on Windows") + def test_restore_preserves_database_directory_permissions(self): + db_dir = self.root / "database" + db_dir.mkdir(mode=0o700) + baseline = self.manager.ensure_root() + store = self._sqlite_owners.enter_context(closing(EventStore(db_dir / "state.sqlite3"))) + os.chmod(db_dir, 0o750) + manager = HistoryManager(store, self.root, self.state, + source_scope_id=source_scope_id(self.root), session_id="inside") + manager._restore(baseline) + self.assertEqual(stat.S_IMODE(db_dir.stat().st_mode), 0o750) + + def test_restore_rejects_file_that_would_replace_database_directory(self): + (self.root / "database").write_text("old file") + baseline = self.manager.ensure_root() + (self.root / "database").unlink() + (self.root / "database").mkdir() + store = self._sqlite_owners.enter_context(closing(EventStore(self.root / "database" / "state.sqlite3"))) + manager = HistoryManager(store, self.root, self.state, + source_scope_id=source_scope_id(self.root), session_id="inside") + (self.root / "later.txt").write_text("keep if preflight fails") + with self.assertRaisesRegex(HistoryError, "active database directory"): + manager._restore(baseline) + self.assertTrue(store.path.exists()) + self.assertTrue((self.root / "later.txt").exists()) + def test_scope_and_stale_head_are_rejected(self): root = self.manager.ensure_root() other = HistoryManager( diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 9c4d093c..b37cd8e9 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import hashlib import tempfile @@ -92,7 +93,7 @@ def test_explicit_file_path_does_not_authorize_descendants(self): def test_legacy_mutation_arguments_are_normalized_for_scope_checks(self): from pathlib import Path - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: plan = {"bounds": {"workspace_paths": ["site"], "capabilities": ["write"], "effects": ["filesystem"], "risk_ceiling": "normal"}} spec = SimpleNamespace(capability="write", side_effects="filesystem", risk="normal", @@ -102,7 +103,7 @@ def test_legacy_mutation_arguments_are_normalized_for_scope_checks(self): {"path": "site/index.html", "content": "ok"}) normalized = json.dumps(_legacy_object_arguments("site/index.html|ok", spec), ensure_ascii=False, sort_keys=True, separators=(",", ":")) - manager = TaskManager(EventStore(Path(directory) / "match.sqlite3"), workspace_id="w", session_id="s") + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "match.sqlite3"))), workspace_id="w", session_id="s") self.assertGreater(manager._receipt_match_score({"acceptance": [{ "action": "write_file", "args": normalized, }]}, "write_file", normalized), 0) @@ -299,7 +300,9 @@ class StrategicPlanAcceptanceTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) - self.store = EventStore(Path(self.directory.name) / "state.sqlite3") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.store = self._sqlite_owners.enter_context(closing(EventStore(Path(self.directory.name) / "state.sqlite3"))) self.controller = InteractionController( self.store, workspace_id="project", session_id="strategic-session", ) @@ -345,6 +348,110 @@ def test_working_plan_edits_cannot_add_or_redefine_unbound_strategic_work(self): binding = next(task for task in result["tasks"] if task["id"] == task_id)["checkpoint"]["strategic_plan"] self.assertEqual(binding["fingerprint"], accepted["fingerprint"]) + def test_strategic_split_retains_approved_anchor_and_adds_bound_strategy(self): + from copy import deepcopy + plan = self._proposal() + accepted, _ = self.controller.accept_plan(self.manager, plan_id=plan["plan_id"], version=1) + anchor_id = accepted["working_task_ids"][0] + before = deepcopy(self.manager.tasks[0]) + result = self.manager.edit_plan( + plan_id=self.manager.working_plan.id, expected_revision=1, + operations=[{"op": "split", "task": anchor_id, "children": [ + {"ref": "inspect", "task": {"description": "Inspect page input", "acceptance": ["Input checked"]}}, + {"ref": "verify", "task": {"description": "Verify page output", "acceptance": ["Output checked"]}}, + ]}], + ) + anchor = next(task for task in result["tasks"] if task["id"] == anchor_id) + self.assertEqual(anchor["status"], "pending") + self.assertEqual(anchor["acceptance"], before["acceptance"]) + self.assertEqual(anchor["description"], before["description"]) + children = [task for task in result["tasks"] if task["id"] != anchor_id] + self.assertEqual(anchor["dependencies"], [task["id"] for task in children]) + for child in children: + self.assertEqual(child["checkpoint"]["strategy_anchor"], anchor_id) + self.assertEqual(child["checkpoint"]["strategic_plan"], before["checkpoint"]["strategic_plan"]) + snapshot = self.manager.working_plan_snapshot() + self.assertEqual(snapshot["tasks"][0]["role"], "approved_objective") + self.assertEqual(snapshot["tasks"][1]["strategy_anchor"], anchor_id) + self.assertEqual(self.controller.state()["executing_plan"], accepted) + self.assertFalse(self.manager._has_evidence(0)) + added = self.manager.edit_plan( + plan_id=self.manager.working_plan.id, expected_revision=2, + operations=[{"op": "add", "anchor": anchor_id, "ref": "extra", "task": { + "description": "Investigate page edge case", "acceptance": ["Edge case checked"], + }}], + ) + self.assertIn(added["references"]["extra"], self.manager.tasks[0]["dependencies"]) + self.assertFalse(self.manager._has_evidence(0)) + self.assertFalse(self.manager.set_status(0, "succeeded", evidence={ + "success": True, "acceptance": "Original objective verified", + "acceptance_ids": before["checkpoint"]["strategic_plan"]["acceptance_ids"], + })) + + def test_strategy_children_can_merge_and_replace_without_replacing_objective(self): + proposal = self._proposal() + accepted, _ = self.controller.accept_plan(self.manager, plan_id=proposal["plan_id"], version=1) + anchor = accepted["working_task_ids"][0] + refined = self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=1, operations=[ + {"op": "split", "task": anchor, "children": [ + {"ref": "a", "task": {"description": "Inspect input", "acceptance": ["Input checked"]}}, + {"ref": "b", "task": {"description": "Inspect output", "acceptance": ["Output checked"]}}, + ]}, + ]) + merged = self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=2, operations=[ + {"op": "merge", "tasks": [refined["references"]["a"], refined["references"]["b"]], + "replacement": {"ref": "both", "task": {"description": "Inspect both", "acceptance": ["Both checked"]}}}, + ]) + merged_id = merged["references"]["both"] + self.assertEqual(self.manager.tasks[0]["dependencies"], [merged_id]) + revised = self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=3, operations=[ + {"op": "replace", "task": merged_id, "replacement": { + "ref": "new", "task": {"description": "Investigate root cause", "acceptance": ["Cause checked"]}, + }}, + ]) + replacement = next(task for task in revised["tasks"] if task["id"] == revised["references"]["new"]) + self.assertEqual(replacement["checkpoint"]["strategy_anchor"], anchor) + self.assertEqual(self.manager.tasks[0]["status"], "pending") + self.assertEqual(self.manager.tasks[0]["checkpoint"]["strategic_plan"]["acceptance_ids"], + proposal["steps"][0]["acceptance_ids"]) + + def test_strategic_completion_rejects_unfinished_refinement_even_after_dependency_removal(self): + proposal = self._proposal() + accepted, _ = self.controller.accept_plan(self.manager, plan_id=proposal["plan_id"], version=1) + anchor_id = accepted["working_task_ids"][0] + refined = self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=1, operations=[ + {"op": "add", "anchor": anchor_id, "ref": "inspect", "task": { + "description": "Inspect page", "acceptance": ["Inspected"], + }}, + ]) + self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=2, operations=[ + {"op": "remove_dependency", "task": anchor_id, "depends_on": refined["references"]["inspect"]}, + ]) + self.assertTrue(self.manager.set_status(0, "succeeded", evidence={ + "success": True, "acceptance": "Verified approved objective", + "acceptance_ids": self.manager.tasks[0]["checkpoint"]["strategic_plan"]["acceptance_ids"], + })) + with self.assertRaises(InteractionError): + self.controller.complete_plan(accepted, task_manager=self.manager) + + def test_material_reapproval_cancels_previous_strategy_children(self): + first = self._proposal() + accepted, _ = self.controller.accept_plan(self.manager, plan_id=first["plan_id"], version=1) + anchor_id = accepted["working_task_ids"][0] + refined = self.manager.edit_plan(plan_id=self.manager.working_plan.id, expected_revision=1, operations=[ + {"op": "add", "anchor": anchor_id, "ref": "inspect", "task": { + "description": "Inspect page", "acceptance": ["Inspected"], + }}, + ]) + revision = self.controller.propose_plan({ + "title": "Revised page", "summary": "A revised outcome", "bounds": first["bounds"], + "steps": [{"id": "page", "title": "Create revised page", "description": "Write revised page.", + "acceptance": ["Revised content correct"]}], + }) + self.controller.accept_plan(self.manager, plan_id=revision["plan_id"], version=2) + child = next(task for task in self.manager.tasks if task["id"] == refined["references"]["inspect"]) + self.assertEqual(child["status"], "cancelled") + def test_accepting_material_revision_replaces_executing_plan_atomically(self): first = self._proposal() accepted, _ = self.controller.accept_plan( @@ -1347,8 +1454,7 @@ def restore(): self.addCleanup(restore) self.agent.short_term_memory = [{"role": "user", "content": "previous project"}] empty_history = type("History", (), {"current": lambda self: None})() - with patch.object(self.agent, "TaskManager", return_value=object()), \ - patch.object(self.agent, "InteractionController", return_value=object()), \ + with patch.object(self.agent, "InteractionController", return_value=object()), \ patch.object(self.agent, "history_manager", return_value=empty_history), \ patch.object(self.agent, "_restore_ponytail_level"): self.agent.bind_interaction_scope("chat-empty") @@ -1392,6 +1498,23 @@ def test_queued_plan_acceptance_rejects_a_newer_proposal(self): self.assertEqual(session.interaction.state()["pending_plan"]["version"], newer["version"]) self.assertEqual(session.interaction.state()["executing_plan"]["version"], first["version"]) + def test_resuming_an_executing_plan_never_clears_its_tasks(self): + session = self.agent.open_session("resume-executing-plan") + proposal = session.interaction.propose_plan({ + "title": "Retain task state", "summary": "Finish the accepted task.", + "steps": [{"id": "retain", "title": "Retain", "description": "Keep the task available.", + "acceptance": ["Task remains available"]}], + }) + session.interaction.accept_plan(session.tasks, plan_id=proposal["plan_id"], + version=proposal["version"]) + before = [task["id"] for task in session.tasks.tasks] + observed = [] + with patch.object(self.agent._agent_engine, "run", + side_effect=lambda turn: observed.append(turn) or "continued"): + self.agent.chat(session, "Continue the accepted work", clear_tasks=True) + self.assertEqual([task["id"] for task in session.tasks.tasks], before) + self.assertFalse(observed[0].clear_tasks) + def test_queued_plan_acceptance_rejects_same_version_proposal_with_new_fingerprint(self): session = self.agent.open_session("queued-plan-fingerprint") first = session.interaction.propose_plan({ @@ -1702,9 +1825,9 @@ def turn_end(self, **_kwargs): return None self.agent.tasks, self.agent._interaction_controller, self.agent.learning_engine, self.agent._get_workspace_root(), self.agent._execution_capability_token, ) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) self.agent.tasks = TaskManager(store, workspace_id="repair", session_id="repair") self.agent._interaction_controller = InteractionController( store, workspace_id="repair", session_id="repair", @@ -1936,9 +2059,9 @@ def turn_end(self, **_kwargs): return None original = (self.agent.tasks, self.agent._interaction_controller, self.agent.learning_engine, self.agent._get_workspace_root(), self.agent._execution_capability_token) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) self.agent.tasks = TaskManager(store, workspace_id="prose", session_id="prose") self.agent._interaction_controller = InteractionController( store, workspace_id="prose", session_id="prose", @@ -2004,9 +2127,9 @@ def turn_end(self, **_kwargs): return None original = (self.agent.tasks, self.agent._interaction_controller, self.agent.learning_engine, self.agent._get_workspace_root(), self.agent._execution_capability_token) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) self.agent.tasks = TaskManager(store, workspace_id="exhaust", session_id="exhaust") self.agent._interaction_controller = InteractionController(store, workspace_id="exhaust", session_id="exhaust") self.agent._interaction_controller.set_mode("plan") @@ -2051,10 +2174,10 @@ def turn_end(self, **_kwargs): return None original = (self.agent.tasks, self.agent._interaction_controller, self.agent.learning_engine, self.agent._get_workspace_root(), self.agent._execution_capability_token) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) (root / "note.txt").write_text("evidence", encoding="utf-8") - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) self.agent.tasks = TaskManager(store, workspace_id="inspect", session_id="inspect") self.agent._interaction_controller = InteractionController(store, workspace_id="inspect", session_id="inspect") self.agent._interaction_controller.set_mode("plan") @@ -2154,9 +2277,9 @@ def test_accepted_plan_rejects_unrelated_mutation_before_execution(self): self.agent.tasks, self.agent._interaction_controller, self.agent._execution_capability_token, self.agent._get_workspace_root(), ) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) manager = TaskManager(store, workspace_id="bound", session_id="surface:tui") controller = InteractionController( store, workspace_id="bound", session_id="surface:tui", @@ -2209,9 +2332,9 @@ def test_pipe_content_cannot_use_a_sibling_file_from_a_strategic_objective(self) self.agent.tasks, self.agent._interaction_controller, self.agent._execution_capability_token, self.agent._get_workspace_root(), ) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) manager = TaskManager(store, workspace_id="bounded", session_id="surface:tui") controller = InteractionController(store, workspace_id="bounded", session_id="surface:tui") proposal = controller.propose_plan({ @@ -2249,9 +2372,9 @@ def test_pipe_content_cannot_use_a_sibling_file_from_a_strategic_objective(self) def test_duplicate_legacy_write_uses_canonical_typed_operation_identity(self): previous = (self.agent.tasks, self.agent._interaction_controller, self.agent._execution_capability_token, self.agent._get_workspace_root()) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) manager = TaskManager(store, workspace_id="duplicate", session_id="surface:tui") controller = InteractionController(store, workspace_id="duplicate", session_id="surface:tui") proposal = controller.propose_plan({ @@ -2286,9 +2409,9 @@ def test_material_revision_blocks_mutations_until_approval(self): self.agent.tasks, self.agent._interaction_controller, self.agent._execution_capability_token, self.agent._get_workspace_root(), ) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) manager = TaskManager(store, workspace_id="bound", session_id="surface:tui") controller = InteractionController(store, workspace_id="bound", session_id="surface:tui") proposal = controller.propose_plan({ @@ -2374,9 +2497,9 @@ def turn_end(self, **_kwargs): return None self.agent.tasks, self.agent._interaction_controller, self.agent.learning_engine, self.agent._get_workspace_root(), self.agent._execution_capability_token, ) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) self.agent.tasks = TaskManager(store, workspace_id="flow", session_id="flow") self.agent._interaction_controller = InteractionController( store, workspace_id="flow", session_id="flow", diff --git a/tests/test_lazy_legacy_protocol.py b/tests/test_lazy_legacy_protocol.py new file mode 100644 index 00000000..e5563336 --- /dev/null +++ b/tests/test_lazy_legacy_protocol.py @@ -0,0 +1,571 @@ +"""Fresh-process checks for the optional legacy text compatibility boundary.""" +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + + +class LazyLegacyProtocolTests(unittest.TestCase): + def run_fresh(self, script): + with tempfile.TemporaryDirectory() as directory: + environment = {key: value for key, value in os.environ.items() if not any( + term in key.upper() for term in ("API_KEY", "ACCESS_KEY", "SECRET_KEY", "SESSION_TOKEN", "ACCESS_TOKEN") + )} + environment.update(HOME=directory, KYROZEN_WORKSPACE_ROOT=directory, + KYROZEN_DB_PATH=str(Path(directory) / "runtime.sqlite3"), + KYROZEN_SKILLS_PATH=str(Path(directory) / "skills"), + KYROZEN_DISABLE_VECTOR_INDEX="1", KYROZEN_APPROVAL_MODE="never") + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(script)], + cwd=Path(__file__).resolve().parents[1], env=environment, + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_native_typed_turn_never_imports_legacy_parser(self): + self.run_fresh(''' + import importlib.abc + import os + import sys + from pathlib import Path + + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native execution imported the legacy parser") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + from openkyrozen.tools import ToolAdapters + + literal = 'Literal documentation: \\nAction: ordinary prose\\nTaskList: ["example"]\\nnot executable' + class NativeProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Legacy provider boundary used") + def chat_response(self, messages, model=None, *, tools=None): + if not any(isinstance(item, ToolReply) for item in messages): + assert "read_file" in {tool["name"] for tool in tools} + return ModelResponse(tool_calls=(ToolCall("read", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text=literal, finish_reason=FinishReason.FINAL) + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "note.txt").write_text("fixture content", encoding="utf-8") + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + runtime.llm_provider = runtime._default_turn.provider = NativeProvider(config) + runtime._provider_config = config + runtime.set_interaction_mode("agent") + answer = runtime.chat(runtime.current_session, "Read note.txt and report its content") + assert isinstance(answer, str) and answer == literal, answer + import json + assert json.loads(json.dumps({"reply": answer}))["reply"] == literal + assert "openkyrozen.agent.protocol" not in sys.modules + native = runtime._last_response_is_native.set(True) + try: + assert runtime._parse_model_response(literal)["clean"] == literal + assert "Action: ordinary prose" in runtime._deterministic_tool_summary([ + {"success": True, "result": literal, "verification": {"status": "verified"}}, + ]) + finally: + runtime._last_response_is_native.reset(native) + assert "openkyrozen.agent.protocol" not in sys.modules + assert runtime._legacy_protocol is None + finally: + app.close() + ''') + + def test_native_reply_remains_literal_through_cli_tui_and_web_frontends(self): + script = r''' + import importlib.abc + import io + import json + import os + import sys + from pathlib import Path + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native frontend imported the legacy parser") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + from openkyrozen.tools import ToolAdapters + literal = 'Thought: literal documentation\nTaskDone: 0\nAction:\n```json\n{"action":"write_file","args":"should-not-exist.txt|bad"}\n```\nTaskList: ["example"]' + class NativeProvider(LLMProvider): + calls = 0 + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Legacy provider boundary used") + def chat_response(self, messages, model=None, *, tools=None): + self.calls += 1 + if not any(isinstance(item, ToolReply) for item in messages): + return ModelResponse(tool_calls=(ToolCall("read", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text=literal, finish_reason=FinishReason.FINAL) + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "note.txt").write_text("fixture content", encoding="utf-8") + app = build_application(surface=SURFACE, memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + provider = NativeProvider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime._provider_config = config + runtime.set_interaction_mode("agent") + if SURFACE == "cli": + from rich.console import Console + output = io.StringIO() + runtime.console = Console(file=output, width=200, force_terminal=False) + runtime._run_cli_chat("Read note.txt and report its content", runtime.interaction_envelope()) + assert "Thought: literal documentation" in output.getvalue(), output.getvalue() + assert "TaskDone: 0" in output.getvalue(), output.getvalue() + elif SURFACE == "tui": + from openkyrozen.interfaces.tui.backend import Backend + backend = Backend(app) + backend._output = io.StringIO() + backend._run_submit("Read note.txt and report its content", "native-frontend") + events = [json.loads(line) for line in backend._output.getvalue().splitlines()] + responses = [event for event in events if event["event"] == "response"] + assert responses and responses[-1]["text"] == literal, events + assert not any(event["event"] == "thinking" for event in events), events + else: + from openkyrozen.interfaces.web.service import WebService + service = WebService(app) + session = service._get_or_create_session("native-web", service._SERVER_ACTOR_ID) + runtime.open_session("native-web", user_id=service._SERVER_ACTOR_ID).interaction.set_mode("agent") + answer = service._run_session_chat(session, "Read note.txt and report its content") + assert answer == literal, answer + assert provider.calls == 2, provider.calls + assert not (root / "should-not-exist.txt").exists() + assert "openkyrozen.agent.protocol" not in sys.modules + assert runtime._legacy_protocol is None + finally: + app.close() + ''' + for surface in ("cli", "tui", "web"): + with self.subTest(surface=surface): + self.run_fresh(script.replace("SURFACE", repr(surface))) + + def test_native_plan_and_auto_modes_inspect_then_stage_a_typed_proposal(self): + script = r''' + import importlib.abc + import os + import sys + from pathlib import Path + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native planning imported the legacy parser") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply, provider_continuation, validate_replied_tool_definitions + from openkyrozen.tools import ToolAdapters + proposal = {"title": "Prepare result", "summary": "Create the requested result after approval.", + "bounds": {"workspace_paths": ["result.txt"], "capabilities": ["read", "write"], + "effects": ["read_only", "filesystem"], "risk_ceiling": "normal"}, + "steps": [{"id": "result", "title": "Create result", "description": "Write result.txt", + "acceptance": [{"criterion": "Requested result exists", "verification": { + "action": "read_file", "arguments": {"path": "result.txt"}, "expect": {"equals": "ready"}}}], + "execution": {"action": "write_file", "arguments": {"path": "result.txt", "content": "ready"}}}]} + class PlanProvider(LLMProvider): + calls = 0 + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Native Plan Mode entered the text provider API") + def chat_response(self, messages, model=None, *, tools=None): + self.calls += 1 + validate_replied_tool_definitions(messages, tools) + if self.calls > (3 if DISCOVER_REQUEST else 2): + return ModelResponse(text="Approved execution remains pending.", finish_reason=FinishReason.FINAL) + names = {item["name"] for item in tools} + if not DISCOVER_REQUEST or self.calls == 1: + assert not names & {"github_read", "graph_query"}, names + else: + assert {"github_read", "graph_query"} <= names, names + assert not names & {"write_file", "run_cmd", "update_plan", "spawn_agents"}, names + if QUESTION_REQUEST: + assert "request_question" in names, names + return ModelResponse(tool_calls=(ToolCall("clarify", "request_question", {"questions": [{ + "id": "format", "header": "Format", "prompt": "Which result format?", "choices": [ + {"id": "plain", "label": "Plain"}, {"id": "rich", "label": "Rich"}, + ]}]}),), finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"request": self.calls})) + if DISCOVER_REQUEST and self.calls == 1: + return ModelResponse(tool_calls=(ToolCall("discover", "discover_tools", {"names": ["github_read", "graph_query"]}),), + finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"request": self.calls})) + if not any(isinstance(item, ToolReply) and item.name == "read_file" for item in messages): + assert "propose_plan" not in names, names + return ModelResponse(tool_calls=(ToolCall("inspect", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"request": self.calls})) + assert "propose_plan" in names, names + return ModelResponse(tool_calls=(ToolCall("proposal", "propose_plan", proposal),), + finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(self, model, "fixture", {"request": self.calls})) + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "note.txt").write_text("Existing context", encoding="utf-8") + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + provider = PlanProvider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime._provider_config = config + runtime.set_interaction_mode(PLAN_MODE) + request = "Create result.txt after preparing the plan" + answer = runtime.chat(runtime.current_session, request) + state = runtime._interaction_controller.state() + if QUESTION_REQUEST: + assert state["pending_plan"] is None, state + assert state["pending_question"]["questions"][0]["id"] == "format", state + assert state["pending_question"]["original_input"] == request, state + assert provider.calls == 1, provider.calls + else: + assert state["pending_plan"] is not None, (state, answer) + assert state["pending_plan"]["version"] == 1, state + assert state["pending_plan"]["original_input"] == request, state + assert provider.calls == (3 if DISCOVER_REQUEST else 2), provider.calls + assert "Prepare result" in answer, answer + assert state["executing_plan"] is None and runtime.tasks.working_plan is None, state + assert not (root / "result.txt").exists() + if not QUESTION_REQUEST: + pending = state["pending_plan"] + followup = runtime.chat(runtime.current_session, "accept plan", plan_acceptance={ + "plan_id": pending["plan_id"], "version": pending["version"], "fingerprint": pending["fingerprint"], + }) + assert "Native model request failed" not in followup, followup + assert "Approved execution remains pending" in followup, followup + assert provider.calls == (4 if DISCOVER_REQUEST else 3), provider.calls + assert "openkyrozen.agent.protocol" not in sys.modules + finally: + app.close() + ''' + for mode, question, discovery in (("plan", False, False), ("auto", False, False), + ("plan", True, False), ("plan", False, True)): + with self.subTest(mode=mode, question=question, discovery=discovery): + self.run_fresh(script.replace("PLAN_MODE", repr(mode)).replace("QUESTION_REQUEST", repr(question)) + .replace("DISCOVER_REQUEST", repr(discovery))) + + def test_native_accepted_plan_distinguishes_material_revision_from_safe_narrowing(self): + script = r''' + import copy + import importlib.abc + import os + import sys + from pathlib import Path + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native revision imported the legacy parser") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + from openkyrozen.tools import ToolAdapters + proposal = {"title": "Page", "summary": "Create the approved page", "bounds": { + "workspace_paths": ["."] if REVISION_CASE == "narrow" else ["page.txt"], + "capabilities": ["read", "write"], "effects": ["read_only", "filesystem"], "risk_ceiling": "normal"}, + "steps": [{"id": "page", "title": "Page", "description": "Write page.txt", "acceptance": [{ + "criterion": "Approved page exists", "verification": {"action": "read_file", + "arguments": {"path": "page.txt"}, "expect": {"equals": "approved"}}}]}]} + revised = copy.deepcopy(proposal) + revised["bounds"]["workspace_paths"] = ["page.txt"] + if REVISION_CASE == "material": + revised["summary"] = "Create an additional page outcome" + revised["bounds"]["workspace_paths"].append("extra.txt") + class RevisionProvider(LLMProvider): + calls = 0 + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Native revision entered text provider API") + def chat_response(self, messages, model=None, *, tools=None): + self.calls += 1 + replies = [item for item in messages if isinstance(item, ToolReply)] + if self.calls == 1: + assert "propose_plan" not in {item["name"] for item in tools} + return ModelResponse(tool_calls=(ToolCall("inspect", "read_file", {"path": "page.txt"}),), finish_reason=FinishReason.TOOL_REQUEST) + if self.calls == 2: + return ModelResponse(tool_calls=(ToolCall("revision", "propose_plan", revised),), finish_reason=FinishReason.TOOL_REQUEST) + assert REVISION_CASE == "narrow", "Material revision must stop pending approval" + if self.calls == 3: + control = next(item for item in replies if item.call_id == "revision") + assert control.output["data"]["requires_approval"] is False, control + return ModelResponse(tool_calls=(ToolCall("continue", "read_file", {"path": "page.txt"}),), finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text="Continued within updated approved scope.", finish_reason=FinishReason.FINAL) + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "page.txt").write_text("approved", encoding="utf-8") + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + provider = RevisionProvider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime._provider_config = config + runtime.set_interaction_mode("agent") + controller = runtime._interaction_controller + initial = controller.propose_plan(proposal) + accepted, _ = controller.accept_plan(runtime.tasks, plan_id=initial["plan_id"], version=1) + answer = runtime.chat(runtime.current_session, "Review the approved page scope") + state = controller.state() + if REVISION_CASE == "material": + assert provider.calls == 2, provider.calls + assert state["pending_plan"]["version"] == 2, state + assert state["executing_plan"]["bounds"] == accepted["bounds"], state + denied = runtime._execute_turn_action("write_file", {"path": "page.txt", "content": "unapproved"}, + operation_scope="post-proposal", successful_operations=set(), call_id="write", call_id_source="native") + assert not denied.success + else: + assert provider.calls == 4, provider.calls + assert state["pending_plan"] is None and state["executing_plan"]["version"] == 2, state + assert state["executing_plan"]["bounds"]["workspace_paths"] == ["page.txt"], state + assert "Continued within updated approved scope" in answer, answer + assert (root / "page.txt").read_text() == "approved" + assert not (root / "extra.txt").exists() + assert "openkyrozen.agent.protocol" not in sys.modules + finally: + app.close() + ''' + for case in ("material", "narrow"): + with self.subTest(case=case): + self.run_fresh(script.replace("REVISION_CASE", repr(case))) + + def test_native_delegated_synthesis_uses_typed_output_or_keeps_verified_reports(self): + script = r''' + import importlib.abc + import os + import sys + from pathlib import Path + from types import SimpleNamespace + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native delegated synthesis imported the legacy parser") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + from openkyrozen.tools import ToolAdapters + literal = "Thought: literal synthesized finding\nAction: documentation only\nAccepted child finding" + class SynthesisProvider(LLMProvider): + calls = 0 + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Native synthesis entered the text provider API") + def chat_response(self, messages, model=None, *, tools=None): + self.calls += 1 + if messages[0]["content"].startswith("Synthesize"): + assert not tools, tools + if SYNTHESIS_CASE == "error": + raise RuntimeError("Synthesis fixture failed") + if SYNTHESIS_CASE == "cancel": + return ModelResponse(finish_reason=FinishReason.CANCELLED) + if SYNTHESIS_CASE == "tool": + return ModelResponse(tool_calls=(ToolCall("unwanted", "write_file", { + "path": "unwanted.txt", "content": "must not execute"}),), finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text=literal, finish_reason=FinishReason.FINAL) + if not any(isinstance(item, ToolReply) for item in messages): + return ModelResponse(tool_calls=(ToolCall("inspect", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + return ModelResponse(text="Root inspection finished; retain child evidence.", finish_reason=FinishReason.FINAL) + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "note.txt").write_text("verified content", encoding="utf-8") + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + provider = SynthesisProvider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime._provider_config = config + runtime.set_interaction_mode("agent") + run = {"run_id": "child", "name": "Inspector", "version": 1, "status": "succeeded", + "assignment": {"task": "Inspect note"}, "parent_receipts": [], "reviews": [], + "report": {"summary": "Accepted child finding", "findings": ["note.txt was verified"], + "evidence": ["note.txt: verified content"], "uncertainties": []}} + calls = [] + def list_runs(): + calls.append(True) + return [] if len(calls) == 1 else [run] + runtime.subagent_manager.coordinator = SimpleNamespace(list=list_runs, close=lambda: None) + events = [] + try: + answer = runtime.chat(runtime.current_session, "Read note.txt and report the findings", on_runtime_event=events.append) + except ProviderError as exc: + assert SYNTHESIS_CASE == "cancel" and exc.kind == ProviderErrorKind.CANCELLED, exc + assert events[-1].kind == "turn.cancelled", events[-1] + assert run["status"] == "succeeded" and run["report"]["summary"] == "Accepted child finding" + else: + assert SYNTHESIS_CASE != "cancel", "Cancelled synthesis must cancel the user turn" + if SYNTHESIS_CASE == "success": + assert answer == literal, answer + else: + assert "Verified delegated findings" in answer and "Accepted child finding" in answer, answer + assert "note.txt" in answer, answer + assert provider.calls == 3, provider.calls + assert not (root / "unwanted.txt").exists() + assert "openkyrozen.agent.protocol" not in sys.modules + finally: + app.close() + ''' + for case in ("success", "error", "tool", "cancel"): + with self.subTest(case=case): + self.run_fresh(script.replace("SYNTHESIS_CASE", repr(case))) + + def test_native_historical_artifact_failure_stops_without_provider_or_legacy_import(self): + self.run_fresh(r''' + import importlib.abc + import os + import sys + from pathlib import Path + from unittest.mock import patch + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native artifact failure imported legacy cleanup") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.agent.conversation import model_response_to_dict, tool_result_to_dict + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, provider_continuation + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.models import tool_success + class UncalledProvider(LLMProvider): + calls = 0 + def chat(self, *args, **kwargs): + raise AssertionError("Legacy provider must not run") + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat_response(self, *args, **kwargs): + self.calls += 1 + raise AssertionError("Provider must not run when prior artifacts cannot be retained") + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + provider = UncalledProvider(config) + runtime.llm_provider = runtime._default_turn.provider = provider + runtime._provider_config = config + runtime.set_interaction_mode("agent") + history = runtime.conversation_history + history.append(history.entry("user.input", {"text": "Earlier read"}, turn_id="previous")) + response = ModelResponse(tool_calls=(ToolCall("old", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(provider, "fixture", "fixture", {"call": "old"})) + history.append(history.entry("model.response", {"response": model_response_to_dict(response)}, turn_id="previous")) + history.append(history.entry("tool.result", {"call_id": "old", "name": "read_file", + "output": tool_result_to_dict(tool_success("Historical file output")), "is_error": False}, turn_id="previous", call_id="old")) + with patch.object(runtime.memory_bank.store.artifacts, "get_or_create", side_effect=OSError("storage unavailable")): + answer = runtime.chat(runtime.current_session, "Continue inspecting note.txt") + assert "Could not retain earlier tool results" in answer, answer + assert provider.calls == 0, provider.calls + assert "openkyrozen.agent.protocol" not in sys.modules + finally: + app.close() + ''') + + def test_native_failure_and_cancellation_do_not_load_legacy_cleanup(self): + script = ''' + import importlib.abc + import os + import sys + from pathlib import Path + class BlockLegacyParser(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "openkyrozen.agent.protocol": + raise AssertionError("Native failure imported legacy cleanup") + return None + sys.meta_path.insert(0, BlockLegacyParser()) + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, ToolCall, ToolReply + from openkyrozen.tools import ToolAdapters + class FailingProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError("Legacy provider boundary used") + def chat_response(self, messages, model=None, *, tools=None): + if not any(isinstance(item, ToolReply) for item in messages): + return ModelResponse(tool_calls=(ToolCall("read", "read_file", {"path": "note.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + if FAILURE_MODE == "cancel": + raise ProviderError(ProviderErrorKind.CANCELLED, terminal=True) + raise RuntimeError("Fixture provider failed after the read") + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + (root / "note.txt").write_text("Action: file data is literal", encoding="utf-8") + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", model_main="fixture") + runtime.llm_provider = runtime._default_turn.provider = FailingProvider(config) + runtime._provider_config = config + runtime.set_interaction_mode("agent") + try: + answer = runtime.chat(runtime.current_session, "Read note.txt and report its content") + except ProviderError as exc: + assert FAILURE_MODE == "cancel" and exc.kind == ProviderErrorKind.CANCELLED, exc + else: + assert FAILURE_MODE == "error" and "Native model request failed" in answer, answer + assert "openkyrozen.agent.protocol" not in sys.modules + assert runtime._legacy_protocol is None + finally: + app.close() + ''' + for mode in ("error", "cancel"): + with self.subTest(mode=mode): + self.run_fresh(script.replace("FAILURE_MODE", repr(mode))) + + def test_legacy_helpers_load_adapter_on_first_use_and_keep_control_semantics(self): + self.run_fresh(''' + import os + import sys + from pathlib import Path + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + root = Path(os.environ["KYROZEN_WORKSPACE_ROOT"]) + app = build_application(memory=build_memory(root / "state.sqlite3", index=None), tools=ToolAdapters(root)) + try: + runtime = app.runtime + assert "openkyrozen.agent.protocol" not in sys.modules + assert runtime._legacy_protocol is None + parsed = runtime._parse_model_response('Action:\\n```json\\n{"action":"read_file","args":"note.txt"}\\n```') + assert parsed["tool_calls"] == [{"action": "read_file", "args": "note.txt"}], parsed + assert parsed["clean"] == "" + adapter = runtime.legacy_protocol + assert adapter is runtime.legacy_protocol + assert "openkyrozen.agent.protocol" in sys.modules + assert runtime._safe_fstring("{value}") == "{{value}}" + assert runtime._clean_final_response("Answer.\\nTaskDone: 0") == "Answer." + finally: + app.close() + ''') diff --git a/tests/test_learning_dispatcher.py b/tests/test_learning_dispatcher.py index e90a0473..5421992a 100644 --- a/tests/test_learning_dispatcher.py +++ b/tests/test_learning_dispatcher.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import asyncio import os import subprocess @@ -65,11 +66,11 @@ def tearDown(self): main.llm_provider = self.original_provider main._provider_config = self.original_provider_config - def _isolated_runtime(self, root: Path) -> None: - memory = MemoryBank( + def _isolated_runtime(self, root: Path, sqlite_owners: ExitStack) -> None: + memory = sqlite_owners.enter_context(closing(MemoryBank( root / "state.sqlite3", user_id="learning-user", workspace_id="learning-project", session_id="learning-session", - ) + ))) main.memory_bank = memory main.learning_engine = main.LearningEngine(memory, registry=SkillRegistry( memory.store, workspace_id="learning-project", root=root / "skills")) @@ -89,8 +90,8 @@ def test_registry_has_nineteen_independent_callable_features(self): self.assertIn(name, main._SELF_LEARNING_FLAGS) def test_conversation_learning_retries_failed_model_and_uses_promoted_fact(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) chat = "User: Remember that Orion uses orion.toml for configuration.\nAssistant: Understood." main.memory_bank.add_log(chat) with patch.object(main, "_learning_model_response", side_effect=[None, "- Orion uses orion.toml for configuration.", "- Orion uses orion.toml for configuration."]): @@ -123,8 +124,8 @@ def run(): self.assertEqual(web_feature["product_status"], "used") def test_importance_scores_change_the_selected_memory(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main.memory_bank.add_log("FACT: lantern configuration alpha") main.memory_bank.add_log("note: lantern configuration beta") before = main._build_memory_context("lantern configuration", n=1) @@ -136,8 +137,8 @@ def test_importance_scores_change_the_selected_memory(self): self.assertNotIn("beta", after) def test_verified_correction_preflight_is_reused_on_matching_task(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) run = main.learning_engine.begin_run("coder", "fix parser") main.learning_engine.record_outcome(run, [], verified=True, success=False, correction=True) result = main.dispatch_learning_cycle(surface="test", trigger="turn", max_features=1, @@ -151,8 +152,8 @@ def test_verified_correction_preflight_is_reused_on_matching_task(self): self.assertEqual(state["product_status"], "used") def test_invented_skill_and_composition_need_distinct_observations(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) for number in range(5): main.memory_bank.add_log(f"User: Verify Orion parser case {number}.\nAssistant: Check its tests.") answer = "Skill Name: Orion verification\nDescription: Verify the parser\nSteps:\n1. Run parser tests\n2. Check output" @@ -185,8 +186,8 @@ def dispatch(feature): def test_matching_learning_products_survive_vector_rank_and_missing_index_entries(self): for omit_products in (False, True): - with self.subTest(omit_products=omit_products), tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with self.subTest(omit_products=omit_products), tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) observations = [main.memory_bank.add_log(f"User: Verify Orion parser observation {number}") for number in range(20)] skill = main.memory_bank.add_log("SKILL: Orion verification - Verify Orion parser tests", @@ -213,8 +214,8 @@ def test_matching_learning_products_survive_vector_rank_and_missing_index_entrie {skill, strategy}) def test_learning_product_fallback_respects_relevance_and_visibility(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) observation = main.memory_bank.add_log("User: Verify Orion parser observation") for text, metadata in ( ("SKILL: private Verify Orion parser", {"visibility": "private", "speaker": "alice"}), @@ -250,24 +251,24 @@ def test_learning_product_fallback_respects_relevance_and_visibility(self): def test_context_digest_survives_restart_only_in_its_session(self): from openkyrozen.agent.compaction import DIGEST_PREFIX - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) digest = {"role": "user", "content": DIGEST_PREFIX + " Orion parser decision"} main._save_learning_context_digest(digest) main.short_term_memory = [] - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) messages = main._build_messages("Continue the parser work") self.assertIn(digest, messages) - other_session = MemoryBank(root / "state.sqlite3", user_id="learning-user", - workspace_id="learning-project", session_id="other-session") + other_session = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", user_id="learning-user", + workspace_id="learning-project", session_id="other-session"))) main.memory_bank = other_session main.short_term_memory = [] self.assertNotIn(digest, main._build_messages("Continue the parser work")) def test_cli_self_learning_toggle_persists_and_blocks_dispatch(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._SELF_LEARNING_FLAGS = {name: True for name in main._LEARNING_FEATURE_ORDER} with patch.object(main.console, "input", side_effect=["1", "done"]), \ patch.object(main.console, "print"): @@ -290,9 +291,9 @@ def test_guidance_features_create_products_used_on_matching_tasks(self): ("targeted_inquiry", "PURPOSE: parse_orion converts parser input", "parse_orion", "CODE_DOC:"), ) for feature, answer, query, marker in scenarios: - with self.subTest(feature=feature), tempfile.TemporaryDirectory() as directory: + with self.subTest(feature=feature), tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) for number in range(5): main.memory_bank.add_log(f"User: Orion parser observation {number}.\nAssistant: Noted.") for number in range(3): @@ -316,9 +317,9 @@ def test_guidance_features_create_products_used_on_matching_tasks(self): self.assertEqual(state["product_status"], "used") def test_autonomous_inspection_and_technology_research_reach_recall(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) (root / "audit.py").write_text("# TODO: validate Orion parser\n", encoding="utf-8") with patch.object(main, "_last_inspection_time", 0), \ patch.object(main, "_last_user_interaction", 0), \ @@ -339,8 +340,8 @@ def test_autonomous_inspection_and_technology_research_reach_recall(self): self.assertEqual(states["auto_patch_technology"]["product_status"], "used") def test_stale_code_cleanup_changes_the_file_index(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main.memory_bank.add_file("deleted.py", "VALUE = 1") with patch.object(main, "_last_code_scan_time", 0): result = main.dispatch_learning_cycle(surface="test", trigger="idle", max_features=1, @@ -351,12 +352,12 @@ def test_stale_code_cleanup_changes_the_file_index(self): self.assertEqual(remaining, 0) def test_tool_observations_survive_worker_restart_without_arguments(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) for _ in range(3): main._track_tool_performance("parse_tool", False, 0.1) - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) with patch.object(main, "_tool_stats", {}): stats = main._learning_tool_stats() self.assertEqual(stats["parse_tool"]["calls"], 3) @@ -366,10 +367,10 @@ def test_tool_observations_survive_worker_restart_without_arguments(self): self.assertTrue(all(set(item["payload"]) == {"tool", "success", "elapsed"} for item in events)) def test_project_scan_and_memory_scoring_have_real_scoped_effects(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) (root / "effect.py").write_text("VALUE = 7\n", encoding="utf-8") - self._isolated_runtime(root) + self._isolated_runtime(root, sqlite_owners) main._project_graph = Mock() main._project_graph.snapshot.return_value = { "status": "missing", "nodes": 0, "edges": 0, "communities": 0, @@ -442,8 +443,8 @@ def test_round_robin_is_bounded_and_flags_are_independent(self): main._SELF_LEARNING_FLAGS = {name: name in names for name in main._LEARNING_FEATURE_ORDER} main._learning_dispatch_cursor = 0 - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) for _ in range(3): result = main.dispatch_learning_cycle(surface="test", trigger="round-robin", max_features=1) self.assertEqual(len(result), 1) @@ -459,10 +460,10 @@ def test_round_robin_is_bounded_and_flags_are_independent(self): self.assertEqual([item["feature"] for item in result], [names[0], names[2]]) def test_verified_preferences_hydrate_in_fresh_runtime_and_prompt(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-preferences-") as directory: + with tempfile.TemporaryDirectory(prefix="openkyrozen-preferences-") as directory, ExitStack() as sqlite_owners: root = Path(directory) db_path = root / "state.sqlite3" - memory = MemoryBank(db_path, user_id="local", workspace_id="default") + memory = sqlite_owners.enter_context(closing(MemoryBank(db_path, user_id="local", workspace_id="default"))) engine = main.LearningEngine(memory) self.assertEqual( engine.submit("preference", "PREF: naming_style=snake_case", evidence_id="signal-one")["status"], @@ -541,8 +542,8 @@ def fail(_context): main._LEARNING_FEATURE_REGISTRY = registry main._SELF_LEARNING_FLAGS = {name: name in names for name in main._LEARNING_FEATURE_ORDER} - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) result = main.dispatch_learning_cycle( surface="test", trigger="failure-isolation", max_features=2, feature_names=tuple(names), @@ -562,8 +563,8 @@ def test_web_scheduler_uses_the_shared_dispatcher(self): dispatch.assert_called_once_with(surface="web", trigger="scheduled", max_features=4) def test_setup_required_blocks_remote_learning_and_reports_reason(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) remote = Mock() main.llm_provider = remote self.assertIsNone(main._learning_model_response([{"role": "system", "content": "test"}], feature="test")) @@ -583,8 +584,8 @@ def test_setup_required_blocks_remote_learning_and_reports_reason(self): def test_local_learning_never_uses_fallback_and_rejects_remote_endpoint(self): with tempfile.TemporaryDirectory() as directory, \ - patch.dict(os.environ, {"KYROZEN_LEARNING_OLLAMA_BASE_URL": "http://localhost:11434/v1"}, clear=False): - self._isolated_runtime(Path(directory)) + patch.dict(os.environ, {"KYROZEN_LEARNING_OLLAMA_BASE_URL": "http://localhost:11434/v1"}, clear=False), ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._set_learning_runtime("local", "ready", model="qwen2.5:7b") local = Mock() local.chat.return_value = ("local result", {"prompt_tokens": 1, "completion_tokens": 1}) @@ -602,8 +603,8 @@ def test_local_learning_never_uses_fallback_and_rejects_remote_endpoint(self): provider.assert_not_called() def test_remote_learning_reuses_chat_provider_and_marks_learning_surface(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._set_learning_runtime("remote", "ready") remote = Mock() remote.chat.return_value = ("remote result", {}) @@ -613,8 +614,8 @@ def test_remote_learning_reuses_chat_provider_and_marks_learning_surface(self): remote.chat.assert_called_once_with(ANY, "configured-model") def test_local_bootstrap_marks_resource_failure_durably(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._set_learning_runtime("local", "installing", model="qwen2.5:7b") with patch.object(main, "_local_learning_resources_ok", return_value=(False, "need RAM")): main._bootstrap_local_learning() @@ -623,8 +624,8 @@ def test_local_bootstrap_marks_resource_failure_durably(self): self.assertEqual(runtime["detail"], "need RAM") def test_local_bootstrap_marks_pull_failure_durably(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._set_learning_runtime("local", "installing", model="qwen2.5:7b") failed_pull = SimpleNamespace(returncode=1, stdout="", stderr="download failed") with patch.object(main, "_local_learning_resources_ok", return_value=(True, "")), \ @@ -636,8 +637,8 @@ def test_local_bootstrap_marks_pull_failure_durably(self): self.assertIn("download failed", main.learning_runtime()["detail"]) def test_local_bootstrap_reuses_ollama_and_smokes_qwen(self): - with tempfile.TemporaryDirectory() as directory: - self._isolated_runtime(Path(directory)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + self._isolated_runtime(Path(directory), sqlite_owners) main._set_learning_runtime("local", "installing", model="qwen2.5:7b") completed = SimpleNamespace(returncode=0, stdout="NAME ID SIZE\nqwen2.5:7b x 4.7 GB", stderr="") local = Mock() diff --git a/tests/test_learning_worker.py b/tests/test_learning_worker.py index f8942323..8566151c 100644 --- a/tests/test_learning_worker.py +++ b/tests/test_learning_worker.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import os import sys import tempfile @@ -19,13 +20,13 @@ def test_custom_database_keeps_worker_files_in_isolated_state(self): self.assertEqual(heartbeat.parent, Path(directory).resolve()) def test_feature_flags_persist_across_store_restarts(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" - first = EventStore(path) + first = sqlite_owners.enter_context(closing(EventStore(path))) first.set_learning_feature_flag("idle_reflection", False, workspace_id="project") first.set_learning_feature_flag("memory_importance_scoring", True, workspace_id="project") - reopened = EventStore(path) + reopened = sqlite_owners.enter_context(closing(EventStore(path))) self.assertEqual( reopened.list_learning_feature_flags(workspace_id="project"), {"idle_reflection": False, "memory_importance_scoring": True}, diff --git a/tests/test_legacy_runtime_acceptance.py b/tests/test_legacy_runtime_acceptance.py new file mode 100644 index 00000000..b3aad960 --- /dev/null +++ b/tests/test_legacy_runtime_acceptance.py @@ -0,0 +1,102 @@ +"""R258: run the complete legacy provider runtime, including durable paging.""" +import copy +import json +import os +from pathlib import Path +import re +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.providers.base import LLMProvider +from openkyrozen.providers.config import ProviderConfig +from openkyrozen.tools import ToolAdapters + + +class LegacyRuntimeAcceptanceTests(unittest.TestCase): + def test_real_legacy_turn_archives_results_and_retrieves_after_restart_without_scope_leak(self): + with tempfile.TemporaryDirectory(prefix='kyrozen-legacy-runtime-') as directory: + root = Path(directory) + workspace = root / 'workspace' + workspace.mkdir() + first = 'FIRST_UNIQUE_CONTENT\n' + 'retained evidence\n' * 500 + 'FIRST_TAIL_SENTINEL' + for name, content in (('first.txt', first), ('second.txt', 'SECOND_UNIQUE_CONTENT'), + ('third.txt', 'THIRD_UNIQUE_CONTENT')): + (workspace / name).write_text(content) + environment = {'HOME': directory, 'USERPROFILE': directory, 'KYROZEN_DB_PATH': str(root / 'state.sqlite3'), + 'KYROZEN_SKILLS_DIR': str(root / 'skills'), 'KYROZEN_DISABLE_VECTOR_INDEX': '1', + 'KYROZEN_CLI_CAPABILITIES': 'read', 'KYROZEN_APPROVAL_MODE': 'never'} + with patch.dict(os.environ, environment, clear=True): + requests = [] + artifact = [] + class Provider(LLMProvider): + def chat(self, messages, model=None): + requests.append(copy.deepcopy(messages)) + number = len(requests) + if number <= 3: + name = ('first.txt', 'second.txt', 'third.txt')[number - 1] + return 'Action: ' + json.dumps({'action': 'read_file', 'args': name}), None + visible = '\n'.join(item.get('content', '') for item in messages) + if number == 4: + found = re.search(r'artifact_[0-9a-f]+', visible) + if found is None: + return 'No archived output reference was provided.', None + artifact.append(found.group()) + arguments = json.dumps({'artifact_id': artifact[0], 'offset': 0, 'limit': 1000}) + return 'Action: ' + json.dumps({'action': 'read_tool_artifact', 'args': arguments}), None + return 'Verified bounded legacy replay.', None + + def application(user): + app = build_application(surface='cli', user_id=user, + memory=build_memory(root / 'state.sqlite3', user_id=user, index=None), + tools=ToolAdapters(workspace)) + app.runtime._set_workspace_root(str(workspace)) + return app + + app = application('alice') + try: + runtime = app.runtime + config = ProviderConfig(provider='openai', api_key='offline-port-only', + model_simple='offline-legacy', model_complex='offline-legacy') + runtime._provider_config = config + runtime.llm_provider = runtime._default_turn.provider = Provider(config) + runtime.dispatch_learning_cycle = lambda **kwargs: None + runtime._plugin_runtime_for_surface = lambda *args: SimpleNamespace( + turn_start=lambda **kwargs: None, turn_end=lambda **kwargs: None) + runtime.set_interaction_mode('agent') + answer = runtime.chat(runtime.current_session, 'Inspect saved notes', profile='researcher') + self.assertIn('Verified bounded legacy replay.', answer) + self.assertEqual(len(requests), 5) + before = '\n'.join(item.get('content', '') for item in requests[3]) + self.assertNotIn('FIRST_UNIQUE_CONTENT', before) + self.assertNotIn('SECOND_UNIQUE_CONTENT', before) + self.assertIn('THIRD_UNIQUE_CONTENT', before) + after = '\n'.join(item.get('content', '') for item in requests[4]) + self.assertIn('FIRST_UNIQUE_CONTENT', after) + finally: + app.close() + + for user in ('alice', 'bob'): + with self.subTest(user=user): + restarted = application(user) + try: + pages, offset = [], 0 + while True: + receipt = restarted.runtime._run_tool('read_tool_artifact', json.dumps({ + 'artifact_id': artifact[0], 'offset': offset, 'limit': 1000}), return_receipt=True) + if user == 'bob': + self.assertFalse(receipt.success) + self.assertNotIn('FIRST_UNIQUE_CONTENT', receipt.result) + break + self.assertTrue(receipt.success, receipt.result) + page = receipt.tool_result.data + pages.append(page['content']) + if page['next_offset'] is None: + retained = json.loads(''.join(pages))['results'][0]['output']['text'] + self.assertIn(first, retained) + break + offset = page['next_offset'] + finally: + restarted.close() diff --git a/tests/test_legacy_tool_artifacts.py b/tests/test_legacy_tool_artifacts.py new file mode 100644 index 00000000..749bc079 --- /dev/null +++ b/tests/test_legacy_tool_artifacts.py @@ -0,0 +1,212 @@ +from contextlib import ExitStack, closing +import copy +import json +import os +import re +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from openkyrozen.agent.turn import TurnContext +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.tools import ToolAdapters + + +class LegacyToolArtifactsTests(unittest.TestCase): + def test_archived_legacy_result_retains_content_beyond_display_limit(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "KYROZEN_WORKSPACE_ROOT": directory, + }), ExitStack() as sqlite_owners: + root = Path(directory) + for index in range(250): + (root / f"file_{index:04d}_long_name.txt").touch() + (root / "note.txt").write_text("note", encoding="utf-8") + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(app.close) + runtime = app.runtime + turn = TurnContext("Inspect files", interaction_mode="agent", complexity="simple", + learning_run={"run_id": "large-test"}, memory_packet="", + response_text='Action: {"action":"list_dir","args":"."}', + tool_calls=[{"action": "list_dir", "args": "."}]) + turn.agent_versions = {} + requests = [] + def response(messages): + requests.append(copy.deepcopy(messages)) + return ('Action: {"action":"read_file","args":"note.txt"}' if len(requests) == 1 + else "Files inspected.") + with patch.object(runtime, "_call_llm_with_spinner", side_effect=response), \ + patch.object(runtime, "_update_tasks_panel"): + runtime._execute_action_rounds(turn) + second = "\n".join(item["content"] for item in requests[1]) + artifact_id = re.search(r"artifact_[0-9a-f]+", second).group() + pages, offset = [], 0 + while True: + result = runtime.tool_registry.get_spec("read_tool_artifact").executor( + {"artifact_id": artifact_id, "offset": offset, "limit": 1000}).data + pages.append(result["content"]) + if result["next_offset"] is None: + break + offset = result["next_offset"] + self.assertIn("file_0249_long_name.txt", "".join(pages)) + + def test_search_limit_synthesis_uses_latest_result_and_archives_earlier_one(self): + from types import SimpleNamespace + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "KYROZEN_WORKSPACE_ROOT": directory, + }), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(app.close) + runtime = app.runtime + turn = TurnContext("Search", interaction_mode="agent", complexity="simple", + learning_run={"run_id": "search-test"}, memory_packet="", + response_text='Action: {"action":"search_web","args":"0"}', + tool_calls=[{"action": "search_web", "args": "0"}]) + turn.agent_versions = {} + requests = [] + def response(messages): + requests.append(copy.deepcopy(messages)) + number = len(requests) + return (f'Action: {{"action":"search_web","args":"{number}"}}' + if number <= 6 else "Search failed; no results verified.") + def batch(calls, **kwargs): + for call in calls: + kwargs["on_receipt"](call, SimpleNamespace(action="search_web", args=call["args"], success=False)) + with patch.object(runtime, "_call_llm_with_spinner", side_effect=response), \ + patch.object(runtime, "_execute_tool_batch", side_effect=batch), \ + patch.object(runtime, "_record_turn_receipt", return_value={"action": "search_web", "success": False}), \ + patch.object(runtime, "_tool_result_for_prompt", side_effect=lambda receipt: "failure-" + receipt.args), \ + patch.object(runtime, "_render_tool_result"), patch.object(runtime, "_render_warning"), \ + patch.object(runtime, "_update_tasks_panel"): + runtime._execute_action_rounds(turn) + final = "\n".join(item["content"] for item in requests[-1]) + self.assertNotIn("failure-5", final) + self.assertIn("failure-6", final) + + def test_legacy_archival_failure_stops_before_next_model_request(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "KYROZEN_WORKSPACE_ROOT": directory, + }), ExitStack() as sqlite_owners: + root = Path(directory) + (root / "first.txt").write_text("first", encoding="utf-8") + (root / "second.txt").write_text("second", encoding="utf-8") + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(app.close) + runtime = app.runtime + turn = TurnContext("Read both", interaction_mode="agent", complexity="simple", + learning_run={"run_id": "legacy-test"}, memory_packet="", + response_text='Action: {"action":"read_file","args":"first.txt"}', + tool_calls=[{"action": "read_file", "args": "first.txt"}]) + turn.agent_versions = {} + with patch.object(runtime, "_call_llm_with_spinner", return_value= + 'Action: {"action":"read_file","args":"second.txt"}') as call, \ + patch.object(runtime._agent_engine, "_store_tool_output", side_effect=OSError("disk full")), \ + patch.object(runtime, "_update_tasks_panel"): + runtime._execute_action_rounds(turn) + self.assertEqual(call.call_count, 1) + self.assertIn("Could not retain earlier tool results", turn.protocol_error_message) + self.assertEqual(len(turn.tool_records), 2) + + def test_legacy_artifact_read_accepts_json_and_enforces_scope(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "KYROZEN_WORKSPACE_ROOT": directory, + }), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(app.close) + runtime = app.runtime + scope = {"user_id": runtime.tasks.user_id, "workspace_id": runtime.tasks.workspace_id, + "session_id": runtime.tasks.session_id} + artifact_id = runtime.memory_bank.store.artifacts.create("abcdef", **scope) + result = runtime._run_tool("read_tool_artifact", '{"artifact_id":"' + artifact_id + '","offset":2,"limit":3}', + return_receipt=True) + self.assertTrue(result.success) + self.assertEqual(result.tool_result.data["content"], "cde") + hidden = runtime.memory_bank.store.artifacts.create("private", **{**scope, "user_id": "bob"}) + denied = runtime._run_tool("read_tool_artifact", '{"artifact_id":"' + hidden + '"}', return_receipt=True) + self.assertFalse(denied.success) + self.assertNotIn("private", denied.result) + + def test_legacy_rounds_archive_results_in_each_prompt_mode(self): + for mode, profile in (("agent", "full"), ("agent", "compact"), ("ask", "full"), ("plan", "full")): + with self.subTest(mode=mode, profile=profile): + self._verify_legacy_rounds(mode, profile) + + def _verify_legacy_rounds(self, mode, profile): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + "KYROZEN_WORKSPACE_ROOT": directory, + }), ExitStack() as sqlite_owners: + root = Path(directory) + (root / "first.txt").write_text("FIRST_UNIQUE_CONTENT", encoding="utf-8") + (root / "second.txt").write_text("SECOND_UNIQUE_CONTENT", encoding="utf-8") + (root / "third.txt").write_text("THIRD_UNIQUE_CONTENT", encoding="utf-8") + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(app.close) + runtime = app.runtime + turn = TurnContext("Read both files", interaction_mode=mode, complexity="simple", + learning_run={"run_id": "legacy-test"}, memory_packet="", + response_text='Action: {"action":"read_file","args":"first.txt"}', + tool_calls=[{"action": "read_file", "args": "first.txt"}]) + turn.agent_versions = {} + requests = [] + final = ('PlanProposal:\n```json\n{"title":"Read files","summary":"Use the inspected files",' + '"assumptions":[],"steps":[{"id":"step-1","title":"Review",' + '"description":"Review the files","acceptance":["Reviewed"]}]}\n```' + if mode == "plan" else "All files were read.") + replies = iter(['Action: {"action":"read_file","args":"second.txt"}', + 'Action: {"action":"read_file","args":"third.txt"}']) + def response(messages): + requests.append(copy.deepcopy(messages)) + if len(requests) <= 2: + return next(replies) + if len(requests) == 3: + text = "\n".join(item["content"] for item in messages) + artifact_id = re.search(r"artifact_[0-9a-f]+", text).group() + args = json.dumps({"artifact_id": artifact_id, "offset": 0, "limit": 1000}) + return "Action: " + json.dumps({"action": "read_tool_artifact", "args": args}) + return final + with patch.object(runtime, "_call_llm_with_spinner", side_effect=response), \ + patch.object(runtime, "_prompt_profile", return_value=profile), \ + patch.object(runtime, "_update_tasks_panel"): + runtime._execute_action_rounds(turn) + self.assertEqual(len(requests), 4) + first = "\n".join(item["content"] for item in requests[0]) + second = "\n".join(item["content"] for item in requests[1]) + self.assertIn("FIRST_UNIQUE_CONTENT", first) + self.assertNotIn("FIRST_UNIQUE_CONTENT", second) + self.assertIn("SECOND_UNIQUE_CONTENT", second) + third = "\n".join(item["content"] for item in requests[2]) + self.assertNotIn("FIRST_UNIQUE_CONTENT", third) + self.assertNotIn("SECOND_UNIQUE_CONTENT", third) + self.assertIn("THIRD_UNIQUE_CONTENT", third) + fourth = "\n".join(item["content"] for item in requests[3]) + self.assertIn("FIRST_UNIQUE_CONTENT", fourth) + self.assertNotIn("THIRD_UNIQUE_CONTENT", fourth) + artifact_id = re.search(r"artifact_[0-9a-f]+", second).group() + receipt = runtime._run_tool("read_tool_artifact", '{"artifact_id":"' + artifact_id + '"}', + return_receipt=True) + self.assertTrue(receipt.success) + self.assertIn("FIRST_UNIQUE_CONTENT", receipt.result) + reopened = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", user_id="alice", index=None))), + tools=ToolAdapters(root), user_id="alice") + self.addCleanup(reopened.close) + reopened.runtime.bind_interaction_scope(runtime.tasks.session_id, user_id="alice") + restored = reopened.runtime._run_tool("read_tool_artifact", '{"artifact_id":"' + artifact_id + '"}', + return_receipt=True) + self.assertTrue(restored.success) + self.assertIn("FIRST_UNIQUE_CONTENT", restored.result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory_claims.py b/tests/test_memory_claims.py index deb429bc..7ec7b840 100644 --- a/tests/test_memory_claims.py +++ b/tests/test_memory_claims.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import tempfile import unittest from pathlib import Path @@ -12,11 +13,14 @@ class MemoryClaimTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() root = Path(self.directory.name) - self.memory = MemoryBank(root / "state.sqlite3", workspace_id="project") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", workspace_id="project"))) self.registry = SkillRegistry(self.memory.store, workspace_id="project", root=root / "skills") self.engine = LearningEngine(self.memory, registry=self.registry) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def test_inferred_claim_needs_two_observations(self): diff --git a/tests/test_multi_party_memory.py b/tests/test_multi_party_memory.py index 02e8988c..96a72bcb 100644 --- a/tests/test_multi_party_memory.py +++ b/tests/test_multi_party_memory.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import os import tempfile import unittest @@ -12,11 +13,14 @@ class MultiPartyMemoryTests(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() - self.memory = MemoryBank(Path(self.directory.name) / "state.sqlite3", workspace_id="project", - session_id="session") + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) + self.memory = self._sqlite_owners.enter_context(closing(MemoryBank(Path(self.directory.name) / "state.sqlite3", workspace_id="project", + session_id="session"))) self.engine = LearningEngine(self.memory) def tearDown(self): + self._sqlite_owners.close() self.directory.cleanup() def _belief(self, speaker, value): @@ -95,8 +99,8 @@ def test_recall_receipt_explains_whose_memory_was_used(self): self.assertEqual(receipt["payload"]["attributions"][0]["speaker"], "alice") def test_memory_bank_reads_are_scoped_to_its_user(self): - alice = MemoryBank(self.memory.db_path, user_id="alice", workspace_id="project", session_id="alice-session") - bob = MemoryBank(self.memory.db_path, user_id="bob", workspace_id="project", session_id="bob-session") + alice = self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="alice", workspace_id="project", session_id="alice-session"))) + bob = self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="bob", workspace_id="project", session_id="bob-session"))) memory_id = alice.add_log( "Private fact from alice — medical note: diagnosis-8x", kind="fact", metadata={"visibility": "private", "speaker": "alice", "claim_type": "private_fact"}, @@ -107,10 +111,10 @@ def test_memory_bank_reads_are_scoped_to_its_user(self): def test_chroma_recall_filters_user_and_session_before_returning_documents(self): with patch.dict(os.environ, {"KYROZEN_VECTOR_PATH": str(Path(self.directory.name) / "chroma")}): - alice = MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared", - session_id="alice-session") - bob = MemoryBank(self.memory.db_path, user_id="bob", workspace_id="shared", - session_id="bob-session") + alice = self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared", + session_id="alice-session"))) + bob = self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="bob", workspace_id="shared", + session_id="bob-session"))) self.assertIsNotNone(alice._collection) alice.add_log( "PRIVATE_MARKER_ALICE", @@ -118,14 +122,14 @@ def test_chroma_recall_filters_user_and_session_before_returning_documents(self) metadata={"visibility": "private", "speaker": "alice"}, ) alice.add_log("SESSION_MARKER_ALICE", kind="fact") - MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared").add_log( + self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared"))).add_log( "GLOBAL_MARKER_ALICE", kind="fact" ) self.assertEqual(bob.recall("PRIVATE_MARKER_ALICE", n_results=5), []) self.assertEqual(bob.recall_records("PRIVATE_MARKER_ALICE", n_results=5), []) - other_session = MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared", - session_id="other-session") + other_session = self._sqlite_owners.enter_context(closing(MemoryBank(self.memory.db_path, user_id="alice", workspace_id="shared", + session_id="other-session"))) self.assertNotIn("SESSION_MARKER_ALICE", other_session.recall("SESSION_MARKER_ALICE", n_results=5)) self.assertIn("GLOBAL_MARKER_ALICE", alice.recall("GLOBAL_MARKER_ALICE", n_results=5)) self.assertIn( @@ -136,13 +140,13 @@ def test_chroma_recall_filters_user_and_session_before_returning_documents(self) ) def test_api_memory_recall_does_not_return_another_user_vector_document(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-memory-api-") as directory: + with tempfile.TemporaryDirectory(prefix="openkyrozen-memory-api-") as directory, ExitStack() as sqlite_owners: root = Path(directory) with patch.dict(os.environ, {"KYROZEN_VECTOR_PATH": str(root / "chroma")}): - owner = MemoryBank(root / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, - workspace_id="api-project", session_id="api-session") - other = MemoryBank(root / "state.sqlite3", user_id="other-user", - workspace_id="api-project", session_id="api-session") + owner = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, + workspace_id="api-project", session_id="api-session"))) + other = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", user_id="other-user", + workspace_id="api-project", session_id="api-session"))) other.add_log( "API_PRIVATE_MARKER_OTHER", kind="fact", diff --git a/tests/test_native_context.py b/tests/test_native_context.py new file mode 100644 index 00000000..aac322a3 --- /dev/null +++ b/tests/test_native_context.py @@ -0,0 +1,309 @@ +"""Native engine context checks using real runtime/compaction and offline providers.""" +import copy +import os +from pathlib import Path +import tempfile +import time +import unittest +from unittest.mock import patch + +from openkyrozen.agent.compaction import ContextState, DIGEST_PREFIX, estimate_messages +from openkyrozen.agent.engine import AgentEngine +from openkyrozen.agent.turn import TurnContext +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.providers import ProviderConfig +from openkyrozen.providers.base import LLMProvider +from openkyrozen.providers.errors import ProviderError, ProviderErrorKind +from openkyrozen.providers.models import ( + FinishReason, ModelResponse, ProviderCapabilities, ResponseError, TextDelta, + ToolCall, ToolReply, provider_continuation, provider_identity, validate_tool_history, +) +from openkyrozen.tools import ToolAdapters + + +class NativeContextTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + env = patch.dict(os.environ, {"KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never"}) + env.start() + self.addCleanup(env.stop) + self.app = build_application(surface="cli", memory=build_memory(self.root / "state.sqlite3", index=None), + tools=ToolAdapters(self.root)) + self.addCleanup(self.app.close) + self.runtime = self.app.runtime + self.runtime._set_workspace_root(self.root) + self.runtime.set_interaction_mode("agent") + + def test_tracked_native_history_compacts_atomic_groups_and_keeps_current_supersession(self): + from openkyrozen.agent.compaction import compact_for_pressure + old_user = {"role": "user", "content": "Original region eu-west-2; owner team-cedar; " + "x" * 2000} + old_call = ModelResponse(tool_calls=(ToolCall("old-call", "read_file", {"path": "facts.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + old_reply = ToolReply("old-call", "read_file", {"text": "rollback release-17 " + "x" * 2000}) + old_final = ModelResponse(text="Earlier region eu-west-2", finish_reason=FinishReason.FINAL) + recent = [{"role": "user", "content": "Latest approved region ap-southeast-1"}, + ModelResponse(text="Updated region", finish_reason=FinishReason.FINAL), + {"role": "user", "content": "Keep team-cedar"}, + ModelResponse(text="Retained owner", finish_reason=FinishReason.FINAL)] + unfinished = ModelResponse(tool_calls=(ToolCall("pending", "read_file", {"path": "pending.txt"}),), + finish_reason=FinishReason.TOOL_REQUEST) + current = {"role": "user", "content": "Return the current region and original rollback"} + history = [old_user, old_call, old_reply, old_final, *recent] + messages = [{"role": "system", "content": "Pinned state"}, *history, unfinished, current] + original = list(messages) + state = ContextState("fixture", configured_window=1800, reserve_tokens=100, + history_message_ids={id(item) for item in history + [unfinished]}) + seen = [] + def summarize(text, budget): + seen.append(text) + return "Original region eu-west-2; owner team-cedar; rollback release-17" + result = compact_for_pressure(messages, state, summarize, force=True) + self.assertFalse(result.impossible) + self.assertTrue(result.compacted) + self.assertEqual(messages, original) # The canonical input is never mutated. + self.assertIn("rollback release-17", "".join(seen)) + self.assertNotIn("Latest approved region", "".join(seen)) + for item in [*recent, unfinished, current]: + self.assertTrue(any(candidate is item for candidate in result.messages)) + for item in [old_call, old_reply]: + self.assertFalse(any(candidate is item for candidate in result.messages)) + self.assertIn("rollback release-17", result.history_digest["content"]) + + def history(self): + return ([{"role": "system", "content": "Keep the current task and trusted tool correlation."}] + + [{"role": "user" if index % 2 == 0 else "assistant", "content": f"old-{index}:" + "x" * 1200} + for index in range(6)] + + [{"role": "user" if index % 2 == 0 else "assistant", "content": f"recent-{index}: keep cedar decision"} + for index in range(4)]) + + def run_native(self, provider, messages, *, window=None): + runtime = self.runtime + context = copy.copy(runtime.execution_context) + context.provider, context.model = provider, "context-fixture" + token = runtime._turn_context.set(context) + self.addCleanup(runtime._turn_context.reset, token) + state = ContextState("context-fixture", configured_window=window, reserve_tokens=100) + state.history_message_ids = {id(item) for item in messages[:-1]} + runtime.short_term_memory = list(messages[1:-1]) + state_token = runtime._active_context_state.set(state) + self.addCleanup(runtime._active_context_state.reset, state_token) + def prepare(turn): + turn.interaction_mode, turn.complexity = "agent", "simple" + turn.turn_start, turn.learning_run, turn.learned_context = time.time(), {"run_id": "context-fixture"}, "" + turn = TurnContext("Continue the current work") + with patch.object(runtime, "_permitted_tool_names", return_value={"read_file", "write_file"}), \ + patch.object(runtime, "_prepare_turn", side_effect=prepare), \ + patch.object(runtime, "_build_messages", return_value=messages), \ + patch.object(runtime, "_summarize_context_with_chat_model", return_value="Decision: keep cedar; current work remains."), \ + patch.object(runtime, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, answer, *_args: answer), \ + patch.object(runtime, "_schedule_post_turn_learning"): + answer = AgentEngine(runtime).run(turn) + return answer, turn, state + + def provider(self, callback, *, stream=False): + class Provider(LLMProvider): + def __init__(self): + super().__init__(ProviderConfig(provider="custom", model_simple="context-fixture")) + self.requests = [] + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True, text_streaming=stream, streaming_tool_calls=stream) + def chat(self, messages, model=None): + raise AssertionError("native context entered legacy text boundary") + def chat_response(self, messages, model=None, *, tools=None): + self.requests.append(list(messages)) + return callback(self, messages, model) + def chat_response_stream(self, messages, model=None, *, tools=None): + self.requests.append(list(messages)) + yield from callback(self, messages, model) + return Provider() + + def test_pressure_compacts_real_native_history_before_the_provider_request(self): + provider = self.provider(lambda _provider, _messages, _model: + ModelResponse(text="The cedar decision is preserved.", finish_reason=FinishReason.FINAL)) + messages = self.history() + call = ToolCall("historical-read", "read_file", {"path": "notes.txt"}) + messages.extend((ModelResponse(tool_calls=(call,), finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(provider, "context-fixture", "fixture", {"id": "history"})), + ToolReply(call.id, call.name, {"text": "known fact"}), {"role": "user", "content": "Current pending work"})) + answer, turn, state = self.run_native(provider, messages, window=1800) + sent = provider.requests[0] + self.assertLessEqual(estimate_messages(sent)[0] + state.reserve_tokens, 1800) + self.assertTrue(any(isinstance(item, dict) and DIGEST_PREFIX in item.get("content", "") for item in sent)) + self.assertEqual(sent[-1]["content"], "Current pending work") + validate_tool_history(sent, provider_id=provider_identity(provider), model="context-fixture", native=True) + self.assertEqual(turn.runtime_status, "completed") + self.assertIn("cedar", answer) + + def test_full_native_pressure_compacts_a_prefix_that_fits_on_its_own(self): + from openkyrozen.agent.compaction import compact_for_pressure + prefix = ([{"role": "system", "content": "fixed"}] + + [{"role": "user", "content": f"old-{index}:" + "x" * 1000} for index in range(6)] + + [{"role": "user", "content": "recent decision cedar"} for _ in range(4)]) + call = ToolCall("recent-call", "read_file", {"path": "notes.txt"}) + response = ModelResponse(tool_calls=(call,), finish_reason=FinishReason.TOOL_REQUEST) + reply = ToolReply(call.id, call.name, {"text": "known " * 200}) + current = {"role": "user", "content": "Continue current work"} + state = ContextState("context-fixture", configured_window=2100, reserve_tokens=100, + native_input_overhead_tokens=150) + state.history_message_ids = {id(item) for item in prefix} + self.assertLess(state.update(prefix)["input_tokens"] + state.reserve_tokens, 2100) + self.assertGreater(state.update(prefix + [response, reply, current])["input_tokens"] + state.reserve_tokens, 2100) + result = compact_for_pressure(prefix + [response, reply, current], state, lambda *_args: "Decision cedar retained") + self.assertFalse(result.impossible) + self.assertTrue(result.compacted) + self.assertLessEqual(state.status["input_tokens"] + state.reserve_tokens, 2100) + self.assertIs(result.messages[-3], response) + self.assertIs(result.messages[-2], reply) + self.assertIs(result.messages[-1], current) + + def test_native_reported_token_cache_reuses_only_the_same_schema_across_turns(self): + messages = [{"role": "system", "content": "fixed"}, {"role": "user", "content": "current"}] + first = ContextState("context-fixture", native_schema_fingerprint="schema-one", native_input_overhead_tokens=80) + token = self.runtime._active_context_state.set(first) + try: + self.runtime._cache_reported_context_tokens(messages, 321) + finally: + self.runtime._active_context_state.reset(token) + same = ContextState("context-fixture", native_schema_fingerprint="schema-one", native_input_overhead_tokens=80) + self.runtime._load_reported_context_tokens(same) + status = same.update(messages) + self.assertEqual(status["input_source"], "provider_reported") + self.assertEqual(status["input_tokens"], 321) + different = ContextState("context-fixture", native_schema_fingerprint="schema-two", native_input_overhead_tokens=90) + self.runtime._load_reported_context_tokens(different) + self.assertEqual(different.update(messages)["input_source"], "estimated") + legacy = ContextState("context-fixture") + self.runtime._load_reported_context_tokens(legacy) + self.assertEqual(legacy.update(messages)["input_source"], "estimated") + + def test_reported_context_counts_do_not_cross_provider_or_model_identity(self): + messages = [{"role": "user", "content": "current"}] + first = ContextState("model-A", native_schema_fingerprint="same", native_input_overhead_tokens=200) + first.request_provider_id = "provider-one" + token = self.runtime._active_context_state.set(first) + try: + self.runtime._cache_reported_context_tokens(messages, 7) + finally: + self.runtime._active_context_state.reset(token) + for model, identity in (("model-B", "provider-one"), ("model-A", "provider-two")): + with self.subTest(model=model, provider=identity): + other = ContextState(model, native_schema_fingerprint="same", native_input_overhead_tokens=200) + other.request_provider_id = identity + self.runtime._load_reported_context_tokens(other) + self.assertEqual(other.update(messages)["input_source"], "estimated") + same = ContextState("model-A", native_schema_fingerprint="same", native_input_overhead_tokens=200) + same.request_provider_id = "provider-one" + self.runtime._load_reported_context_tokens(same) + self.assertEqual(same.update(messages)["input_tokens"], 7) + + def test_explicit_native_model_switch_recomputes_catalog_window_before_transport(self): + from openkyrozen.agent.types import ContextTooLargeError + provider = self.provider(lambda *_args: ModelResponse(text="Must not run", finish_reason=FinishReason.FINAL)) + context = copy.copy(self.runtime.execution_context) + context.provider = provider + turn_token = self.runtime._turn_context.set(context) + state = ContextState("deepseek-flash", reserve_tokens=100) + state_token = self.runtime._active_context_state.set(state) + try: + with self.assertRaises(ContextTooLargeError): + self.runtime._get_model_response([{"role": "system", "content": "x" * 600000}, + {"role": "user", "content": "current"}], model="gpt-4o", tool_names={"read_file"}) + self.assertEqual(provider.requests, []) + self.assertEqual(state.window_tokens, 128000) + finally: + self.runtime._active_context_state.reset(state_token) + self.runtime._turn_context.reset(turn_token) + + def test_explicit_configured_window_survives_a_native_model_switch(self): + provider = self.provider(lambda *_args: ModelResponse(text="Fits configured window", finish_reason=FinishReason.FINAL)) + context = copy.copy(self.runtime.execution_context) + context.provider = provider + turn_token = self.runtime._turn_context.set(context) + state = ContextState("deepseek-flash", configured_window=5000, reserve_tokens=100) + state_token = self.runtime._active_context_state.set(state) + try: + self.runtime._get_model_response([{"role": "user", "content": "current"}], + model="gpt-4o", tool_names={"read_file"}) + self.assertEqual(state.window_tokens, 5000) + self.assertEqual(state.window_source, "configured_override") + finally: + self.runtime._active_context_state.reset(state_token) + self.runtime._turn_context.reset(turn_token) + + def test_native_schema_payload_is_reserved_before_provider_dispatch(self): + provider = self.provider(lambda *_args: ModelResponse(text="Should not fit", finish_reason=FinishReason.FINAL)) + original = self.runtime.tool_registry.get_spec("read_file") + self.runtime.tool_registry.register_spec(original.replace(description="Read contract: " + "x" * 20000), replace=True) + messages = [{"role": "system", "content": "fixed"}, {"role": "user", "content": "Read a file"}] + answer, turn, state = self.run_native(provider, messages, window=500) + self.assertEqual(len(provider.requests), 0, answer) + self.assertGreater(state.native_input_overhead_tokens, 500) + self.assertEqual(turn.runtime_status, "incomplete") + + def test_native_continuation_payload_cannot_hide_from_the_context_limit(self): + provider = self.provider(lambda *_args: ModelResponse(text="Should not fit", finish_reason=FinishReason.FINAL)) + messages = [{"role": "system", "content": "fixed"}, ModelResponse(text="previous response", + finish_reason=FinishReason.FINAL, continuation=provider_continuation(provider, "context-fixture", "fixture", + {"opaque_replay_signature": "x" * 12000})), {"role": "user", "content": "Continue"}] + answer, turn, _state = self.run_native(provider, messages, window=500) + self.assertEqual(len(provider.requests), 0, answer) + self.assertEqual(turn.runtime_status, "incomplete") + + def test_impossible_fixed_native_context_never_calls_the_provider(self): + provider = self.provider(lambda *_args: ModelResponse(text="Should not run", finish_reason=FinishReason.FINAL)) + messages = [{"role": "system", "content": "x" * 10000}, {"role": "user", "content": "Current work"}] + answer, turn, _state = self.run_native(provider, messages, window=300) + self.assertEqual(provider.requests, []) + self.assertEqual(turn.runtime_status, "incomplete") + self.assertIn("ContextTooLargeError", answer) + + def test_overflow_retries_only_generation_after_an_effect_without_replaying_the_write(self): + def respond(provider, messages, model): + if len(provider.requests) == 1: + return ModelResponse(tool_calls=(ToolCall("write-once", "write_file", {"path": "result.txt", "content": "once"}),), + finish_reason=FinishReason.TOOL_REQUEST, + continuation=provider_continuation(provider, model, "fixture", {"id": "write"})) + if len(provider.requests) == 2: + raise ProviderError(ProviderErrorKind.CONTEXT_OVERFLOW) + validate_tool_history(messages, provider_id=provider_identity(provider), model=model, native=True) + return ModelResponse(text="The file was written once and its result retained.", finish_reason=FinishReason.FINAL) + provider = self.provider(respond) + original = self.runtime.tool_registry.get_spec("write_file") + effects = [] + def write(arguments): + effects.append(dict(arguments)) + return original.executor(arguments) + self.runtime.tool_registry.register_spec(original.replace(executor=write), replace=True) + messages = self.history() + [{"role": "user", "content": "Write result.txt once"}] + answer, turn, state = self.run_native(provider, messages) + self.assertEqual(len(provider.requests), 3, answer) + self.assertEqual(len(effects), 1) + self.assertEqual((self.root / "result.txt").read_text(), "once") + self.assertTrue(state.overflow_retried) + self.assertTrue(any(isinstance(item, ToolReply) and item.call_id == "write-once" for item in provider.requests[-1])) + self.assertEqual(turn.runtime_status, "completed") + + def test_a_second_native_overflow_stops_without_further_generation(self): + def respond(*_args): + raise ProviderError(ProviderErrorKind.CONTEXT_OVERFLOW) + provider = self.provider(respond) + answer, turn, state = self.run_native(provider, self.history() + [{"role": "user", "content": "Current work"}]) + self.assertEqual(len(provider.requests), 2, answer) + self.assertTrue(state.overflow_retried) + self.assertEqual(turn.runtime_status, "incomplete") + + def test_overflow_after_a_stream_delta_is_never_replayed(self): + def stream(*_args): + yield TextDelta("Already delivered partial text.") + yield ResponseError(ProviderError(ProviderErrorKind.CONTEXT_OVERFLOW)) + provider = self.provider(stream, stream=True) + _answer, turn, state = self.run_native(provider, self.history() + [{"role": "user", "content": "Current work"}]) + self.assertEqual(len(provider.requests), 1) + self.assertFalse(state.overflow_retried) + self.assertEqual(turn.runtime_status, "incomplete") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_controls.py b/tests/test_native_controls.py new file mode 100644 index 00000000..77966c5d --- /dev/null +++ b/tests/test_native_controls.py @@ -0,0 +1,128 @@ +import copy +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.tools import ToolAdapters +from openkyrozen.tools.models import tool_success + + +class NativeControlTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) / "workspace" + self.root.mkdir() + environment = patch.dict(os.environ, {"HOME": directory.name, + "KYROZEN_WORKSPACE_ROOT": str(self.root), + "KYROZEN_DB_PATH": str(self.root / "state.sqlite3"), + "KYROZEN_SKILLS_PATH": str(self.root / "skills"), + "KYROZEN_DISABLE_VECTOR_INDEX": "1"}) + environment.start() + self.addCleanup(environment.stop) + self.app = build_application(memory=build_memory(self.root / "state.sqlite3", index=None), + tools=ToolAdapters(self.root)) + self.addCleanup(self.app.close) + self.runtime = self.app.runtime + self.runtime.set_interaction_mode("agent") + self.runtime.execution_context.native_control_input = "Revise the approved page outcome" + self.runtime.execution_context.native_plan_inspected = True + self.proposal = {"title": "Page", "summary": "Create one approved page", "bounds": { + "workspace_paths": ["page.txt"], "capabilities": ["read", "write"], + "effects": ["read_only", "filesystem"], "risk_ceiling": "normal", + }, "steps": [{"id": "page", "title": "Page", "description": "Write page.txt", "acceptance": [ + {"criterion": "Page contains approved content", "verification": { + "action": "read_file", "arguments": {"path": "page.txt"}, "expect": {"equals": "approved"}, + }}]}]} + + def accept(self): + controller = self.runtime._interaction_controller + pending = controller.propose_plan(self.proposal) + return controller.accept_plan(self.runtime.tasks, plan_id=pending["plan_id"], version=1)[0] + + def execute_control(self, name, arguments): + return self.runtime._execute_turn_action(name, arguments, operation_scope="native-control-test", + successful_operations=set(), call_id="control", call_id_source="native") + + def test_native_material_proposal_preserves_bounds_and_pauses_mutations(self): + accepted = self.accept() + revised = copy.deepcopy(self.proposal) + revised["summary"] = "Create a different page outcome" + revised["bounds"]["workspace_paths"] = ["page.txt", "additional.txt"] + receipt = self.execute_control("propose_plan", revised) + self.assertTrue(receipt.success, receipt.result) + state = self.runtime._interaction_controller.state() + self.assertEqual(state["pending_plan"]["version"], 2) + self.assertEqual(state["executing_plan"]["bounds"], accepted["bounds"]) + self.assertEqual(state["pending_plan"]["original_input"], "Revise the approved page outcome") + self.assertIsNone(receipt.task_id) + self.assertEqual(self.runtime.tasks.tasks[0]["evidence"], []) + write = self.runtime._execute_turn_action("write_file", {"path": "page.txt", "content": "unapproved"}, + operation_scope="native-control-test", successful_operations=set(), call_id="blocked-write", call_id_source="native") + self.assertFalse(write.success) + self.assertFalse((self.root / "page.txt").exists()) + + def test_nonmaterial_narrowing_updates_scope_without_requesting_new_approval(self): + self.proposal["bounds"]["workspace_paths"] = ["."] + accepted = self.accept() + revised = copy.deepcopy(self.proposal) + revised["bounds"]["workspace_paths"] = ["page.txt"] + receipt = self.execute_control("propose_plan", revised) + self.assertTrue(receipt.success, receipt.result) + self.assertIs(receipt.result_data["requires_approval"], False) + self.assertIn("within approved scope", receipt.result) + state = self.runtime._interaction_controller.state() + self.assertIsNone(state["pending_plan"]) + self.assertEqual(state["executing_plan"]["version"], accepted["version"] + 1) + self.assertEqual(state["executing_plan"]["bounds"]["workspace_paths"], ["page.txt"]) + + def test_dynamic_control_replacement_cannot_inherit_accepted_scope_exemption(self): + self.accept() + called = [] + registry = self.runtime.tool_registry + original = registry.get_spec("propose_plan") + def replacement(arguments): + called.append(arguments) + return tool_success("Replacement ran") + registry.register_spec(original.replace(executor=replacement), replace=True) + receipt = self.execute_control("propose_plan", self.proposal) + self.assertFalse(receipt.success, receipt.result) + self.assertEqual(called, []) + + def test_questions_preserve_pending_ui_and_cannot_request_secrets_or_approval(self): + valid = {"questions": [{"id": "format", "header": "Format", "prompt": "Which page format?", "choices": [ + {"id": "plain", "label": "Plain"}, {"id": "rich", "label": "Rich"}, + ]}]} + for prompt in ("What is your API key?", "May I execute this command?"): + request = copy.deepcopy(valid) + request["questions"][0]["prompt"] = prompt + receipt = self.execute_control("request_question", request) + self.assertFalse(receipt.success) + self.assertIsNone(self.runtime._interaction_controller.state()["pending_question"]) + receipt = self.execute_control("request_question", valid) + self.assertTrue(receipt.success, receipt.result) + pending = self.runtime._interaction_controller.state()["pending_question"] + self.assertEqual(pending["questions"][0]["id"], "format") + self.assertEqual(pending["original_input"], "Revise the approved page outcome") + self.assertIsNone(receipt.task_id) + + def test_child_and_noninteractive_surfaces_cannot_stage_controls(self): + accepted = self.accept() + request = {"questions": [{"id": "format", "header": "Format", "prompt": "Which format?", "choices": [ + {"id": "a", "label": "A"}, {"id": "b", "label": "B"}, + ]}]} + for child, interactive in (("child", True), (None, False)): + self.runtime.execution_context.child_run_id = child + token = self.runtime._interaction_controls_enabled.set(interactive) + try: + self.assertFalse(self.execute_control("propose_plan", self.proposal).success) + self.assertFalse(self.execute_control("request_question", request).success) + finally: + self.runtime._interaction_controls_enabled.reset(token) + state = self.runtime._interaction_controller.state() + self.assertIsNone(state["pending_question"]) + self.assertIsNone(state["pending_plan"]) + self.assertEqual(state["executing_plan"], accepted) diff --git a/tests/test_planning_service.py b/tests/test_planning_service.py new file mode 100644 index 00000000..30c372af --- /dev/null +++ b/tests/test_planning_service.py @@ -0,0 +1,80 @@ +from contextlib import ExitStack, closing +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + + +class PlanningServiceOwnershipTests(unittest.TestCase): + def test_tasks_service_owns_strategic_lifecycle_without_loading_agent(self): + code = ''' + import importlib.abc + import json + import sys + from pathlib import Path + class NoAgent(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'openkyrozen.agent' or fullname.startswith('openkyrozen.agent.'): + raise AssertionError('Planning feature imported agent implementation: ' + fullname) + sys.meta_path.insert(0, NoAgent()) + from openkyrozen.tasks.planning import PlanningService, InteractionError + from openkyrozen.tasks.engine import TaskManager + from openkyrozen.persistence.store import EventStore + store = EventStore(Path(sys.argv[1]) / 'state.sqlite3') + planner = PlanningService(store, user_id='alice', workspace_id='project', session_id='work') + manager = TaskManager(store, user_id='alice', workspace_id='project', session_id='work') + proposal = planner.propose_plan({ + 'title': 'Inspect', 'summary': 'Inspect exact source', + 'bounds': {'workspace_paths': ['note.txt'], 'capabilities': ['read'], + 'effects': ['read_only'], 'risk_ceiling': 'normal'}, + 'steps': [{'id': 'inspect', 'title': 'Inspect', 'description': 'Read note.txt', + 'acceptance': [{'criterion': 'Exact source checked', 'verification': { + 'action': 'read_file', 'arguments': {'path': 'note.txt'}, 'expect': {'equals': 'known'}}}]}], + }) + accepted, created = planner.accept_plan(manager, plan_id=proposal['plan_id'], + version=proposal['version'], fingerprint=proposal['fingerprint']) + assert created and planner.state()['executing_plan']['plan_id'] == accepted['plan_id'] + try: + planner.complete_plan(accepted, task_manager=manager) + except InteractionError: + pass + else: + raise AssertionError('Unverified completion was accepted') + manager.record_evidence(task_id=accepted['working_task_ids'][0], action='read_file', + args=json.dumps({'path': 'note.txt'}, sort_keys=True, separators=(',', ':')), + result='known', verification_result='known', success=True, + verification_status='not_applicable', receipt_id='verified-source') + assert manager.set_status(0, 'succeeded') + assert manager.tasks[0]['status'] == 'succeeded', manager.tasks + planner.reconcile_plan_completion(manager) + assert planner.state()['executing_plan'] is None + assert not any(name == 'openkyrozen.agent' or name.startswith('openkyrozen.agent.') for name in sys.modules) + ''' + with tempfile.TemporaryDirectory() as directory: + env = {**os.environ, 'HOME': directory, 'KYROZEN_DB_PATH': str(Path(directory) / 'isolated.sqlite3'), + 'KYROZEN_DISABLE_VECTOR_INDEX': '1'} + result = subprocess.run([sys.executable, '-c', textwrap.dedent(code), directory], + cwd=Path(__file__).resolve().parents[1], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_legacy_interaction_imports_preserve_error_and_validator_identity(self): + from openkyrozen.tasks.planning import PlanningService, InteractionError, validate_plan_proposal + from openkyrozen.agent import modes + self.assertIs(modes.InteractionError, InteractionError) + self.assertIs(modes.validate_plan_proposal, validate_plan_proposal) + self.assertTrue(issubclass(modes.InteractionController, PlanningService)) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + from openkyrozen.persistence.store import EventStore + controller = modes.InteractionController(sqlite_owners.enter_context(closing(EventStore(Path(directory) / 'state.sqlite3'))), session_id='ui') + controller.set_mode('ask') + proposal = controller.propose_plan({'title': 'Inspect', 'summary': 'Inspect source', + 'steps': [{'id': 'inspect', 'title': 'Inspect', 'description': 'Read source', + 'acceptance': ['Source checked']}]}) + state = controller.state() + self.assertEqual(state['preference_mode'], 'ask') + self.assertEqual(state['pending_plan']['fingerprint'], proposal['fingerprint']) + controller.cancel_plan(proposal['plan_id'], proposal['version']) + self.assertIsNone(controller.state()['pending_plan']) diff --git a/tests/test_process_audit.py b/tests/test_process_audit.py new file mode 100644 index 00000000..08963aaa --- /dev/null +++ b/tests/test_process_audit.py @@ -0,0 +1,554 @@ +import json +import os +from pathlib import Path +import shlex +import sqlite3 +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.tools import ToolAdapters +from openkyrozen.tools.processes import SessionProcessManager +from openkyrozen.tasks.engine import TaskManager + + +class ProcessAuditTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.environment = patch.dict(os.environ, { + 'HOME': str(self.root), 'KYROZEN_WORKSPACE_ROOT': str(self.root), + 'KYROZEN_DB_PATH': str(self.root / 'state.sqlite3'), + 'KYROZEN_DISABLE_VECTOR_INDEX': '1', + }) + self.environment.start() + self.addCleanup(self.environment.stop) + self.app = build_application(memory=build_memory(self.root / 'state.sqlite3', index=None), + tools=ToolAdapters(self.root)) + self.addCleanup(self.app.close) + self.runtime = self.app.runtime + self.session = self.runtime.current_session + self.manager = self.session.process_manager + self.store = self.session.memory.store + self.scope = dict(user_id=self.session.memory.user_id, + workspace_id=self.session.memory.workspace_id, + session_id=self.session.session_id) + self.command = shlex.join([sys.executable, '-c', 'import time; time.sleep(30)']) + + def events(self): + return self.store.list_events('process.lifecycle', limit=None, **self.scope) + + def wait_status(self, manager, process_id, status): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + record = next((r for r in manager._audit_records() if r['process_id'] == process_id), None) + if record and record['status'] == status: + return record + time.sleep(.01) + self.fail(f'Process audit did not reach {status}') + + def reopened(self, **changes): + scope = {**self.scope, **changes} + manager = SessionProcessManager(scope['user_id'], str(self.root), scope['session_id']) + from openkyrozen.persistence.store import EventStore + store = EventStore(self.store.path) + self.addCleanup(store.close) + manager._bind_audit(store, scope['workspace_id']) + return manager + + def test_real_session_launch_cancellation_and_restart_preserve_scoped_uncertainty(self): + cancelled = threading.Event() + handle = self.manager.start(self.command, cwd=str(self.root), env=None, + timeout_seconds=30, cancelled=cancelled) + self.assertEqual({event['payload']['status'] for event in self.events()}, {'launch_intent', 'started'}) + recovered = self.reopened() + pending = recovered._audit_records()[0] + self.assertEqual(pending['process_id'], handle.process_id) + self.assertEqual(pending['owned_process_state'], 'unknown') + self.assertEqual(pending['descendant_state'], 'unknown') + self.assertTrue(pending['potentially_active']) + for change in ({'user_id': 'other'}, {'workspace_id': 'other'}, {'session_id': 'other'}): + self.assertEqual(self.reopened(**change)._audit_records(), []) + cancelled.set() + record = self.wait_status(recovered, handle.process_id, 'cancelled') + self.assertEqual(record['reason'], 'cancelled') + self.assertEqual(record['owned_process_state'], 'exited') + self.assertEqual(record['descendant_state'], 'unknown') + self.assertIsNotNone(handle.process.poll()) + + def test_two_project_roots_with_same_global_memory_and_session_cannot_read_each_others_audit(self): + first_root, second_root = self.root / 'first', self.root / 'second' + first_root.mkdir() + second_root.mkdir() + self.runtime.configure_launch_context(project_path=first_root) + first = self.runtime.open_session('shared-session') + first.process_manager.start(self.command, cwd=str(first_root), env=None, timeout_seconds=30) + self.runtime.configure_launch_context(project_path=second_root) + second = self.runtime.open_session('shared-session') + self.assertEqual(first.memory.workspace_id, second.memory.workspace_id) + self.assertEqual(first.memory.user_id, second.memory.user_id) + self.assertEqual(first.session_id, second.session_id) + self.assertNotEqual(first.tasks.workspace_id, second.tasks.workspace_id) + self.assertEqual(second.process_manager._audit_records(), []) + self.assertEqual(len(first.process_manager._audit_records()), 1) + + def test_actual_restarted_native_turn_gets_only_own_bounded_unknown_process_context(self): + from openkyrozen.workspace.context import source_scope_id + project = self.root / 'restarted-project' + project.mkdir() + workspace_id, session_id = source_scope_id(project), 'restarted-chat' + own_scope = dict(user_id=self.session.memory.user_id, workspace_id=workspace_id, session_id=session_id) + for index in range(7): + self.store.append_event('process.lifecycle', { + 'process_id': f'own-unknown-{index}', 'manager_id': 'previous', 'status': 'started', + 'pid': os.getpid(), 'owned_process_state': 'running', 'descendant_state': 'unknown', + 'command': 'SECRET_COMMAND_NOT_CONTEXT', 'env': {'TOKEN': 'SECRET_ENV_NOT_CONTEXT'}, + }, **own_scope) + for field, value in (('user_id', 'other-user'), ('workspace_id', 'other-project'), ('session_id', 'other-chat')): + self.store.append_event('process.lifecycle', {'process_id': f'foreign-{field}', + 'manager_id': 'previous', 'status': 'started', 'owned_process_state': 'unknown'}, + **{**own_scope, field: value}) + self.store.append_event('process.lifecycle', {'process_id': 'known-cancelled', 'manager_id': 'previous', + 'status': 'cancelled', 'reason': 'cancelled', 'owned_process_state': 'exited', + 'descendant_state': 'unknown'}, **own_scope) + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason + observed = [] + class InspectProvider(LLMProvider): + def chat(self, messages, model=None): + raise AssertionError('legacy path used') + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat_response(self, messages, model=None, *, tools=None): + observed.append(json.dumps(messages, default=str)) + return ModelResponse(text='Unknown previous work needs inspection.', finish_reason=FinishReason.FINAL) + # New application and session reopen the same actual SQLite journal. + fresh = build_application(memory=build_memory(self.root / 'state.sqlite3', index=None), tools=ToolAdapters(project)) + self.addCleanup(fresh.close) + runtime = fresh.runtime + runtime.configure_launch_context(project_path=project) + session = runtime.open_session(session_id) + config = ProviderConfig(provider='custom', model_simple='fixture', model_complex='fixture') + runtime.llm_provider = runtime._default_turn.provider = InspectProvider(config) + runtime._provider_config = config + session.interaction.set_mode('agent') + with patch('openkyrozen.tools.processes.os.kill', side_effect=AssertionError('historical PID authority')), patch( + 'openkyrozen.tools.processes.os.killpg', side_effect=AssertionError('historical PID authority')), patch.object( + runtime, '_schedule_post_turn_learning'): + answer = runtime.chat(session, 'Hello') + self.assertIn('needs inspection', answer) + text = observed[0] + self.assertIn('Prior process outcomes are unknown', text) + self.assertIn('total=7', text) + self.assertIn('omitted=2', text) + self.assertEqual(text.count('own-unknown-'), 5) + for forbidden in ('foreign-', 'known-cancelled', 'SECRET_COMMAND_NOT_CONTEXT', 'SECRET_ENV_NOT_CONTEXT'): + self.assertNotIn(forbidden, text) + self.assertIn('not authority', text) + + def test_audit_read_failure_retains_explicit_unknown_context(self): + manager = SessionProcessManager(self.scope['user_id'], str(self.root), self.scope['session_id']) + with patch.object(self.store, 'list_events', side_effect=sqlite3.OperationalError('SECRET_READ_ERROR')): + manager._bind_audit(self.store, self.scope['workspace_id']) + context = manager._recovery_context() + self.assertIn('audit unavailable', context) + self.assertIn('unknown', context) + self.assertNotIn('SECRET_READ_ERROR', context) + + def test_intent_failure_prevents_any_process_launch(self): + original = self.store._append_control_audit + def fail_intent(kind, payload, **scope): + if kind == 'process.lifecycle': + raise sqlite3.OperationalError('audit unavailable') + return original(kind, payload, **scope) + with patch.object(self.store, '_append_control_audit', side_effect=fail_intent), patch( + 'openkyrozen.tools.processes.subprocess.Popen', side_effect=AssertionError('untracked launch')): + with self.assertRaisesRegex(RuntimeError, 'audit'): + self.manager.start(self.command, cwd=str(self.root), env=None, timeout_seconds=30) + + def test_started_record_failure_stops_only_new_owned_process_and_leaves_durable_intent(self): + original = self.store._append_control_audit + spawned = [] + import subprocess + popen = subprocess.Popen + def capture(*args, **kwargs): + child = popen(*args, **kwargs) + if kwargs.get('start_new_session') or kwargs.get('creationflags'): + spawned.append(child) + return child + def fail_started(kind, payload, **scope): + if kind == 'process.lifecycle' and payload['status'] == 'started': + raise sqlite3.OperationalError('SECRET_DATABASE_ERROR') + return original(kind, payload, **scope) + with patch.object(self.store, '_append_control_audit', side_effect=fail_started), patch( + 'openkyrozen.tools.processes.subprocess.Popen', side_effect=capture): + with self.assertRaisesRegex(RuntimeError, 'audit'): + self.manager.start(self.command, cwd=str(self.root), + env={**os.environ, 'PRIVATE_ENV': 'SECRET_ENVIRONMENT'}, timeout_seconds=30) + self.assertEqual(len(spawned), 1) + self.assertIsNotNone(spawned[0].poll()) + text = json.dumps(self.events()) + for secret in ('SECRET_DATABASE_ERROR', 'SECRET_ENVIRONMENT', self.command): + self.assertNotIn(secret, text) + self.assertIn('launch_intent', text) + + def test_held_sqlite_writer_cannot_delay_owned_cancellation_past_existing_deadline(self): + cancelled, auditing, expired = threading.Event(), threading.Event(), threading.Event() + original_expire = self.manager._expire + def expire(*args, **kwargs): + try: + return original_expire(*args, **kwargs) + finally: + expired.set() + with patch.object(self.manager, '_expire', side_effect=expire): + handle = self.manager.start(self.command, cwd=str(self.root), env=None, + timeout_seconds=30, cancelled=cancelled) + original = self.manager._audit_event + def observe(status, *args, **kwargs): + if status == 'stop_requested': + auditing.set() + return original(status, *args, **kwargs) + with self.store.database.connection() as writer, patch.object( + self.manager, '_audit_event', side_effect=observe): + writer.execute('BEGIN IMMEDIATE') + try: + cancelled.set() + self.assertTrue(auditing.wait(2)) + handle.process.wait(timeout=3) + deadline = time.monotonic() + 3 + while not self.manager.read(handle.process_id)['cancelled'] and time.monotonic() < deadline: + time.sleep(.01) + self.assertTrue(self.manager.read(handle.process_id)['cancelled']) + self.assertTrue(handle.audit_error) + # Keep the writer held through the expiry's final audit attempt. + self.assertTrue(expired.wait(3)) + finally: + writer.rollback() + record = self.reopened()._audit_records()[0] + self.assertEqual(record['owned_process_state'], 'unknown') + self.assertTrue(record['potentially_active']) + + def test_held_database_thread_lock_fails_intent_without_launch(self): + settled, errors = threading.Event(), [] + def start(): + try: + self.manager.start(self.command, cwd=str(self.root), env=None, timeout_seconds=30) + except RuntimeError as error: + errors.append(error) + finally: + settled.set() + self.store.database._lock.acquire() + worker = threading.Thread(target=start) + worker.start() + try: + self.assertTrue(settled.wait(1), 'audit lock blocked launch intent indefinitely') + finally: + self.store.database._lock.release() + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertEqual(len(errors), 1) + self.assertIn('audit', str(errors[0])) + self.assertEqual(self.manager._handles, {}) + + def test_lost_journal_after_intent_remains_unknown_despite_owned_cleanup(self): + original = self.store._append_control_audit + def fail_after_intent(kind, payload, **scope): + if kind == 'process.lifecycle' and payload['status'] != 'launch_intent': + raise TypeError('SECRET_SERIALIZATION_DETAIL') + return original(kind, payload, **scope) + with patch.object(self.store, '_append_control_audit', side_effect=fail_after_intent): + with self.assertRaisesRegex(RuntimeError, 'audit'): + self.manager.start(self.command, cwd=str(self.root), env=None, timeout_seconds=30) + handle = next(iter(self.manager._handles.values())) + self.assertIsNotNone(handle.process.poll()) + recovered = self.reopened()._audit_records()[0] + self.assertEqual(recovered['status'], 'launch_intent') + self.assertEqual(recovered['owned_process_state'], 'unknown') + self.assertTrue(recovered['potentially_active']) + self.assertNotIn('SECRET_SERIALIZATION_DETAIL', json.dumps(self.events())) + + def test_natural_exit_and_timeout_are_distinct_owned_observations_not_descendant_cleanup(self): + command = shlex.join([sys.executable, '-c', 'pass']) + natural = self.manager.start(command, cwd=str(self.root), env=None, timeout_seconds=30) + exited = self.wait_status(self.reopened(), natural.process_id, 'exited') + self.assertEqual(exited['exit_code'], 0) + timed = self.manager.start(self.command, cwd=str(self.root), env=None, timeout_seconds=.05) + timeout = self.wait_status(self.reopened(), timed.process_id, 'timed_out') + self.assertEqual(timeout['reason'], 'timed_out') + for record in (exited, timeout): + self.assertEqual(record['owned_process_state'], 'exited') + self.assertEqual(record['descendant_state'], 'unknown') + self.assertTrue(record['potentially_active']) + + def test_cancel_stop_failure_is_durable_unknown_with_cancellation_reason(self): + if os.name == 'nt': + self.skipTest('POSIX signal fault injection') + cancelled = threading.Event() + handle = self.manager.start(self.command, cwd=str(self.root), env=None, + timeout_seconds=30, cancelled=cancelled) + with patch('openkyrozen.tools.processes.os.killpg', side_effect=PermissionError('blocked')): + cancelled.set() + record = self.wait_status(self.reopened(), handle.process_id, 'termination_failed') + self.assertEqual(record['reason'], 'cancelled') + self.assertEqual(record['owned_process_state'], 'unknown') + self.assertTrue(record['potentially_active']) + self.assertIsNone(handle.process.poll()) + temporary = handle.temporary_directory + self.assertTrue(self.manager.stop(handle.process_id)) + if temporary is not None: + self.assertFalse(Path(temporary.name).exists()) + + def test_close_waits_for_owned_expiry_finalization_before_journal_shutdown(self): + finalizing, release, expired, closing, closed = (threading.Event() for _ in range(5)) + original_expire = self.manager._expire + def expire(handle, cancelled=None): + try: + original_expire(handle, cancelled) + finalizing.set() + if not release.wait(3): + raise AssertionError('expiry finalization was not released') + self.manager._audit_event('exited', handle.process_id, handle=handle, owned_process_state='exited') + finally: + expired.set() + with patch.object(self.manager, '_expire', side_effect=expire): + handle = self.manager.start(shlex.join([sys.executable, '-c', 'pass']), + cwd=str(self.root), env=None, timeout_seconds=30) + self.assertTrue(finalizing.wait(3)) + def close(): + closing.set() + self.manager.close() + closed.set() + worker = threading.Thread(target=close) + worker.start() + try: + self.assertTrue(closing.wait(1)) + self.assertFalse(closed.wait(.1), 'close returned before owned audit finalization') + finally: + release.set() + worker.join(3) + self.assertTrue(expired.wait(3)) + self.assertTrue(closed.is_set()) + self.assertIsNotNone(handle.process.poll()) + + def test_failed_stop_close_disables_a_late_expiry_journal_write(self): + if os.name == 'nt': + self.skipTest('POSIX signal fault injection') + cancelled, finalizing, release, expired = (threading.Event() for _ in range(4)) + original_expire = self.manager._expire + def expire(handle, token=None): + try: + original_expire(handle, token) + finalizing.set() + if not release.wait(3): + raise AssertionError('late expiry was not released') + self.manager._audit_outcome(handle) + finally: + expired.set() + with patch.object(self.manager, '_expire', side_effect=expire): + handle = self.manager.start(self.command, cwd=str(self.root), env=None, + timeout_seconds=30, cancelled=cancelled) + try: + with patch('openkyrozen.tools.processes.os.killpg', side_effect=PermissionError('blocked')): + cancelled.set() + self.assertTrue(finalizing.wait(3)) + self.manager.close() + before = len(self.events()) + self.assertEqual(self.reopened()._audit_records()[0]['owned_process_state'], 'unknown') + release.set() + self.assertTrue(expired.wait(3)) + self.assertEqual(len(self.events()), before) + self.assertTrue(handle.audit_error) + finally: + release.set() + self.manager.stop(handle.process_id) + self.assertTrue(expired.wait(3)) + + def test_restart_never_uses_historical_pid_as_kill_authority(self): + self.store.append_event('process.lifecycle', { + 'process_id': 'old', 'manager_id': 'previous-instance', 'status': 'started', + 'pid': os.getpid(), 'owned_process_state': 'running', 'descendant_state': 'unknown', + }, **self.scope) + with patch('openkyrozen.tools.processes.os.kill', side_effect=AssertionError('historical PID kill')), patch( + 'openkyrozen.tools.processes.os.killpg', side_effect=AssertionError('historical group kill')): + recovered = self.reopened() + self.assertEqual(recovered._audit_records()[0]['owned_process_state'], 'unknown') + recovered.close() + self.assertEqual(os.getpid(), self.store.list_events('process.lifecycle', **self.scope)[0]['payload']['pid']) + + def _accepted_native_cancel(self, hold): + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason + from openkyrozen.providers.errors import ProviderErrorKind + controller, tasks = self.runtime._interaction_controller, self.runtime.tasks + proposal = controller.propose_plan({'title': 'Inspect', 'summary': 'Inspect source', + 'bounds': {'workspace_paths': ['.'], 'capabilities': ['read', 'write'], + 'effects': ['read_only', 'filesystem', 'internal'], 'risk_ceiling': 'normal'}, + 'steps': [{'id': 'inspect', 'title': 'Inspect', 'description': 'Inspect marker.txt', + 'acceptance': ['Source verified']}]}) + controller.accept_plan(tasks, plan_id=proposal['plan_id'], version=proposal['version']) + self.assertTrue(tasks.set_status(0, 'running')) + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + errors, events = [], [] + class CancelProvider(LLMProvider): + def chat(self, messages, model=None): + raise AssertionError('legacy path used') + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat_response(self, messages, model=None, *, tools=None): + entered.set() + if not release.wait(5): + raise AssertionError('native provider was not released') + return ModelResponse(finish_reason=FinishReason.CANCELLED) + config = ProviderConfig(provider='custom', model_simple='fixture', model_complex='fixture') + self.runtime.llm_provider = self.runtime._default_turn.provider = CancelProvider(config) + self.runtime._provider_config = config + def chat(): + try: + self.runtime.chat(self.session, 'Continue accepted inspection', on_runtime_event=events.append) + except Exception as error: + errors.append(error) + finally: + finished.set() + worker = threading.Thread(target=chat) + worker.start() + try: + self.assertTrue(entered.wait(5)) + if hold == 'sqlite': + with self.store.database.connection() as writer: + writer.execute('BEGIN IMMEDIATE') + try: + release.set() + self.assertTrue(finished.wait(3), 'SQLite contention delayed cancellation') + finally: + writer.rollback() + elif hold == 'thread-lock': + self.store.database._lock.acquire() + try: + release.set() + self.assertTrue(finished.wait(3), 'thread-lock contention delayed cancellation') + finally: + self.store.database._lock.release() + else: + release.set() + self.assertTrue(finished.wait(3)) + finally: + release.set() + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertEqual(len(errors), 1) + self.assertEqual(getattr(errors[0], 'kind', None), ProviderErrorKind.CANCELLED) + self.assertEqual(events[-1].kind, 'turn.cancelled') + if hold: + self.assertTrue(any(event.kind == 'turn.audit_failed' for event in events)) + self.assertTrue(any(event.kind == 'turn.effect_uncertain' and event.payload['durable'] is False + for event in events)) + # No uncommitted in-memory cancellation may pretend the running task was saved. + self.assertEqual(tasks.tasks[0]['status'], 'running') + recovered = TaskManager(self.store, user_id=tasks.user_id, workspace_id=tasks.workspace_id, + session_id=tasks.session_id) + recovered.recover() + self.assertEqual(recovered.tasks[0]['status'], 'blocked') + self.assertEqual(recovered.tasks[0]['checkpoint']['recovery_effect']['status'], 'unknown') + else: + self.assertEqual(tasks.tasks[0]['status'], 'cancelled') + entries = self.runtime.conversation_history.active_entries() + self.assertTrue(any(entry.kind == 'turn.outcome' and entry.payload['status'] == 'cancelled' + for entry in entries)) + audit = self.store.list_events('turn.effect_uncertain', user_id=tasks.user_id, + workspace_id=tasks.workspace_id, session_id=tasks.session_id) + self.assertTrue(audit[0]['payload']['durable']) + + def test_accepted_native_cancel_remains_primary_with_real_sqlite_writer(self): + self._accepted_native_cancel('sqlite') + + def test_accepted_native_cancel_remains_primary_with_real_database_thread_lock(self): + self._accepted_native_cancel('thread-lock') + + def test_cancellation_deadline_leaves_normal_other_thread_and_other_database_waits_unchanged(self): + from openkyrozen.persistence.store import EventStore + other = EventStore(self.root / 'other.sqlite3') + self.addCleanup(other.close) + observed = [] + def observe_other_thread(): + with self.store.database.connection() as db: + observed.append(db.execute('PRAGMA busy_timeout').fetchone()[0]) + with self.store._cancellation_deadline(): + with self.store.database.connection() as db: + self.assertGreater(db.execute('PRAGMA busy_timeout').fetchone()[0], 0) + self.assertLessEqual(db.execute('PRAGMA busy_timeout').fetchone()[0], 300) + with other.database.connection() as db: + self.assertEqual(db.execute('PRAGMA busy_timeout').fetchone()[0], 30000) + worker = threading.Thread(target=observe_other_thread) + worker.start() + worker.join(1) + self.assertFalse(worker.is_alive()) + self.assertEqual(observed, [30000]) + # Ordinary RLock recursion remains valid inside an unexpired deadline. + with self.store.database._lock: + self.assertTrue(self.store.database._lock.acquire(blocking=False)) + self.store.database._lock.release() + with self.store.database.connection() as db: + self.assertEqual(db.execute('PRAGMA busy_timeout').fetchone()[0], 30000) + + def test_cancellation_deadline_keeps_canonical_history_compare_and_swap(self): + from openkyrozen.agent.conversation import ConversationHistoryError + history = self.runtime.conversation_history + first = history.entry('user.input', {'text': 'first'}, parent_id=None) + history.append(first, expected_head=None) + stale = history.entry('turn.outcome', {'status': 'cancelled'}, parent_id=None) + with self.store._cancellation_deadline(): + with self.assertRaises(ConversationHistoryError): + history.append(stale, expected_head=None) + self.assertEqual(history.head, first.id) + + def test_uncontended_native_cancel_preserves_task_cancellation_and_canonical_history(self): + self._accepted_native_cancel(None) + + def test_native_cancellation_uncertainty_survives_restart_and_db_error_keeps_primary_cancel(self): + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + class CancelProvider(LLMProvider): + def chat(self, messages, model=None): + raise AssertionError('legacy provider path used') + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat_response(self, messages, model=None, *, tools=None): + return ModelResponse(finish_reason=FinishReason.CANCELLED) + project = self.root / 'native-project' + project.mkdir() + self.runtime.configure_launch_context(project_path=project) + self.session = self.runtime.open_session('native-cancel-session') + self.scope = dict(user_id=self.session.memory.user_id, workspace_id=self.session.tasks.workspace_id, + session_id=self.session.session_id) + config = ProviderConfig(provider='custom', model_simple='fixture', model_complex='fixture') + self.runtime.llm_provider = self.runtime._default_turn.provider = CancelProvider(config) + self.runtime._provider_config = config + self.runtime.set_interaction_mode('agent') + with self.assertRaises(ProviderError) as error: + self.runtime.chat(self.session, 'Hello') + self.assertEqual(error.exception.kind, ProviderErrorKind.CANCELLED) + uncertain = self.store.list_events('turn.effect_uncertain', **self.scope) + self.assertEqual(len(uncertain), 1) + self.assertEqual(uncertain[0]['payload']['reason'], 'cancelled_during_execution') + original = self.store._append_control_audit + def unavailable(kind, payload, **scope): + if kind == 'turn.effect_uncertain': + raise sqlite3.OperationalError('SECRET_ERROR') + return original(kind, payload, **scope) + events = [] + with patch.object(self.store, '_append_control_audit', side_effect=unavailable): + with self.assertRaises(ProviderError) as error: + self.runtime.chat(self.session, 'Hello again', on_runtime_event=events.append) + self.assertEqual(error.exception.kind, ProviderErrorKind.CANCELLED) + self.assertTrue(any(event.kind == 'turn.audit_failed' for event in events)) + self.assertTrue(any(event.kind == 'turn.effect_uncertain' and event.payload['durable'] is False + for event in events)) diff --git a/tests/test_process_lifecycle.py b/tests/test_process_lifecycle.py index a00d6744..bafda3ad 100644 --- a/tests/test_process_lifecycle.py +++ b/tests/test_process_lifecycle.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import sys import time import unittest @@ -7,6 +8,46 @@ class ProcessLifecycleTests(unittest.TestCase): + def test_application_close_releases_its_memory_index(self): + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application + from openkyrozen.persistence.store import EventStore + from openkyrozen.memory.service import MemoryBank + + class Index: + memories = object() + files = object() + closed = False + + def close(self): + self.closed = True + + with tempfile.TemporaryDirectory() as root, patch.dict('os.environ', {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), ExitStack() as sqlite_owners: + path = Path(root) / 'state.sqlite3' + index = Index() + memory = sqlite_owners.enter_context(closing(MemoryBank(path, store=sqlite_owners.enter_context(closing(EventStore(path))), index=index))) + app = build_application(memory=memory) + app.close() + self.assertTrue(index.closed) + + def test_unowned_chroma_index_releases_its_client(self): + import gc + from openkyrozen.memory.vector import ChromaIndex + + class Client: + closed = False + + def close(self): + self.closed = True + + index = ChromaIndex.__new__(ChromaIndex) + index.client = Client() + client = index.client + del index + gc.collect() + self.assertTrue(client.closed) + def test_foreground_shell_cancellation_terminates_process_group(self): from openkyrozen.tools import ToolAdapters with tempfile.TemporaryDirectory() as root: @@ -31,10 +72,10 @@ def test_runtime_exposes_session_process_tools_and_application_close_stops_proce from openkyrozen.tools import ToolAdapters from openkyrozen.tools.models import CommandResult, invoke_tool_result - with tempfile.TemporaryDirectory() as root, patch.dict('os.environ', {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): + with tempfile.TemporaryDirectory() as root, patch.dict('os.environ', {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), ExitStack() as sqlite_owners: workspace = Path(root) / 'workspace' workspace.mkdir() - app = build_application(memory=build_memory(Path(root) / 'state.sqlite3'), tools=ToolAdapters(workspace)) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(root) / 'state.sqlite3'))), tools=ToolAdapters(workspace)) try: started = app.runtime.run_command({ 'command': shlex.join([sys.executable, '-c', 'import time; print("ready",flush=True); time.sleep(30)']), @@ -110,6 +151,111 @@ def test_background_timeout_is_enforced_without_a_read_poll(self): self.assertTrue(page['timed_out']) manager.close() + def test_expiry_flags_wait_for_process_stop_to_return(self): + from types import SimpleNamespace + from unittest.mock import patch + from openkyrozen.tools.processes import ProcessHandle, SessionProcessManager + + for reason in ("timed_out", "cancelled"): + with self.subTest(reason=reason): + manager = SessionProcessManager("user", "workspace", "session") + process = SimpleNamespace(returncode=None) + process.poll = lambda: process.returncode + handle = ProcessHandle("process", process, "user", "workspace", "session", 0) + manager._handles[handle.process_id] = handle + cancelled = threading.Event() + if reason == "cancelled": + cancelled.set() + stopping, release = threading.Event(), threading.Event() + + def stop(process_id): + self.assertEqual(process_id, handle.process_id) + stopping.set() + if not release.wait(2): + raise AssertionError("test did not release process stop") + process.returncode = -15 + return True + + with patch.object(manager, "stop", side_effect=stop): + worker = threading.Thread(target=manager._expire, args=(handle, cancelled)) + worker.start() + try: + self.assertTrue(stopping.wait(2)) + page = manager.read(handle.process_id) + self.assertTrue(page["running"]) + self.assertFalse(page["timed_out"]) + self.assertFalse(page["cancelled"]) + self.assertIsNone(handle.finished_at) + finally: + release.set() + worker.join(2) + self.assertFalse(worker.is_alive()) + page = manager.read(handle.process_id) + self.assertTrue(page[reason]) + self.assertFalse(page["running"]) + self.assertIsNotNone(handle.finished_at) + + def test_failed_expiry_stop_does_not_claim_process_finished_or_remove_sandbox(self): + from types import SimpleNamespace + from unittest.mock import Mock, patch + from openkyrozen.tools.processes import ProcessHandle, SessionProcessManager + + manager = SessionProcessManager("user", "workspace", "session") + process = SimpleNamespace(poll=lambda: None) + sandbox = Mock() + handle = ProcessHandle("process", process, "user", "workspace", "session", 0, + temporary_directory=sandbox) + manager._handles[handle.process_id] = handle + with patch.object(manager, "stop", return_value=False): + manager._expire(handle) + page = manager.read(handle.process_id) + self.assertTrue(page["timed_out"]) + self.assertTrue(page["running"]) + self.assertIsNone(page["exit_code"]) + self.assertIsNone(handle.finished_at) + sandbox.cleanup.assert_not_called() + + def test_cancellation_publication_can_follow_observed_os_exit(self): + from unittest.mock import patch + from openkyrozen.tools.processes import SessionProcessManager + + with tempfile.TemporaryDirectory() as root: + manager = SessionProcessManager('user', root, 'session') + cancelled, stopped, release = threading.Event(), threading.Event(), threading.Event() + original_stop = manager.stop + + def delayed_stop(process_id): + result = original_stop(process_id) + stopped.set() + if not release.wait(2): + raise AssertionError('test did not release process stop') + return result + + try: + with patch.object(manager, 'stop', side_effect=delayed_stop): + handle = manager.start(shlex.join([sys.executable, '-c', 'import time; time.sleep(30)']), + cwd=root, env=None, timeout_seconds=30, cancelled=cancelled) + cancelled.set() + try: + self.assertTrue(stopped.wait(2)) + self.assertIsNotNone(handle.process.poll()) + # Observing OS exit is not a cancellation-publication + # barrier while the stopping thread has not returned. + self.assertFalse(manager.read(handle.process_id)['cancelled']) + finally: + release.set() + deadline = time.monotonic() + 2 + page = manager.read(handle.process_id) + while not page['cancelled'] and time.monotonic() < deadline: + time.sleep(.01) + page = manager.read(handle.process_id) + self.assertTrue(page['cancelled']) + self.assertFalse(page['running']) + self.assertFalse(page['timed_out']) + finally: + release.set() + manager.close() + def test_process_output_cap_is_reported_and_can_be_paged(self): from unittest.mock import patch from openkyrozen.tools.processes import SessionProcessManager diff --git a/tests/test_production_soak.py b/tests/test_production_soak.py new file mode 100644 index 00000000..76e62f90 --- /dev/null +++ b/tests/test_production_soak.py @@ -0,0 +1,273 @@ +import os +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from scripts import production_soak + + +class ProductionSoakProcessTests(unittest.TestCase): + def windows_table(self, rows, probe=99): + with patch('scripts.production_soak.platform.system',return_value='Windows'), \ + patch('scripts.production_soak.shutil.which',return_value='powershell'), \ + patch('scripts.production_soak._process_output',return_value=(json.dumps(rows),probe)): + return production_soak._process_table() + + @staticmethod + def row(pid, ppid, second, **extra): + return dict(pid=pid,ppid=ppid,CreationDate=(f'2026-11-07T08:00:{second:02d}.0000000Z' + if second is not None else None),Name='python.exe',**extra) + + def test_windows_stale_parent_pid_cannot_adopt_an_older_process_or_its_children(self): + table=self.windows_table([self.row(10,1,20),self.row(11,10,10),self.row(12,11,11), + self.row(13,10,21),self.row(14,13,22)]) + self.assertEqual(production_soak._descendants(10,table),{13,14}) + + def test_windows_reused_tracked_pid_is_not_the_same_surviving_process(self): + before=self.windows_table([self.row(10,1,10),self.row(11,10,11)]) + after=self.windows_table([self.row(11,500,30)]) + with tempfile.TemporaryDirectory() as directory: + checkpoint=Path(directory) + with patch('scripts.production_soak._process_table',return_value=before): + tracked=production_soak._capture_process_tree(10,checkpoint/'before.json') + with patch('scripts.production_soak._process_table',return_value=after): + production_soak._assert_processes_stopped(tracked,checkpoint/'after.json') + report=json.loads((checkpoint/'after.json').read_text()) + self.assertEqual(report['offending'],[]) + self.assertEqual(report['reused_pids'],[11]) + + def test_windows_same_identity_orphan_still_fails_after_reparenting_with_evidence(self): + before=self.windows_table([self.row(10,1,10),self.row(11,10,11)]) + after=self.windows_table([self.row(11,500,11)]) + with tempfile.TemporaryDirectory() as directory: + checkpoint=Path(directory) + with patch('scripts.production_soak._process_table',return_value=before): + tracked=production_soak._capture_process_tree(10,checkpoint/'before.json') + with patch('scripts.production_soak._process_table',return_value=after), \ + self.assertRaisesRegex(AssertionError,'orphan gateway child'): + production_soak._assert_processes_stopped(tracked,checkpoint/'after.json') + report=json.loads((checkpoint/'after.json').read_text()) + self.assertEqual(report['offending'],[self.row(11,500,11)]) + + def test_windows_missing_relevant_creation_identity_fails_closed_and_is_retained(self): + for rows in ([self.row(10,1,None),self.row(11,10,11)], + [self.row(10,1,10),self.row(11,10,None)]): + with self.subTest(rows=rows),tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'before.json' + with patch('scripts.production_soak._process_table',return_value=self.windows_table(rows)), \ + self.assertRaisesRegex(AssertionError,'creation identity'): + production_soak._capture_process_tree(10,path) + self.assertIn('error',json.loads(path.read_text())) + before=self.windows_table([self.row(10,1,10),self.row(11,10,11)]) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value=before): + tracked=production_soak._capture_process_tree(10,path/'before.json') + with patch('scripts.production_soak._process_table',return_value=self.windows_table([self.row(11,500,None)])), \ + self.assertRaisesRegex(AssertionError,'creation identity'): + production_soak._assert_processes_stopped(tracked,path/'after.json') + self.assertIn('error',json.loads((path/'after.json').read_text())) + + def test_posix_stopped_descendant_check_preserves_pid_membership_behavior(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value={10:1,11:10}): + tracked=production_soak._capture_process_tree(10,path/'before.json') + with patch('scripts.production_soak._process_table',return_value={11:500}), \ + self.assertRaisesRegex(AssertionError,'orphan gateway child'): + production_soak._assert_processes_stopped(tracked,path/'after.json') + + def test_audit_baseline_does_not_hide_a_new_child_reusing_a_baseline_pid(self): + baseline=self.windows_table([self.row(10,1,10),self.row(11,10,11)]) + current=self.windows_table([self.row(10,1,10),self.row(11,10,30)]) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value=baseline): + tracked=production_soak._capture_process_tree(10,path/'baseline.json') + with patch('scripts.production_soak._process_table',return_value=current), \ + self.assertRaisesRegex(AssertionError,'orphan audit child'): + production_soak._assert_no_new_audit_children(10,tracked,path/'after.json') + self.assertEqual(json.loads((path/'after.json').read_text())['offending_pids'],[11]) + + def test_audit_orphan_identity_outside_excerpt_preserves_capture_metadata(self): + rows=[self.row(10,1,10),self.row(11,10,9),self.row(99,10,12)] + rows.extend(self.row(pid,10,11) for pid in range(100,700)) + baseline=self.windows_table(rows) + orphan=self.row(900,10,12) + current=self.windows_table(rows+[orphan]) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value=baseline): + tracked=production_soak._capture_process_tree(10,path/'baseline.json') + with patch('scripts.production_soak._process_table',return_value=current), \ + self.assertRaisesRegex(AssertionError,'orphan audit child'): + production_soak._assert_no_new_audit_children(10,tracked,path/'after.json') + report=json.loads((path/'after.json').read_text()) + self.assertEqual(report['offending'],[orphan]) + self.assertEqual(report['offending_count'],1) + self.assertEqual(report['offending_pids'],[900]) + self.assertEqual(report['root_pid'],10) + self.assertEqual(report['tracked_count'],601) + self.assertEqual(len(report['tracked_pids']),512) + self.assertEqual(report['excluded_stale_parent_pids'],[11]) + self.assertEqual(report['excluded_stale_parent_count'],1) + self.assertEqual(report['excluded_probe_pid'],99) + self.assertEqual(report['excluded_probe'],self.row(99,10,12)) + self.assertTrue(report['rows_truncated']) + self.assertEqual(report['row_count'],603) + self.assertNotIn(900,[row['pid'] for row in report['rows']]) + + def test_process_capture_failure_is_explicit_in_checkpoint_instead_of_missing(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'before.json' + with patch('scripts.production_soak._process_table',side_effect=OSError('offline probe failed')), \ + self.assertRaises(OSError): + production_soak._capture_process_tree(10,path) + self.assertTrue(path.is_file()) + self.assertEqual(json.loads(path.read_text())['status'],'capture_failed') + + def test_missing_unrelated_identity_is_ignored_and_one_tick_reuse_is_distinct(self): + old=self.row(11,10,11) + new=dict(old,CreationDate='2026-11-07T08:00:11.0000001Z') + before=self.windows_table([self.row(10,1,10),old,self.row(20,1,None)]) + after=self.windows_table([new,self.row(20,1,None)]) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value=before): + tracked=production_soak._capture_process_tree(10,path/'before.json') + with patch('scripts.production_soak._process_table',return_value=after): + production_soak._assert_processes_stopped(tracked,path/'after.json') + self.assertEqual(json.loads((path/'after.json').read_text())['reused_pids'],[11]) + + def test_checkpoint_bounds_do_not_hide_real_orphans_outside_the_logged_excerpt(self): + before=self.windows_table([self.row(10,1,10)]+[self.row(pid,10,11) for pid in range(100,700)]) + after=self.windows_table([self.row(pid,5000,11) for pid in range(100,700)]) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + with patch('scripts.production_soak._process_table',return_value=before): + tracked=production_soak._capture_process_tree(10,path/'before.json') + report=json.loads((path/'before.json').read_text()) + self.assertEqual(report['row_count'],601);self.assertTrue(report['rows_truncated']) + self.assertLessEqual(len(report['rows']),512) + self.assertLessEqual(len(report['tracked_pids']),512) + with patch('scripts.production_soak._process_table',return_value=after), \ + self.assertRaisesRegex(AssertionError,'orphan gateway child'): + production_soak._assert_processes_stopped(tracked,path/'after.json') + report=json.loads((path/'after.json').read_text()) + self.assertEqual(report['offending_count'],600) + self.assertEqual(len(report['offending']),512) + + @unittest.skipUnless(os.name=='posix','local POSIX identity-observer smoke') + def test_native_observer_detects_live_owned_child_then_certifies_its_exit(self): + import select,signal,subprocess,sys + hold='import sys;sys.stdin.buffer.read()' + family_code=''' +import subprocess,sys +child=subprocess.Popen([sys.executable,'-I','-c',sys.argv[1]],stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) +try: + print(child.pid,flush=True) + sys.stdin.buffer.read() +finally: + try:child.communicate(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.communicate(timeout=5) +''' + def drain(process): + try:process.communicate(timeout=5) + except subprocess.TimeoutExpired: + if process.poll() is None: + try:os.killpg(process.pid,signal.SIGKILL) + except ProcessLookupError:pass + process.communicate(timeout=5) + # Other tests may own live siblings beneath the unittest process. + unrelated=subprocess.Popen([sys.executable,'-I','-c',hold],stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) + self.addCleanup(drain,unrelated) + family=subprocess.Popen([sys.executable,'-I','-c',family_code,hold],stdin=subprocess.PIPE, + stdout=subprocess.PIPE,stderr=subprocess.PIPE,start_new_session=True) + self.addCleanup(drain,family) + self.assertTrue(select.select([family.stdout],[],[],5)[0],'child family did not become ready') + child_pid=int(family.stdout.readline()) + with tempfile.TemporaryDirectory() as directory: + path=Path(directory) + tracked=production_soak._capture_process_tree(family.pid,path/'before.json') + self.assertEqual(tracked['tracked'],{child_pid}) + self.assertNotIn(unrelated.pid,tracked['tracked']) + with self.assertRaisesRegex(AssertionError,'orphan gateway child'): + production_soak._assert_processes_stopped(tracked,path/'live.json') + _,error=family.communicate(timeout=5) + self.assertEqual(family.returncode,0,error.decode(errors='replace')) + production_soak._assert_processes_stopped(tracked,path/'exited.json') + self.assertEqual(json.loads((path/'exited.json').read_text())['status'],'stopped') + self.assertIsNone(unrelated.poll()) + + def test_candidate_dependency_inventory_does_not_require_psutil(self): + dependencies = production_soak.candidate_identity()["dependencies"] + + self.assertNotIn("psutil", dependencies) + + def test_process_table_tracks_descendants_across_multiple_generations(self): + parents = production_soak._parse_process_table("10 1\n11 10\n12 11\n20 1\n") + + self.assertEqual(production_soak._descendants(10, parents), {11, 12}) + self.assertEqual(production_soak._descendants(20, parents), set()) + + def test_windows_process_table_excludes_its_own_probe(self): + rows=[self.row(10,1,10),self.row(11,10,11),self.row(99,10,12)] + output=json.dumps(rows) + with patch("scripts.production_soak.platform.system", return_value="Windows"), \ + patch("scripts.production_soak.shutil.which", return_value="powershell"), \ + patch("scripts.production_soak._process_output", return_value=(output, 99)) as probe: + table=production_soak._process_table() + self.assertEqual(table,{10:rows[0],11:rows[1]}) + self.assertEqual(table.probe_pid,99) + self.assertEqual(table.probe,rows[2]) + query=probe.call_args.args[0][-1] + self.assertIn('CreationDate.ToUniversalTime()',query) + self.assertNotIn('CommandLine',query);self.assertNotIn('ExecutablePath',query) + + def test_windows_process_snapshot_maps_native_resource_counters(self): + with patch("scripts.production_soak.platform.system", return_value="Windows"), \ + patch("scripts.production_soak._powershell", return_value= + '{"rssBytes":4096,"threads":3,"openHandles":8}'): + self.assertEqual(production_soak._process_snapshot(10), + {"rss_bytes": 4096, "threads": 3, "open_handles": 8}) + + def test_handle_bound_uses_windows_growth_and_preserves_posix_cap(self): + with patch("scripts.production_soak.platform.system", return_value="Windows"): + self.assertEqual(production_soak._handle_limit(125), 225) + with patch("scripts.production_soak.platform.system", return_value="Linux"): + self.assertEqual(production_soak._handle_limit(125), 99) + + def test_durable_task_wait_allows_sqlite_busy_timeout_under_load(self): + class Gateway: + statuses = iter(("running", "succeeded")) + + def _request(self, *_args, **_kwargs): + return {"tasks": [{"id": "task-1", "session_id": "soak-a", + "status": next(self.statuses)}]} + + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + (workspace / "soak-a-29.txt").write_text("soak-a:29") + with patch("scripts.production_soak.time.monotonic", side_effect=(0, 16, 17)), \ + patch("scripts.production_soak.time.sleep"): + production_soak._wait_for_task_completion( + Gateway(), "http://localhost", workspace, "soak-a", 29, + "soak-a-29.txt", "task-1", + ) + + def test_current_platform_process_probe_returns_bounded_resource_counts(self): + snapshot = production_soak._process_snapshot(os.getpid()) + + self.assertGreater(snapshot["rss_bytes"], 0) + self.assertGreaterEqual(snapshot["threads"], 1) + self.assertGreater(snapshot["open_handles"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_worker.py b/tests/test_prompt_worker.py new file mode 100644 index 00000000..38296250 --- /dev/null +++ b/tests/test_prompt_worker.py @@ -0,0 +1,41 @@ +import unittest + +from benchmarks.prompt_worker import _accepted_scope_preserved + + +class PromptWorkerPlanEvidenceTests(unittest.TestCase): + def test_completed_accepted_plan_preserves_scope_after_active_state_is_cleared(self): + history = [{"event_type": "interaction.plan_completed", "payload": { + "plan_id": "plan-1", "version": 2, "fingerprint": "accepted-plan", "status": "completed", + }}] + + self.assertTrue(_accepted_scope_preserved( + history, ["accepted-plan"], + {"plan_id": "plan-1", "version": 2, "fingerprint": "accepted-plan"}, [])) + + def test_cancelled_or_different_plan_does_not_preserve_scope(self): + for fingerprint, version, status in (("accepted-plan", 2, "cancelled"), + ("other-plan", 2, "completed"), + ("accepted-plan", 1, "completed")): + with self.subTest(fingerprint=fingerprint, version=version, status=status): + history = [{"event_type": "interaction.plan_completed", "payload": { + "plan_id": "plan-1", "version": version, + "fingerprint": fingerprint, "status": status, + }}] + self.assertFalse(_accepted_scope_preserved( + history, ["accepted-plan"], + {"plan_id": "plan-1", "version": 2, "fingerprint": "accepted-plan"}, [])) + + def test_scope_requires_an_accepted_plan_and_keeps_active_bindings(self): + plan = {"plan_id": "plan-1", "version": 2, "fingerprint": "accepted-plan"} + history = [] + tasks = [{"status": "succeeded", "strategic_binding": {"fingerprint": "accepted-plan"}}] + + self.assertFalse(_accepted_scope_preserved(history, [], plan, tasks)) + self.assertTrue(_accepted_scope_preserved(history, ["accepted-plan"], plan, tasks)) + tasks[0]["status"] = "cancelled" + self.assertFalse(_accepted_scope_preserved(history, ["accepted-plan"], plan, tasks)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_worker_baseline.py b/tests/test_prompt_worker_baseline.py new file mode 100644 index 00000000..eb3b02ae --- /dev/null +++ b/tests/test_prompt_worker_baseline.py @@ -0,0 +1,105 @@ +"""The candidate observer must run against the actual immutable V2 snapshot.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from benchmarks.v3_fixtures import FIXTURES +from benchmarks.v3_matched_live import BASELINE, _extract_baseline, _worker_env, prepare_fixture, verify_fixture + + +class FrozenBaselineWorkerTests(unittest.TestCase): + def test_actual_frozen_v2_worker_imports_and_observes_eleven_local_transport_turns(self): + candidate = Path(__file__).resolve().parents[1] + case = next(item for item in FIXTURES if item["fixture_id"] == "long_context-1") + requests = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): pass + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + requests.append(request) + items = request["input"] + last = items[-1].get("content", "") + text = json.dumps(case["verifier"]["expected"]) if str(last).startswith("Return only a JSON") else "Acknowledged the current facts." + if any("UNTRUSTED TRANSCRIPT:" in str(item.get("content", "")) for item in items): + text = "owner team-cedar; rollback release-17; latest approved region ap-southeast-1" + response = {"id": "local-offline-response", "object": "response", "status": "completed", + "model": "offline-fixture", "output": [{"type": "message", "id": "message-local", + "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": text, "annotations": []}]}]} + if request.get("stream"): + events = [{"type": "response.output_text.delta", "delta": text}, + {"type": "response.completed", "response": response}] + payload = "".join("event: " + event["type"] + "\ndata: " + json.dumps(event) + "\n\n" for event in events).encode() + else: payload = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream" if request.get("stream") else "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + baseline, workspace, home = base / "baseline", base / "workspace", base / "home" + _extract_baseline(baseline, candidate, BASELINE) + prepared = prepare_fixture(workspace, case) + home.mkdir() + prompt, conversation, result = base / "prompt.txt", base / "conversation.json", base / "result.json" + prompt.write_text(case["prompt"]) + conversation.write_text(json.dumps({"followups": case["turns"][1:], "context_window_tokens": 16384, + "output_reserve_tokens": 4096})) + env = _worker_env(base, case, type("LocalRelay", (), {"client_key": "offline-local"})()) + env["KYROZEN_APPROVAL_MODE"] = "never" + completed = subprocess.run([sys.executable, str(candidate / "benchmarks" / "prompt_worker.py"), + "--source", str(baseline), "--workspace", str(workspace), "--url", f"http://127.0.0.1:{server.server_port}/v1", + "--model", "offline-fixture", "--protocol", "responses", "--prompt-file", str(prompt), + "--conversation-file", str(conversation), "--result", str(result)], + cwd=workspace, env=env, text=True, capture_output=True, timeout=90) + self.assertEqual(completed.returncode, 0, completed.stderr[-4000:]) + worker = json.loads(result.read_text()) + self.assertEqual(len(worker["conversation_records"]), 11) + self.assertEqual(len(worker["request_context_records"]), len(requests)) + # This immutable V2 ask path loses prior turns. Observation must expose + # its absent pressure/compaction independently of the physical task oracle. + self.assertFalse(any(item["kind"] == "summary" for item in worker["request_context_records"])) + self.assertEqual(len({item["component_estimates"]["conversation"] for item in worker["request_context_records"]}), 1) + for index, request in enumerate(requests): + visible_users = [item.get("content") for item in request["input"] if item.get("role") == "user"] + self.assertFalse(any(old_prompt in visible_users for old_prompt in case["turns"][:index])) + self.assertTrue(all(item["retained_history_tokens"] is None for item in worker["request_context_records"])) + self.assertTrue(all(item["provider_input_tokens"] is None for item in worker["request_context_records"])) + verified = verify_fixture(case, workspace, worker["answer"], worker, prepared) + self.assertEqual(verified["result"], "passed") # The fake transport supplies correct facts. + self.assertIs(verified["context_compaction_observed"], False) + # A real V2 receipt is a plain-string projection, not V3 typed arguments. + receipt_script = '''import json,runpy,sys +sys.path.insert(0,sys.argv[1]) +from openkyrozen.app.bootstrap import build_application +helpers=runpy.run_path(sys.argv[2]) +app=build_application(surface='cli') +try: + app.runtime._set_workspace_root(sys.argv[3]) + receipt=app.runtime._execute_turn_action('read_file','README.md',operation_scope='offline-observation',successful_operations=set()) + print('OBSERVED='+json.dumps(helpers['_normalize_tool_receipt'](receipt.as_dict(),sys.argv[3]))) +finally: app.close() +''' + receipt_result = subprocess.run([sys.executable, "-c", receipt_script, str(baseline), + str(candidate / "benchmarks" / "prompt_worker.py"), str(workspace)], cwd=workspace, env=env, + text=True, capture_output=True, timeout=30) + self.assertEqual(receipt_result.returncode, 0, receipt_result.stderr[-2000:]) + observed = json.loads(next(line[9:] for line in receipt_result.stdout.splitlines() if line.startswith("OBSERVED="))) + self.assertEqual(observed["target_path"], "README.md") + self.assertTrue(observed["success"]) + self.assertTrue(observed["authorized"]) + self.assertIsNone(observed["background"]) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_prompt_worker_observations.py b/tests/test_prompt_worker_observations.py new file mode 100644 index 00000000..92a7a82e --- /dev/null +++ b/tests/test_prompt_worker_observations.py @@ -0,0 +1,169 @@ +from contextlib import ExitStack, closing +import hashlib +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from benchmarks.prompt_worker import _normalize_tool_receipt, _context_observation, _run_conversation, _ProviderObservations +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.providers.models import ToolCall +from openkyrozen.tools import ToolAdapters + + +class PromptWorkerObservationTests(unittest.TestCase): + def test_real_native_receipts_preserve_background_relative_path_and_git_reference(self): + from benchmarks.v3_fixtures import FIXTURES + from benchmarks.v3_matched_live import prepare_fixture + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + "KYROZEN_DISABLE_VECTOR_INDEX": "1", "KYROZEN_APPROVAL_MODE": "never", + }), ExitStack() as sqlite_owners: + root = Path(directory) + case = next(item for item in FIXTURES if item["fixture_id"] == "git_history-1") + prepared = prepare_fixture(root, case) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(root / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + runtime._set_workspace_root(root) + write = runtime.execute_call(runtime.current_session, ToolCall("native-write", "write_file", {"path": "notes.txt", "content": "value"})) + raw = write.as_dict() + self.assertIsInstance(raw["args"], str) + observed = _normalize_tool_receipt(raw, root) + self.assertEqual(observed["target_path"], "notes.txt") + self.assertEqual(observed["call_id"], "native-write") + self.assertEqual(observed['evidence'], raw) + raw['arguments']['content'] = 'later mutation' + self.assertEqual(observed['evidence']['arguments']['content'], 'value') + expected = hashlib.sha256(json.dumps({"path": "notes.txt", "content": "value"}, sort_keys=True, + ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + self.assertEqual(observed["args_hash"], expected) + background = runtime.execute_call(runtime.current_session, ToolCall("native-process", "run_cmd", { + "command": "printf ready", "background": True})) + self.assertTrue(background.success, background.result) + self.assertIs(_normalize_tool_receipt(background.as_dict(), root)["background"], True) + show = runtime.execute_call(runtime.current_session, ToolCall("native-git", "git_show", {"reference": prepared["expected_commit_full"]})) + self.assertTrue(show.success, show.result) + self.assertEqual(_normalize_tool_receipt(show.as_dict(), root)["git_reference"], prepared["expected_commit_full"]) + projected = {**raw, "arguments": None} + self.assertEqual(_normalize_tool_receipt(projected, root)["target_path"], "notes.txt") + + def test_real_native_script_observes_compaction_and_bounded_context(self): + from benchmarks.v3_fixtures import FIXTURES + from benchmarks.v3_matched_live import prepare_fixture, verify_fixture + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities, provider_continuation + case = next(item for item in FIXTURES if item["fixture_id"] == "long_context-1") + class Provider(LLMProvider): + def get_capabilities(self, model=None): return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): return self.chat_response(messages, model).as_legacy_tuple() + def chat_response(self, messages, model=None, *, tools=None): + content = messages[-1].get("content", "") if isinstance(messages[-1], dict) else "" + text = json.dumps(case["verifier"]["expected"]) if content.startswith("Return only a JSON") else "Acknowledged the current facts." + if any(isinstance(item, dict) and "UNTRUSTED TRANSCRIPT:" in item.get("content", "") for item in messages): + text = "Preserved facts: owner team-cedar; rollback release-17; latest approved region ap-southeast-1." + return ModelResponse(text=text, finish_reason=FinishReason.FINAL, + continuation=provider_continuation(self, model, "fixture", {"assistant": {"role": "assistant", "content": text}})) + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + root = Path(directory) / "workspace" + prepared = prepare_fixture(root, case) + app = build_application(surface="cli", memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3", index=None))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + runtime._set_workspace_root(root) + runtime.set_interaction_mode("ask") + config = ProviderConfig(provider="custom", model_simple="fixture", model_complex="fixture", context_window_tokens=16384) + runtime._provider_config = config + runtime.llm_provider = runtime._default_turn.provider = Provider(config) + observations = _ProviderObservations(runtime, runtime.llm_provider) + with patch.object(runtime, "_schedule_post_turn_learning"): + answer, records = _run_conversation(runtime, runtime.current_session, case["turns"], observations) + worker = {"conversation_records": records, "request_context_records": observations.records, + "provider_error": False, "processes_alive": 0} + result = verify_fixture(case, root, answer, worker, prepared) + self.assertEqual(result["result"], "passed", answer) + self.assertTrue(any(record["messages_compacted"] is True for record in records)) + summaries = [record for record in observations.records if record["kind"] == "summary"] + self.assertTrue(summaries) + self.assertTrue(all(record["message_count"] == 1 for record in summaries)) + self.assertTrue(all(record["retained_history_tokens"] is None for record in summaries)) + self.assertTrue(all(record["provider_input_tokens"] is None for record in observations.records)) + for altered in ("missing_dispatches", "oversized_summary", "superseded_answer"): + import copy + negative = copy.deepcopy(worker) + negative_answer = answer + if altered == "missing_dispatches": negative["request_context_records"] = [] + elif altered == "oversized_summary": + summary = next(item for item in negative["request_context_records"] if item["kind"] == "summary") + summary["estimated_input_tokens"] = 16384 + else: negative_answer = json.dumps({**case["verifier"]["expected"], "region": "eu-west-2"}) + self.assertEqual(verify_fixture(case, root, negative_answer, negative, prepared)["result"], "failed", altered) + self.assertTrue(all(record["context"]["input_tokens"] + record["context"]["reserve_tokens"] <= 16384 for record in records)) + + def test_stream_observer_preserves_terminal_close_and_counts_nested_bridge_once(self): + from openkyrozen.providers.models import ModelResponse, ResponseComplete, TextDelta + runtime = SimpleNamespace() + seen = [] + class Provider: + config = SimpleNamespace(context_window_tokens=16384) + def chat_response(self, messages, model=None, *, tools=None): + seen.append("response") + return ModelResponse(text="hello", usage={"prompt_tokens": 80, "completion_tokens": 2}) + def chat_response_stream(self, messages, model=None, *, tools=None): + response = self.chat_response(messages, model, tools=tools) + seen.append("delta") + yield TextDelta(response.text) + seen.append("complete") + yield ResponseComplete(response) + seen.append("after-terminal") + provider = Provider() + observation = _ProviderObservations(runtime, provider) + iterator = provider.chat_response_stream([{"role": "user", "content": "hello"}], "fixture", tools=[]) + self.assertEqual(next(iterator).text, "hello") + self.assertIsInstance(next(iterator), ResponseComplete) + iterator.close() # Production stops consuming immediately at terminal. + self.assertEqual(seen, ["response", "delta", "complete"]) + self.assertEqual(len(observation.records), 1) + self.assertEqual(observation.records[0]["outcome"], "returned") + self.assertEqual(observation.records[0]["provider_input_tokens"], 80) + self.assertEqual(observation.records[0]["provider_usage_source"], "provider_reported_total") + self.assertIsNone(observation.records[0]["retained_history_tokens"]) + + def test_missing_context_components_remain_unknown(self): + runtime = SimpleNamespace(context_usage=lambda: {"input_tokens": 200, "input_source": "provider_reported", + "window_tokens": 1000, "reserve_tokens": 100, "breakdown": {"conversation": 160}}) + observed = _context_observation(runtime) + self.assertEqual(observed["input_tokens"], 200) + self.assertEqual(observed["input_source"], "provider_reported") + self.assertIsNone(observed["native_input_overhead_tokens"]) + self.assertIsNone(observed["retained_history_tokens"]) + self.assertIsNone(observed["retransmitted_history_tokens"]) + self.assertIsNone(_context_observation(SimpleNamespace())) + + def test_scripted_turns_reuse_the_session_and_record_real_context_snapshots(self): + session = SimpleNamespace(messages_compacted=False) + seen = [] + context = {"input_tokens": 100, "reserve_tokens": 20, "window_tokens": 500, + "input_source": "estimated", "compaction": {"status": "not_needed"}} + def chat(actual_session, prompt, **kwargs): + self.assertIs(actual_session, session) + seen.append(prompt) + if len(seen) == 2: + session.messages_compacted = True + context["compaction"] = {"status": "summarized"} + return f"answer-{len(seen)}" + runtime = SimpleNamespace(chat=chat, context_usage=lambda: dict(context)) + answer, records = _run_conversation(runtime, session, ("Remember cedar", "Replace birch", "Recall current")) + self.assertEqual(seen, ["Remember cedar", "Replace birch", "Recall current"]) + self.assertEqual(answer, "answer-3") + self.assertEqual(records[0]["prompt_sha256"], hashlib.sha256(b"Remember cedar").hexdigest()) + self.assertEqual(records[0]["context"]["compaction"]["status"], "not_needed") + self.assertEqual(records[1]["context"]["compaction"]["status"], "summarized") + self.assertIs(records[1]["messages_compacted"], True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_provider_contracts.py b/tests/test_provider_contracts.py index 9138459f..628d1d9d 100644 --- a/tests/test_provider_contracts.py +++ b/tests/test_provider_contracts.py @@ -1,4 +1,5 @@ """Offline regression checks for provider-independent response boundaries.""" +from contextlib import ExitStack, closing import unittest import copy from types import SimpleNamespace as NS @@ -53,8 +54,8 @@ def test_runtime_receives_typed_calls_and_rejects_text_conversion(self): response = ModelResponse(tool_calls=(ToolCall("id", "read_file", {"path": "a"}),), usage={"prompt_tokens": 2, "completion_tokens": 3}, finish_reason=FinishReason.TOOL_REQUEST) - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): - application = build_application(memory=build_memory(Path(directory) / "state.sqlite3")) + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + application = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3")))) runtime = application.runtime runtime.llm_provider = NS(chat_response=Mock(return_value=response)) try: diff --git a/tests/test_provider_native_tools.py b/tests/test_provider_native_tools.py index 4f44de41..6860ead3 100644 --- a/tests/test_provider_native_tools.py +++ b/tests/test_provider_native_tools.py @@ -1,4 +1,5 @@ """Offline tests for schema-backed native provider requests and replay history.""" +from contextlib import ExitStack, closing import unittest import json import sys @@ -139,8 +140,8 @@ def chat_response(self, messages, model=None, *, tools=None): self.tools = tools return ModelResponse(text="ready") - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): - app = build_application(memory=build_memory(Path(directory) / "state.sqlite3")) + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3")))) try: provider = CaptureProvider(ProviderConfig(provider="custom", model_simple="model")) app.runtime.llm_provider = provider @@ -222,11 +223,11 @@ def test_native_request_executes_through_runtime_without_action_parsing(self): from openkyrozen.tools import ToolAdapters from openkyrozen.providers.openai import OpenAICompatProvider - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: workspace = Path(directory) (workspace / "note.txt").write_text("native result | 雪", encoding="utf-8") adapters = ToolAdapters(str(workspace)) - app = build_application(memory=build_memory(workspace / "state.sqlite3"), tools=adapters) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(workspace / "state.sqlite3"))), tools=adapters) runtime = app.runtime from openkyrozen.agent.compaction import ContextState runtime._active_context_state.set(ContextState("gemini-3-flash")) diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 2d837ddf..67450ee4 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -11,6 +11,21 @@ class ProviderRegistryTests(unittest.TestCase): + def test_default_deepseek_uses_flash_without_losing_complex_planning(self): + with patch.dict(os.environ, {"KYROZEN_MODEL_SIMPLE": "", "KYROZEN_MODEL_COMPLEX": ""}): + config = providers.ProviderConfig(provider="deepseek") + for complex_task in (False, True): + with self.subTest(complex_task=complex_task): + decision, _ = decide_request( + config, {}, "task", + lambda _: providers.model_for_complexity(config, complex_task), + lambda _: "complex" if complex_task else "simple", + "coder", "auto", "agent", + ) + self.assertEqual(decision.model, "deepseek-flash") + self.assertEqual(decision.reasoning, "standard") + self.assertEqual(decision.planning_required, complex_task) + def test_main_model_override_applies_to_both_complexity_routes(self): config = providers.ProviderConfig(provider="deepseek", model_simple="quick", model_complex="reasoning", model_main="pinned") self.assertEqual(providers.model_for_complexity(config, False), "pinned") diff --git a/tests/test_provider_review_regressions.py b/tests/test_provider_review_regressions.py index 29748815..3583aa1f 100644 --- a/tests/test_provider_review_regressions.py +++ b/tests/test_provider_review_regressions.py @@ -1,4 +1,5 @@ """Reproductions of verified GitHub review findings for V3 issue #231.""" +from contextlib import ExitStack, closing import threading import time import unittest @@ -188,8 +189,8 @@ def test_legacy_context_error_is_typed_at_structured_runtime_boundary(self): from pathlib import Path from unittest.mock import patch from openkyrozen.app.bootstrap import build_application, build_memory - with tempfile.TemporaryDirectory() as directory, patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}): - app=build_application(memory=build_memory(Path(directory)/'state.sqlite3')) + with tempfile.TemporaryDirectory() as directory, patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}), ExitStack() as sqlite_owners: + app=build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory)/'state.sqlite3')))) app.runtime.llm_provider=NS(chat=lambda *args:('[Ollama Error] maximum context length exceeded',None)) try: with self.assertRaises(ProviderError) as caught: diff --git a/tests/test_provider_stream_events.py b/tests/test_provider_stream_events.py index 47acf35a..25cde060 100644 --- a/tests/test_provider_stream_events.py +++ b/tests/test_provider_stream_events.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import unittest from types import SimpleNamespace from unittest.mock import Mock, patch @@ -187,8 +188,8 @@ def chat_response_stream(self, messages, model=None, *, tools=None): yield ResponseComplete(ModelResponse(text="live", finish_reason=FinishReason.FINAL, usage={"prompt_tokens": 2, "completion_tokens": 1})) - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): - app = build_application(memory=build_memory(Path(directory) / "state.sqlite3")) + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3")))) try: app.runtime.llm_provider = StreamingProvider(ProviderConfig(provider="custom", model_simple="m")) observed = [] @@ -232,8 +233,8 @@ class LegacyStreamProvider: def chat_stream(self, messages, model=None): yield "[Ollama Error] context length exceeded" - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): - app = build_application(memory=build_memory(Path(directory) / "state.sqlite3")) + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3")))) try: app.runtime.llm_provider = LegacyStreamProvider() context_token = app.runtime._active_context_state.set(object()) diff --git a/tests/test_providers.py b/tests/test_providers.py index e878015d..1b5599ec 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,4 +1,5 @@ import base64 +import json import os import stat import tempfile @@ -78,6 +79,36 @@ def test_main_model_override_round_trips_and_can_be_cleared(self): )) self.assertEqual(detect_provider().model_main, "") + def test_deepseek_missing_slots_use_flash_without_replacing_saved_models(self): + cases = ( + ({}, ("deepseek-flash", "deepseek-flash", "")), + ({"model_simple": "legacy-simple"}, ("legacy-simple", "deepseek-flash", "")), + ({"model_complex": "deepseek-v4-pro"}, ("deepseek-flash", "deepseek-v4-pro", "")), + ({"model_simple": "legacy-simple", "model_complex": "deepseek-v4-pro"}, + ("legacy-simple", "deepseek-v4-pro", "")), + ({"model_simple": "legacy-simple", "model_complex": "deepseek-v4-pro", "model_main": "auto"}, + ("legacy-simple", "deepseek-v4-pro", "auto")), + ({"model_main": "pinned-model"}, ("deepseek-flash", "deepseek-flash", "pinned-model")), + ) + with patch.dict(os.environ, {"KYROZEN_MODEL_SIMPLE": "", "KYROZEN_MODEL_COMPLEX": ""}): + for saved, expected in cases: + with self.subTest(saved=saved): + (Path.home() / ".kyrozen_config.json").write_text(json.dumps({"provider": "deepseek", **saved})) + config = detect_provider() + self.assertEqual((config.model_simple, config.model_complex, config.model_main), expected) + + def test_explicit_deepseek_slot_environment_overrides_saved_models(self): + (Path.home() / ".kyrozen_config.json").write_text(json.dumps({ + "provider": "deepseek", "model_simple": "saved-simple", "model_complex": "saved-complex", + })) + with patch.dict(os.environ, { + "KYROZEN_MODEL_SIMPLE": "chosen-simple", "KYROZEN_MODEL_COMPLEX": "deepseek-v4-pro", + "KYROZEN_MODEL_MAIN": "auto", + }): + config = detect_provider() + self.assertEqual((config.model_simple, config.model_complex, config.model_main), + ("chosen-simple", "deepseek-v4-pro", "auto")) + def test_legacy_xor_ciphertext_remains_readable(self): plaintext = "sk-legacy" key = _get_encryption_key() @@ -109,6 +140,39 @@ def test_sync_maps_deepseek_complex_model_to_openai_default(self): self.assertEqual(primary.models, ["deepseek-reasoner"]) self.assertEqual(fallback.models, ["gpt-4o"]) + def test_legacy_deepseek_pro_pin_keeps_primary_model_and_maps_fallback_complex_slot(self): + for stream in (False, True): + with self.subTest(stream=stream): + primary = _ProbeProvider("deepseek", "deepseek-flash", "deepseek-flash", + error=ConnectionError("primary down")) + primary.config.model_main = "deepseek-v4-pro" + fallback = _ProbeProvider("openai", "fallback-simple", "fallback-complex", response="fallback") + wrapper = self._fallback(primary, fallback) + if stream: + self.assertEqual(list(wrapper.chat_stream([], primary.config.model_main)), ["fallback"]) + self.assertEqual(primary.stream_models, ["deepseek-v4-pro"]) + self.assertEqual(fallback.stream_models, ["fallback-complex"]) + else: + self.assertEqual(wrapper.chat([], primary.config.model_main)[0], "fallback") + self.assertEqual(primary.models, ["deepseek-v4-pro"]) + self.assertEqual(fallback.models, ["fallback-complex"]) + + def test_legacy_deepseek_complex_alias_respects_explicit_simple_slot(self): + primary = _ProbeProvider("deepseek", "deepseek-v4-pro", "deepseek-flash", + error=ConnectionError("primary down")) + fallback = _ProbeProvider("openai", "fallback-simple", "fallback-complex", response="fallback") + self.assertEqual(self._fallback(primary, fallback).chat([], "deepseek-v4-pro")[0], "fallback") + self.assertEqual(primary.models, ["deepseek-v4-pro"]) + self.assertEqual(fallback.models, ["fallback-simple"]) + + def test_deepseek_legacy_alias_does_not_rewrite_other_providers_explicit_names(self): + primary = _ProbeProvider("openai", "primary-simple", "primary-complex", + error=ConnectionError("primary down")) + fallback = _ProbeProvider("anthropic", "fallback-simple", "fallback-complex", response="fallback") + self.assertEqual(self._fallback(primary, fallback).chat([], "deepseek-v4-pro")[0], "fallback") + self.assertEqual(primary.models, ["deepseek-v4-pro"]) + self.assertEqual(fallback.models, ["deepseek-v4-pro"]) + def test_stream_maps_anthropic_simple_model_to_deepseek_default(self): primary = _ProbeProvider("anthropic", "claude-sonnet-4-20250514", "claude-sonnet-4-20250514", error=ConnectionError("primary down")) diff --git a/tests/test_runtime_reliability.py b/tests/test_runtime_reliability.py new file mode 100644 index 00000000..1eb218fc --- /dev/null +++ b/tests/test_runtime_reliability.py @@ -0,0 +1,273 @@ +from contextlib import ExitStack, closing +import os +import shlex +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from openkyrozen.app.bootstrap import build_application, build_memory +from openkyrozen.tools import ToolAdapters +from openkyrozen.persistence.store import EventStore +from openkyrozen.tasks.engine import TaskManager +from openkyrozen.tasks.worker import TaskWorker + + +class RuntimeReliabilityTests(unittest.TestCase): + def test_turn_cancellation_stops_its_background_process_and_reports_cancelled(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + 'KYROZEN_DISABLE_VECTOR_INDEX': '1', + }), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(root / 'state.sqlite3'))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + ready, release = threading.Event(), threading.Event() + handles, events = [], [] + + def turn(*args, **kwargs): + result = runtime.run_command({ + 'command': shlex.join([sys.executable, '-c', + 'import time; print("ready",flush=True); time.sleep(30)']), + 'background': True, 'timeout_seconds': 30, + }) + self.assertTrue(result.success) + handles.append(runtime.current_session.process_manager._handles[result.process_id]) + ready.set() + release.wait(5) + return 'Cancellation requested.' + + session = runtime.current_session + with patch.object(runtime, '_chat_turn', side_effect=turn): + worker = threading.Thread(target=lambda: runtime.chat(session, 'start', on_runtime_event=events.append)) + worker.start() + try: + self.assertTrue(ready.wait(5)) + self.assertTrue(runtime.cancel_turn(session.session_id, user_id=session.memory.user_id)) + deadline = time.monotonic() + 3 + page = session.process_manager.read(handles[0].process_id) + # OS exit can precede stop() returning and the expiry + # thread publishing its cancellation result. + while not page['cancelled'] and time.monotonic() < deadline: + time.sleep(.02) + page = session.process_manager.read(handles[0].process_id) + self.assertIsNotNone(handles[0].process.poll()) + self.assertTrue(page['cancelled']) + self.assertFalse(page['running']) + self.assertFalse(page['timed_out']) + finally: + release.set() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(events[-1].kind, 'turn.cancelled') + + def test_turn_cancelled_signal_controls_terminal_event_when_tool_raises(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(root / 'state.sqlite3'))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime, events = app.runtime, [] + session = runtime.current_session + def cancelled_turn(*args, **kwargs): + runtime.cancel_turn(session.session_id, user_id=session.memory.user_id) + raise RuntimeError('tool stopped after cancellation') + with patch.object(runtime, '_chat_turn', side_effect=cancelled_turn), self.assertRaises(RuntimeError): + runtime.chat(session, 'run', on_runtime_event=events.append) + self.assertEqual(events[-1].kind, 'turn.cancelled') + + def test_background_cancellation_keeps_other_turn_process_running(self): + from openkyrozen.tools.processes import SessionProcessManager + with tempfile.TemporaryDirectory() as directory: + manager = SessionProcessManager('user', directory, 'session') + self.addCleanup(manager.close) + cancelled = threading.Event() + command = shlex.join([sys.executable, '-c', 'import time; time.sleep(30)']) + first = manager.start(command, cwd=directory, env=None, timeout_seconds=30, cancelled=cancelled) + other = manager.start(command, cwd=directory, env=None, timeout_seconds=30, cancelled=threading.Event()) + cancelled.set() + deadline = time.monotonic() + 3 + while first.process.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertIsNotNone(first.process.poll()) + self.assertIsNone(other.process.poll()) + + def test_recovery_blocks_unknown_mutation_without_dispatching_it(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + root = Path(directory) + store = sqlite_owners.enter_context(closing(EventStore(root / 'state.sqlite3'))) + manager = TaskManager(store, workspace_id='project', session_id='session') + index = manager.add_task('append once', checkpoint={'action': 'run_cmd', 'args': 'echo duplicate >> marker'}) + manager.set_status(index, 'running') + recovered = TaskManager(store, workspace_id='project', session_id='session') + recovered.recover() + self.assertEqual(recovered.tasks[0]['status'], 'blocked') + self.assertEqual(recovered.tasks[0]['checkpoint']['recovery_effect']['status'], 'unknown') + calls = [] + worker = TaskWorker(recovered, lambda task: calls.append(task) or {'success': True}) + self.assertIsNone(worker.run_once()) + self.assertEqual(calls, []) + # A generic resume request cannot acknowledge or erase an unknown effect. + self.assertIsNone(recovered.resume(recovered.tasks[0]['id'])) + self.assertIsNone(worker.run_once()) + self.assertEqual(calls, []) + + def test_common_resume_unblock_and_native_binding_cannot_replay_unknown_effect(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + 'KYROZEN_DISABLE_VECTOR_INDEX': '1', 'KYROZEN_WORKSPACE_ROOT': directory}), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(root / 'state.sqlite3'))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime, manager = app.runtime, app.runtime.tasks + created = manager.edit_plan(expected_revision=0, operations=[ + {'op': 'add', 'ref': 'write', 'task': {'description': 'Write once', 'acceptance': ['Written'], + 'execution': {'action': 'write_file', 'arguments': {'path': 'marker.txt', 'content': 'duplicate'}}}}, + {'op': 'start', 'task': 'write'}, + ]) + task_id = created['references']['write'] + manager.recover() + self.assertIsNone(manager.resume(task_id)) + for operation in ({'op': 'unblock', 'task': task_id, 'reason': 'Model says retry'}, + {'op': 'start', 'task': task_id}, + {'op': 'replace', 'task': task_id, 'replacement': {'ref': 'retry', 'task': { + 'description': 'Retry same effect', 'acceptance': ['Written'], + 'execution': {'action': 'write_file', 'arguments': {'path': 'marker.txt', 'content': 'duplicate'}}}}}): + with self.subTest(operation=operation), self.assertRaises(ValueError): + manager.edit_plan(plan_id=created['plan_id'], expected_revision=1, operations=[operation]) + # Even stale in-memory task state cannot authorize an unresolved database row. + manager.tasks[0]['status'] = 'running' + self.assertFalse(manager.set_status(0, 'pending')) + manager.tasks[0]['checkpoint'].pop('recovery_effect') + self.assertIsNone(manager.execution_task_id) + receipt = runtime._execute_turn_action('write_file', {'path': 'marker.txt', 'content': 'duplicate'}, + operation_scope='restart', successful_operations=set(), call_id='replay', call_id_source='native', + task_id=task_id, task_plan_id=created['plan_id']) + self.assertFalse(receipt.success) + self.assertFalse((root / 'marker.txt').exists()) + + def test_unclassified_running_native_task_is_not_treated_as_read_only_after_restart(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / 'state.sqlite3'))) + manager = TaskManager(store, workspace_id='project', session_id='native') + manager.edit_plan(expected_revision=0, operations=[ + {'op': 'add', 'ref': 'work', 'task': {'description': 'Unclassified work', 'acceptance': ['Verified']}}, + {'op': 'start', 'task': 'work'}, + ]) + manager.recover() + self.assertEqual(manager.tasks[0]['status'], 'blocked') + self.assertEqual(manager.tasks[0]['checkpoint']['recovery_effect']['status'], 'unknown') + + def test_completed_background_handles_do_not_consume_live_process_limit(self): + from openkyrozen.tools.processes import SessionProcessManager + with tempfile.TemporaryDirectory() as directory: + manager = SessionProcessManager('user', directory, 'history') + self.addCleanup(manager.close) + first_id = None + for index in range(33): + handle = manager.start('echo retained-output', cwd=directory, env=None, timeout_seconds=5) + first_id = first_id or handle.process_id + handle.process.wait(timeout=3) + deadline = time.monotonic() + 2 + page = manager.read(first_id) + while not page['output'] and time.monotonic() < deadline: + time.sleep(.01) + page = manager.read(first_id) + self.assertEqual(page['output'], 'retained-output\n') + self.assertFalse(page['running']) + + def test_native_cancel_terminalizes_accepted_strategy_children_only(self): + self._assert_native_cancel_terminalizes_strategy_children(narrow_scope=False) + + def test_native_cancel_after_safe_narrowing_terminalizes_original_strategy_children(self): + self._assert_native_cancel_terminalizes_strategy_children(narrow_scope=True) + + def _assert_native_cancel_terminalizes_strategy_children(self, *, narrow_scope): + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import ModelResponse, ProviderCapabilities, FinishReason + from openkyrozen.providers.errors import ProviderError, ProviderErrorKind + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + 'KYROZEN_DISABLE_VECTOR_INDEX': '1', 'KYROZEN_WORKSPACE_ROOT': directory}), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(root / 'state.sqlite3'))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime, manager = app.runtime, app.runtime.tasks + controller = runtime._interaction_controller + proposal = controller.propose_plan({'title': 'Inspect', 'summary': 'Inspect assigned source', + 'bounds': {'workspace_paths': ['.'] if narrow_scope else ['marker.txt'], 'capabilities': ['read', 'write'], + 'effects': ['read_only', 'filesystem', 'internal'], 'risk_ceiling': 'normal'}, + 'steps': [{'id': 'source', 'title': 'Source', 'description': 'Inspect marker.txt', + 'acceptance': ['Exact source verified']}]}) + accepted, _ = controller.accept_plan(manager, plan_id=proposal['plan_id'], version=proposal['version']) + anchor = accepted['working_task_ids'][0] + split = manager.edit_plan(plan_id=manager.working_plan.id, expected_revision=1, operations=[ + {'op': 'split', 'task': anchor, 'children': [ + {'ref': 'inspect', 'task': {'description': 'Inspect source', 'acceptance': ['Inspected']}}, + {'ref': 'verify', 'task': {'description': 'Verify source', 'acceptance': ['Verified']}}, + ]}, {'op': 'start', 'task': 'inspect'}, + ]) + if narrow_scope: + import copy + revision = copy.deepcopy(proposal) + revision['bounds']['workspace_paths'] = ['marker.txt'] + narrowed = controller.propose_plan(revision) + self.assertEqual(narrowed['revision_materiality'], 'within_bounds') + self.assertEqual(controller.state()['executing_plan']['version'], accepted['version'] + 1) + self.assertEqual(manager.tasks[0]['checkpoint']['strategic_plan']['version'], accepted['version']) + unrelated = manager.add_task('Unrelated later work') + unrelated_id = manager.tasks[unrelated]['id'] + class CancelProvider(LLMProvider): + def get_capabilities(self, model=None): + return ProviderCapabilities(native_tools=True) + def chat(self, messages, model=None): + raise AssertionError('legacy path used') + def chat_response(self, messages, model=None, *, tools=None): + return ModelResponse(finish_reason=FinishReason.CANCELLED) + config = ProviderConfig(provider='custom', model_simple='fixture', model_complex='fixture') + provider = CancelProvider(config) + runtime._provider_config = config + runtime.llm_provider = runtime._default_turn.provider = provider + with self.assertRaises(ProviderError) as cancelled: + runtime.chat(runtime.current_session, 'Continue accepted source inspection') + self.assertEqual(cancelled.exception.kind, ProviderErrorKind.CANCELLED) + expected = {anchor, split['references']['inspect'], split['references']['verify']} + self.assertEqual({task['id'] for task in manager.tasks if task['status'] == 'cancelled'}, expected) + self.assertEqual(next(task['status'] for task in manager.tasks if task['id'] == unrelated_id), 'pending') + self.assertEqual(manager.working_plan.status, 'cancelled') + self.assertIsNone(controller.state()['executing_plan']) + recovered = TaskManager(manager.store, user_id=manager.user_id, workspace_id=manager.workspace_id, + session_id=manager.session_id) + recovered.recover() + self.assertTrue(all(task['status'] == 'cancelled' for task in recovered.tasks if task['id'] in expected)) + + def test_recovery_resumes_only_declared_read_only_operation(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / 'state.sqlite3'))) + manager = TaskManager(store, workspace_id='project', session_id='session') + index = manager.add_task('inspect', checkpoint={'action': 'read_file', 'args': 'note.txt'}) + manager.set_status(index, 'running') + recovered = TaskManager(store, workspace_id='project', session_id='session') + recovered.recover() + self.assertEqual(recovered.tasks[0]['status'], 'pending') + worker = TaskWorker(recovered, lambda task: {'success': True, 'action': 'read_file', 'result': 'note'}) + self.assertEqual(worker.run_once()['status'], 'succeeded') + + def test_jev_allow_remains_advisory_for_high_impact_operation(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), ExitStack() as sqlite_owners: + root = Path(directory) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(root / 'state.sqlite3'))), tools=ToolAdapters(root)) + self.addCleanup(app.close) + runtime = app.runtime + with patch.object(runtime, '_EXECUTION_SURFACE', 'tui'), \ + patch.object(runtime, 'permission_mode', return_value='full_jev'), \ + patch.object(runtime, '_jev_permission_check', return_value=True): + self.assertFalse(runtime._authorize_tool_action('git_push', {'arguments': ['origin', 'main']}, + approve=lambda *_: False)) + self.assertTrue(runtime._authorize_tool_action('git_push', {'arguments': ['origin', 'main']}, + approve=lambda *_: True)) + with patch.object(runtime, '_EXECUTION_SURFACE', 'tui'), \ + patch.object(runtime, 'permission_mode', return_value='full'): + self.assertTrue(runtime._authorize_tool_action('git_push', {'arguments': ['origin', 'main']}, + approve=lambda *_: False)) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 263bc12b..132e6b18 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import tempfile import unittest from datetime import datetime, timedelta, timezone @@ -9,8 +10,8 @@ class SchedulerTests(unittest.TestCase): def test_due_one_shot_is_claimed_once_and_audited(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) scheduler = JobScheduler(store, workspace_id="project") seen = [] scheduler.register_callback("test", lambda job: seen.append(job["id"])) diff --git a/tests/test_server.py b/tests/test_server.py index 0c0d5ae2..77870c82 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import os import asyncio import json @@ -74,16 +75,16 @@ def test_mcp_preserves_structured_working_plan_result(self): def test_history_api_lists_tree_rejects_stale_or_unconfirmed_rollbacks_and_restores(self): client = TestClient(server.app) - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) / "workspace" state = Path(directory) / "state" root.mkdir() state.mkdir() workspace_id = server._agent.interaction_workspace_id() - memory = MemoryBank( + memory = sqlite_owners.enter_context(closing(MemoryBank( state / "history.sqlite3", user_id=server._SERVER_ACTOR_ID, workspace_id=workspace_id, - ) + ))) session_id = "history-api-session" manager = HistoryManager( memory.store, root, state, source_scope_id=source_scope_id(root), @@ -134,9 +135,9 @@ def test_web_chat_does_not_reconcile_stale_snapshot_over_journal(self): original_sessions = server._sessions server._sessions = {} try: - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, - workspace_id=server._agent.interaction_workspace_id()) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, + workspace_id=server._agent.interaction_workspace_id()))) with patch.object(server._agent.current_session, "memory", memory): session = server._get_or_create_session(session_id, server._SERVER_ACTOR_ID) bound = server._agent.open_session(session_id, user_id=server._SERVER_ACTOR_ID) @@ -167,9 +168,9 @@ def test_web_session_read_prefers_journal_over_older_workspace_snapshot(self): original_sessions = server._sessions server._sessions = {} try: - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, - workspace_id=server._agent.interaction_workspace_id()) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, + workspace_id=server._agent.interaction_workspace_id()))) with patch.object(server._agent.current_session, "memory", memory): bound = server._agent.open_session(session_id, user_id=server._SERVER_ACTOR_ID) with server._agent.use_session(bound): @@ -200,9 +201,9 @@ def test_empty_canonical_web_branch_does_not_restore_legacy_events(self): original_sessions = server._sessions server._sessions = {} try: - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, - workspace_id=server._agent.interaction_workspace_id()) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, + workspace_id=server._agent.interaction_workspace_id()))) with patch.object(server._agent.current_session, "memory", memory): bound = server._agent.open_session(session_id, user_id=server._SERVER_ACTOR_ID) with server._agent.use_session(bound): @@ -225,8 +226,8 @@ def test_empty_canonical_web_branch_does_not_restore_legacy_events(self): def test_chat_interaction_controls_persist_and_restore_without_new_endpoint(self): client = TestClient(server.app) - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="interaction-web") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="interaction-web"))) original_sessions = server._sessions server._sessions = {} try: @@ -389,6 +390,9 @@ def test_subagent_api_executes_allowed_file_action_and_rejects_disallowed_action original_root = runtime._get_workspace_root() runtime._set_workspace_root(root) class FixtureProvider: + name = "server-native-fixture" + _continuation_id = "server-delegation-fixture" + def get_capabilities(self, model): from openkyrozen.providers.models import ProviderCapabilities return ProviderCapabilities(native_tools=True) @@ -396,16 +400,30 @@ def get_capabilities(self, model): from openkyrozen.providers.models import FinishReason, ModelResponse, ToolCall responses = iter([ ModelResponse(tool_calls=(ToolCall("write", "write_file", {"path": "allowed.txt", "content": "created"}),), finish_reason=FinishReason.TOOL_REQUEST), - ModelResponse(text=json.dumps(result("Created allowed.txt"))), + ModelResponse(text=json.dumps(result("Created allowed.txt")), finish_reason=FinishReason.FINAL), ModelResponse(tool_calls=(ToolCall("review-read", "read_file", {"path": "allowed.txt"}),), finish_reason=FinishReason.TOOL_REQUEST), - ModelResponse(text=json.dumps(reviewed())), + ModelResponse(text=json.dumps(reviewed()), finish_reason=FinishReason.FINAL), ModelResponse(tool_calls=(ToolCall("deny-write", "write_file", {"path": "denied.txt", "content": "must not write"}),), finish_reason=FinishReason.TOOL_REQUEST), - ModelResponse(text=json.dumps(result("Write denied", status="blocked"))), + ModelResponse(text=json.dumps(result("Write denied", status="blocked")), finish_reason=FinishReason.FINAL), ]) provider = FixtureProvider() + + def native_response(messages, model=None, **kwargs): + from dataclasses import replace + from openkyrozen.providers.models import provider_continuation + + response = next(responses) + request_model = model or runtime.DEEPSEEK_MODEL + return replace(response, continuation=provider_continuation( + provider, request_model, "fixture", { + "role": "assistant", "content": response.text, + "tool_calls": [{"id": call.id, "name": call.name, "arguments": call.arguments} + for call in response.tool_calls], + }), metadata={"provider": provider.name, "model": request_model}) + try: with (patch.object(runtime, "get_provider", return_value=provider), - patch.object(runtime, "_get_model_response", side_effect=lambda *args, **kwargs: next(responses)), + patch.object(runtime, "_get_model_response", side_effect=native_response), patch.object(runtime, "_provider_config", ProviderConfig(provider="deepseek", api_key="fixture-key"))): allowed = client.post("/api/v2/agents/run", json={ "profile": "coder", "task": "Create allowed.txt", "session_id": "delegation-api-test", @@ -771,9 +789,9 @@ def fake_chat(message, clear_tasks=False, profile=None, memory_context=None): self.assertIs(server._agent.tasks.tasks, original_tasks) def test_single_user_web_chat_feeds_learning_without_attributed_chat(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, - workspace_id=server._agent.memory_bank.workspace_id) + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", user_id=server._SERVER_ACTOR_ID, + workspace_id=server._agent.memory_bank.workspace_id))) with patch.object(server._agent.current_session, "memory", memory), \ patch.object(server._agent, "_chat_turn", return_value="noted"): server._run_session_chat({"session_id": "solo-audit", "messages": [], "updated": 0}, diff --git a/tests/test_site_release_boundary.py b/tests/test_site_release_boundary.py new file mode 100644 index 00000000..17f7d555 --- /dev/null +++ b/tests/test_site_release_boundary.py @@ -0,0 +1,38 @@ +"""Public installation commands must resolve before the V3 release exists.""" +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('release_site', ROOT / 'website/build_site.py') +site = importlib.util.module_from_spec(spec) +spec.loader.exec_module(site) + + +class SiteReleaseBoundaryTests(unittest.TestCase): + def test_rendered_downloads_use_published_stable_release(self): + for lang in ('en', 'zh-cn'): + for slug in ('home', 'install'): + with self.subTest(lang=lang, slug=slug): + rendered = site.document(lang, slug, site.PAGES[lang][slug]) + self.assertIn('/v2.0.8/install.', rendered) + self.assertNotIn('/v3.0.0/', rendered) + self.assertNotIn('openkyrozen-3.0.0-py3', rendered) + + def test_platform_switcher_preserves_published_download(self): + script = (ROOT/'website/assets/install-platform.js').read_text() + self.assertIn('/v2.0.8/install.sh', script) + self.assertIn('/v2.0.8/install.ps1', script) + self.assertNotIn('/v3.0.0/', script) + + def test_v3_is_identified_as_unreleased_in_both_languages(self): + for lang, phrase in (('en','V3 is not yet released'),('zh-cn','V3 尚未发布')): + rendered = site.document(lang,'install',site.PAGES[lang]['install']) + self.assertIn(phrase, rendered) + + def test_readme_downloads_also_use_the_published_release(self): + for readme in ROOT.glob('README*.md'): + text=readme.read_text() + with self.subTest(readme=readme.name): + self.assertTrue('/v2.0.8/install.' in text) + self.assertFalse('/v3.0.0/' in text) diff --git a/tests/test_skill_registry.py b/tests/test_skill_registry.py index b89b7be7..76c79314 100644 --- a/tests/test_skill_registry.py +++ b/tests/test_skill_registry.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import tempfile import unittest @@ -14,9 +15,9 @@ class SkillRegistryTests(unittest.TestCase): def test_packaged_builtins_seed_with_pinned_versions_and_match_runtime(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + registry = SkillRegistry(sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))), root=root / "installed") seeded = registry.seed_builtins() self.assertEqual({item["manifest"]["name"] for item in seeded}, { "bug-fix", "git-workflow", "graphify", "github-cli", "ponytail", @@ -45,7 +46,7 @@ def test_packaged_builtins_seed_with_pinned_versions_and_match_runtime(self): self.assertIn("Graphify project intelligence", installed.read_text(encoding="utf-8")) def test_builtin_upgrade_retains_previous_version_for_rollback(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) source = root / "old" source.mkdir() @@ -58,7 +59,7 @@ def test_builtin_upgrade_retains_previous_version_for_rollback(self): "profiles": ["coder", "researcher"], "triggers": ["project"], "checksum": "sha256:" + hashlib.sha256(body.encode()).hexdigest(), }), encoding="utf-8") - registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + registry = SkillRegistry(sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))), root=root / "installed") old = registry.install(source, activate=True, source_name="builtin") registry.seed_builtins() previous = next(item for item in registry.list() if item["id"] == old["id"]) @@ -66,7 +67,7 @@ def test_builtin_upgrade_retains_previous_version_for_rollback(self): self.assertTrue(Path(previous["path"]).is_dir()) def test_skill_install_validates_permissions_and_requires_explicit_activation(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) source = root / "source" source.mkdir() @@ -75,7 +76,7 @@ def test_skill_install_validates_permissions_and_requires_explicit_activation(se "name": "test-skill", "version": "1.0.0", "description": "test", "permissions": ["workspace.read"], "profiles": ["coder"], "triggers": ["pytest"], }), encoding="utf-8") - registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + registry = SkillRegistry(sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))), root=root / "installed") installed = registry.install(source) self.assertEqual(installed["status"], "candidate") self.assertTrue(registry.activate(installed["id"])) @@ -83,18 +84,18 @@ def test_skill_install_validates_permissions_and_requires_explicit_activation(se self.assertTrue(registry.rollback(installed["id"])) def test_skill_rejects_undeclared_permission(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) source = root / "source" source.mkdir() (source / "SKILL.md").write_text("# Test\n", encoding="utf-8") (source / "skill.json").write_text(json.dumps({"name": "bad-skill", "permissions": ["secrets.read"]}), encoding="utf-8") - registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + registry = SkillRegistry(sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))), root=root / "installed") with self.assertRaises(ValueError): registry.install(source) def test_runtime_local_skill_requires_profiles_and_triggers(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) source = root / "source" source.mkdir() @@ -102,7 +103,7 @@ def test_runtime_local_skill_requires_profiles_and_triggers(self): (source / "skill.json").write_text(json.dumps({ "name": "missing-runtime-fields", "version": "1.0.0", "permissions": [], }), encoding="utf-8") - registry = SkillRegistry(EventStore(root / "state.sqlite3"), root=root / "installed") + registry = SkillRegistry(sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))), root=root / "installed") with self.assertRaisesRegex(ValueError, "profiles"): registry.install(source) @@ -111,7 +112,7 @@ def test_active_local_skill_reaches_runtime_prompt_with_auditable_receipt(self): original_registry = main.skill_registry original_engine = main.learning_engine original_root = main._get_workspace_root() - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) source = root / "source" source.mkdir() @@ -132,8 +133,8 @@ def test_active_local_skill_reaches_runtime_prompt_with_auditable_receipt(self): "description": "A local runtime guide", "permissions": [], "profiles": ["coder"], "triggers": ["pytest"], }), encoding="utf-8") - memory = MemoryBank(root / "state.sqlite3", user_id="skill-user", - workspace_id="skill-workspace", session_id="skill-session") + memory = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3", user_id="skill-user", + workspace_id="skill-workspace", session_id="skill-session"))) registry = SkillRegistry(memory.store, workspace_id="skill-workspace", root=root / "installed") engine = LearningEngine(memory, registry=registry) installed = registry.install(source, activate=True) diff --git a/tests/test_sqlite_connection_lifecycle.py b/tests/test_sqlite_connection_lifecycle.py new file mode 100644 index 00000000..2a921714 --- /dev/null +++ b/tests/test_sqlite_connection_lifecycle.py @@ -0,0 +1,467 @@ +"""Application-owned WAL lifetime, using real SQLite without agent/SDK startup.""" +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing, contextmanager +from contextvars import copy_context +from pathlib import Path +import sqlite3 +import os +import sys +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from openkyrozen.app.container import Application +from openkyrozen.app.bootstrap import build_memory +from openkyrozen.memory.service import MemoryBank +from openkyrozen.persistence.store import EventStore +from openkyrozen.providers import usage + + +class SQLiteConnectionLifecycleTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix='kyrozen-sqlite-lifetime-') + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + @staticmethod + def _cleanup_store(store): + close = getattr(store, 'close', None) + if callable(close): + close() + + def _store(self, name='state.sqlite3'): + store = EventStore(self.root / name) + self.addCleanup(self._cleanup_store, store) + return store + + def _close(self, store): + close = getattr(store, 'close', None) + self.assertTrue(callable(close), 'EventStore.close() must own explicit SQLite shutdown') + return close() + + @contextmanager + def _observed_store(self): + """Observe real handle lifetime without depending on a private anchor name.""" + opened = [] + real_connect = sqlite3.connect + + class ObservedConnection(sqlite3.Connection): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.close_started = self.closed = False + opened.append(self) + + def close(self): + self.close_started = True + super().close() + self.closed = True + + def connect(*args, **kwargs): + kwargs.setdefault('factory', ObservedConnection) + return real_connect(*args, **kwargs) + + with patch.object(sqlite3, 'connect', side_effect=connect): + yield self._store(), opened + + def _application(self, store): + shutdown_started = threading.Event() + + class Executor(ThreadPoolExecutor): + def shutdown(self, *args, **kwargs): + shutdown_started.set() + return super().shutdown(*args, **kwargs) + + executor = Executor(max_workers=1) + self.addCleanup(executor.shutdown, wait=True, cancel_futures=True) + memory = MemoryBank(store=store) + session = SimpleNamespace(subagents=SimpleNamespace(coordinator=None), + process_manager=SimpleNamespace(close=lambda: None)) + runtime = SimpleNamespace(subagent_manager=SimpleNamespace(coordinator=None), + _default_session=session, _sessions={}, _technology_executor=executor, + _workspaces={}, memory_bank=memory) + return Application(runtime, None, store), executor, shutdown_started + + def test_ordinary_leases_keep_wal_materialized_while_owner_is_alive(self): + store = self._store() + wal = Path(str(store.path) + '-wal') + for index in range(3): + store.append_event('lifetime.probe', {'index': index}) + self.assertTrue(wal.is_file(), 'An ordinary lease performed last-client WAL cleanup') + self.assertGreater(wal.stat().st_size, 32) + with store.connection() as connection: + self.assertEqual(connection.execute('SELECT COUNT(*) FROM events').fetchone()[0], index + 1) + self.assertTrue(wal.is_file(), 'A finished read must not tear down the owned WAL client') + + def test_idle_owner_holds_neither_writer_lock_nor_read_transaction(self): + store = self._store() + store.append_event('lifetime.probe', {}) + with closing(sqlite3.connect(store.path, timeout=.05, isolation_level=None)) as writer: + writer.execute('BEGIN IMMEDIATE') + writer.execute("INSERT INTO events(id,event_type,payload,created_at) VALUES('external','probe','{}','now')") + writer.execute('COMMIT') + checkpoint = writer.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone() + self.assertEqual(checkpoint[0], 0, 'An idle owner retained a read transaction') + with store.connection() as connection: + self.assertFalse(connection.in_transaction) + self.assertEqual(connection.execute("SELECT COUNT(*) FROM events WHERE id='external'").fetchone()[0], 1) + self.assertTrue(Path(str(store.path) + '-wal').exists(), 'The idle owner must remain materialized') + + def test_explicit_owner_close_checkpoints_idempotently_and_rejects_new_leases(self): + with self._observed_store() as (store, opened): + store.append_event('lifetime.probe', {'committed': True}) + # A failed open must release its reserved lease before owner shutdown. + with patch.object(sqlite3, 'connect', side_effect=sqlite3.OperationalError('unavailable database')): + with self.assertRaisesRegex(sqlite3.OperationalError, 'unavailable database'): + with store.connection(): + self.fail('Unavailable SQLite unexpectedly admitted a lease') + self._close(store) + self.assertTrue(all(connection.closed for connection in opened)) + self.assertFalse(Path(str(store.path) + '-wal').exists(), 'Final writable owner close must checkpoint WAL') + count = len(opened) + self._close(store) + with self.assertRaisesRegex(RuntimeError, 'clos'): + with store.connection(): + self.fail('A closed store admitted a new connection lease') + self.assertEqual(len(opened), count, 'Closed storage reopened SQLite before rejecting the lease') + with closing(sqlite3.connect(store.path)) as persisted: + self.assertEqual(persisted.execute('SELECT COUNT(*) FROM events').fetchone()[0], 1) + + def test_owner_close_defers_handle_close_until_existing_writer_lease_drains(self): + with self._observed_store() as (store, opened): + self.assertTrue(callable(getattr(store, 'close', None)), 'EventStore.close() is required') + idle = [connection for connection in opened if not connection.closed] + self.assertTrue(idle, 'The application must own a materialized idle SQLite client') + entered, release, closing_started, rejected = (threading.Event() for _ in range(4)) + errors, leases = [], [] + + def writer(): + try: + with store.connection() as connection: + leases.append(connection) + connection.execute('BEGIN IMMEDIATE') + connection.execute("INSERT INTO events(id,event_type,payload,created_at) VALUES('leased','probe','{}','now')") + entered.set() + if not release.wait(5): + raise AssertionError('Test did not release the writer lease') + connection.execute('COMMIT') + except BaseException as error: + errors.append(error) + + def close_owner(): + closing_started.set() + try: + store.close() + except BaseException as error: + errors.append(error) + + def late_lease(): + try: + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + try: + with store.connection() as connection: + connection.execute('SELECT 1') + except RuntimeError as error: + if 'clos' not in str(error).lower(): + raise + rejected.set() + return + time.sleep(.005) + raise AssertionError('Closing storage kept admitting new leases') + except BaseException as error: + errors.append(error) + + worker = threading.Thread(target=writer, daemon=True) + closer = threading.Thread(target=close_owner, daemon=True) + late = threading.Thread(target=late_lease, daemon=True) + worker.start() + try: + self.assertTrue(entered.wait(3)) + closer.start() + self.assertTrue(closing_started.wait(3)) + late.start() + self.assertTrue(rejected.wait(3), errors) + self.assertTrue(all(not connection.close_started for connection in idle), + 'Owner close began before an existing lease drained') + self.assertFalse(leases[0].close_started) + finally: + release.set() + for thread in (worker, closer, late): + if thread.ident is not None: + thread.join(3) + self.assertFalse(thread.is_alive(), 'SQLite shutdown left an owned thread alive') + self.assertEqual(errors, []) + self.assertTrue(all(connection.closed for connection in idle + leases)) + self.assertFalse(Path(str(store.path) + '-wal').exists()) + self._close(store) + with closing(sqlite3.connect(store.path)) as persisted: + self.assertEqual(persisted.execute("SELECT COUNT(*) FROM events WHERE id='leased'").fetchone()[0], 1) + + def test_closed_store_rejects_late_background_writes_without_reopening(self): + with self._observed_store() as (store, opened): + self._close(store) + count = len(opened) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(store.append_event, 'late.background', {}) + with self.assertRaisesRegex(RuntimeError, 'clos'): + future.result(timeout=3) + self.assertEqual(len(opened), count) + self.assertFalse(Path(str(store.path) + '-wal').exists()) + + def test_unscoped_usage_helpers_close_temporary_stores(self): + operations = ( + lambda: usage._track_cost('ollama', {'prompt_tokens': 1}), + usage.get_cost_report, + usage.reset_cost_tracker, + ) + for operation in operations: + with self.subTest(operation=operation), self._observed_store() as (store, opened), closing(store): + with patch.object(usage, 'EventStore', return_value=store): + operation() + self.assertTrue(all(connection.closed for connection in opened), + 'A temporary usage-store owner retained its SQLite anchor') + self.assertFalse(Path(str(store.path) + '-wal').exists()) + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('after.usage.close', {}) + + def test_unscoped_usage_helpers_close_temporary_stores_when_operation_raises(self): + operations = ( + (lambda: usage._track_cost('ollama', {'prompt_tokens': 1}), 'record_usage_attempt'), + (usage.get_cost_report, 'usage_totals'), + (usage.reset_cost_tracker, 'create_usage_reset'), + ) + for operation, method in operations: + with self.subTest(method=method), self._observed_store() as (store, opened), closing(store): + with patch.object(usage, 'EventStore', return_value=store), \ + patch.object(store, method, side_effect=RuntimeError('usage failure')): + with self.assertRaisesRegex(RuntimeError, 'usage failure'): + operation() + self.assertTrue(all(connection.closed for connection in opened), + 'A failed usage operation leaked its temporary store') + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('after.usage.failure', {}) + + def test_usage_helpers_leave_borrowed_store_open_on_success_and_error(self): + store = self._store() + operations = ( + (lambda: usage._track_cost('ollama', {'prompt_tokens': 1}), 'record_usage_attempt'), + (lambda: usage.get_cost_report(store=store), 'usage_totals'), + (lambda: usage.reset_cost_tracker(store=store), 'create_usage_reset'), + ) + with usage.usage_scope(store=store): + for operation, method in operations: + with self.subTest(method=method): + operation() + store.append_event('borrowed.usage.success', {}) + with patch.object(store, method, side_effect=RuntimeError('usage failure')): + with self.assertRaisesRegex(RuntimeError, 'usage failure'): + operation() + store.append_event('borrowed.usage.failure', {}) + self.assertEqual(sum(event['event_type'].startswith('borrowed.usage.') + for event in store.list_events()), 6) + + def test_usage_pricing_error_does_not_create_a_fallback_store(self): + with patch.object(usage, '_legacy_pricing_snapshot', side_effect=ValueError('pricing failure')), \ + patch.object(usage, 'EventStore') as constructor: + with self.assertRaisesRegex(ValueError, 'pricing failure'): + usage._track_cost('ollama', {'prompt_tokens': 1}) + constructor.assert_not_called() + + def test_scoped_memory_close_leaves_shared_store_usable(self): + with patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): + memory = build_memory(self.root / 'memory.sqlite3') + store = memory.store + self.addCleanup(self._cleanup_store, store) + first = memory.scoped(session_id='first') + second = memory.scoped(session_id='second') + first.close() + store.append_event('lifetime.first_closed', {}) + second.close() + store.append_event('lifetime.second_closed', {}) + self.assertIs(first.store, second.store) + self.assertEqual(len(store.list_events()), 2) + memory.close() + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('after.owner.close', {}) + + def test_standalone_memory_close_releases_its_created_database(self): + with patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): + memory = build_memory(self.root / 'memory.sqlite3') + self.addCleanup(self._cleanup_store, memory.store) + memory.store.append_event('owned.memory', {}) + memory.close() + memory.close() + with self.assertRaisesRegex(RuntimeError, 'clos'): + memory.store.append_event('after.owner.close', {}) + self.assertFalse(Path(str(memory.store.path) + '-wal').exists()) + + def test_memory_with_injected_store_borrows_database_ownership(self): + store = self._store() + with patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): + memory = build_memory(store=store) + memory.close() + store.append_event('borrowed.store', {}) + self.assertEqual(len(store.list_events()), 1) + + def test_failed_memory_construction_closes_only_a_created_store(self): + for owned in (True, False): + with self.subTest(owned=owned), self._observed_store() as (store, opened), closing(store): + with patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), \ + patch('openkyrozen.persistence.store.EventStore', return_value=store), \ + patch('openkyrozen.memory.service.MemoryBank', side_effect=ValueError('memory initialization failed')): + with self.assertRaisesRegex(ValueError, 'memory initialization failed'): + build_memory(self.root / 'memory.sqlite3', store=None if owned else store) + if owned: + self.assertTrue(all(connection.closed for connection in opened), + 'Failed memory construction retained its new SQLite owner') + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('after.failed.startup', {}) + else: + store.append_event('borrowed.after.failed.startup', {}) + + def test_one_shot_v1_migration_closes_owned_memory_on_success_and_error(self): + from openkyrozen.persistence import migration + for failing in (False, True): + with self.subTest(failing=failing), patch.dict(os.environ, {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): + source = self.root / f'legacy-{failing}' + source.mkdir() + memory = build_memory(self.root / f'migrated-{failing}.sqlite3') + self.addCleanup(memory.close) + + class Client: + def get_collection(self, name): + if failing: + def fail(**kwargs): + raise ValueError('legacy collection failed') + return SimpleNamespace(get=fail) + raise KeyError(name) + + modules = { + 'chromadb': SimpleNamespace(PersistentClient=lambda **kwargs: Client()), + 'chromadb.config': SimpleNamespace(Settings=lambda **kwargs: None), + } + with patch.dict(sys.modules, modules), patch.object(migration, 'MemoryBank', return_value=memory): + if failing: + with self.assertRaisesRegex(ValueError, 'legacy collection failed'): + migration.migrate_v1_chroma(source, memory.db_path) + else: + report = migration.migrate_v1_chroma(source, memory.db_path) + self.assertEqual(report['imported_logs'], 0) + self.assertEqual(report['imported_files'], 0) + with self.assertRaisesRegex(RuntimeError, 'clos'): + memory.store.append_event('after.migration', {}) + self.assertFalse(Path(str(memory.db_path) + '-wal').exists()) + + def test_scoped_memory_close_does_not_close_shared_index(self): + calls = [] + index = SimpleNamespace(memories=None, files=None, close=lambda: calls.append('closed')) + memory = MemoryBank(store=self._store(), index=index) + memory.scoped(session_id='borrowed').close() + self.assertEqual(calls, []) + memory.close() + memory.close() + self.assertEqual(calls, ['closed']) + + def test_application_is_the_explicit_store_shutdown_owner(self): + store = self._store() + app, _, _ = self._application(store) + store.append_event('lifetime.probe', {}) + app.close() + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('after.application.close', {}) + self.assertFalse(Path(str(store.path) + '-wal').exists()) + + def test_application_close_uses_default_memory_owner_when_active_view_is_borrowed(self): + store = self._store() + calls = [] + index = SimpleNamespace(memories=None, files=None, close=lambda: calls.append('closed')) + owner = MemoryBank(store=store, index=index) + app, _, _ = self._application(store) + app.runtime._default_session.memory = owner + app.runtime.memory_bank = owner.scoped(session_id='borrowed') + app.close() + self.assertEqual(calls, ['closed']) + + def test_application_shutdown_preserves_an_existing_background_writer(self): + with self._observed_store() as (store, opened): + app, executor, shutdown_started = self._application(store) + idle = [connection for connection in opened if not connection.closed] + entered, release = threading.Event(), threading.Event() + errors, leases = [], [] + + def write(): + with store.connection() as connection: + leases.append(connection) + connection.execute('BEGIN IMMEDIATE') + connection.execute("INSERT INTO events(id,event_type,payload,created_at) VALUES('background','probe','{}','now')") + entered.set() + if not release.wait(5): + raise AssertionError('Test did not release the background writer') + connection.execute('COMMIT') + + def close(): + try: + app.close() + except BaseException as error: + errors.append(error) + + future = executor.submit(write) + closer = threading.Thread(target=close, daemon=True) + try: + self.assertTrue(entered.wait(3)) + closer.start() + self.assertTrue(shutdown_started.wait(3)) + self.assertFalse(leases[0].close_started) + self.assertTrue(all(not connection.close_started for connection in idle)) + finally: + release.set() + if closer.ident is not None: + closer.join(3) + self.assertFalse(closer.is_alive()) + future.result(timeout=3) + self.assertEqual(errors, []) + with self.assertRaisesRegex(RuntimeError, 'clos'): + store.append_event('late.background', {}) + self.assertFalse(Path(str(store.path) + '-wal').exists()) + with closing(sqlite3.connect(store.path)) as persisted: + self.assertEqual(persisted.execute("SELECT COUNT(*) FROM events WHERE id='background'").fetchone()[0], 1) + + def test_lease_lifetime_preserves_per_database_and_per_thread_cancellation_budget(self): + store, other = self._store(), self._store('other.sqlite3') + observed = [] + with store._cancellation_deadline(): + with store.connection() as connection: + budget = connection.execute('PRAGMA busy_timeout').fetchone()[0] + self.assertGreater(budget, 0) + self.assertLessEqual(budget, 300) + with other.connection() as connection: + self.assertEqual(connection.execute('PRAGMA busy_timeout').fetchone()[0], 30000) + context = copy_context() + + def read_budget(): + with store.connection() as connection: + observed.append(connection.execute('PRAGMA busy_timeout').fetchone()[0]) + + thread = threading.Thread(target=context.run, args=(read_budget,), daemon=True) + thread.start() + thread.join(3) + self.assertFalse(thread.is_alive()) + self.assertEqual(observed, [30000]) + with store.connection() as connection: + self.assertEqual(connection.execute('PRAGMA busy_timeout').fetchone()[0], 30000) + + def test_exception_in_lease_rolls_back_without_retaining_a_writer_transaction(self): + store = self._store() + with self.assertRaisesRegex(RuntimeError, 'rollback probe'): + with store.connection() as connection: + connection.execute('BEGIN IMMEDIATE') + connection.execute("INSERT INTO events(id,event_type,payload,created_at) VALUES('rolled-back','probe','{}','now')") + raise RuntimeError('rollback probe') + with store.connection() as connection: + self.assertFalse(connection.in_transaction) + self.assertEqual(connection.execute("SELECT COUNT(*) FROM events WHERE id='rolled-back'").fetchone()[0], 0) + connection.execute('BEGIN IMMEDIATE') + connection.execute('ROLLBACK') diff --git a/tests/test_sqlite_migrations.py b/tests/test_sqlite_migrations.py index 2fe98918..fced9cf4 100644 --- a/tests/test_sqlite_migrations.py +++ b/tests/test_sqlite_migrations.py @@ -1,4 +1,5 @@ from __future__ import annotations +from contextlib import ExitStack, closing import sqlite3 import stat @@ -110,6 +111,15 @@ def _sqlite(path): class SQLiteMigrationTests(unittest.TestCase): + def test_each_runtime_connection_uses_the_configured_wal_sync_mode(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + database = sqlite_owners.enter_context(closing(SQLiteDatabase(Path(directory) / "sync.sqlite3"))) + + with database.connection() as connection: + synchronous = connection.execute("PRAGMA synchronous").fetchone()[0] + + self.assertEqual(synchronous, 1) # NORMAL is configured by SQLiteDatabase initialization. + def _historical_database(self, path: Path, version: int, *, malformed: bool = False) -> None: db = sqlite3.connect(path) try: @@ -145,11 +155,11 @@ def _historical_database(self, path: Path, version: int, *, malformed: bool = Fa def test_historical_versions_upgrade_in_order_and_preserve_user_data(self): for version in (1, 2, 3): - with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" self._historical_database(path, version) - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -165,12 +175,12 @@ def test_historical_versions_upgrade_in_order_and_preserve_user_data(self): self.assertEqual(db.execute("SELECT id FROM usage_attempts").fetchone()[0], "usage-1") if version >= 3: self.assertEqual(db.execute("SELECT head_id FROM history_heads").fetchone()[0], "history-1") - second_open = SQLiteDatabase(path) + second_open = sqlite_owners.enter_context(closing(SQLiteDatabase(path))) self.assertIsNone(second_open.last_backup_path) self.assertEqual(len(list(Path(directory).glob("*.backup.*"))), 1) def test_incompatible_existing_table_rolls_back_migration(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" self._historical_database(path, 2) with _sqlite(path) as db: @@ -180,7 +190,7 @@ def test_incompatible_existing_table_rolls_back_migration(self): ) with self.assertRaisesRegex(RuntimeError, "history_nodes is missing required columns"): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -193,10 +203,10 @@ def test_incompatible_existing_table_rolls_back_migration(self): ) def test_fresh_database_records_each_ordered_migration(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "fresh.sqlite3" - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -205,14 +215,14 @@ def test_fresh_database_records_each_ordered_migration(self): ) def test_compacted_prior_version_history_remains_valid_after_upgrade(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "compacted-history.sqlite3" self._historical_database(path, 2) with _sqlite(path) as db: db.execute("DELETE FROM schema_migrations WHERE version < 2") - SQLiteDatabase(path) - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -221,14 +231,14 @@ def test_compacted_prior_version_history_remains_valid_after_upgrade(self): ) def test_upgrade_accepts_prior_current_version_marker_without_backfilled_history(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "legacy-history.sqlite3" self._historical_database(path, 3) with _sqlite(path) as db: db.execute("DELETE FROM schema_migrations WHERE version < 3") - SQLiteDatabase(path) - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -237,13 +247,13 @@ def test_upgrade_accepts_prior_current_version_marker_without_backfilled_history ) def test_sparse_legacy_history_is_valid_when_declared_schema_is_complete(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "sparse-history.sqlite3" self._historical_database(path, 3) with _sqlite(path) as db: db.execute("DELETE FROM schema_migrations WHERE version = 2") - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual( @@ -252,7 +262,7 @@ def test_sparse_legacy_history_is_valid_when_declared_schema_is_complete(self): ) def test_missing_task_columns_are_rejected_before_startup_succeeds(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "missing-task-columns.sqlite3" self._historical_database(path, 1) with _sqlite(path) as db: @@ -260,12 +270,12 @@ def test_missing_task_columns_are_rejected_before_startup_succeeds(self): before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "tasks is missing required columns"): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) self.assertEqual(path.read_bytes(), before) def test_future_version_is_rejected_without_modifying_database(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "future.sqlite3" with _sqlite(path) as db: db.execute("CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)") @@ -275,14 +285,14 @@ def test_future_version_is_rejected_without_modifying_database(self): before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "newer schema"): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) self.assertEqual(path.read_bytes(), before) self.assertFalse(list(Path(directory).glob("*.backup.*"))) def test_nonpositive_or_gapped_migration_history_is_rejected(self): for versions in ((0,), (1, 3)): - with self.subTest(versions=versions), tempfile.TemporaryDirectory() as directory: + with self.subTest(versions=versions), tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "invalid-history.sqlite3" with _sqlite(path) as db: db.execute("CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)") @@ -290,24 +300,24 @@ def test_nonpositive_or_gapped_migration_history_is_rejected(self): before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "inconsistent|migration"): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) self.assertEqual(path.read_bytes(), before) self.assertFalse(list(Path(directory).glob("*.backup.*"))) def test_inconsistent_core_schema_is_rejected_without_repairing_it(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "inconsistent.sqlite3" self._historical_database(path, 1, malformed=True) before = path.read_bytes() with self.assertRaisesRegex(RuntimeError, "inconsistent"): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) self.assertEqual(path.read_bytes(), before) def test_upgrade_backup_is_private_and_contains_committed_wal_data(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "legacy.sqlite3" self._historical_database(path, 1) wal_writer = sqlite3.connect(path) @@ -315,7 +325,7 @@ def test_upgrade_backup_is_private_and_contains_committed_wal_data(self): wal_writer.execute("UPDATE tasks SET description='committed WAL state' WHERE id='legacy-task'") wal_writer.commit() - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) backups = list(Path(directory).glob("legacy.sqlite3.backup.*")) self.assertEqual(len(backups), 1) @@ -326,14 +336,14 @@ def test_upgrade_backup_is_private_and_contains_committed_wal_data(self): wal_writer.close() def test_failed_migration_rolls_back_schema_and_version(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "rollback.sqlite3" self._historical_database(path, 1) with _sqlite(path) as db: db.execute("CREATE VIEW usage_attempts AS SELECT 1 AS id") with self.assertRaises(sqlite3.OperationalError): - SQLiteDatabase(path) + sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertEqual(db.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0], 1) @@ -342,13 +352,13 @@ def test_failed_migration_rolls_back_schema_and_version(self): self.assertIsNone(db.execute("SELECT name FROM sqlite_master WHERE name='learning_runtime'").fetchone()) def test_same_version_additive_schema_variant_is_completed_with_backup(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "additive.sqlite3" self._historical_database(path, 3) with _sqlite(path) as db: db.execute("DROP TABLE learning_runtime") - database = SQLiteDatabase(path) + database = sqlite_owners.enter_context(closing(SQLiteDatabase(path))) with _sqlite(path) as db: self.assertIsNotNone(db.execute("SELECT name FROM sqlite_master WHERE name='learning_runtime'").fetchone()) @@ -365,7 +375,7 @@ def test_concurrent_initializers_apply_legacy_migrations_once(self): def initialize(): try: barrier.wait() - SQLiteDatabase(path) + SQLiteDatabase(path).close() except Exception as exc: errors.append(exc) @@ -385,8 +395,8 @@ def initialize(): ) def test_task_replacement_rolls_back_if_any_new_row_is_invalid(self): - with tempfile.TemporaryDirectory() as directory: - database = SQLiteDatabase(Path(directory) / "tasks.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + database = sqlite_owners.enter_context(closing(SQLiteDatabase(Path(directory) / "tasks.sqlite3"))) repository = TasksRepository(database) previous = {"id": "old", "description": "keep", "status": "pending"} repository.replace_tasks([previous], workspace_id="project") @@ -401,8 +411,8 @@ def test_task_replacement_rolls_back_if_any_new_row_is_invalid(self): self.assertEqual([row["id"] for row in rows], ["old"]) def test_approval_decisions_are_scoped_versioned_history_not_authority(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) request = ApprovalRequest( call_id="call-1", tool_name="write_file", arguments={"path": "notes.txt", "api_key": "secret-value"}, @@ -442,8 +452,8 @@ def interaction_workspace_id(): self.assertEqual(payload["mode"], "ask") def test_cli_approval_is_persisted_to_the_same_scoped_event_history(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) request = ApprovalRequest( call_id="call-cli", tool_name="git_push", arguments={"remote": "origin"}, workspace_id="project", session_id="session-cli", tool_version="1", @@ -483,8 +493,8 @@ def interaction_workspace_id(): self.assertEqual(events[0]["payload"]["call_id"], "call-cli") def test_fallback_tui_approval_records_one_permission_decision(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) class Runtime: _EXECUTION_SURFACE = "tui" diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 84bbe164..a6931992 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import asyncio import json import tempfile @@ -140,8 +141,8 @@ async def exercise(): frames.append(frame.decode() if isinstance(frame, bytes) else frame) return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-native-events-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="native-events") + with tempfile.TemporaryDirectory(prefix="openkyrozen-native-events-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="native-events"))) original_sessions = server._sessions server._sessions = {} def emit_native_text(*_args, **_kwargs): @@ -204,8 +205,8 @@ async def exercise(): pass return leading, first, first_elapsed, provider_complete_before_release, remaining - with tempfile.TemporaryDirectory(prefix="openkyrozen-stream-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="stream-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-stream-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="stream-test"))) original_sessions = server._sessions server._sessions = {} try: @@ -253,8 +254,8 @@ async def exercise(): except StopAsyncIteration: return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-dsml-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="dsml-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-dsml-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="dsml-test"))) original_sessions = server._sessions server._sessions = {} try: @@ -316,8 +317,8 @@ async def exercise(): except StopAsyncIteration: return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-inline-control-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="inline-control-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-inline-control-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="inline-control-test"))) original_sessions = server._sessions server._sessions = {} try: @@ -370,8 +371,8 @@ async def exercise(): except StopAsyncIteration: return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-prose-alias-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="prose-alias-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-prose-alias-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="prose-alias-test"))) original_sessions = server._sessions server._sessions = {} try: @@ -423,8 +424,8 @@ async def exercise(): except StopAsyncIteration: return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-tool-name-prose-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="tool-name-prose-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-tool-name-prose-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="tool-name-prose-test"))) original_sessions = server._sessions server._sessions = {} try: @@ -483,8 +484,8 @@ async def exercise(): except StopAsyncIteration: return frames - with tempfile.TemporaryDirectory(prefix="openkyrozen-generic-invoke-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="generic-invoke-test") + with tempfile.TemporaryDirectory(prefix="openkyrozen-generic-invoke-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="generic-invoke-test"))) original_sessions = server._sessions server._sessions = {} try: diff --git a/tests/test_subagents.py b/tests/test_subagents.py index 700de427..1a1ba336 100644 --- a/tests/test_subagents.py +++ b/tests/test_subagents.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import tempfile @@ -442,8 +443,8 @@ def write(path, entered): unrelated.join(timeout=1) def test_profile_has_independent_session_memory_and_capabilities(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project"))) manager = SubAgentManager( memory, runner=lambda profile, task, context, tools, *, run_id: f"done:{profile.name}", ) @@ -521,8 +522,8 @@ def test_action_loop_retries_unsupported_provider_wrapper(self): self.assertNotIn("SSAI_ACTION", result["result"]) def test_manager_persists_tool_receipts_failures_and_evidence(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project"))) manager = SubAgentManager(memory, runner=lambda profile, task, context, tools, *, run_id: { "result": "completed", "tool_records": [{"receipt_id": "receipt-1", "action": "write_file", @@ -544,8 +545,8 @@ def test_manager_persists_tool_receipts_failures_and_evidence(self): self.assertEqual(len(completed["payload"]["tool_receipts"]), 2) def test_manager_persists_nonzero_runner_metrics_without_zero_fallback(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project"))) manager = SubAgentManager( memory, learning_engine=LearningEngine(memory), provider_model=lambda: "deepseek:deepseek-v4-flash", @@ -578,8 +579,8 @@ def test_main_subagent_runner_uses_its_usage_ledger_rows(self): provider._client = SimpleNamespace(chat=SimpleNamespace( completions=SimpleNamespace(create=lambda **_kwargs: response), )) - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project"))) manager = SubAgentManager( memory, runner=main._run_subagent_llm, learning_engine=LearningEngine(memory), provider_model=lambda: "deepseek:deepseek-v4-flash", diff --git a/tests/test_task_consistency.py b/tests/test_task_consistency.py index 4ba64caf..c5d60e01 100644 --- a/tests/test_task_consistency.py +++ b/tests/test_task_consistency.py @@ -1,7 +1,9 @@ +from contextlib import ExitStack, closing import hashlib import json import subprocess import tempfile +import threading import time import unittest from openkyrozen.tools.models import ToolResult @@ -28,8 +30,8 @@ def test_unfenced_action_json_parses_braces_inside_string_arguments(self): ) def test_recovery_keeps_unfinished_standalone_tasks_with_ordered_plan(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="mixed", session_id="recover") standalone = manager.add_task("Standalone retry") standalone_id = manager.tasks[standalone]["id"] @@ -45,8 +47,8 @@ def test_recovery_keeps_unfinished_standalone_tasks_with_ordered_plan(self): self.assertEqual(len(recovered.tasks), 3) def test_long_receipt_arguments_match_complete_checkpoint(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="long", session_id="args") args = "result.txt|" + "x" * 1100 index = manager.add_task( @@ -65,8 +67,8 @@ def test_long_receipt_arguments_match_complete_checkpoint(self): self.assertEqual(manager.tasks[index]["status"], "succeeded") def test_long_receipt_fingerprint_matches_inferred_acceptance(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="long-inferred", session_id="args") args = "result.txt|" + "x" * 1100 index = manager.add_task("Write long result", acceptance=[ @@ -81,8 +83,8 @@ def test_long_receipt_fingerprint_matches_inferred_acceptance(self): self.assertNotIn("unmatched_reason", evidence) def test_failed_inferred_long_receipt_can_match_successful_retry(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="long-retry", session_id="args") args = "result.txt|" + "x" * 1100 full_hash = hashlib.sha256(args.encode()).hexdigest() @@ -106,8 +108,10 @@ def test_failed_inferred_long_receipt_can_match_successful_retry(self): def setUp(self): self._original_interaction = main._interaction_controller self._interaction_directory = tempfile.TemporaryDirectory() + self._sqlite_owners = ExitStack() + self.addCleanup(self._sqlite_owners.close) controller = InteractionController( - MemoryBank(Path(self._interaction_directory.name) / "interaction.sqlite3").store, + self._sqlite_owners.enter_context(closing(MemoryBank(Path(self._interaction_directory.name) / "interaction.sqlite3"))).store, workspace_id="legacy-tests", session_id="legacy-tests", ) controller.set_mode("agent") @@ -115,11 +119,12 @@ def setUp(self): def tearDown(self): main._interaction_controller = self._original_interaction + self._sqlite_owners.close() self._interaction_directory.cleanup() def test_explicit_task_ids_win_over_description_deduplication(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="project", session_id="turn") block = ( 'TaskList:\n```json\n' @@ -186,8 +191,8 @@ def test_inline_and_malformed_protocol_is_removed_before_learning_persistence(se self.assertEqual(" ".join(clean.split()), expected) self.assertNotRegex(clean, r"Action:| ToolResult: return original_write(args) main._set_workspace_root(root) - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store main.tasks = TaskManager(store, workspace_id="project", session_id="receipt-test") main.learning_engine = StubLearning() try: @@ -427,12 +432,12 @@ def artifact_context(self, _run): 'Action: {"action":"git_commit","args":"audit: post-merge"}', "Committed audit file.", ] - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=root, check=True) subprocess.run(["git", "config", "user.name", "OpenKyrozen Test"], cwd=root, check=True) - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store original_root = main._get_workspace_root() original_tasks = main.tasks original_learning = main.learning_engine @@ -469,8 +474,8 @@ def artifact_context(self, _run): self.assertEqual(reopened.recover(), []) def test_plain_string_plan_reconciles_in_order_after_restart_and_leaves_unmatched_receipts(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="project", session_id="plain-plan") manager.from_llm_block( "TaskList:\n```json\n" @@ -572,12 +577,12 @@ def artifact_context(self, _run): 'Action: {"action":"git_commit","args":"audit: plain plan"}', "Committed the plain-string plan.", ] - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=root, check=True) subprocess.run(["git", "config", "user.name", "OpenKyrozen Test"], cwd=root, check=True) - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store original_root = main._get_workspace_root() original_tasks = main.tasks original_learning = main.learning_engine @@ -610,8 +615,8 @@ def artifact_context(self, _run): self.assertIn("plain-string plan", reply) def test_plan_only_tasks_reconcile_in_order_after_restart(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="project", session_id="plan-only") original_tasks = main.tasks main.tasks = manager @@ -669,12 +674,12 @@ def artifact_context(self, _run): 'Action: {"action":"git_commit","args":"audit: plan-only"}', "Committed the Plan-only work.", ] - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) subprocess.run(["git", "init", "-q"], cwd=root, check=True) subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=root, check=True) subprocess.run(["git", "config", "user.name", "OpenKyrozen Test"], cwd=root, check=True) - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store original_root = main._get_workspace_root() original_tasks = main.tasks original_learning = main.learning_engine @@ -707,10 +712,10 @@ def artifact_context(self, _run): self.assertIn("Plan-only work", reply) def test_web_natural_plan_receipts_match_and_survive_restart(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) (root / "README.md").write_text("Usage: run the baseline check.\n", encoding="utf-8") - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="web-project", session_id="web-natural") manager.add_task("Read the existing Python file before making changes.", checkpoint={"action": "read_file", "args": "notes.py"}) @@ -758,8 +763,8 @@ def test_web_natural_plan_receipts_match_and_survive_restart(self): self.assertEqual(reopened.recover(), []) def test_receipt_cannot_satisfy_a_neighboring_task_without_exact_contract(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="project", session_id="receipt-match") read_index = manager.add_task( "Inspect the README", checkpoint={"action": "read_file", "args": "README.md"} @@ -800,8 +805,8 @@ def record_outcome(self, *args, **kwargs): self.outcome = kwargs return [] - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store manager = TaskManager(store, workspace_id="project", session_id="learning-blocked") index = manager.add_task( "Run the verification command", @@ -915,10 +920,10 @@ def artifact_context(self, _run): original_root = main._get_workspace_root() original_tasks, original_learning = main.tasks, main.learning_engine - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) main._set_workspace_root(root) - main.tasks = TaskManager(MemoryBank(root / "state.sqlite3").store, + main.tasks = TaskManager(sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store, workspace_id="project", session_id="malformed-alias") main.learning_engine = StubLearning() try: @@ -958,9 +963,9 @@ def artifact_context(self, _run): original_root = main._get_workspace_root() original_tasks, original_learning = main.tasks, main.learning_engine - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: main._set_workspace_root(Path(directory)) - main.tasks = TaskManager(MemoryBank(Path(directory) / "state.sqlite3").store, + main.tasks = TaskManager(sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store, workspace_id="project", session_id="ssai-retry") main.learning_engine = StubLearning() try: @@ -995,38 +1000,52 @@ def test_failed_operation_can_retry_and_provider_deadline_is_honest(self): self.assertEqual(failed.failure, "command_failed") self.assertTrue(retried.success) + entered, release, finished = threading.Event(), threading.Event(), threading.Event() class SlowProvider: def chat(self, _messages, _model): - time.sleep(0.2) + entered.set() + release.wait(5) + finished.set() return "late", None original_provider = main.llm_provider main.llm_provider = SlowProvider() try: - with patch.object(main, "_provider_timeout_seconds", return_value=0.01): - started = time.monotonic() + with patch.object(main, "_provider_timeout_seconds", return_value=0.1): response = main._get_llm_response([]) + self.assertTrue(entered.wait(2), "provider worker did not start") + self.assertFalse(finished.is_set(), "provider returned before timeout completed") finally: + release.set() + finished.wait(2) main.llm_provider = original_provider - self.assertLess(time.monotonic() - started, 0.1) + self.assertTrue(finished.is_set(), "provider worker did not finish after release") self.assertIn("Provider timed out", response) def test_streaming_provider_obeys_deadline_and_discards_late_chunks(self): + entered, release, finished = threading.Event(), threading.Event(), threading.Event() class SlowStreamingProvider: def chat_stream(self, _messages, _model): - time.sleep(0.2) - yield "late" + entered.set() + release.wait(5) + try: + yield "late" + finally: + finished.set() original_provider = main.llm_provider main.llm_provider = SlowStreamingProvider() chunks = [] try: - with patch.object(main, "_provider_timeout_seconds", return_value=0.01): - started = time.monotonic() + with patch.object(main, "_provider_timeout_seconds", return_value=0.1): response = main._get_llm_response([], stream=True, on_chunk=chunks.append) + self.assertTrue(entered.wait(2), "provider worker did not start") + self.assertFalse(finished.is_set(), "provider returned before timeout completed") finally: + release.set() + finished.wait(2) main.llm_provider = original_provider - self.assertLess(time.monotonic() - started, 0.1) + self.assertTrue(finished.is_set(), "provider worker did not finish after release") self.assertIn("Provider timed out", response) self.assertEqual(chunks, []) @@ -1085,11 +1104,75 @@ def test_completion_keeps_verified_answer_without_second_recap_call(self): self.assertEqual(answer, "Verified result") self.assertEqual(recap.call_count, 0) + def test_protocol_error_marks_turn_incomplete_without_tool_receipts(self): + from types import SimpleNamespace + original_manager, original_tasks = main.subagent_manager, main.tasks + main.subagent_manager = SimpleNamespace(coordinator=None) + main.tasks = SimpleNamespace(tasks=[], working_plan=None) + turn = SimpleNamespace( + final_answer="No work was run.", protocol_error_message="A Working Plan is required.", + tool_records=[], turn_prompt_total=0, turn_completion_total=0, turn_start=time.time(), + tool_calls=[], pending_interaction_reply=None, interaction_mode="agent", fix_workflow=None, + user_input="complex task", response_text="", learning_run=None, learning_receipts=[], + complexity="complex", runtime_status="completed", + ) + try: + with patch.object(main, "_advance_fix_workflow", side_effect=lambda state, _request, answer, *_args: (state, answer)), \ + patch.object(main, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, result, *_args: result): + answer = main._complete_turn(turn) + finally: + main.subagent_manager, main.tasks = original_manager, original_tasks + self.assertEqual(answer, "A Working Plan is required.") + self.assertEqual(turn.runtime_status, "incomplete") + + def test_completed_old_plan_does_not_hide_a_protocol_error(self): + from types import SimpleNamespace + original_manager, original_tasks = main.subagent_manager, main.tasks + main.subagent_manager = SimpleNamespace(coordinator=None) + main.tasks = SimpleNamespace(tasks=[], working_plan=SimpleNamespace(status="completed", task_ids=[])) + turn = SimpleNamespace( + final_answer="No work was run.", protocol_error_message="A Working Plan is required.", + tool_records=[], turn_prompt_total=0, turn_completion_total=0, turn_start=time.time(), + tool_calls=[], pending_interaction_reply=None, interaction_mode="agent", fix_workflow=None, + user_input="complex task", response_text="", learning_run=None, learning_receipts=[], + complexity="complex", runtime_status="completed", + ) + try: + with patch.object(main, "_advance_fix_workflow", side_effect=lambda state, _request, answer, *_args: (state, answer)), \ + patch.object(main, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, result, *_args: result): + main._complete_turn(turn) + finally: + main.subagent_manager, main.tasks = original_manager, original_tasks + self.assertEqual(turn.runtime_status, "incomplete") + + def test_protocol_error_preserves_blocked_plan_status(self): + from types import SimpleNamespace + original_manager, original_tasks = main.subagent_manager, main.tasks + main.subagent_manager = SimpleNamespace(coordinator=None) + main.tasks = SimpleNamespace( + tasks=[{"id": "task-1", "status": "blocked", "description": "Wait for access"}], + working_plan=SimpleNamespace(status="active", task_ids=["task-1"]), + ) + turn = SimpleNamespace( + final_answer="Work blocked.", protocol_error_message="A Working Plan is required.", + tool_records=[], turn_prompt_total=0, turn_completion_total=0, turn_start=time.time(), + tool_calls=[], pending_interaction_reply=None, interaction_mode="agent", fix_workflow=None, + user_input="complex task", response_text="", learning_run=None, learning_receipts=[], + complexity="complex", runtime_status="completed", + ) + try: + with patch.object(main, "_advance_fix_workflow", side_effect=lambda state, _request, answer, *_args: (state, answer)), \ + patch.object(main, "_finish_learning_run", side_effect=lambda _run, _receipts, _task, result, *_args: result): + main._complete_turn(turn) + finally: + main.subagent_manager, main.tasks = original_manager, original_tasks + self.assertEqual(turn.runtime_status, "blocked") + def test_simple_turn_completion_ignores_an_unrelated_pending_task_ledger(self): from types import SimpleNamespace original_manager, original_tasks = main.subagent_manager, main.tasks - with tempfile.TemporaryDirectory() as directory: - tasks = TaskManager(MemoryBank(Path(directory) / "state.sqlite3").store, + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + tasks = TaskManager(sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store, workspace_id="completion", session_id="simple-turn") tasks.add_task("Unrelated unfinished work") main.subagent_manager, main.tasks = SimpleNamespace(coordinator=None), tasks @@ -1112,8 +1195,8 @@ def test_simple_turn_completion_ignores_an_unrelated_pending_task_ledger(self): def test_complex_plan_keeps_answer_and_reports_incomplete_or_blocked_state(self): from types import SimpleNamespace original_manager, original_tasks = main.subagent_manager, main.tasks - with tempfile.TemporaryDirectory() as directory: - tasks = TaskManager(MemoryBank(Path(directory) / "state.sqlite3").store, + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + tasks = TaskManager(sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store, workspace_id="completion", session_id="complex-turn") plan = tasks.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "inspect", "task": { diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 761d0c57..4ead21d0 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import unittest from openkyrozen.tools.models import ToolResult from openkyrozen.tools.manifest import ToolRegistry @@ -145,8 +146,8 @@ def test_runtime_blocks_output_writing_git_options_before_execution(self): from openkyrozen.providers.models import ToolCall with tempfile.TemporaryDirectory() as home, patch.dict('os.environ', { 'KYROZEN_DISABLE_VECTOR_INDEX': '1', 'KYROZEN_CLI_CAPABILITIES': 'full', - }): - app = build_application(memory=build_memory(Path(home) / 'state.sqlite3'), tools=ToolAdapters(home)) + }), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(home) / 'state.sqlite3'))), tools=ToolAdapters(home)) try: with patch('openkyrozen.tools.git.subprocess.run') as run: receipt = app.runtime.execute_call( @@ -159,16 +160,16 @@ def test_runtime_blocks_output_writing_git_options_before_execution(self): finally: app.close() - def test_all53_bound_tools_and_workspace_isolation(self): + def test_all55_bound_tools_and_workspace_isolation(self): import tempfile from pathlib import Path from unittest.mock import patch from openkyrozen.app.bootstrap import build_application,build_memory - with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}): - app=build_application(memory=build_memory(Path(home)/'state.sqlite3')) + with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}), ExitStack() as sqlite_owners: + app=build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(home)/'state.sqlite3')))) try: runtime=app.runtime - self.assertEqual(len(runtime.AVAILABLE_TOOLS),53) + self.assertEqual(len(runtime.AVAILABLE_TOOLS),55) self.assertEqual(set(runtime.AVAILABLE_TOOLS),set(runtime.tool_registry.specs)) self.assertTrue(all(getattr(spec.executor, "__tool_result_executor__", None) for spec in runtime.tool_registry.specs.values())) @@ -198,8 +199,8 @@ def test_optional_builtin_fields_remain_optional(self): from pathlib import Path from unittest.mock import patch from openkyrozen.app.bootstrap import build_application, build_memory - with tempfile.TemporaryDirectory() as directory, patch.dict('os.environ', {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}): - app = build_application(memory=build_memory(Path(directory) / 'state.sqlite3'), tools=ToolAdapters(directory)) + with tempfile.TemporaryDirectory() as directory, patch.dict('os.environ', {'KYROZEN_DISABLE_VECTOR_INDEX': '1'}), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / 'state.sqlite3'))), tools=ToolAdapters(directory)) registry = app.runtime.tool_registry try: self._assert_optional_examples(registry) @@ -329,8 +330,8 @@ def test_registered_names_and_aliases_immediately_parse(self): from pathlib import Path from unittest.mock import patch from openkyrozen.app.bootstrap import build_application,build_memory - with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}): - app=build_application(memory=build_memory(Path(home)/'state.sqlite3')) + with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}), ExitStack() as sqlite_owners: + app=build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(home)/'state.sqlite3')))) try: runtime=app.runtime spec=RegistryContractTests().make() @@ -368,8 +369,8 @@ def test_runtime_removal_and_case_preserving_parser(self): from pathlib import Path from unittest.mock import patch from openkyrozen.app.bootstrap import build_application,build_memory - with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}): - app=build_application(memory=build_memory(Path(home)/'state.sqlite3')) + with tempfile.TemporaryDirectory() as home,patch.dict('os.environ',{'KYROZEN_DISABLE_VECTOR_INDEX':'1'}), ExitStack() as sqlite_owners: + app=build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(home)/'state.sqlite3')))) try: runtime=app.runtime runtime.tool_registry.register_spec(RegistryContractTests().make('MyTool',aliases=('MyAlias',))) diff --git a/tests/test_tool_results.py b/tests/test_tool_results.py index 5786e380..c42a300e 100644 --- a/tests/test_tool_results.py +++ b/tests/test_tool_results.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import dataclasses import json import unittest @@ -98,11 +99,11 @@ def test_runtime_does_not_classify_successful_error_prefixed_file_content(self): from openkyrozen.app.bootstrap import build_application, build_memory from openkyrozen.tools import ToolAdapters - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: root = Path(directory) / "workspace" root.mkdir() (root / "note.txt").write_text("Error: this is the file's content", encoding="utf-8") - app = build_application(memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(root)) + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(root)) try: app.runtime._set_workspace_root(root) receipt = app.runtime.execute(app.runtime.current_session, "read_file", "note.txt", capabilities={"read"}) @@ -120,8 +121,8 @@ def test_runtime_uses_command_status_even_when_failure_text_has_no_error_marker( from openkyrozen.tools import ToolAdapters from openkyrozen.tools.models import CommandResult - with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): - app = build_application(memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}), ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3"))), tools=ToolAdapters(directory)) try: with patch.object(app.runtime, "run_command", return_value=CommandResult("permission denied", False, 1)): result, success = app.runtime._run_tool("run_cmd", "false", return_success=True) diff --git a/tests/test_tui_protocol.py b/tests/test_tui_protocol.py index 2c342171..41a3963d 100644 --- a/tests/test_tui_protocol.py +++ b/tests/test_tui_protocol.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing from openkyrozen.updates.models import UpdateResult import io import json @@ -125,9 +126,9 @@ def test_permissions_jev_alias_is_normalized_before_persistence(self): self.assertEqual(event["text"], "Permissions set to full_jev.") def test_navigation_groups_global_project_and_legacy_chats_without_leakage(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) memory = SimpleNamespace(store=store, user_id="local", workspace_id="global", _collection=None, _files_collection=None, _last_error=None) project = root / "project" @@ -161,9 +162,9 @@ def test_navigation_groups_global_project_and_legacy_chats_without_leakage(self) ) def test_empty_chat_is_not_persisted_until_the_first_user_message(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) memory = SimpleNamespace(store=store, user_id="local", workspace_id="global", _collection=None, _files_collection=None, _last_error=None) project = root / "project" @@ -193,9 +194,9 @@ def test_empty_chat_is_not_persisted_until_the_first_user_message(self): ) def test_repeated_empty_project_startup_does_not_add_empty_chats_or_duplicate_projects(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) memory = SimpleNamespace(store=store, user_id="local", workspace_id="global") project = root / "project" project.mkdir() @@ -219,14 +220,37 @@ def test_blank_tui_submission_does_not_persist_a_chat(self): self.backend._run_submit(" ", "blank-chat") chat.assert_not_called() + def test_completed_turn_emits_ready_after_busy_clears(self): + self.backend._busy = True + with patch.object(self.backend.agent, "llm_provider", object()), \ + patch.object(self.backend.agent, "_sanitize_input", return_value=("question", False)), \ + patch.object(self.backend.agent, "interaction_envelope", return_value={ + "pending_question": None, "pending_plan": None, + }), \ + patch.object(self.backend.agent, "is_plan_acceptance", return_value=False), \ + patch.object(self.backend.agent, "chat", return_value="answer"), \ + patch.object(self.backend.agent, "_clean_final_response", return_value="answer"), \ + patch.object(self.backend.agent, "_split_reply", return_value=("", "answer")), \ + patch.object(self.backend.agent.memory_bank, "add_log"), \ + patch.object(self.backend, "interaction"), \ + patch.object(self.backend, "usage"), \ + patch.object(self.backend, "navigation"): + self.backend._run_submit("question", "turn-ready") + + events = [json.loads(line) for line in self.output.getvalue().splitlines()] + ready = [event for event in events if event.get("event") == "status" + and event.get("state") == "ready"] + self.assertTrue(ready) + self.assertFalse(ready[-1]["busy"]) + def test_delete_chat_requires_confirmation_and_keeps_workspace_files(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) project = root / "project" project.mkdir() file = project / "keep.txt" file.write_text("workspace survives", encoding="utf-8") - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) scope_id = source_scope_id(project) store.append_event("tui.chat_metadata", {"title": "Keep me"}, user_id="local", workspace_id=scope_id, session_id="chat-delete") @@ -255,12 +279,12 @@ def test_delete_chat_requires_confirmation_and_keeps_workspace_files(self): self.assertEqual(file.read_text(encoding="utf-8"), "workspace survives") def test_delete_project_trashes_workspace_and_purges_scoped_data(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) project = root / "project" project.mkdir() (project / "keep-until-trash.txt").write_text("project content", encoding="utf-8") - store = EventStore(root / "state.sqlite3") + store = sqlite_owners.enter_context(closing(EventStore(root / "state.sqlite3"))) scope_id = source_scope_id(project) memory = SimpleNamespace(store=store, user_id="local", workspace_id="global") store.append_event("tui.project_opened", { @@ -495,10 +519,11 @@ def test_structured_approval_keeps_long_arguments_in_redacted_scroll_chunks(self thread = threading.Thread(target=lambda: result.setdefault("approved", self.backend._approval(request))) thread.start() deadline = time.monotonic() + 1 - while "request_id" not in self.output.getvalue() and time.monotonic() < deadline: + while "approval_detail_complete" not in self.output.getvalue() and time.monotonic() < deadline: time.sleep(0.005) events = [json.loads(line) for line in self.output.getvalue().splitlines()] event = events[0] + self.assertTrue(any(item.get("event") == "approval_detail_complete" for item in events)) self.assertEqual(event["request_id"], request.request_id) encoded = event["args"] + "".join(item.get("text", "") for item in events[1:]) displayed = json.loads(encoded) @@ -821,10 +846,10 @@ def test_decision_assist_jev_prompts_for_missing_key(self): self.assertEqual(event["request_id"], "assist-key") def test_interaction_envelope_and_structured_commands_are_correlated(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: original = self.backend.agent._interaction_controller controller = InteractionController( - EventStore(Path(directory) / "state.sqlite3"), + sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="tui", session_id="surface:tui", ) self.backend.agent._interaction_controller = controller diff --git a/tests/test_update_notice.py b/tests/test_update_notice.py index e0229a7d..cfab71dd 100644 --- a/tests/test_update_notice.py +++ b/tests/test_update_notice.py @@ -1,12 +1,65 @@ import io +import tempfile import unittest +from contextlib import nullcontext +from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch -from openkyrozen.updates.service import _available_update +from openkyrozen.updates.service import _available_update, _self_update, _update_version_key class UpdateNoticeTests(unittest.TestCase): + def test_preview_update_version_order(self): + current = _update_version_key("3.0.0.dev1") + for version, allowed in (("2.0.8", False), ("3.0.0.dev0", False), + ("3.0.0.dev1", True), ("3.0.0.dev2", True), + ("3.0.0a1", True), ("3.0.0rc1", True), + ("3.0.0", True), ("3.0.1.dev0", True)): + with self.subTest(version=version): + self.assertEqual(_update_version_key(version) >= current, allowed) + self.assertIsNone(_update_version_key("not-a-version")) + + def test_preview_update_blocks_downgrade_and_unknown_version_before_staging(self): + revision = "a" * 40 + for response in (io.BytesIO(b'[project]\nversion = "2.0.8"\n'), + io.BytesIO(b'[project]\nversion = "3.0.0.dev0"\n'), + io.BytesIO(b'[project]\nversion = "unsupported"\n'), OSError("offline")): + with self.subTest(response=type(response).__name__): + runtime = SimpleNamespace( + RELEASE_VERSION="3.0.0.dev1", _resolve_update_revision=lambda: revision, + UPDATE_REPOSITORY_URL="https://example.invalid/repo", _IS_WINDOWS=False, + _update_lock=nullcontext, _fix_safe_text=lambda value, *_: str(value), + _stage_update_tui=Mock(side_effect=RuntimeError("staging reached")), + ) + with tempfile.TemporaryDirectory() as directory, \ + patch("openkyrozen.updates.service.Path.home", return_value=Path(directory)), \ + patch("openkyrozen.updates.service.shutil.which", return_value="/usr/bin/uv"), \ + patch("openkyrozen.updates.service.urllib.request.urlopen", side_effect=( + response if isinstance(response, Exception) else None), return_value=response): + result = _self_update(runtime) + self.assertEqual(result.status, "failed") + self.assertIn("version", result.message.lower()) + runtime._stage_update_tui.assert_not_called() + + def test_preview_update_checks_the_exact_revision_and_allows_a_newer_version(self): + revision = "a" * 40 + runtime = SimpleNamespace( + RELEASE_VERSION="3.0.0.dev1", _resolve_update_revision=lambda: revision, + UPDATE_REPOSITORY_URL="https://example.invalid/repo", _IS_WINDOWS=False, + _update_lock=nullcontext, _fix_safe_text=lambda value, *_: str(value), + _stage_update_tui=Mock(side_effect=RuntimeError("staging reached")), + ) + with tempfile.TemporaryDirectory() as directory, \ + patch("openkyrozen.updates.service.Path.home", return_value=Path(directory)), \ + patch("openkyrozen.updates.service.shutil.which", return_value="/usr/bin/uv"), \ + patch("openkyrozen.updates.service.urllib.request.urlopen", + return_value=io.BytesIO(b'[project]\nversion = "3.0.0"\n')) as open_url: + result = _self_update(runtime) + self.assertIn("staging reached", result.message) + runtime._stage_update_tui.assert_called_once() + self.assertIn(f"/{revision}/pyproject.toml", open_url.call_args.args[0].full_url) + def check(self, latest, current="2.0.4", installed="a" * 40, contained=1, checkout=True): runtime = SimpleNamespace(__version__=current, _resolve_update_revision=lambda *a, **k: "b" * 40) document = f'[project]\nversion = "{latest}"\n'.encode() @@ -28,6 +81,8 @@ def test_versions_and_main_revision_match_updater_source(self): self.assertEqual(self.check("2.0.4"), "2.0.4 · bbbbbbb") self.assertEqual(self.check("2.0.4", checkout=False), "2.0.4 · bbbbbbb") self.assertIsNone(self.check("not-a-version")) + self.assertEqual(self.check("3.0.0", current="3.0.0.dev1"), "3.0.0") + self.assertIsNone(self.check("2.0.8", current="3.0.0.dev1")) def test_offline_check_is_silent(self): runtime = SimpleNamespace(_resolve_update_revision=lambda **_: None) diff --git a/tests/test_update_transaction.py b/tests/test_update_transaction.py index 984cea9a..d0376b51 100644 --- a/tests/test_update_transaction.py +++ b/tests/test_update_transaction.py @@ -48,6 +48,7 @@ def activate(*args): with patch('openkyrozen.updates.service.Path.home', return_value=self.home), \ patch('openkyrozen.updates.service.shutil.which', return_value='/bin/uv'), \ patch.object(self.runtime, '_resolve_update_revision', return_value=self.revision), \ + patch('openkyrozen.updates.service._read_update_version', return_value='3.0.0'), \ patch.object(self.runtime, '_update_tui_binary', return_value=(True, 'fixture TUI')), \ patch.object(self.runtime, '_stage_update_tui', stage, create=True), \ patch.object(self.runtime, '_verify_update_package', verify, create=True), \ @@ -128,6 +129,18 @@ def test_no_verified_source_does_not_install(self): self.assertEqual(result.status, 'failed') self.assertEqual(result.components['python'], 'unchanged') + def test_preview_does_not_fall_back_to_unverified_release_version(self): + with patch.object(self.runtime, 'RELEASE_VERSION', '3.0.0.dev1'), \ + patch('openkyrozen.updates.service.Path.home', return_value=self.home), \ + patch('openkyrozen.updates.service.shutil.which', return_value='/bin/uv'), \ + patch.object(self.runtime, '_resolve_update_revision', return_value=None), \ + patch.object(self.runtime, '_release_tui_asset_available', return_value=True), \ + patch.object(self.runtime, '_stage_update_tui', side_effect=RuntimeError('unexpected staging')) as stage: + result = self.runtime._self_update() + self.assertEqual(result.status, 'failed') + self.assertEqual(result.components['python'], 'unchanged') + stage.assert_not_called() + def test_concurrent_update_is_rejected_before_installation(self): with patch('openkyrozen.updates.service.Path.home', return_value=self.home): with self.runtime._update_lock(): diff --git a/tests/test_usage_ledger.py b/tests/test_usage_ledger.py index eacc54b0..e53953b3 100644 --- a/tests/test_usage_ledger.py +++ b/tests/test_usage_ledger.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import subprocess @@ -31,12 +32,12 @@ def test_server_cost_api_reconstructs_usage_after_process_restart(self): "import json; from fastapi.testclient import TestClient; from openkyrozen.interfaces.web.service import WebService; server = WebService(); " "print(json.dumps(TestClient(server.app).get('/api/cost').json()))" ) - with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-restart-") as directory: + with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-restart-") as directory, ExitStack() as sqlite_owners: root = Path(directory) home = root / "home" home.mkdir() database = root / "state.sqlite3" - self._record(EventStore(database), workspace="default") + self._record(sqlite_owners.enter_context(closing(EventStore(database))), workspace="default") environment = os.environ.copy() environment.update({ "HOME": str(home), "KYROZEN_DB_PATH": str(database), @@ -57,8 +58,8 @@ def test_server_cost_api_reconstructs_usage_after_process_restart(self): self.assertEqual(reports[1]["totals"]["workspace"]["completion_tokens"], 50) def test_concurrent_attempts_keep_unique_ids_and_pricing_snapshots(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-concurrent-") as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-concurrent-") as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) def record(index: int) -> None: with usage_scope(store=store, workspace_id="project", session_id=f"session-{index % 2}", @@ -98,8 +99,8 @@ def record(index: int) -> None: self.assertTrue(all(item["completion_state"] == "completed" for item in attempts)) def test_reset_requires_explicit_confirmation_and_preserves_installation_ledger(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-reset-") as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project") + with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-reset-") as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3", workspace_id="project"))) self._record(memory.store) original_memory = server._agent.memory_bank server._agent.memory_bank = memory @@ -119,8 +120,8 @@ def test_reset_requires_explicit_confirmation_and_preserves_installation_ledger( server._agent.memory_bank = original_memory def test_short_and_mixed_calls_aggregate_before_display_rounding(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-precision-") as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory(prefix="openkyrozen-usage-precision-") as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) peak_time = datetime(2026, 9, 7, 2, tzinfo=timezone.utc) with usage_scope(store=store, workspace_id="project", session_id="short"): for _ in range(1000): @@ -161,8 +162,8 @@ def test_deepseek_v4_cache_pricing_uses_fixed_peak_and_off_peak_timestamps(self) "prompt_cache_miss_tokens": 750_000, "completion_tokens": 1_000_000, } - with tempfile.TemporaryDirectory(prefix="openkyrozen-deepseek-pricing-") as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory(prefix="openkyrozen-deepseek-pricing-") as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) with usage_scope(store=store, workspace_id="project", session_id="peak"): _track_cost("deepseek", usage, model="deepseek-v4-flash", occurred_at=datetime(2026, 9, 7, 2, tzinfo=timezone.utc)) @@ -194,8 +195,8 @@ def test_openai_compatible_provider_preserves_deepseek_cache_usage_fields(self): provider._client = SimpleNamespace(chat=SimpleNamespace( completions=SimpleNamespace(create=lambda **_kwargs: response), )) - with tempfile.TemporaryDirectory(prefix="openkyrozen-deepseek-usage-") as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory(prefix="openkyrozen-deepseek-usage-") as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) with usage_scope(store=store, workspace_id="project"): text, usage = provider.chat([], "deepseek-v4-flash") attempt = store.list_usage_attempts(workspace_id="project", user_id="local")[0] @@ -266,8 +267,8 @@ def test_completed_stream_records_final_usage_once(self): provider._client = SimpleNamespace(chat=SimpleNamespace( completions=SimpleNamespace(create=lambda **kwargs: calls.append(kwargs) or iter(chunks)), )) - with tempfile.TemporaryDirectory(prefix="openkyrozen-stream-usage-") as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory(prefix="openkyrozen-stream-usage-") as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) with usage_scope(store=store, workspace_id="project"): self.assertEqual(list(provider.chat_stream([], "deepseek-v4-flash")), ["STREAM", "_OK"]) attempts = store.list_usage_attempts(workspace_id="project", user_id="local") diff --git a/tests/test_v2_core.py b/tests/test_v2_core.py index 1e83b45c..c8489cc7 100644 --- a/tests/test_v2_core.py +++ b/tests/test_v2_core.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import tempfile import unittest from pathlib import Path @@ -9,32 +10,32 @@ class V2CoreTests(unittest.TestCase): def test_memory_persists_deduplicates_and_deletes_by_document_compatibly(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: db_path = Path(directory) / "state.sqlite3" - first = MemoryBank(db_path, workspace_id="project-a") + first = sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="project-a"))) file_id = first.add_file("src/app.py", "print('hello')") self.assertEqual(file_id, first.add_file("src/app.py", "print('hello')")) first.add_log("FACT: durable memory", kind="fact") self.assertEqual(first.count_logs(), 1) - reopened = MemoryBank(db_path, workspace_id="project-a") + reopened = sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="project-a"))) self.assertIn("FACT: durable memory", reopened.get_recent(10)) self.assertEqual(reopened.delete_logs(["FACT: durable memory"]), 1) self.assertEqual(reopened.count_logs(), 0) def test_memory_isolated_by_workspace(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: db_path = Path(directory) / "state.sqlite3" - MemoryBank(db_path, workspace_id="one").add_log("FACT: secret one", kind="fact") - other = MemoryBank(db_path, workspace_id="two") + sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="one"))).add_log("FACT: secret one", kind="fact") + other = sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="two"))) self.assertEqual(other.get_recent(10), []) self.assertEqual(other.recall("secret"), []) def test_global_personal_state_can_share_while_file_snapshots_stay_project_scoped(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: db_path = Path(directory) / "state.sqlite3" - first = MemoryBank(db_path, workspace_id="default", file_scope_id="source-one") - second = MemoryBank(db_path, workspace_id="default", file_scope_id="source-two") + first = sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="default", file_scope_id="source-one"))) + second = sqlite_owners.enter_context(closing(MemoryBank(db_path, workspace_id="default", file_scope_id="source-two"))) first.add_log("FACT: shared personal preference", kind="fact") first.add_file("app.py", "print('one')") second.add_file("app.py", "print('two')") @@ -49,8 +50,8 @@ def test_global_personal_state_can_share_while_file_snapshots_stay_project_scope self.assertEqual([row["content"] for row in rows], ["print('two')"]) def test_task_list_merge_and_evidence_gate(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))) tasks = TaskManager(memory.store, workspace_id="project", session_id="session") index = tasks.add_task("run tests") block = 'TaskList:\n```json\n[{"description":"run tests"}]\n```' @@ -65,8 +66,8 @@ def test_task_list_merge_and_evidence_gate(self): self.assertEqual(tasks.tasks[index]["status"], "succeeded") def test_tasks_are_collision_free_and_evidence_targets_one_scope(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store alice = TaskManager(store, user_id="alice", workspace_id="project", session_id="alice-session") bob = TaskManager(store, user_id="bob", workspace_id="project", session_id="bob-session") alice_index = alice.add_task("deploy the service", task_id="step-1") @@ -82,10 +83,10 @@ def test_tasks_are_collision_free_and_evidence_targets_one_scope(self): self.assertEqual(store.list_tasks(workspace_id="project", user_id="bob")[0]["status"], "pending") def test_running_task_recovery_is_scoped_and_audited(self): - with tempfile.TemporaryDirectory() as directory: - store = MemoryBank(Path(directory) / "state.sqlite3").store + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))).store owner = TaskManager(store, user_id="alice", workspace_id="project", session_id="session") - index = owner.add_task("resume me") + index = owner.add_task("resume read-only work", checkpoint={"action": "read_file", "args": "note.txt"}) owner.set_status(index, "running") recovered = TaskManager(store, user_id="alice", workspace_id="project", session_id="session").recover() self.assertEqual(len(recovered), 1) @@ -95,9 +96,9 @@ def test_running_task_recovery_is_scoped_and_audited(self): self.assertEqual(TaskManager(store, user_id="bob", workspace_id="project", session_id="session").recover(), []) def test_task_worker_restarts_and_produces_a_real_effect(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: root = Path(directory) - store = MemoryBank(root / "state.sqlite3").store + store = sqlite_owners.enter_context(closing(MemoryBank(root / "state.sqlite3"))).store manager = TaskManager(store, user_id="local", workspace_id="project", session_id="restart") manager.add_task("write marker", task_id="marker", checkpoint={"action": "write_file", "args": "marker.txt|ok"}) @@ -117,8 +118,8 @@ def execute(task): for event in store.list_events(user_id="local", workspace_id="project"))) def test_learning_requires_repeated_observation_and_can_roll_back(self): - with tempfile.TemporaryDirectory() as directory: - memory = MemoryBank(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + memory = sqlite_owners.enter_context(closing(MemoryBank(Path(directory) / "state.sqlite3"))) engine = LearningEngine(memory) first = engine.submit("fact", "The project uses Python", evidence_id="event-1") self.assertEqual(first["status"], "candidate") diff --git a/tests/test_v3_actor_dispatch.py b/tests/test_v3_actor_dispatch.py new file mode 100644 index 00000000..69eaac99 --- /dev/null +++ b/tests/test_v3_actor_dispatch.py @@ -0,0 +1,40 @@ +"""The matched harness must use the privileged actor boundary for both arms.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +class ActorDispatchTests(unittest.TestCase): + def test_shell_adapter_accepts_the_authenticated_actor_client(self): + from benchmarks import v3_shell_launcher as module + shell, client = SimpleNamespace(), object() + with patch.dict(sys.modules, {'openkyrozen.tools': SimpleNamespace(shell=shell)}), patch.object( + module, '_install_legacy_adapter') as install, patch.dict(os.environ, {}, clear=True): + self.assertTrue(module.install_worker_adapter(client=client)) + install.assert_called_once_with(client, shell) + + def test_both_arms_dispatch_through_the_parent_and_revoke_on_timeout(self): + from benchmarks import v3_matched_live as module + from benchmarks import v3_actor_supervisor as supervisor + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + process = SimpleNamespace(communicate=unittest.mock.Mock(side_effect=[ + subprocess.TimeoutExpired('worker', 1), (b'', b'')])) + launch = unittest.mock.MagicMock() + launch.__enter__.return_value = launch + launch.popen.return_value = process + factory, revoke = object(), object() + with patch.object(supervisor, 'ActorSupervisor', return_value=launch) as create: + result = module._run_worker(['worker'], cwd=root, env={}, source=root, + provider_config=factory, revoke_actor=revoke, timeout=1) + self.assertEqual(result, (-1, 'timeout')) + self.assertIs(create.call_args.kwargs['source'], root) + self.assertIs(create.call_args.kwargs['provider_config'], factory) + self.assertIs(create.call_args.kwargs['revoke_actor'], revoke) + launch.close.assert_called_once() + self.assertEqual(process.communicate.call_count, 2) diff --git a/tests/test_v3_actor_native_acceptance.py b/tests/test_v3_actor_native_acceptance.py new file mode 100644 index 00000000..7856e323 --- /dev/null +++ b/tests/test_v3_actor_native_acceptance.py @@ -0,0 +1,187 @@ +"""Original V2/V3 author and reviewer runtimes behind the real kernel boundary.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + + +_ROOT = r''' +import json,os,sys,threading +from pathlib import Path +source,apparatus,workspace,result,url=sys.argv[1:6] +sys.path[:0]=[source,apparatus] +from v3_process_owner import connect_actor_client +from v3_actor_worker import install_delegated_adapter,install_mutation_adapter,install_read_adapter +from v3_shell_launcher import install_worker_adapter +from openkyrozen.app.bootstrap import build_application +from openkyrozen.providers import ProviderConfig +client=connect_actor_client() +install_worker_adapter(client=client) +install_mutation_adapter(client); install_read_adapter(client) +app=build_application(surface='cli') +runtime=app.runtime +try: + runtime._set_workspace_root(workspace) + runtime._provider_config=ProviderConfig(provider='openai',api_key=os.environ['KYROZEN_BENCHMARK_API_KEY'], + base_url=url,model_simple='offline-actor',model_complex='offline-actor',context_window_tokens=1000000) + runtime.DEEPSEEK_MODEL=runtime.DEEPSEEK_MODEL_SIMPLE=runtime.DEEPSEEK_MODEL_COMPLEX='offline-actor' + install_delegated_adapter(runtime,client,source=source) + coordinator=runtime.delegation() + run={'run_id':'author1','budget_id':'author1','profile':'researcher','name':'Author','reviewer':'Reviewer', + 'review_agent':{},'reviews':[],'status':'succeeded','usage':{}, + 'assignment':{'objective':'Read source.py','context':'Inspect actual source evidence','scope':['source.py']}} + coordinator.runs['author1']=run + coordinator.cancelled['author1']=threading.Event() + author=runtime._invoke_delegated(run,review=False,feedback=None,coordinator=coordinator) + run['report']=json.loads(author['result']); run['result']=author + reviewer=runtime._invoke_delegated(run,review=True,feedback=None,coordinator=coordinator) + run['reviews']=[{'session_id':reviewer['session_id']}] + detail=coordinator.detail('author1') + journals=None + if hasattr(app.store,'list_conversation_entries'): + scope=run.get('conversation_workspace_id',runtime.interaction_workspace_id()) + journals={role:[entry['kind'] for entry in app.store.list_conversation_entries( + user_id=runtime.memory_bank.user_id,workspace_id=scope,session_id=value['session_id'])] + for role,value in [('author',author),('reviewer',reviewer)]} + Path(result).write_text(json.dumps({'author':author,'reviewer':reviewer, + 'records':runtime._benchmark_child_context_records,'usage':run['usage'], + 'messages':detail['messages'],'review_messages':detail['review_messages'],'journals':journals})) +finally: app.close() +''' + + +@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0 and os.name == 'posix', + 'disposable privileged Unix actor acceptance required') +class NativeActorAcceptanceTests(unittest.TestCase): + def test_actual_v2_v3_author_review_tool_continuations_and_usage(self): + from benchmarks import v3_actor_supervisor as supervisor_module + from benchmarks.v3_actor_supervisor import ActorSupervisor + from benchmarks.v3_matched import BudgetLedger + from benchmarks.v3_matched_live import BASELINE, _extract_baseline + from benchmarks.v3_process_owner import process_snapshot + from benchmarks.v3_responses_relay import RelayConfig, ResponsesRelay + from benchmarks.v3_shell_launcher import ShellPolicy + + requests, counts = [], {} + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + requests.append(body) + visible = json.dumps(body['input']) + review = 'Independently verify this assignment and submitted result.' in visible + key = bool(body.get('tools')), review + count = counts.get(key, 0) + counts[key] = count + 1 + report = ({'verdict': 'verified', 'summary': 'Independently read actual source', + 'findings': [], 'evidence': ['source.py']} if review else + {'status': 'completed', 'summary': 'Read actual source', 'findings': [], + 'evidence': ['source.py'], 'artifacts': [], 'checks': ['read'], + 'uncertainties': [], 'suggested_followups': []}) + if count == 0 and body.get('tools'): + output = [{'type': 'function_call', 'id': 'fc-read', 'call_id': 'call-read', + 'name': 'read_file', 'arguments': json.dumps({'path': 'source.py'})}] + else: + text = ('Action: {"action":"read_file","args":"source.py"}' if count == 0 + else json.dumps(report)) + output = [{'type': 'message', 'id': 'message-local', 'role': 'assistant', + 'status': 'completed', 'content': [{'type': 'output_text', + 'text': text, 'annotations': []}]}] + response = {'id': 'response-local', 'object': 'response', 'status': 'completed', + 'model': 'offline-actor', 'output': output, + 'usage': {'input_tokens': 100, 'output_tokens': 20}} + if body.get('stream'): + events = [{'type': 'response.completed', 'response': response}] + payload = ''.join('event: ' + event['type'] + '\ndata: ' + json.dumps(event) + '\n\n' + for event in events).encode() + else: + payload = json.dumps(response).encode() + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream' if body.get('stream') else 'application/json') + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + repository = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory(dir='/var/tmp', prefix='kyrozen-native-actors-') as directory: + base = Path(directory).resolve() + base.chmod(0o711) + baseline = base / 'baseline' + _extract_baseline(baseline, repository, BASELINE) + for arm, selected in (('v2', baseline), ('v3', repository)): + with self.subTest(arm=arm): + counts.clear() + start = len(requests) + fixture = base / arm + fixture.mkdir(mode=0o711) + workspace = fixture / 'workspace' + workspace.mkdir() + (workspace / 'source.py').write_text('kernel actor evidence') + guard = workspace / 'test_guard.py' + guard.write_text('original') + result = fixture / 'result.json' + budget = BudgetLedger(fixture / 'fake-transport-budget.sqlite3', limit_rmb='1') + relay = ResponsesRelay(endpoint=f'http://127.0.0.1:{server.server_port}/v1', + api_key='offline-upstream-only', budget=budget, config=RelayConfig(model='offline-actor')) + relay.label = (arm, 'kernel-native', 0) + relay.start() + try: + diagnostics = [] + original_collect = supervisor_module._communicate + def collect(*args, **kwargs): + output, error = original_collect(*args, **kwargs) + diagnostics.append(error) + return output, error + command = [sys.executable, '-I', '-c', _ROOT, str(selected), str(repository / 'benchmarks'), + str(workspace), str(result), relay.url, '--model', 'offline-actor', '--result', str(result)] + policy = ShellPolicy(workspace, {'read', 'network'}) + with patch.object(supervisor_module, '_communicate', side_effect=collect), ActorSupervisor(command, cwd=workspace, env={ + 'PATH': os.environ['PATH'], 'KYROZEN_DISABLE_VECTOR_INDEX': '1', + 'KYROZEN_CLI_CAPABILITIES': 'read,network'}, protected_files=[guard], + source=selected, policy=policy, provider_config=relay.issue_actor_config, + revoke_actor=relay.revoke_actor) as supervisor: + process = supervisor.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=90) + self.assertEqual(process.returncode, 0, error[-6000:] + b''.join(diagnostics)[-12000:]) + identities = [actor['owner'].uid for actor in supervisor.actors.values()] + self.assertEqual(len(identities), 3) + self.assertEqual(len(set(identities)), 3) + self.assertFalse(any(uid in identities and not state.startswith(('Z', 'X')) + for uid, state in process_snapshot().values())) + observed = json.loads(result.read_text()) + for role in ('author', 'reviewer'): + self.assertTrue(any(item['action'] == 'read_file' and item['success'] + for item in observed[role]['tool_records']), + (arm, role, observed[role], counts)) + self.assertEqual(json.loads(observed['author']['result'])['status'], 'completed') + self.assertEqual(json.loads(observed['reviewer']['result'])['verdict'], 'verified') + self.assertEqual(len(observed['records']), len(requests) - start) + self.assertTrue(all(record['outcome'] == 'returned' for record in observed['records'])) + self.assertEqual({item['review'] for item in observed['records']}, {False, True}) + self.assertEqual(len({row['actor_id'] for row in relay.rows}), 2) + self.assertTrue(all(row['status'] == 'settled' and row['usage']['input_tokens'] == 100 + for row in relay.rows)) + self.assertTrue(observed['usage']) + self.assertTrue(observed['messages']) + self.assertTrue(observed['review_messages'][observed['reviewer']['session_id']]) + if arm == 'v3': + for kinds in observed['journals'].values(): + self.assertIn('model.response', kinds) + self.assertIn('tool.result', kinds) + self.assertEqual(guard.read_text(), 'original') + self.assertEqual((workspace / 'source.py').read_text(), 'kernel actor evidence') + finally: + relay.close() diff --git a/tests/test_v3_actor_supervisor.py b/tests/test_v3_actor_supervisor.py new file mode 100644 index 00000000..d11bd7be --- /dev/null +++ b/tests/test_v3_actor_supervisor.py @@ -0,0 +1,1288 @@ +"""Parent policy and authenticated actor boundary regressions, without providers.""" +import importlib +import importlib.util +import io +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from benchmarks.v3_shell_launcher import ShellPolicy + + +class ActorPolicyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name).resolve() + self.workspace = self.base / 'workspace' + self.workspace.mkdir() + self.source = self.base / 'selected' + self.source.mkdir() + (self.workspace / 'source.py').write_text('before') + (self.workspace / 'other.py').write_text('other') + self.guard = self.workspace / 'test_guard.py' + self.guard.write_text('original') + self.policy = ShellPolicy(self.workspace, {'read', 'write', 'shell', 'network'}, + run_id='fixture', actor_id='root', session_id='parent') + + def module(self): + self.assertIsNotNone(importlib.util.find_spec('benchmarks.v3_actor_supervisor'), + 'trusted actor supervisor is missing') + return importlib.import_module('benchmarks.v3_actor_supervisor') + + def payload(self, scope=None, **overrides): + value = {'schema_version': 1, 'source': str(self.source), 'workspace': str(self.workspace), + 'review': False, 'report_repair': False, 'supports_report_repair': True, + 'feedback': None, 'messages': {}, + 'run': {'run_id': 'subagent_author', 'budget_id': 'subagent_author', 'profile': 'coder', + 'assignment': {'scope': scope or ['source.py'], 'objective': 'fix'}}, + 'parent': {'model': 'fixture-model', 'capabilities': ['read', 'write', 'shell'], + 'profiles': []}} + value.update(overrides) + return value + + def assignment(self, payload=None): + return self.module().ActorAssignment(self.policy, payload or self.payload(), + source=self.source, model='fixture-model') + + def test_exact_file_author_can_mutate_only_its_assigned_file(self): + assignment = self.assignment() + request = assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'after'}, [self.guard]) + self.assertEqual(request, {'path': 'source.py', 'content': 'after'}) + for target in ('other.py', 'test_guard.py', '../outside'): + with self.subTest(target=target), self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': target, 'content': 'changed'}, [self.guard]) + + def test_file_scope_never_grants_shell_write_to_the_containing_directory(self): + assignment = self.assignment() + self.assertNotIn('write', assignment.shell_policy.capabilities) + (self.workspace / 'package').mkdir() + directory = self.assignment(self.payload(['package'])) + self.assertIn('write', directory.shell_policy.capabilities) + self.assertEqual(directory.shell_policy.scope_paths, (self.workspace / 'package',)) + + def test_reviewer_and_report_repair_cannot_mutate_even_with_forged_parent_grants(self): + for flags in ({'review': True}, {'report_repair': True}): + with self.subTest(flags=flags): + assignment = self.assignment(self.payload(**flags)) + self.assertNotIn('write', assignment.shell_policy.capabilities) + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'bad'}, [self.guard]) + + def test_parent_claims_and_unknown_profiles_do_not_expand_fixture_authority(self): + self.policy.narrow(capabilities={'read'}, scope_paths=['.']) + assignment = self.assignment() + self.assertEqual(assignment.capabilities, frozenset({'read'})) + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'bad'}, []) + payload = self.payload() + payload['run']['profile'] = 'custom-root' + with self.assertRaises(PermissionError): + self.assignment(payload) + + def test_source_workspace_model_and_custom_profile_claims_are_checked(self): + payloads = [self.payload(source=str(self.base)), self.payload(workspace=str(self.base))] + for key, value in (('model', 'other-model'), ('provider', 'other-provider'), ('custom_profile', {'capabilities': 'full'})): + payload = self.payload() + payload['run']['assignment'][key] = value + payloads.append(payload) + payload = self.payload() + payload['parent']['model'] = 'other-model' + payloads.append(payload) + for payload in payloads: + with self.subTest(payload=payload), self.assertRaises(PermissionError): + self.assignment(payload) + + def test_explicit_canonical_relay_provider_and_model_do_not_change_authority(self): + module = self.module() + payload = self.payload() + payload['run']['assignment'].update(provider='openai', model='fixture-model') + assignment = module.ActorAssignment(self.policy, payload, source=self.source, + model='fixture-model', provider='openai') + self.assertEqual(assignment.capabilities, frozenset({'read', 'write', 'shell', 'network'})) + for provider in ('custom', 'deepseek'): + payload['run']['assignment']['provider'] = provider + with self.subTest(provider=provider), self.assertRaises(PermissionError): + module.ActorAssignment(self.policy, payload, source=self.source, + model='fixture-model', provider='openai') + + def test_symlink_alias_and_new_symlink_parent_are_rejected(self): + (self.workspace / 'alias.py').symlink_to('source.py') + with self.assertRaises(PermissionError): + self.assignment(self.payload(['alias.py'])) + assignment = self.assignment() + (self.workspace / 'source.py').unlink() + (self.workspace / 'source.py').symlink_to('other.py') + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'bad'}, []) + + def test_parent_traversal_cannot_erase_a_symlink_before_scope_validation(self): + (self.workspace / 'nested' / 'deeper').mkdir(parents=True) + (self.workspace / 'alias').symlink_to('nested/deeper') + assignment = self.assignment() + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'alias/../source.py', 'content': 'bad'}, []) + + def test_hardlink_and_protected_test_are_denied_even_inside_directory_scope(self): + assignment = self.assignment(self.payload(['.'])) + os.link(self.workspace / 'source.py', self.workspace / 'alias.py') + for path in ('source.py', 'alias.py', 'test_guard.py'): + with self.subTest(path=path), self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': path, 'content': 'bad'}, [self.guard]) + + def test_legacy_and_structured_mutation_paths_keep_original_argument_shape(self): + assignment = self.assignment() + value = ' source.py |left|right' + self.assertEqual(assignment.authorize_mutation('write_file', value, []), value) + edit = json.dumps({'path': 'source.py', 'old_text': 'before', 'new_text': 'after', 'expected_sha256': 'a' * 64}) + self.assertEqual(assignment.authorize_mutation('edit_file', edit, []), edit) + with self.assertRaises(PermissionError): + assignment.authorize_mutation('__getattribute__', {'path': 'source.py'}, []) + + def test_structured_paths_do_not_gain_scope_by_whitespace_normalization(self): + assignment = self.assignment() + for path in ('source.py ', ' source.py'): + with self.subTest(path=path), self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': path, 'content': 'bad'}, []) + with self.subTest(read=path), self.assertRaises(PermissionError): + assignment.authorize_read('read_file', {'path': path}) + + def test_start_revalidates_every_scope_before_spawning_the_ticketed_command(self): + from contextlib import nullcontext + module = self.module() + for kind in ('symlink', 'directory'): + with self.subTest(kind=kind): + target = self.workspace / f'second-{kind}.py' + target.write_text('before') + assignment = self.assignment(self.payload(['source.py', target.name])) + policy = assignment.shell_policy + request = {key: getattr(policy, key) for key in ('run_id', 'actor_id', 'session_id', 'revision')} + request.update(call_id='call', command='printf ready', cwd=str(self.workspace), + env={}, timeout_seconds=1, background=False) + from benchmarks.v3_shell_launcher import TicketAuthority + authority = TicketAuthority(policy) + ticket = authority.issue(request) + target.unlink() + if kind == 'symlink': + target.symlink_to('source.py') + else: + target.mkdir() + broker = module._ActorBroker.__new__(module._ActorBroker) + broker.actor, broker.policy, broker.authority = {'assignment': assignment}, policy, authority + broker.closed, broker.close_lock = threading.Event(), threading.RLock() + broker.processes = {} + owner = SimpleNamespace(popen=lambda *args, **kwargs: self.fail('invalid scope reached process execution')) + owned = SimpleNamespace(owner=owner, close=lambda: None, invocation=SimpleNamespace( + command=[sys.executable], env={}, backend='test')) + broker.pending = {ticket: (request, request, owned, time.monotonic()+5)} + broker.supervisor = SimpleNamespace(execution_lock=threading.RLock(), + _frozen=nullcontext, protected_files=[], group=SimpleNamespace(uid=12345)) + with self.assertRaises(PermissionError): + broker._dispatch({'op':'start', 'ticket':ticket, 'request':request}) + + def test_narrowed_fixture_invalidates_existing_assignment(self): + assignment = self.assignment() + self.policy.narrow(capabilities={'read'}, scope_paths=['.']) + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'bad'}, []) + + def test_assignment_snapshot_cannot_be_modified_after_authorization(self): + payload = self.payload() + assignment = self.assignment(payload) + payload['run']['assignment']['scope'] = ['.'] + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'other.py', 'content': 'bad'}, []) + + def test_mutation_effects_must_fit_the_accepted_plan(self): + self.policy.effects = frozenset({'read'}) + assignment = self.assignment() + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'source.py', 'content': 'bad'}, []) + + def test_parent_read_scope_blocks_sibling_and_traversal_patterns(self): + assignment = self.assignment() + self.assertEqual(assignment.authorize_read('read_file', 'source.py'), 'source.py') + for action, arguments in (('read_file', 'other.py'), ('list_dir', '.'), + ('find_files', {'pattern': '../*', 'directory': '.'}), + ('search_files', {'query': 'x', 'glob': '../*', 'path': 'source.py'})): + with self.subTest(action=action), self.assertRaises(PermissionError): + assignment.authorize_read(action, arguments) + + def test_review_extra_reads_require_parent_authorized_artifacts(self): + module = self.module() + assignment = module.ActorAssignment(self.policy, self.payload(review=True), + source=self.source, model='fixture-model', review_artifacts=[self.workspace / 'other.py']) + self.assertEqual(assignment.authorize_read('read_file', 'other.py'), 'other.py') + with self.assertRaises(PermissionError): + self.assignment(self.payload(review=True)).authorize_read('read_file', 'other.py') + with self.assertRaises(PermissionError): + assignment.authorize_mutation('write_file', {'path': 'other.py', 'content': 'bad'}, []) + + def test_supervisor_requires_privileged_parent(self): + with patch.object(self.module().os, 'geteuid', return_value=501): + with self.assertRaisesRegex(PermissionError, 'privileged'): + self.module().ActorSupervisor([sys.executable, '-c', 'pass'], cwd=self.workspace, + env={}, protected_files=[self.guard], policy=self.policy, source=self.source) + + def test_actor_environment_pins_the_native_skills_directory_to_its_private_home(self): + supervisor = self.module().ActorSupervisor.__new__(self.module().ActorSupervisor) + supervisor.env = {'KYROZEN_SKILLS_DIR': '/shared-skills', 'KYROZEN_SKILLS_PATH': '/stale-path'} + supervisor.workspace = self.workspace + with socket.socket(socket.AF_UNIX) as listener: + supervisor.address = str(self.base / 'listener') + listener.bind(supervisor.address) + environment = supervisor._environment(self.base / 'private-home') + self.assertEqual(environment['KYROZEN_SKILLS_DIR'], str(self.base / 'private-home' / 'skills')) + self.assertNotIn('KYROZEN_SKILLS_PATH', environment) + + def test_actor_git_environment_trusts_only_the_canonical_parent_fixture(self): + supervisor = self.module().ActorSupervisor.__new__(self.module().ActorSupervisor) + supervisor.workspace = self.workspace + supervisor.env = {'PATH': '/trusted/bin', 'GIT_CONFIG_COUNT': '2', + 'GIT_CONFIG_KEY_0': 'safe.directory', 'GIT_CONFIG_VALUE_0': '*', + 'GIT_CONFIG_KEY_1': 'safe.directory', 'GIT_CONFIG_VALUE_1': str(self.base / 'sibling'), + 'GIT_CONFIG_GLOBAL': '/inherited-global', 'GIT_CONFIG_SYSTEM': '/inherited-system', + 'GIT_CONFIG_PARAMETERS': "'safe.directory=*'", 'GIT_CONFIG': '/inherited-config'} + with socket.socket(socket.AF_UNIX) as listener: + supervisor.address = str(self.base / 'git-listener') + listener.bind(supervisor.address) + environment = supervisor._environment(self.base / 'private-home') + self.assertEqual({key: value for key, value in environment.items() if key.startswith('GIT_CONFIG')}, + {'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'safe.directory', + 'GIT_CONFIG_VALUE_0': str(self.workspace), 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_CONFIG_GLOBAL': os.devnull}) + self.assertEqual(environment['PATH'], '/trusted/bin') + + def test_managed_shell_git_environment_is_canonical_before_the_ticket_is_bound(self): + module = self.module() + from benchmarks.v3_shell_launcher import TicketAuthority + request = {key: getattr(self.policy, key) for key in ('run_id', 'actor_id', 'session_id', 'revision')} + request.update(call_id='git-call', command='git log -1', cwd=str(self.workspace), + env={'PATH': '/trusted/bin', 'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'safe.directory', + 'GIT_CONFIG_VALUE_0': '*', 'GIT_CONFIG_PARAMETERS': "'safe.directory=*'"}, + timeout_seconds=1, background=False) + original = json.loads(json.dumps(request)) + broker = module._ActorBroker.__new__(module._ActorBroker) + broker.actor, broker.policy = {'root': True}, self.policy + broker.pending, broker.processes, broker.protected_files = {}, {}, [] + broker.closed, broker.close_lock = threading.Event(), threading.RLock() + broker.authority = TicketAuthority(self.policy) + owner = SimpleNamespace(close=lambda: None) + broker.supervisor = SimpleNamespace(execution_lock=threading.RLock(), + workspace=self.workspace, _owner=lambda: owner, _home=lambda owner: self.base / 'command-home') + def protected(command, *, env, **kwargs): + return SimpleNamespace(command=[sys.executable], env=dict(env), shell=False, + temporary_directory=None, close=lambda: None) + with patch.object(module, 'protected_shell_command', side_effect=protected), \ + patch.object(module, '_restrict_reads'): + ticket = broker._dispatch({'op': 'ticket', 'request': request}) + concrete = broker.pending[ticket][1] + self.assertEqual(concrete['env']['GIT_CONFIG_COUNT'], '1') + self.assertEqual(concrete['env']['GIT_CONFIG_VALUE_0'], str(self.workspace)) + self.assertNotIn('GIT_CONFIG_PARAMETERS', concrete['env']) + self.assertEqual(request, original) + forged = dict(concrete, env=dict(concrete['env'], GIT_CONFIG_VALUE_0='*')) + with self.assertRaises(PermissionError): + broker.authority.consume(ticket, forged) + + @unittest.skipUnless(os.name == 'posix', 'Unix actor listener directory') + def test_socket_factory_uses_a_private_directory_under_root_owned_sticky_var_tmp(self): + module=self.module() + self.assertTrue(hasattr(module,'_actor_socket_directory'), 'portable actor socket factory is missing') + directory=module._actor_socket_directory() + try: + self.assertEqual(directory.parent,Path('/var/tmp').resolve()) + self.assertEqual(directory.stat().st_mode & 0o777,0o711) + self.assertEqual(directory.stat().st_uid,os.geteuid()) + self.assertEqual(directory.parent.stat().st_uid,0) + self.assertTrue(directory.parent.stat().st_mode & 0o1000) + self.assertFalse(directory.is_relative_to(self.base)) + finally: + directory.rmdir() + + @unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0, 'uses unprivileged directories to force rejection') + def test_failed_constructor_releases_unexecuted_identity_and_private_directories(self): + module = self.module() + state, channel = self.base / 'state', self.base / 'channel' + state.mkdir() + channel.mkdir() + owner = SimpleNamespace(uid=1_234_567_890, closed=False) + owner.close = lambda: setattr(owner, 'closed', True) + with patch.object(module.os, 'geteuid', return_value=0), patch.object(module, 'UIDOwner', return_value=owner), \ + patch.object(module.tempfile, 'mkdtemp', side_effect=[str(state), str(channel)]): + with self.assertRaisesRegex(PermissionError, 'root-owned'): + module.ActorSupervisor([sys.executable, '-c', 'pass'], cwd=self.workspace, + env={}, protected_files=[self.guard], policy=self.policy, source=self.source) + self.assertTrue(owner.closed) + self.assertFalse(state.exists()) + self.assertFalse(channel.exists()) + + +@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0, + 'privileged disposable benchmark runner required') +class PrivilegedActorSupervisorTests(unittest.TestCase): + def test_native_and_managed_git_trust_only_the_fixture_for_both_sources_and_actor_roles(self): + import tarfile + from benchmarks.v3_actor_supervisor import ActorAssignment, ActorSupervisor + from benchmarks.v3_process_owner import process_snapshot + repository = Path(__file__).resolve().parents[1] + archive = subprocess.run(['git', '-C', str(repository), 'archive', + '1bfc82c793df49e76097061ec5adfef0e4e2e883'], check=True, capture_output=True).stdout + managed_probe = '''import json,os,subprocess,sys +def git(path,*args): + p=subprocess.run(['git','-C',path,*args],capture_output=True,text=True) + return {'code':p.returncode,'out':p.stdout,'error':p.stderr} +print(json.dumps({'uid':os.geteuid(),'log':git(sys.argv[1],'log','-1','--format=%s'), + 'diff':git(sys.argv[1],'diff','--no-ext-diff','--no-textconv'), + 'sibling':git(sys.argv[2],'log','-1'), + 'config':{k:v for k,v in os.environ.items() if k.startswith('GIT_CONFIG')}})) +''' + code = '''import json,os,shlex,subprocess,sys +sys.path.insert(0,sys.argv[2]) +from benchmarks.v3_process_owner import connect_actor_client +c=connect_actor_client() +sys.path.insert(0,sys.argv[1]) +from openkyrozen.tools import ToolAdapters +t=ToolAdapters(root=sys.argv[3]) +sibling=subprocess.run(['git','-C',sys.argv[4],'log','-1'],capture_output=True,text=True) +out={'uid':os.geteuid(),'owner':os.stat(sys.argv[3]).st_uid,'log':str(t.git_log('-1')), + 'diff':str(t.git_diff('')),'sibling':{'code':sibling.returncode,'error':sibling.stderr}, + 'config':{k:v for k,v in os.environ.items() if k.startswith('GIT_CONFIG')}} +env=dict(os.environ,GIT_CONFIG_COUNT='1',GIT_CONFIG_KEY_0='safe.directory',GIT_CONFIG_VALUE_0='*') +p=c.start(shlex.join([sys.executable,'-I','-c',sys.argv[5],sys.argv[3],sys.argv[4]]),sys.argv[3],env,10) +p.wait(15) +stdout=bytearray();stderr=bytearray() +while chunk:=p.stdout.read1(4096): stdout.extend(chunk) +while chunk:=p.stderr.read1(4096): stderr.extend(chunk) +assert p.returncode==0,stderr.decode() +out['managed']=json.loads(stdout) +print(json.dumps(out)) +''' + with tempfile.TemporaryDirectory(dir='/var/tmp', prefix='actor-git-fixture-') as directory: + base = Path(directory).resolve();base.chmod(0o755) + baseline = base / 'baseline';baseline.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(baseline, filter='data') + for name, selected in (('v3', repository), ('v2', baseline)): + for child in (False, True): + with self.subTest(source=name, child=child): + workspace = base / f'{name}-{child}';workspace.mkdir() + sibling = workspace / 'sibling';sibling.mkdir() + setup_env = {'PATH': os.environ['PATH'], 'HOME': str(base), + 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull} + for target in (workspace, sibling): + subprocess.run(['git', 'init', '-q', str(target)], env=setup_env, check=True) + (target / 'example.txt').write_text('before\n') + subprocess.run(['git', '-C', str(target), 'add', 'example.txt'], env=setup_env, check=True) + subprocess.run(['git', '-C', str(target), '-c', 'user.name=Fixture', + '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', + 'commit', '-qm', 'synthetic fixture'], env=setup_env, check=True) + (workspace / 'example.txt').write_text('after\n') + command = [sys.executable, '-I', '-c', code, str(selected), str(repository), + str(workspace), str(sibling), managed_probe] + policy = ShellPolicy(workspace, {'read', 'shell', 'git'}) + environment = dict(setup_env, LC_ALL='C', GIT_CONFIG_COUNT='1', + GIT_CONFIG_KEY_0='safe.directory', GIT_CONFIG_VALUE_0='*', + GIT_CONFIG_PARAMETERS="'safe.directory=*'") + with ActorSupervisor(command, cwd=workspace, env=environment, + protected_files=[], policy=policy, source=selected) as supervisor: + if child: + payload = {'schema_version':1, 'source':str(selected), 'workspace':str(workspace), + 'review':False, 'report_repair':False, 'parent':{'model':'fixture-model'}, + 'run':{'run_id':'git-author','profile':'coder','assignment':{'scope':['.']}}} + assignment = ActorAssignment(policy, payload, source=selected, model='fixture-model') + actor = {'root':False, 'policy':assignment.shell_policy, 'assignment':assignment, + 'connected':False, 'broker':None, 'condition':threading.Condition(), + 'callbacks':{}, 'cancelled':False, 'disconnected':False, 'state':'running'} + process = supervisor._spawn(command, actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + process = supervisor.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, error) + result = json.loads(output) + self.assertNotEqual(result['uid'], result['owner']) + self.assertIn('synthetic fixture', result['log']) + self.assertIn('+after', result['diff']) + self.assertNotEqual(result['managed']['uid'], result['uid']) + self.assertEqual(result['managed']['log']['code'], 0) + self.assertIn('synthetic fixture', result['managed']['log']['out']) + self.assertEqual(result['managed']['diff']['code'], 0) + self.assertIn('+after', result['managed']['diff']['out']) + for observation in (result, result['managed']): + self.assertNotEqual(observation['sibling']['code'], 0) + self.assertIn('dubious ownership', observation['sibling']['error']) + self.assertEqual(observation['config'], { + 'GIT_CONFIG_COUNT':'1', 'GIT_CONFIG_KEY_0':'safe.directory', + 'GIT_CONFIG_VALUE_0':str(workspace), 'GIT_CONFIG_NOSYSTEM':'1', + 'GIT_CONFIG_GLOBAL':os.devnull}) + owners = {owner.uid for owner in supervisor.owners} + self.assertFalse(any(uid in owners and state != 'Z' for uid,state in process_snapshot().values())) + + def test_narrowed_root_policy_bounds_raw_and_managed_shell_reads(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory).resolve() + base.chmod(0o755) + workspace = base/'workspace' + allowed = workspace/'allowed' + allowed.mkdir(parents=True) + (allowed/'source.py').write_text('visible') + (workspace/'sibling.py').write_text('private') + source = Path(__file__).resolve().parents[1] + probe = '''import json,pathlib,sys +p=pathlib.Path(sys.argv[1]) +out=[(p/'allowed/source.py').read_text()] +try: + (p/'sibling.py').read_text() + out.append(False) +except OSError: + out.append(True) +try: + names=[item.name for item in p.iterdir()] +except OSError: + names=[] +out.append('sibling.py' not in names) +print(json.dumps(out)) +''' + code = '''import json,os,shlex,sys +sys.path.insert(0,sys.argv[1]) +from benchmarks.v3_process_owner import connect_actor_client +c=connect_actor_client() +args=sys.argv +sys.argv=['probe',args[2]] +exec(compile(args[3],'','exec')) +sys.argv=args +p=c.start(shlex.join([sys.executable,'-I','-c',sys.argv[3],sys.argv[2]]), + os.path.join(sys.argv[2],'allowed'),{'PATH':os.environ['PATH']},5) +p.wait(10) +out=bytearray() +while chunk:=p.stdout.read1(4096): out.extend(chunk) +while p.stderr.read1(4096): pass +assert p.returncode==0 +print(out.decode().strip()) +''' + policy = ShellPolicy(workspace, {'read','write','shell'}, scope_paths=['allowed']) + with ActorSupervisor([sys.executable,'-I','-c',code,str(source),str(workspace),probe], + cwd=workspace, env={'PATH':os.environ['PATH']}, protected_files=[], + policy=policy, source=source) as supervisor: + process = supervisor.popen(stdout=subprocess.PIPE,stderr=subprocess.PIPE) + output,error=process.communicate(timeout=20) + self.assertEqual(process.returncode,0,error) + self.assertEqual([json.loads(line) for line in output.splitlines()], + [['visible',True,True],['visible',True,True]]) + + def test_directory_child_shell_cannot_hide_protected_test_mounts(self): + from benchmarks.v3_actor_supervisor import ActorAssignment, ActorSupervisor + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory).resolve() + base.chmod(0o755) + workspace = base / 'workspace' + package = workspace / 'package' + package.mkdir(parents=True) + guard = package / 'test_guard.py' + guard.write_text('original') + (package / 'source.py').write_text('before') + source = Path(__file__).resolve().parents[1] + policy = ShellPolicy(workspace, {'read', 'write', 'shell'}) + payload = {'schema_version': 1, 'source': str(source), 'workspace': str(workspace), + 'review': False, 'report_repair': False, 'parent': {'model': 'fixture-model'}, + 'run': {'run_id': 'directory-author', 'profile': 'coder', 'assignment': {'scope': ['package']}}} + assignment = ActorAssignment(policy, payload, source=source, model='fixture-model') + code = '''import json,os,sys +sys.path.insert(0,sys.argv[1]) +from benchmarks.v3_process_owner import connect_actor_client +c=connect_actor_client() +p=c.start('printf changed > test_guard.py; printf after > source.py',sys.argv[2],{'PATH':os.environ['PATH']},5) +p.wait(10) +while p.stdout.read1(4096): pass +while p.stderr.read1(4096): pass +print(json.dumps(p.returncode)) +''' + with ActorSupervisor([sys.executable, '-c', 'pass'], cwd=workspace, + env={'PATH': os.environ['PATH']}, protected_files=[guard], policy=policy, source=source) as supervisor: + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'cancelled': False, 'disconnected': False, 'state': 'running'} + process = supervisor._spawn([sys.executable, '-I', '-c', code, str(source), str(package)], + actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=20) + self.assertEqual(process.returncode, 0, error) + self.assertEqual(json.loads(output), 0) + self.assertEqual(guard.read_text(), 'original') + self.assertEqual((package / 'source.py').read_text(), 'after') + + def test_no_read_actor_starts_native_runtime_without_access_to_fixture_data(self): + from benchmarks.v3_actor_supervisor import ActorAssignment, ActorSupervisor + for repair in (False, True): + with self.subTest(report_repair=repair), tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory).resolve() + base.chmod(0o755) + workspace = base / 'workspace' + workspace.mkdir() + (workspace / 'secret.py').write_text('private-fixture') + source = Path(__file__).resolve().parents[1] + policy = ShellPolicy(workspace, {'read', 'write'} if repair else set()) + payload = {'schema_version': 1, 'source': str(source), 'workspace': str(workspace), + 'review': False, 'report_repair': repair, 'parent': {'model': 'fixture-model'}, + 'run': {'run_id': 'repair-author', 'profile': 'coder', 'assignment': {'scope': ['.']}}} + assignment = ActorAssignment(policy, payload, source=source, model='fixture-model') + code = '''import json,pathlib,sys +sys.path.insert(0,sys.argv[1]) +from benchmarks.v3_process_owner import connect_actor_client +c=connect_actor_client() +from openkyrozen.app.bootstrap import build_application +app=build_application() +app.runtime._set_workspace_root(sys.argv[2]) +app.close() +try: + pathlib.Path(sys.argv[2],'secret.py').read_text() + denied=False +except OSError: + denied=True +try: + names=[p.name for p in pathlib.Path(sys.argv[2]).iterdir()] +except OSError: + names=[] +print(json.dumps([denied,names])) +''' + with ActorSupervisor([sys.executable, '-I', '-c', code, str(source), str(workspace)], + cwd=workspace, env={'PATH': os.environ['PATH'], 'KYROZEN_DISABLE_VECTOR_INDEX': '1'}, + protected_files=[], policy=policy, source=source) as supervisor: + if repair: + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'cancelled': False, 'disconnected': False, 'state': 'running'} + process = supervisor._spawn(supervisor.command, actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + process = supervisor.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, error) + self.assertEqual(json.loads(output), [True, []]) + + def test_scoped_child_helpers_see_atomic_replacement_and_new_files(self): + from benchmarks.v3_actor_supervisor import ActorAssignment, ActorSupervisor + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory).resolve() + base.chmod(0o755) + workspace = base / 'workspace' + workspace.mkdir() + (workspace / 'source.py').write_text('before') + (workspace / 'sibling.py').write_text('private') + guard = workspace / 'test_guard.py' + guard.write_text('original') + source = Path(__file__).resolve().parents[1] + policy = ShellPolicy(workspace, {'read', 'write', 'shell'}) + payload = {'schema_version': 1, 'source': str(source), 'workspace': str(workspace), + 'review': False, 'report_repair': False, 'parent': {'model': 'fixture-model'}, + 'run': {'run_id': 'subagent_author', 'profile': 'coder', + 'assignment': {'scope': ['source.py', 'created.py']}}} + assignment = ActorAssignment(policy, payload, source=source, model='fixture-model') + code = '''import json,pathlib,sys +import os +pathlib.Path(os.environ['TMPDIR']).joinpath('child-temp-probe').write_text('private') +sys.path.insert(0,sys.argv[1]) +from benchmarks.v3_process_owner import connect_actor_client +c=connect_actor_client() +original=c.rpc('file_read',action='read_file',arguments={'path':'source.py'}) +digest=original['data']['sha256'] +c.rpc('file_mutation',action='edit_file',arguments={'path':'source.py','old_text':'before','new_text':'after','expected_sha256':digest}) +updated=c.rpc('file_read',action='read_file',arguments={'path':'source.py'}) +c.rpc('file_mutation',action='write_file',arguments={'path':'created.py','content':'new'}) +created=c.rpc('file_read',action='read_file',arguments={'path':'created.py'}) +out=[updated['data']['content'],created['data']['content']] +for action,target in [('file_read','sibling.py'),('file_mutation','test_guard.py')]: + try: + c.rpc(action,action='read_file' if action=='file_read' else 'write_file',arguments={'path':target,'content':'bad'}) + out.append(False) + except PermissionError: + out.append(True) +print(json.dumps(out)) +''' + with ActorSupervisor([sys.executable, '-c', 'pass'], cwd=workspace, + env={'PATH': os.environ['PATH']}, protected_files=[guard], policy=policy, + source=source, provider_config={'model_main': 'fixture-model'}) as supervisor: + actor = {'root': False, 'policy': assignment.shell_policy, 'assignment': assignment, + 'connected': False, 'broker': None, 'condition': threading.Condition(), + 'callbacks': {}, 'cancelled': False, 'disconnected': False, 'state': 'running'} + process = supervisor._spawn([sys.executable, '-I', '-c', code, str(source)], actor, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, error) + self.assertEqual(json.loads(output), ['after', 'new', True, True]) + self.assertEqual((workspace / 'source.py').read_text(), 'after') + self.assertEqual(guard.read_text(), 'original') + + def test_registered_uid_bootstrap_is_canonical_and_unregistered_uid_is_denied(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory) + base.chmod(0o755) + workspace = base / 'workspace' + workspace.mkdir() + source = Path(__file__).resolve().parents[1] + script = ('import json,sys,os,pathlib; ' + 'pathlib.Path(os.environ["TMPDIR"]).joinpath("root-temp-probe").write_text("private"); ' + 'sys.path.insert(0,sys.argv[1]); ' + 'from benchmarks.v3_process_owner import connect_actor_client; ' + 'c=connect_actor_client(); print(json.dumps(c.identity),flush=True)') + policy = ShellPolicy(workspace, {'read'}, actor_id='canonical-root') + with ActorSupervisor([sys.executable, '-I', '-c', script, str(source)], cwd=workspace, + env={'PATH': os.environ['PATH']}, protected_files=[], policy=policy, + source=source) as supervisor: + process = supervisor.popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=15) + self.assertEqual(process.returncode, 0, error) + self.assertEqual(json.loads(output)['actor_id'], 'canonical-root') + with socket.socket(socket.AF_UNIX) as outsider: + outsider.settimeout(5) + outsider.connect(supervisor.address) + outsider.sendall(b'{"op":"hello","actor_id":"canonical-root"}\n') + self.assertFalse(json.loads(outsider.makefile('rb').readline())['ok']) + + +class ActorChannelTests(unittest.TestCase): + def supervisor(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + supervisor = ActorSupervisor.__new__(ActorSupervisor) + supervisor.closed = threading.Event() + supervisor.handles = {} + return supervisor + + def actor(self, parent): + return {'root': False, 'parent': parent, 'condition': threading.Condition(), + 'callbacks': {}, 'cancelled': False, 'disconnected': False, 'state': 'running'} + + def test_live_cancellation_queries_progress_without_root_callback_reply_and_release_shared_client(self): + from benchmarks.v3_actor_worker import _CoordinatorPort + from benchmarks.v3_shell_launcher import BrokerClient, _encoded + from openkyrozen.agent.turn import CancellationEvents + supervisor=self.supervisor();parent=object();actor=self.actor(parent) + supervisor.handles['owned']=actor + ready,handling,resume=(threading.Event() for _ in range(3)) + complete={name:threading.Event() for name in ('provider','consumer')} + results={};requests=[];errors=[] + class ObservedCondition(threading.Condition): + def wait(self, timeout=None): + ready.set() # old transport has queued a callback and released this lock next + return super().wait(timeout) + actor['condition']=ObservedCondition() + def stop(owned): + with owned['condition']: + owned['cancelled']=True + owned['condition'].notify_all() + supervisor._stop_actor=stop + server,channel=socket.socketpair();client=BrokerClient(channel.detach(),{}) + def serve(): + try: + with server.makefile('rwb',buffering=0) as stream: + while line:=stream.readline(): + request=json.loads(line);requests.append(request) + try: + value=supervisor._callback(actor,request) + response={'ok':True,'value':value} + except PermissionError as error: + response={'ok':False,'message':str(error)} + ready.set() # the direct live-state query has already returned + server.sendall(_encoded(response)+b'\n') + except OSError: + pass + def root_poll(): + try: + if not ready.wait(2):raise RuntimeError('child query never reached the broker') + response=supervisor._delegated(parent,{'op':'delegated_poll','id':'owned', + 'replies':[],'cancelled':False}) + handling.set() + if not resume.wait(3):raise RuntimeError('root callback barrier was not released') + supervisor._delegated(parent,{'op':'delegated_poll','id':'owned','cancelled':False, + 'replies':[{'id':row['id'],'ok':True,'value':False} for row in response['requests']]}) + except Exception as error: + errors.append(error) + threads=[] + with tempfile.TemporaryDirectory() as directory: + payload={'workspace':directory,'run':{'run_id':'owned'},'review':False,'messages':{}, + 'parent':{'has_budget':False}} + coordinator=_CoordinatorPort(payload,client) + flag=coordinator.cancelled['owned'] + # Match the original delegation -> runtime -> provider composition: + # one uncancelled check reaches the same port three times. + cancelled=CancellationEvents(CancellationEvents(CancellationEvents(flag,flag),threading.Event()),flag) + def check(name): + try:results[name]=cancelled.is_set() + except Exception as error:errors.append(error) + finally:complete[name].set() + try: + for target in (serve,root_poll,lambda:check('provider')): + thread=threading.Thread(target=target,daemon=True);threads.append(thread);thread.start() + self.assertTrue(handling.wait(2),'root did not reach the controlled callback-service barrier') + consumer=threading.Thread(target=lambda:check('consumer'),daemon=True) + threads.append(consumer);consumer.start() + self.assertTrue(complete['provider'].wait(1), + 'cancellation waited for root callback service while holding BrokerClient.lock') + self.assertTrue(complete['consumer'].wait(1),'the provider cancellation query blocked its peer') + self.assertEqual(results,{'provider':False,'consumer':False}) + self.assertFalse(resume.is_set()) + self.assertEqual(len(requests),6) + self.assertEqual(actor['callbacks'],{}) + # The same bound parent poll/stop path must still cancel this actor. + supervisor._delegated(parent,{'op':'delegated_poll','id':'owned','replies':[],'cancelled':True}) + self.assertTrue(cancelled.is_set()) + client.connection.shutdown(socket.SHUT_RDWR) + with self.assertRaises((ConnectionError,OSError)): + cancelled.is_set() # a disconnected transport must never become cached False + finally: + resume.set();supervisor.closed.set() + with actor['condition']:actor['condition'].notify_all() + for connection in (client.connection,server): + try:connection.shutdown(socket.SHUT_RDWR) + except OSError:pass + for thread in threads:thread.join(timeout=2) + client.stream.close();client.connection.close();server.close() + self.assertTrue(all(not thread.is_alive() for thread in threads)) + self.assertEqual(errors,[]) + + def test_cancellation_query_reads_each_live_actor_state_without_queueing_or_worker_claims(self): + supervisor=self.supervisor();parent=object();actor=self.actor(parent) + supervisor.handles['owned']=actor + request={'operation':'cancelled','values':{}} + for claim in ({'cancelled':False},{'run_id':'sibling'}): + with self.subTest(claim=claim),self.assertRaises(PermissionError): + supervisor._callback(actor,dict(request,values=claim)) + for claim in (0,1,'true',None): + with self.subTest(parent_claim=claim),self.assertRaises(PermissionError): + supervisor._delegated(parent,{'op':'delegated_poll','id':'owned','replies':[],'cancelled':claim}) + with patch.object(actor['condition'],'wait',side_effect=AssertionError('cancellation entered callback queue')): + self.assertIs(supervisor._callback(actor,request),False) + actor['cancelled']=True + self.assertIs(supervisor._callback(actor,request),True) + actor['cancelled']=False;actor['disconnected']=True + self.assertIs(supervisor._callback(actor,request),True) + actor['disconnected']=False;supervisor.closed.set() + self.assertIs(supervisor._callback(actor,request),True) + self.assertEqual(actor['callbacks'],{}) + + def test_callback_only_its_parent_can_reply_and_replays_fail(self): + supervisor = self.supervisor() + parent = object() + actor = self.actor(parent) + supervisor.handles['owned'] = actor + result = [] + thread = threading.Thread(target=lambda: result.append(supervisor._callback(actor, + {'operation': 'budget_can_continue', 'values': {'cost_picos': 7}}))) + thread.start() + try: + deadline = time.monotonic() + 2 + while not actor['callbacks'] and time.monotonic() < deadline: + time.sleep(.001) + with self.assertRaises(PermissionError): + supervisor._delegated(object(), {'op': 'delegated_poll', 'id': 'owned', 'cancelled': False, 'replies': []}) + poll = supervisor._delegated(parent, {'op': 'delegated_poll', 'id': 'owned', 'cancelled': False, 'replies': []}) + self.assertEqual(poll['requests'][0]['op'], 'budget_can_continue') + reply = {'id': poll['requests'][0]['id'], 'ok': True, 'value': False} + supervisor._delegated(parent, {'op': 'delegated_poll', 'id': 'owned', 'cancelled': False, 'replies': [reply]}) + thread.join(timeout=2) + self.assertEqual(result, [False]) + with self.assertRaises(PermissionError): + supervisor._delegated(parent, {'op': 'delegated_poll', 'id': 'owned', 'cancelled': False, 'replies': [reply]}) + finally: + supervisor.closed.set() + thread.join(timeout=2) + + def test_callback_shape_is_finite_and_cannot_choose_arbitrary_service(self): + supervisor = self.supervisor() + actor = self.actor(object()) + for operation, values in (('__getattribute__', {}), ('budget_can_continue', {'cost_picos': 1, 'run_id': 'sibling'}), + ('cancelled', {'object': 'root'}), ('access_acquire', {'path': '/', 'reading': 'yes'})): + with self.subTest(operation=operation, values=values), patch.object(actor['condition'], 'wait', + side_effect=AssertionError('invalid callback reached the parent queue')), self.assertRaises(PermissionError): + supervisor._callback(actor, {'operation': operation, 'values': values}) + + def test_freeze_resumes_only_processes_it_stopped(self): + from benchmarks import v3_actor_supervisor as module + supervisor = self.supervisor() + supervisor.owners = [SimpleNamespace(uid=12345, closed=False)] + snapshots = [{20: (12345, 'S'), 21: (12345, 'T'), 99: (501, 'S')}, + {20: (12345, 'T'), 21: (12345, 'T'), 99: (501, 'S')}, + {20: (12345, 'T'), 21: (12345, 'T'), 99: (501, 'S')}] + with patch.object(module, 'process_snapshot', side_effect=snapshots), patch.object(module.os, 'kill') as kill: + with supervisor._frozen(): + pass + import signal + self.assertEqual([(call.args[0], call.args[1]) for call in kill.call_args_list], + [(20, signal.SIGSTOP), (20, signal.SIGCONT)]) + + def test_bounded_actor_collection_preserves_stdin_stdout_and_stderr(self): + from benchmarks import v3_actor_supervisor as module + self.assertTrue(hasattr(module, '_communicate'), 'bounded actor output collection is missing') + process = subprocess.Popen([sys.executable, '-I', '-c', + 'import sys; print(sys.stdin.read()); print("diagnostic",file=sys.stderr)'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + owner = SimpleNamespace(close=lambda: process.kill() if process.poll() is None else None) + try: + output, error = module._communicate(process, owner, input_data=b'hello', timeout=5) + self.assertEqual((output, error, process.returncode), (b'hello\n', b'diagnostic\n', 0)) + finally: + owner.close() + process.wait(timeout=5) + + def test_output_overflow_stops_the_owned_process_instead_of_buffering_forever(self): + from benchmarks import v3_actor_supervisor as module + self.assertTrue(hasattr(module, '_communicate'), 'bounded actor output collection is missing') + process = subprocess.Popen([sys.executable, '-I', '-c', + 'import os,time; os.write(1,b"x"*128); time.sleep(120)'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + owner = SimpleNamespace(close=lambda: process.kill() if process.poll() is None else None) + try: + with self.assertRaisesRegex(PermissionError, 'output'): + module._communicate(process, owner, timeout=5, limit=64) + self.assertIsNotNone(process.poll()) + finally: + owner.close() + process.wait(timeout=5) + + def test_relay_revocation_failure_cannot_skip_kernel_identity_cleanup(self): + supervisor = self.supervisor() + supervisor.failures = [] + def revoke(_): + raise RuntimeError('revocation failed') + supervisor.revoke_actor = revoke + owner = SimpleNamespace(closed=False) + owner.close = lambda: setattr(owner, 'closed', True) + invocation = SimpleNamespace(closed=False) + invocation.close = lambda: setattr(invocation, 'closed', True) + actor = {'root': True, 'stop_lock': threading.RLock(), 'stopped': False, + 'owner': owner, 'invocation': invocation, 'broker': None, + 'policy': SimpleNamespace(actor_id='root')} + with self.assertRaises(RuntimeError): + supervisor._stop_actor(actor) + self.assertTrue(owner.closed) + self.assertTrue(invocation.closed) + + def test_normal_channel_eof_allows_python_shutdown_before_uid_drain(self): + from benchmarks.v3_actor_supervisor import _ActorBroker + supervisor = self.supervisor() + supervisor.failures, supervisor.revoke_actor, supervisor.protected_files = [], None, [] + parent, child = socket.socketpair() + process = subprocess.Popen([sys.executable, '-I', '-c', + 'import os,sys,time; os.close(int(sys.argv[1])); time.sleep(.05); print("complete")', + str(child.fileno())], pass_fds=(child.fileno(),), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child.close() + owner = SimpleNamespace(closed=False) + def close(): + if process.poll() is None: + process.kill() + process.wait(timeout=5) + owner.closed = True + owner.close = close + with tempfile.TemporaryDirectory() as directory: + actor = {'root': True, 'policy': ShellPolicy(directory, {'read'}), + 'stop_lock': threading.RLock(), 'stopped': False, 'owner': owner, + 'process': process, 'invocation': SimpleNamespace(close=lambda: None), 'broker': None} + broker = _ActorBroker(parent, supervisor, actor) + actor['broker'] = broker + try: + output, error = process.communicate(timeout=5) + broker.thread.join(timeout=5) + self.assertEqual(process.returncode, 0, error) + self.assertEqual(output, b'complete\n') + self.assertTrue(owner.closed) + finally: + broker.close() + owner.close() + + def test_failed_identity_drain_remains_retryable_and_never_certifies_stopped(self): + supervisor = self.supervisor() + supervisor.failures, supervisor.revoke_actor = [], None + owner = SimpleNamespace(closed=False) + def fail(): + raise RuntimeError('kernel cleanup failed') + owner.close = fail + actor = {'root': True, 'stop_lock': threading.RLock(), 'stopped': False, + 'owner': owner, 'invocation': SimpleNamespace(close=lambda: None), + 'broker': None, 'policy': SimpleNamespace(actor_id='root')} + with self.assertRaises(RuntimeError): + supervisor._stop_actor(actor) + self.assertFalse(actor['stopped']) + owner.close = lambda: setattr(owner, 'closed', True) + supervisor._stop_actor(actor) + self.assertTrue(owner.closed) + + def test_shared_group_reservation_survives_any_failed_actor_drain(self): + supervisor = self.supervisor() + group = SimpleNamespace(closed=False) + group.close = lambda: setattr(group, 'closed', True) + owner = SimpleNamespace(closed=False) + def fail(): + raise RuntimeError('actor remained alive') + owner.close = fail + supervisor.group, supervisor.owners = group, [group, owner] + supervisor.actors, supervisor.failures = {}, [] + supervisor.listener = supervisor.thread = supervisor.state = supervisor.socket_directory = None + with self.assertRaises(RuntimeError): + supervisor.close() + self.assertFalse(group.closed) + + +class ActorBrokerCleanupTests(unittest.TestCase): + def broker(self, command_invocations, pending=()): + from benchmarks.v3_actor_supervisor import ActorSupervisor, _ActorBroker + supervisor = ActorSupervisor.__new__(ActorSupervisor) + supervisor.failures, supervisor.revoke_actor, supervisor.handles = [], None, {} + owner = SimpleNamespace(closed=False) + owner.close = lambda: setattr(owner, 'closed', True) + actor = {'root': True, 'owner': owner, 'stopped': False, 'stop_lock': threading.RLock(), + 'invocation': SimpleNamespace(close=lambda: None), 'policy': SimpleNamespace(actor_id='root')} + broker = _ActorBroker.__new__(_ActorBroker) + broker.actor, broker.supervisor = actor, supervisor + broker.closed, broker.close_lock = threading.Event(), threading.RLock() + broker.connection = SimpleNamespace(shutdown=lambda how: None, close=lambda: None) + broker.pending = {str(index): ({}, {}, invocation, 0) for index, invocation in enumerate(pending)} + broker.authority = SimpleNamespace(tickets={}) + broker.processes = {str(index): SimpleNamespace(pid=12345) for index, _ in enumerate(command_invocations)} + broker.records = {str(index): {'invocation': invocation, 'lock': threading.Lock(), 'eof': set()} + for index, invocation in enumerate(command_invocations)} + actor['broker'] = broker + return broker, actor, supervisor + + def test_uid_broker_never_uses_historical_pid_or_process_group_authority(self): + from benchmarks import v3_shell_launcher as launcher + drained = [] + invocation = SimpleNamespace(close=lambda: drained.append('command')) + broker, actor, _ = self.broker([invocation]) + with patch.object(launcher, '_kill_tree', side_effect=AssertionError('historical PID used')), patch.object( + launcher.os, 'killpg', side_effect=AssertionError('historical group used')): + broker.close() + self.assertEqual(drained, ['command']) + self.assertTrue(actor['owner'].closed) + + def test_actor_cancel_owns_a_command_during_popen_and_rejects_late_publication(self): + from contextlib import nullcontext + from benchmarks.v3_actor_supervisor import _OwnedInvocation + from benchmarks.v3_shell_launcher import TicketAuthority + entered, release, close_entered, finished = (threading.Event() for _ in range(4)) + process = SimpleNamespace(pid=12345, returncode=None, stdout=io.BytesIO(), stderr=io.BytesIO()) + process.poll = lambda: process.returncode + def wait(timeout): + if not finished.wait(timeout): + raise subprocess.TimeoutExpired('benign barrier', timeout) + return process.returncode + process.wait = wait + owner = SimpleNamespace(closed=False, lock=threading.RLock()) + def popen(*args, **kwargs): + with owner.lock: + entered.set() + if not release.wait(5): + raise RuntimeError('test did not release popen') + return process + def close(): + close_entered.set() + with owner.lock: + process.returncode = -9 + owner.closed = True + finished.set() + owner.popen, owner.close = popen, close + invocation = SimpleNamespace(command=['benign'], env={}, backend='test', close=lambda: None) + owned = _OwnedInvocation(invocation, owner) + broker, actor, supervisor = self.broker([]) + with tempfile.TemporaryDirectory() as directory: + policy = ShellPolicy(directory, {'read','shell'}) + broker.policy = actor['policy'] = policy + broker.protected_files = [] + actor.update(root=False, condition=threading.Condition(), callbacks={}, disconnected=False) + supervisor.execution_lock, supervisor._frozen = threading.RLock(), nullcontext + supervisor.group = SimpleNamespace(uid=12345) + broker.authority = TicketAuthority(policy) + request = {key:getattr(policy,key) for key in ('run_id','actor_id','session_id','revision')} + request.update(call_id='barrier',command='printf benign',cwd=directory,env={}, + timeout_seconds=1800,background=False) + ticket = broker.authority.issue(request) + broker.pending[ticket] = (request,request,owned,time.monotonic()+5) + results, errors, cancel_errors = [], [], [] + cancelled = threading.Event() + def start(): + try: + results.append(broker._dispatch({'op':'start','ticket':ticket,'request':request})) + except Exception as exc: + errors.append(exc) + def cancel(): + try: + supervisor._stop_actor(actor) + except Exception as exc: + cancel_errors.append(exc) + finally: + cancelled.set() + starter = threading.Thread(target=start) + stopper = threading.Thread(target=cancel) + starter.start() + try: + self.assertTrue(entered.wait(3)) + stopper.start() + deadline = time.monotonic()+3 + while not (close_entered.is_set() or cancelled.is_set()) and time.monotonic()/dev/null 2>&1; sleep .15; printf done > "{target}"; exit {code}' + process = subprocess.Popen(['/bin/sh', '-c', command], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + drained = [] + def close(): + if process.poll() is None: + process.kill() + process.wait(timeout=5) + drained.append(True) + broker = _ActorBroker.__new__(_ActorBroker) + broker.closed = threading.Event() + broker.processes = {'command': process} + record = {'stdout': tempfile.TemporaryFile(), 'stderr': tempfile.TemporaryFile(), + 'background': background, 'eof': set(), 'timed_out': False, 'lock': threading.Lock(), + 'invocation': SimpleNamespace(close=close)} + broker.records = {'command': record} + collectors = [threading.Thread(target=broker._collect, args=('command', name)) + for name in ('stdout', 'stderr')] + expiry = threading.Thread(target=broker._expire, args=('command', 5)) + for thread in collectors: + thread.start() + expiry.start() + try: + self.assertEqual(process.wait(timeout=5), code) + self.assertEqual(target.read_text(), 'done') + expiry.join(timeout=5) + self.assertTrue(drained) + self.assertFalse(record['timed_out']) + finally: + close() + for thread in collectors: + thread.join(timeout=5) + expiry.join(timeout=5) + record['stdout'].close() + record['stderr'].close() + + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class ActorReadSandboxTests(unittest.TestCase): + def test_actor_runtime_starts_from_private_home_without_widening_workspace_reads(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + source = Path(__file__).resolve().parents[1] + for view in ('file', 'directory', 'no-read', 'workspace'): + with self.subTest(view=view), tempfile.TemporaryDirectory() as directory: + base = Path(directory).resolve() + workspace, home = base / 'workspace', base / 'home' + allowed = workspace / 'allowed' + allowed.mkdir(parents=True) + home.mkdir() + (allowed / 'source.py').write_text('allowed') + (workspace / 'sibling.py').write_text('private') + supervisor = ActorSupervisor.__new__(ActorSupervisor) + supervisor.workspace, supervisor.protected_files = workspace, [] + supervisor.policy = ShellPolicy(workspace, {'read'} if view != 'no-read' else set()) + supervisor.execution_lock, supervisor.closed = threading.RLock(), threading.Event() + supervisor.failures, supervisor.actors = [], {} + supervisor.provider_factory, supervisor.provider_config, supervisor.revoke_actor = None, {}, None + supervisor.group = SimpleNamespace(uid=os.getgid()) + owner = SimpleNamespace(uid=os.getuid(), process=None) + def popen(command, *, cwd, env, group, stdout, stderr, stdin=None): + owner.process = subprocess.Popen(command, cwd=cwd, env=env, stdin=stdin, stdout=stdout, stderr=stderr) + return owner.process + def close(): + if owner.process is not None: + if owner.process.poll() is None: + owner.process.kill() + owner.process.wait(timeout=5) + owner.popen, owner.close = popen, close + supervisor._owner, supervisor._home = lambda: owner, lambda *args: home + supervisor._chown = lambda *args: None + supervisor._environment = lambda home: { + 'PATH': os.environ['PATH'], 'HOME': str(home), 'KYROZEN_DB_PATH': str(home / 'private.sqlite3'), + 'KYROZEN_SKILLS_DIR': str(home / 'skills'), 'KYROZEN_DISABLE_VECTOR_INDEX': '1'} + scopes = {'file': ((allowed / 'source.py', False),), 'directory': ((allowed, True),), + 'no-read': (), 'workspace': ((workspace, True),)}[view] + actor = {'root': False, 'policy': supervisor.policy, + 'assignment': SimpleNamespace(capabilities=supervisor.policy.capabilities, read_scopes=scopes)} + code = '''import json,os,pathlib,sys +sys.path.insert(0,sys.argv[1]) +from openkyrozen.app.bootstrap import build_application +app=build_application() +try: + app.runtime._set_workspace_root(sys.argv[2]) + def read(path): + try: return path.read_text() + except OSError: return None + workspace=pathlib.Path(sys.argv[2]) + try: siblings='sibling.py' in [path.name for path in workspace.iterdir()] + except OSError: siblings=False + print(json.dumps([os.getcwd(),str(app.runtime._get_workspace_root()), + read(workspace/'allowed/source.py'),read(workspace/'sibling.py'),siblings])) +finally: app.close() +''' + try: + process = supervisor._spawn([sys.executable, '-I', '-c', code, str(source), str(workspace)], + actor, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, error.decode()) + self.assertEqual(json.loads(output), [str(home), str(workspace), + None if view == 'no-read' else 'allowed', 'private' if view == 'workspace' else None, + view == 'workspace']) + finally: + close() + if actor.get('invocation'): + actor['invocation'].close() + + def test_root_read_view_defaults_to_parent_approved_directories(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + with tempfile.TemporaryDirectory() as directory: + base=Path(directory).resolve() + workspace=base/'workspace' + (workspace/'allowed').mkdir(parents=True) + (workspace/'allowed/source.py').write_text('visible') + (workspace/'sibling.py').write_text('private') + home=base/'home' + home.mkdir() + supervisor=ActorSupervisor.__new__(ActorSupervisor) + supervisor.workspace=workspace + supervisor.policy=ShellPolicy(workspace,{'read'},scope_paths=['allowed']) + supervisor.protected_files=[] + supervisor._environment=lambda home:{'PATH':os.environ['PATH'],'HOME':str(home)} + code='''import json,pathlib,sys +p=pathlib.Path(sys.argv[1]) +out=[(p/'allowed/source.py').read_text()] +try: + (p/'sibling.py').read_text() + out.append(False) +except OSError: + out.append(True) +print(json.dumps(out)) +''' + invocation=supervisor._protected_actor([sys.executable,'-I','-c',code,str(workspace)],home) + try: + result=subprocess.run(invocation.command,cwd=workspace,env=invocation.env, + capture_output=True,text=True,timeout=10) + self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(json.loads(result.stdout),['visible',True]) + finally: + invocation.close() + + def test_empty_read_scope_denies_file_data_and_directory_listing(self): + from benchmarks import v3_actor_supervisor as module + from benchmarks.v3_protected_worker import protected_shell_command + import shlex + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + (workspace / 'secret').write_text('private') + code = '''import json,pathlib,sys +p=pathlib.Path(sys.argv[1]) +try: + (p/'secret').read_text() + denied=False +except OSError: + denied=True +try: + names=[item.name for item in p.iterdir()] +except OSError: + names=[] +print(json.dumps([denied,names])) +''' + invocation = protected_shell_command(shlex.join([sys.executable,'-I','-c',code,str(workspace)]), + policy=ShellPolicy(workspace, {'read'}), cwd=str(workspace), + env={'PATH': os.environ['PATH']}, protected_files=[]) + try: + module._restrict_reads(invocation, workspace, ()) + result = subprocess.run(invocation.command, cwd=workspace, env=invocation.env, + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), [True, []]) + finally: + invocation.close() + + def test_raw_python_reads_only_assigned_file_and_cannot_discover_siblings(self): + from benchmarks import v3_actor_supervisor as module + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + allowed = workspace / 'source.py' + allowed.write_text('allowed') + (workspace / 'sibling.py').write_text('private') + policy = ShellPolicy(workspace, {'read'}) + code = '''import json,pathlib,sys +p=pathlib.Path(sys.argv[1]) +out=[(p/"source.py").read_text()] +try: + (p/"sibling.py").read_text() + out.append(False) +except OSError: + out.append(True) +try: + out.append(sorted(path.name for path in p.iterdir())) +except OSError: + out.append(True) +print(json.dumps(out)) +''' + from benchmarks.v3_protected_worker import protected_shell_command + import shlex + invocation = protected_shell_command(shlex.join([sys.executable, '-I', '-c', code, str(workspace)]), + policy=policy, cwd=str(workspace), env={'PATH': os.environ['PATH']}, protected_files=[]) + try: + self.assertTrue(hasattr(module, '_restrict_reads'), 'actor read sandbox is missing') + module._restrict_reads(invocation, workspace, ((allowed, False),)) + result = subprocess.run(invocation.command, env=invocation.env, cwd=workspace, + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) + values = json.loads(result.stdout) + self.assertEqual(values[:2], ['allowed', True]) + self.assertIn(values[2], (True, ['source.py'])) + finally: + invocation.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_v3_actor_worker.py b/tests/test_v3_actor_worker.py new file mode 100644 index 00000000..a070ae19 --- /dev/null +++ b/tests/test_v3_actor_worker.py @@ -0,0 +1,908 @@ +"""Offline contract checks for the benchmark-only fresh-exec actor adapter.""" +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +def actor_module(case): + case.assertIsNotNone(importlib.util.find_spec('benchmarks.v3_actor_worker'), + 'fresh-exec actor adapter is missing') + return importlib.import_module('benchmarks.v3_actor_worker') + + +class MutationAdapterTests(unittest.TestCase): + def test_mutations_preserve_real_atomic_edit_results_without_local_authority(self): + module = actor_module(self) + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.models import invoke_tool_result + from openkyrozen.agent.conversation import tool_result_to_dict + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / 'source.py' + target.write_text('before\n') + original = {name: getattr(ToolAdapters, name) for name in + ('write_file', 'edit_file', 'apply_patch')} + trusted = ToolAdapters(root) + class Client: + def rpc(self, op, **values): + if op != 'file_mutation': + raise AssertionError(op) + action, arguments = values['action'], values['arguments'] + if arguments['path'] != 'source.py': + raise PermissionError('outside assignment') + return tool_result_to_dict(invoke_tool_result( + original[action].__get__(trusted, ToolAdapters), arguments)) + with patch.multiple(ToolAdapters, **original): + module.install_mutation_adapter(Client()) + actor = ToolAdapters(root) + result = invoke_tool_result(actor.edit_file, { + 'path': 'source.py', 'old_text': 'before', 'new_text': 'after', + 'expected_sha256': hashlib.sha256(b'before\n').hexdigest()}) + self.assertTrue(result.success) + self.assertEqual(target.read_text(), 'after\n') + stale = invoke_tool_result(actor.edit_file, { + 'path': 'source.py', 'old_text': 'after', 'new_text': 'bad', + 'expected_sha256': hashlib.sha256(b'before\n').hexdigest()}) + self.assertEqual(stale.error.code, 'stale_file') + with self.assertRaisesRegex(PermissionError, 'outside assignment'): + invoke_tool_result(actor.write_file, {'path': 'other.py', 'content': 'bad'}) + self.assertFalse((root / 'other.py').exists()) + + +class DelegationAdapterTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.environment = patch.dict(os.environ, { + 'HOME': str(self.root), 'KYROZEN_DB_PATH': str(self.root / 'state.sqlite3'), + 'KYROZEN_SKILLS_DIR': str(self.root / 'skills'), + 'KYROZEN_WORKSPACE_ROOT': str(self.root), 'KYROZEN_DISABLE_VECTOR_INDEX': '1'}, clear=True) + self.environment.start() + from openkyrozen.app.bootstrap import build_application + from openkyrozen.providers.config import ProviderConfig + self.application = build_application() + self.runtime = self.application.runtime + self.runtime._set_workspace_root(str(self.root)) + self.runtime._provider_config = ProviderConfig(provider='deepseek', api_key='must-not-leak', + base_url='http://127.0.0.1:1/v1', model_simple='offline', model_complex='offline') + self.runtime.DEEPSEEK_MODEL = 'offline' + self.coordinator = None + self.run = {'run_id': 'run1', 'budget_id': 'run1', 'profile': 'researcher', + 'name': 'Author', 'reviewer': 'Reviewer', 'review_agent': {}, 'status': 'succeeded', + 'assignment': {'objective': 'Read source', 'context': 'Offline fixture', + 'scope': ['source.py']}, 'usage': {}} + + def tearDown(self): + self.application.close() + self.environment.stop() + self.temporary.cleanup() + + def test_adapter_services_actual_shared_budget_and_returns_original_result(self): + module = actor_module(self) + from openkyrozen.agent.delegation import DelegationBudget + budget = DelegationBudget() + budget.reserve('run1', review=False) + expected = {'result': 'checked', 'session_id': 'run1', 'tool_records': [], + 'evidence': [], 'metrics': {'cost_picos': 12}} + outer = self + class Client: + stopped = False + stage = 0 + def rpc(self, op, **values): + if op == 'delegated_launch': + encoded = json.dumps(values['payload']) + outer.assertNotIn('must-not-leak', encoded) + outer.assertNotIn('api_key', encoded) + outer.assertNotIn('sibling-private', encoded) + return {'id': 'handle'} + if op == 'delegated_poll': + self.stage += 1 + if self.stage == 1: + return {'state': 'running', 'requests': [ + {'id': 'cost', 'op': 'budget_record_cost', 'values': {'cost_picos': 12}}, + {'id': 'continue', 'op': 'budget_can_continue', 'values': {'cost_picos': 0}}, + {'id': 'history', 'op': 'canonical_history', 'values': { + 'session_id': 'run1', 'kind': 'finish', 'offset': 0, 'chunk': '', 'final': True}}]} + outer.assertTrue(values['replies'][0]['ok']) + outer.assertIs(values['replies'][1]['value'], True) + return {'state': 'complete', 'requests': [], 'result': dict(expected, + _benchmark_request_context_records=[{'actor_id': 'author', 'kind': 'generation'}])} + if op == 'delegated_stop': + self.stopped = True + return None + raise AssertionError(op) + client = Client() + self.assertTrue(hasattr(module, 'install_delegated_adapter'), 'delegation adapter is missing') + module.install_delegated_adapter(self.runtime, client, source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.runs['run1'] = self.run + self.coordinator.messages['sibling-run'] = [{'role': 'user', 'content': 'sibling-private'}] + self.coordinator.budgets['run1'] = budget + import threading + self.coordinator.cancelled['run1'] = threading.Event() + result = self.runtime._invoke_delegated(self.run, review=False, feedback=None, + coordinator=self.coordinator) + self.assertEqual(result, expected) + self.assertEqual(self.runtime._benchmark_child_context_records, + [{'actor_id': 'author', 'kind': 'generation'}]) + self.assertEqual(budget._spent['run1'], 12) + self.assertTrue(client.stopped) + + def test_unknown_child_callback_fails_closed_and_releases_parent_access(self): + module = actor_module(self) + outer = self + class Client: + stage = 0 + def rpc(self, op, **values): + if op == 'delegated_launch': return {'id': 'handle'} + if op == 'delegated_stop': return None + self.stage += 1 + if self.stage == 1: + return {'state': 'running', 'requests': [ + {'id': 'access', 'op': 'access_acquire', 'values': { + 'path': str(outer.root / 'source.py'), 'reading': False, 'recursive': False}}, + {'id': 'bad', 'op': 'arbitrary_python', 'values': {'code': 'bad'}}]} + if values['replies'][1]['ok']: + raise AssertionError('unknown callback was granted') + return {'state': 'failed', 'requests': [], 'error': 'unsupported callback'} + self.assertTrue(hasattr(module, 'install_delegated_adapter'), 'delegation adapter is missing') + module.install_delegated_adapter(self.runtime, Client(), source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.runs['run1'] = self.run + import threading + self.coordinator.cancelled['run1'] = threading.Event() + with self.assertRaisesRegex(RuntimeError, 'unsupported callback'): + self.runtime._invoke_delegated(self.run, review=False, feedback=None, + coordinator=self.coordinator) + self.assertFalse(self.coordinator.access.paths) + + + def test_child_cannot_append_to_the_parent_or_a_sibling_message_history(self): + module = actor_module(self) + class Client: + stage = 0 + def rpc(self, op, **values): + if op == 'delegated_launch': return {'id': 'handle'} + if op == 'delegated_stop': return None + self.stage += 1 + if self.stage == 1: + return {'state': 'running', 'requests': [ + {'id': 'forged', 'op': 'message', 'values': { + 'child_id': 'surface:cli', 'message': {'role': 'user', 'content': 'forged'}}}, + {'id': 'history', 'op': 'canonical_history', 'values': { + 'session_id': 'run1', 'kind': 'finish', 'offset': 0, 'chunk': '', 'final': True}}]} + return {'state': 'complete', 'requests': [], 'result': { + 'session_id': 'run1', 'denied': not values['replies'][0]['ok']}} + module.install_delegated_adapter(self.runtime, Client(), source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.runs['run1'] = self.run + import threading + self.coordinator.cancelled['run1'] = threading.Event() + result = self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + self.assertTrue(result['denied']) + self.assertNotIn('surface:cli', self.coordinator.messages) + + + def test_existing_learning_state_is_rejected_before_actor_dispatch(self): + module = actor_module(self) + class Client: + def rpc(self, *args, **kwargs): + raise AssertionError('actor launched despite unsupported shared learning state') + module.install_delegated_adapter(self.runtime, Client(), source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.runs['run1'] = self.run + import threading + self.coordinator.cancelled['run1'] = threading.Event() + self.runtime.memory_bank.store.append_event('learning.regression_case_created', + {'task_signature': 'prior', 'required_outcome': 'shared guidance'}, + user_id=self.runtime.memory_bank.user_id, workspace_id=self.runtime.memory_bank.workspace_id) + with self.assertRaisesRegex(RuntimeError, 'existing skill or learning regression state'): + self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + + def test_native_child_history_survives_private_home_deletion_and_incremental_replay(self): + from contextlib import ExitStack + import threading + from openkyrozen.app.bootstrap import build_application + from openkyrozen.agent.conversation import ConversationHistory + from openkyrozen.persistence.store import EventStore + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import (FinishReason, ModelResponse, ProviderCapabilities, + ProviderContinuation, ToolCall) + from openkyrozen.tools import ToolAdapters + module = actor_module(self) + source = Path(__file__).resolve().parents[1] + (self.root / 'source.py').write_text(('actual source evidence ' * 20 + '\n') * 500) + scopes = {'user_id': self.runtime.memory_bank.user_id, + 'workspace_id': self.runtime.interaction_workspace_id()} + phases = [] + originals = {name: getattr(ToolAdapters, name) for name in + ('write_file', 'edit_file', 'apply_patch', 'read_file', 'search_files', + 'list_dir', 'list_tree', 'find_files')} + with ExitStack() as homes: + author_home = Path(homes.enter_context(tempfile.TemporaryDirectory())) + for phase, review in enumerate((False, False, True, True, False)): + home = Path(homes.enter_context(tempfile.TemporaryDirectory())) if review else author_home + callbacks = [] + class Provider(LLMProvider): + _continuation_id = 'offline-history' + def __init__(provider): + super().__init__(ProviderConfig(provider='deepseek', model_simple='offline')) + provider._continuation_id = 'offline-history' + provider.calls = 0 + def get_capabilities(provider, model=None): + return ProviderCapabilities(native_tools=True) + def chat(provider, messages, model=None): + raise AssertionError('native history used the legacy provider boundary') + def chat_response(provider, messages, model=None, *, tools=None): + provider.calls += 1 + if provider.calls == 1: + return ModelResponse(tool_calls=(ToolCall(f'read-{phase}', 'read_file', + {'path': 'source.py'}),), finish_reason=FinishReason.TOOL_REQUEST, + usage={'prompt_tokens': 100, 'completion_tokens': 20}, + metadata={'original_phase': phase}, continuation=ProviderContinuation( + 'offline-history', 'offline', 'fixture', {'padding': 'p' * 1_050_000})) + if phase == 4: + raise RuntimeError('offline native failure') + report = ({'verdict': 'verified', 'summary': f'Review {phase}', + 'findings': [], 'evidence': ['source.py']} if review else + {'status': 'completed', 'summary': f'Author {phase}', 'findings': [], + 'evidence': ['source.py'], 'artifacts': [], 'checks': ['read'], + 'uncertainties': [], 'suggested_followups': []}) + return ModelResponse(text=json.dumps(report), finish_reason=FinishReason.FINAL, + usage={'prompt_tokens': 120, 'completion_tokens': 25}) + class ChildClient: + def rpc(client, op, **values): + if op == 'file_read': + from openkyrozen.agent.conversation import tool_result_to_dict + from openkyrozen.tools.models import invoke_tool_result + adapter = ToolAdapters(self.root) + return tool_result_to_dict(invoke_tool_result( + originals[values['action']].__get__(adapter, ToolAdapters), values['arguments'])) + if op != 'delegated_call': raise AssertionError(op) + operation, data = values['operation'], values['values'] + if operation == 'cancelled': return False + if operation in {'memory_recall_records', 'memory_recall', 'memory_recent'}: return [] + if operation == 'parent_interaction_state': return {'mode': 'agent'} + if operation == 'parent_task_state': return {'tasks': [], 'working_plan': None} + if operation == 'parent_active_task': return None + if operation == 'access_acquire': return 'access' + if operation in {'canonical_history', 'receipt'}: + callbacks.append({'id': str(len(callbacks)), 'op': operation, 'values': data}) + self.assertLessEqual(len(json.dumps(values, ensure_ascii=False).encode()), 1_000_000) + return None + if operation in {'access_release', 'publish', 'message', 'usage', 'usage_attempts', 'event'}: + return None + raise AssertionError(operation) + environment = {'HOME': str(home), 'KYROZEN_DB_PATH': str(home / 'private.sqlite3'), + 'KYROZEN_SKILLS_DIR': str(home / 'skills'), 'KYROZEN_WORKSPACE_ROOT': str(self.root), + 'KYROZEN_DISABLE_VECTOR_INDEX': '1'} + with patch.dict(os.environ, environment, clear=True), patch.multiple(ToolAdapters, **originals): + child = build_application() + child.runtime.get_provider = lambda config: Provider() + payload = {'schema_version': 1, 'source': str(source), 'workspace': str(self.root), + 'run': dict(self.run), 'review': review, 'feedback': {'findings': ['recheck']} if phase == 1 else None, + 'report_repair': False, 'supports_report_repair': True, 'messages': {}, + 'parent': dict(scopes, workspace_id='default', task_workspace_id=scopes['workspace_id'], + session_id=self.runtime.current_session.session_id, + capabilities=['read', 'network'], interaction_mode='agent', model='offline', + has_budget=False, profiles=[])} + with patch('openkyrozen.app.bootstrap.build_application', return_value=child): + config = ProviderConfig(provider='deepseek', api_key='offline-only', + model_simple='offline', model_complex='offline', context_window_tokens=1_000_000) + if phase == 4: + with self.assertRaisesRegex(RuntimeError, 'Native model request failed'): + module.run_actor(payload, ChildClient(), source=source, provider_config=config) + result = {'session_id': 'run1', '_test_failed': True} + else: + result = module.run_actor(payload, ChildClient(), source=source, provider_config=config) + store = EventStore(home / 'private.sqlite3') + try: + scope = dict(scopes, session_id=result['session_id']) + entries = store.list_conversation_entries(**scope) + display = ConversationHistory(store, **scope).display_messages() + referenced = module._artifact_references(entries) + for callback in callbacks: + if callback['op'] == 'receipt': + referenced.update(module._artifact_references(callback['values']['receipt'])) + with store.connection() as connection: + artifacts = [dict(row) for row in connection.execute('SELECT * FROM tool_artifacts') + if row['id'] in referenced] + phases.append((review, callbacks, result, entries, display, artifacts)) + finally: + store.close() + # All source-owned private databases and artifact files are gone here. + self.assertFalse(author_home.exists()) + self.assertGreater(len(phases[1][3]), len(phases[0][3])) + self.assertNotEqual(phases[2][2]['session_id'], phases[3][2]['session_id']) + outer = self + class RootClient: + index = -1 + stage = 0 + def rpc(client, op, **values): + if op == 'delegated_launch': + client.index += 1; client.stage = 0 + return {'id': str(client.index)} + if op == 'delegated_stop': return None + if op != 'delegated_poll': raise AssertionError(op) + client.stage += 1 + if client.stage == 1: + return {'state': 'running', 'requests': phases[client.index][1]} + outer.assertTrue(all(reply['ok'] for reply in values['replies']), values['replies']) + if phases[client.index][2].get('_test_failed'): + return {'state': 'failed', 'requests': [], 'error': 'Native model request failed'} + return {'state': 'complete', 'requests': [], 'result': phases[client.index][2]} + module.install_delegated_adapter(self.runtime, RootClient(), source=source) + self.coordinator = self.runtime.delegation() + self.run['reviews'] = [] + self.coordinator.runs['run1'] = self.run + self.coordinator.cancelled['run1'] = threading.Event() + for review, _, expected, entries, display, artifacts in phases: + if expected.get('_test_failed'): + with self.assertRaisesRegex(RuntimeError, 'Native model request failed'): + self.runtime._invoke_delegated(self.run, review=review, feedback=None, coordinator=self.coordinator) + result = expected + self.assertTrue(any(entry['kind'] == 'model.failure' for entry in entries)) + else: + result = self.runtime._invoke_delegated(self.run, review=review, feedback=None, + coordinator=self.coordinator) + if review: + self.run['reviews'].append({'session_id': result['session_id']}) + detail = self.coordinator.detail('run1') + self.assertEqual(detail['review_messages'][result['session_id']] if review else detail['messages'], display) + scope = dict(scopes, session_id=result['session_id']) + self.assertEqual(self.application.store.list_conversation_entries(**scope), entries) + self.assertTrue(any(entry['kind'] == 'tool.result' for entry in entries)) + self.assertTrue(artifacts) + for artifact in artifacts: + from openkyrozen.tools.models import invoke_tool_result + owned_child = self.runtime.open_session(result['session_id'], user_id=scopes['user_id']) + with self.runtime.use_session(owned_child): + page = invoke_tool_result(self.runtime._read_tool_artifact, + {'artifact_id': artifact['id'], 'offset': 7, 'limit': 1000}) + self.assertTrue(page.success) + actual = page.data + self.assertEqual(actual['content'], artifact['content'][7:1007]) + self.assertIsNone(self.application.store.artifacts.read_page(artifact['id'], 0, 1000, + **dict(scope, session_id='sibling'))) + + def test_original_unbound_delegation_detail_reads_its_actual_native_journal_scope(self): + import threading + from openkyrozen.agent.conversation import ConversationHistory + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.config import ProviderConfig + from openkyrozen.providers.models import FinishReason, ModelResponse, ProviderCapabilities + class Provider(LLMProvider): + def __init__(provider): + super().__init__(ProviderConfig(provider='deepseek', model_simple='offline')) + def get_capabilities(provider, model=None): + return ProviderCapabilities(native_tools=True) + def chat(provider, messages, model=None): + raise AssertionError('native fixture entered the legacy provider') + def chat_response(provider, messages, model=None, *, tools=None): + return ModelResponse(text=json.dumps({'status': 'completed', 'summary': 'Native evidence', + 'findings': [], 'evidence': [], 'artifacts': [], 'checks': [], + 'uncertainties': [], 'suggested_followups': []}), finish_reason=FinishReason.FINAL, + usage={'prompt_tokens': 100, 'completion_tokens': 20}) + self.runtime.get_provider = lambda config: Provider() + self.coordinator = self.runtime.delegation() + self.run['reviews'] = [] + self.coordinator.runs['run1'] = self.run + self.coordinator.cancelled['run1'] = threading.Event() + result = self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + scope = dict(user_id=self.runtime.memory_bank.user_id, + workspace_id=self.runtime.interaction_workspace_id(), session_id=result['session_id']) + original = ConversationHistory(self.application.store, **scope).display_messages() + self.assertTrue(original) + self.assertEqual(self.coordinator.detail('run1')['messages'], original, + f"journal={scope['workspace_id']}, tasks={self.runtime.tasks.workspace_id}, " + f"file_scope={self.runtime.memory_bank.file_scope_id}") + + def test_rejected_canonical_import_stops_before_accepting_a_completed_result(self): + import threading + module = actor_module(self) + class Client: + stopped = False + def rpc(client, op, **values): + if op == 'delegated_launch': return {'id': 'handle'} + if op == 'delegated_stop': client.stopped = True; return None + return {'state': 'running', 'requests': [{'id': 'forged', 'op': 'canonical_history', + 'values': {'session_id': 'surface:cli', 'kind': 'finish', + 'offset': 0, 'chunk': '', 'final': True}}]} + client = Client() + module.install_delegated_adapter(self.runtime, client, source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.cancelled['run1'] = threading.Event() + with self.assertRaisesRegex(RuntimeError, 'canonical history import failed'): + self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + self.assertTrue(client.stopped) + + def test_native_completion_cannot_omit_its_session_or_skip_canonical_finish(self): + import threading + module = actor_module(self) + cases = [(True, {}), (True, {'session_id': None}), + (False, {'session_id': 'run1'}), (True, {'session_id': 'sibling'})] + class Client: + case = -1 + stage = 0 + stopped = 0 + def rpc(client, op, **values): + if op == 'delegated_launch': + client.case += 1; client.stage = 0 + return {'id': str(client.case)} + if op == 'delegated_stop': client.stopped += 1; return None + client.stage += 1 + finish, result = cases[client.case] + if client.stage == 1 and finish: + return {'state': 'running', 'requests': [{'id': 'history', 'op': 'canonical_history', + 'values': {'session_id': 'run1', 'kind': 'finish', + 'offset': 0, 'chunk': '', 'final': True}}]} + return {'state': 'complete', 'requests': [], 'result': dict(result, result='claimed complete')} + client = Client() + module.install_delegated_adapter(self.runtime, client, source=Path(__file__).resolve().parents[1]) + self.coordinator = self.runtime.delegation() + self.coordinator.cancelled['run1'] = threading.Event() + for case in cases: + with self.subTest(case=case), self.assertRaisesRegex(RuntimeError, 'without its canonical history'): + self.runtime._invoke_delegated(self.run, review=False, feedback=None, coordinator=self.coordinator) + self.assertEqual(client.stopped, len(cases)) + + +class CanonicalHistoryImportTests(unittest.TestCase): + def setUp(self): + from openkyrozen.persistence.store import EventStore + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.store = EventStore(self.root / 'parent.sqlite3') + self.module = actor_module(self) + + def tearDown(self): + self.store.close() + self.temporary.cleanup() + + def receiver(self, *, review=False): + def owned(session_id): + if not (session_id.startswith('run1_review_') if review else session_id == 'run1'): + raise PermissionError('unowned child session') + return self.module._CanonicalHistoryImport(self.store, user_id='alice', workspace_id='interaction', + artifact_workspace_id='tasks', budget_id='run1', review=review, owned_session=owned) + + def entry(self, **changes): + from openkyrozen.agent.conversation import ConversationEntry + value = ConversationEntry(id='conv_original', kind='user.input', payload={'text': 'source input'}, + user_id='alice', workspace_id='interaction', session_id='run1', turn_id='turn-original').as_dict() + return dict(value, **changes) + + def send(self, receiver, kind, value, *, session_id='run1'): + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False) + for offset in range(0, len(encoded), self.module._HISTORY_CHUNK): + end = offset + self.module._HISTORY_CHUNK + values = dict(session_id=session_id, kind=kind, offset=offset, + chunk=encoded[offset:end], final=end >= len(encoded)) + self.module._json_copy(values) + receiver.receive(**values) + + def finish(self, receiver, session_id='run1'): + receiver.receive(session_id=session_id, kind='finish', offset=0, chunk='', final=True) + + def test_cross_scope_malformed_chain_and_conflicting_ids_are_rejected(self): + variants = [dict(user_id='sibling'), dict(workspace_id='memory'), dict(session_id='surface:cli'), + dict(parent_id='missing-head'), dict(version=2), dict(turn_id=['malformed'])] + for changes in variants: + with self.subTest(changes=changes), self.assertRaises((ValueError, TypeError, PermissionError)): + self.send(self.receiver(), 'entry', self.entry(**changes)) + entry = self.entry() + receiver = self.receiver() + self.send(receiver, 'entry', entry) + with self.assertRaises((ValueError, PermissionError)): + self.send(receiver, 'entry', entry) + with self.assertRaisesRegex(ValueError, 'prefix'): + self.send(self.receiver(), 'entry', dict(entry, payload={'text': 'conflicting replay'})) + self.assertEqual(self.store.list_conversation_entries(user_id='alice', workspace_id='interaction', + session_id='run1'), [entry]) + + def test_referenced_artifacts_keep_original_identity_and_separate_task_scope_in_chunks(self): + identifier = 'artifact_' + 'a' * 32 + summary = {'artifact_id': identifier, 'total_chars': 1_260_000, 'preview': 'source-owned content'} + entry = self.entry(kind='tool.result', payload={'call_id': 'read-source', 'name': 'read_file', + 'output': {'data': summary, 'text': json.dumps(summary), 'error': None, + 'metadata': {'artifact_id': identifier}}, 'is_error': False}) + artifact = dict(id=identifier, user_id='alice', workspace_id='tasks', session_id='run1', + content='source-owned content\n' * 60_000, created_at=entry['created_at']) + for changes in (dict(user_id='sibling'), dict(workspace_id='interaction'), + dict(session_id='sibling'), dict(id='unreferenced')): + receiver = self.receiver() + self.send(receiver, 'entry', entry) + with self.subTest(changes=changes), self.assertRaises(PermissionError): + self.send(receiver, 'artifact', dict(artifact, **changes)) + for _ in range(2): + receiver = self.receiver() + self.send(receiver, 'entry', entry) + self.send(receiver, 'artifact', artifact) + self.finish(receiver) + self.assertEqual(len(self.store.list_conversation_entries(user_id='alice', workspace_id='interaction', + session_id='run1')), 1) + page = self.store.artifacts.read_page(identifier, 5, 1000, + user_id='alice', workspace_id='tasks', session_id='run1') + self.assertEqual(page['content'], artifact['content'][5:1005]) + self.assertIsNone(self.store.artifacts.read_page(identifier, 0, 1000, + user_id='alice', workspace_id='interaction', session_id='run1')) + receiver = self.receiver() + self.send(receiver, 'entry', entry) + with self.assertRaisesRegex(ValueError, 'conflicts'): + self.send(receiver, 'artifact', dict(artifact, content='forged')) + with self.store.connection() as connection: + self.assertEqual(dict(connection.execute('SELECT * FROM tool_artifacts').fetchone()), artifact) + + def test_partial_chunks_missing_artifacts_stale_heads_and_reused_review_sessions_fail_closed(self): + receiver = self.receiver() + receiver.receive(session_id='run1', kind='entry', offset=0, chunk='{', final=False) + with self.assertRaisesRegex(ValueError, 'out of order'): + receiver.receive(session_id='run1', kind='entry', offset=0, chunk='}', final=True) + with self.assertRaisesRegex(ValueError, 'incomplete'): + self.finish(receiver) + identifier = 'artifact_' + 'b' * 32 + entry = self.entry(kind='tool.result', payload={'call_id': 'read-source', 'name': 'read_file', + 'output': {'data': {'artifact_id': identifier, 'total_chars': 40}, 'text': 'archived', + 'error': None, 'metadata': {'artifact_id': identifier}}, 'is_error': False}) + receiver = self.receiver() + self.send(receiver, 'entry', entry) + with self.assertRaisesRegex(ValueError, 'dangling'): + self.finish(receiver) + review_id = 'run1_review_original' + review_entry = self.entry(id='conv_review', session_id=review_id) + receiver = self.receiver(review=True) + self.send(receiver, 'entry', review_entry, session_id=review_id) + self.finish(receiver, review_id) + with self.assertRaisesRegex(PermissionError, 'reused'): + self.send(self.receiver(review=True), 'entry', review_entry, session_id=review_id) + + def test_concurrent_native_head_change_is_not_overwritten(self): + entry = self.entry() + receiver = self.receiver() + self.send(receiver, 'entry', entry) + other = self.entry(id='conv_concurrent', parent_id=entry['id'], payload={'text': 'concurrent native entry'}) + self.store.append_conversation_entry(other, expected_head=entry['id']) + with self.assertRaisesRegex(ValueError, 'incomplete'): + self.finish(receiver) + self.assertEqual(self.store.conversation_head(user_id='alice', workspace_id='interaction', session_id='run1'), + other['id']) + + def test_plain_json_user_source_and_opaque_provider_values_export_without_artifacts(self): + from contextlib import closing + from types import SimpleNamespace + from openkyrozen.agent.conversation import ConversationHistory + from openkyrozen.persistence.store import EventStore + records = [('user.input', {'text': '{"artifact_id":"ordinary-user-record"}'}), + ('tool.result', {'call_id': 'read-source', 'name': 'read_file', 'is_error': False, + 'output': {'data': {'content': '{"artifact_id":"ordinary-source-record"}'}, + 'text': 'read source JSON', 'error': None, 'metadata': {}}}), + ('model.response', {'response': {'text': '{"artifact_id":"ordinary-model-record"}', + 'continuation': {'provider_id': 'fixture', 'request_model': 'offline', 'protocol': 'opaque', + 'payload': {'artifact_id': 'ordinary-provider-state'}}}}), + ('user.input', {'text': json.dumps({'artifact_id': 'artifact_' + 'd' * 32})}), + ('tool.result', {'call_id': 'read-json', 'name': 'read_file', 'is_error': False, + 'output': {'data': {'content': json.dumps({'artifact_id': 'artifact_' + 'd' * 32})}, + 'text': 'ordinary source JSON', 'error': None, 'metadata': {}}})] + scope = dict(user_id='alice', workspace_id='interaction', session_id='run1') + for index, (kind, payload) in enumerate(records): + with self.subTest(kind=kind), closing(EventStore(self.root / f'source-{index}.sqlite3')) as store: + history = ConversationHistory(store, **scope) + history.append(history.entry(kind, payload)) + calls = [] + coordinator = SimpleNamespace(artifact_references={}, call=lambda op, **values: calls.append((op, values))) + self.module._export_canonical_history(store, coordinator, + {'run1': (scope, dict(scope, workspace_id='tasks'))}) + self.assertEqual([values['kind'] for _, values in calls], ['entry', 'finish']) + + def test_only_matched_runtime_artifact_and_successful_page_envelopes_are_references(self): + identifier = 'artifact_' + 'c' * 32 + summary = {'artifact_id': identifier, 'total_chars': 123} + output = {'data': summary, 'text': 'Earlier tool result archived; use read_tool_artifact to retrieve it.', + 'error': None, 'metadata': {'artifact_id': identifier}} + archived = self.entry(kind='tool.result', payload={'call_id': 'read', 'name': 'read_file', + 'output': output, 'is_error': False}) + receipt = {'receipt_id': 'receipt-source', 'action': 'read_file', 'success': True, + 'tool_result': output, 'result_data': summary, 'arguments': {'artifact_id': 'ordinary-argument'}} + self.assertEqual(self.module._artifact_references([archived, receipt]), {identifier}) + arbitrary = self.entry(kind='tool.result', payload={'call_id': 'read', 'name': 'read_file', + 'output': dict(output, metadata={}, data={'artifact_id': identifier, 'domain_record': True}), 'is_error': False}) + self.assertEqual(self.module._artifact_references(arbitrary), set()) + page = dict(artifact_id=identifier, offset=0, limit=1000, total_chars=123, content='source page', next_offset=None) + paged = self.entry(kind='tool.result', payload={'call_id': 'page', 'name': 'read_tool_artifact', + 'output': dict(output, data=page, metadata={}), 'is_error': False}) + self.assertEqual(self.module._artifact_references(paged), {identifier}) + failure = {'receipt_id': 'failed-page', 'action': 'read_tool_artifact', 'success': False, + 'arguments': {'artifact_id': 'missing'}, 'result_data': None, 'tool_error': {'code': 'not_found'}} + self.assertEqual(self.module._artifact_references(failure), set()) + + +class ActorDriverTests(unittest.TestCase): + def test_driver_keeps_real_author_and_review_scope_receipts_and_report_contract(self): + module = actor_module(self) + from openkyrozen.app.bootstrap import build_application + from openkyrozen.tools import ToolAdapters + from openkyrozen.providers.config import ProviderConfig + source = Path(__file__).resolve().parents[1] + for review in (False, True): + with self.subTest(review=review), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workspace = root / 'workspace' + workspace.mkdir() + (workspace / 'source.py').write_text(json.dumps({'artifact_id': 'artifact_' + 'd' * 32})) + environment = {'HOME': str(root), 'KYROZEN_DB_PATH': str(root / 'private.sqlite3'), + 'KYROZEN_WORKSPACE_ROOT': str(workspace), + 'KYROZEN_SKILLS_DIR': str(root / 'skills'), 'KYROZEN_DISABLE_VECTOR_INDEX': '1'} + original_mutators = {name: getattr(ToolAdapters, name) for name in + ('write_file', 'edit_file', 'apply_patch', 'read_file', 'search_files', + 'list_dir', 'list_tree', 'find_files')} + with patch.dict(os.environ, environment, clear=True), patch.multiple(ToolAdapters, **original_mutators): + app = build_application() + runtime = app.runtime + seen = [] + class Provider: + def chat(self, messages, model): + seen.append((runtime.execution_context.child_run_id, + runtime.execution_context.capability_token.capabilities)) + if len(seen) == 1: + return 'Action: {"action":"read_file","args":"source.py"}', None + answer = ({'verdict': 'verified', 'summary': 'Independently read source', + 'findings': [], 'evidence': ['source.py']} if review else + {'status': 'completed', 'summary': 'Read source evidence', + 'findings': [], 'evidence': ['source.py'], 'artifacts': [], 'checks': ['read'], + 'uncertainties': [], 'suggested_followups': []}) + return json.dumps(answer), None + runtime.get_provider = lambda config: Provider() + events = [] + synchronized = [] + class Client: + def rpc(self, op, **values): + if op == 'file_read': + from openkyrozen.tools.models import invoke_tool_result + from openkyrozen.agent.conversation import tool_result_to_dict + trusted = ToolAdapters(workspace) + method = original_mutators[values['action']].__get__(trusted, ToolAdapters) + return tool_result_to_dict(invoke_tool_result(method, values['arguments'])) + if op != 'delegated_call': raise AssertionError(op) + operation, data = values['operation'], values['values'] + if operation == 'cancelled': return False + if operation in {'memory_recall_records', 'memory_recall', 'memory_recent'}: return [] + if operation == 'parent_interaction_state': return {'mode': 'agent'} + if operation == 'parent_task_state': return {'tasks': [], 'working_plan': None} + if operation == 'parent_active_task': return None + if operation == 'access_acquire': return 'access' + if operation == 'usage': + synchronized.append('usage') + if review and any(not key.startswith('run1_review_') for key in data['usage']): + raise PermissionError('reviewer tried to replace author usage') + return None + if operation in {'access_release', 'publish', 'receipt', 'message', 'usage_attempts', + 'canonical_history'}: + if operation == 'usage_attempts': synchronized.append(operation) + return None + if operation == 'event': events.append(data['event']); return None + raise AssertionError(operation) + payload = {'schema_version': 1, 'source': str(source), 'workspace': str(workspace), + 'run': {'run_id': 'run1', 'budget_id': 'run1', 'profile': 'researcher', 'name': 'Author', + 'reviewer': 'Reviewer', 'review_agent': {}, + 'usage': {'run1': {'cost_picos': 17}}, + 'report': {'artifacts': ['source.py']}, + 'assignment': {'objective': 'Read source.py', 'context': 'Inspect actual file', + 'scope': ['source.py']}}, + 'review': review, 'feedback': None, 'report_repair': False, + 'supports_report_repair': True, 'messages': {}, + 'parent': {'session_id': 'surface:cli', 'user_id': 'local', 'workspace_id': 'default', + 'task_workspace_id': runtime.tasks.workspace_id, + 'capabilities': ['read', 'write', 'shell', 'network'], + 'interaction_mode': 'agent', 'model': 'offline', 'has_budget': False, + 'profiles': []}} + with patch('openkyrozen.app.bootstrap.build_application', return_value=app): + result = module.run_actor(payload, Client(), source=source, + provider_config=ProviderConfig(provider='deepseek', api_key='actor-local', + base_url='http://127.0.0.1:1/v1', model_simple='offline', model_complex='offline')) + if review: + self.assertTrue(result['session_id'].startswith('run1_review_')) + self.assertEqual(json.loads(result['result'])['verdict'], 'verified') + else: + self.assertEqual(result['session_id'], 'run1') + self.assertEqual(json.loads(result['result'])['status'], 'completed') + self.assertEqual(result['tool_records'][0]['action'], 'read_file') + context = result['_benchmark_request_context_records'] + self.assertEqual(len(context), len(seen)) + self.assertGreaterEqual(len(context), 2) + self.assertTrue(all(item['actor_run_id'] == 'run1' and item['review'] is review + and item['outcome'] == 'returned' for item in context)) + self.assertTrue(result['tool_records'][0]['success']) + self.assertTrue(all(child_id == 'run1' for child_id, _ in seen)) + self.assertTrue(all('write' not in caps and 'shell' not in caps for _, caps in seen)) + self.assertTrue(any(event.get('event') == 'tool_receipt' for event in events)) + self.assertEqual(synchronized, ['usage', 'usage_attempts']) + + +class MutationHelperTests(unittest.TestCase): + def test_helper_uses_selected_current_and_frozen_snapshot_without_app_startup(self): + module = actor_module(self) + self.assertTrue(hasattr(module, 'run_file_mutation'), 'selected-source mutation helper is missing') + import io + import subprocess + import sys + import tarfile + repository = Path(__file__).resolve().parents[1] + archive = subprocess.run(['git', 'archive', '1bfc82c793df49e76097061ec5adfef0e4e2e883'], + cwd=repository, capture_output=True, check=True).stdout + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = root / 'baseline' + baseline.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(baseline, filter='data') + for name, selected in (('current', repository), ('baseline', baseline)): + workspace = root / ('workspace-' + name) + workspace.mkdir() + (workspace / 'sitecustomize.py').write_text("from pathlib import Path; Path('poisoned').touch()") + arguments = ({'path': 'source.py', 'content': 'checked'} if name == 'current' + else 'source.py|checked') + env = {'PATH': '/usr/bin:/bin', 'HOME': str(root / 'home'), 'PYTHONDONTWRITEBYTECODE': '1'} + result = subprocess.run([sys.executable, '-I', str(repository / 'benchmarks/v3_actor_worker.py'), + '--source', str(selected), '--file-mutation'], + input=json.dumps({'workspace': str(workspace), 'action': 'write_file', 'arguments': arguments}), + capture_output=True, text=True, cwd=workspace, env=env, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + output = json.loads(result.stdout) + self.assertIsInstance(output, dict if name == 'current' else str) + self.assertEqual((workspace / 'source.py').read_text(), 'checked') + self.assertFalse((workspace / 'poisoned').exists()) + self.assertFalse((root / 'home' / '.kyrozen').exists()) + read = subprocess.run([sys.executable, '-I', str(repository / 'benchmarks/v3_actor_worker.py'), + '--source', str(selected), '--file-read'], + input=json.dumps({'workspace': str(workspace), 'action': 'read_file', + 'arguments': {'path': 'source.py'} if name == 'current' else 'source.py'}), + capture_output=True, text=True, cwd=workspace, env=env, timeout=30) + self.assertEqual(read.returncode, 0, read.stderr) + content = json.loads(read.stdout) + self.assertEqual(content['data']['content'] if name == 'current' else content, 'checked') + + +class SourceBindingTests(unittest.TestCase): + def test_mutation_helper_rejects_a_different_loaded_product_source(self): + module = actor_module(self) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaisesRegex(PermissionError, 'selected source'): + module.run_file_mutation({'workspace': str(root), 'action': 'write_file', + 'arguments': {'path': 'source.py', 'content': 'bad'}}, source=root) + self.assertFalse((root / 'source.py').exists()) + + +class FrozenDelegationTests(unittest.TestCase): + def test_frozen_snapshot_keeps_its_original_delegation_and_has_no_synthetic_budget(self): + import io + import subprocess + import sys + import tarfile + repository = Path(__file__).resolve().parents[1] + archive = subprocess.run(['git', 'archive', '1bfc82c793df49e76097061ec5adfef0e4e2e883'], + cwd=repository, capture_output=True, check=True).stdout + script = r''' +import json,os,sys,threading +from pathlib import Path +source, apparatus, workspace, home = map(Path, sys.argv[1:]) +sys.path.insert(0,str(apparatus)); sys.path.insert(0,str(source)) +os.environ.update(HOME=str(home),KYROZEN_DB_PATH=str(home/'private.sqlite3'), + KYROZEN_WORKSPACE_ROOT=str(workspace),KYROZEN_SKILLS_DIR=str(home/'skills'),KYROZEN_DISABLE_VECTOR_INDEX='1') +from openkyrozen.app.bootstrap import build_application +from openkyrozen.providers.config import ProviderConfig +from v3_actor_worker import install_delegated_adapter,run_actor +app=build_application() +runtime=app.runtime +config=ProviderConfig(provider='deepseek',api_key='offline',model_simple='offline',model_complex='offline') +runtime._provider_config=config; runtime.DEEPSEEK_MODEL='offline' +run={'run_id':'run1','profile':'researcher','name':'Author','reviewer':'Reviewer','review_agent':{}, + 'status':'succeeded','assignment':{'objective':'Read source.py','context':'Inspect evidence','scope':['source.py']}} +class RootClient: + def rpc(self,op,**values): + if op=='delegated_launch': + assert values['payload']['parent']['has_budget'] is False + assert 'conversation_workspace_id' not in values['payload']['run'] + return {'id':'handle'} + if op=='delegated_poll': return {'state':'complete','requests':[],'result':{'result':'transport'}} + if op=='delegated_stop': return None + raise AssertionError(op) +try: + install_delegated_adapter(runtime,RootClient(),source=source) + coordinator=runtime.delegation(); coordinator.runs['run1']=run; coordinator.cancelled['run1']=threading.Event() + assert runtime._invoke_delegated(run,review=False,feedback=None,coordinator=coordinator)['result']=='transport' + try: runtime._invoke_delegated(run,review=False,feedback=None,coordinator=coordinator,report_repair=True) + except RuntimeError as exc: assert 'does not support' in str(exc) + else: raise AssertionError('invented baseline report repair') +finally: app.close() +from openkyrozen.tools import ToolAdapters +original_read=ToolAdapters.read_file +class ChildClient: + def rpc(self,op,**values): + if op=='file_read': return original_read(ToolAdapters(workspace),values['arguments']) + assert op=='delegated_call' + operation=values['operation'] + if operation=='cancelled': return False + if operation in {'memory_recall_records','memory_recall','memory_recent'}: return [] + if operation=='parent_interaction_state': return {'mode':'agent'} + if operation=='parent_task_state': return {'tasks':[],'working_plan':None} + if operation=='access_acquire': return 'access' + if operation in {'access_release','publish','receipt','message','usage','usage_attempts','event'}: return None + raise AssertionError(operation) +from unittest.mock import patch +app=build_application(); runtime=app.runtime +calls=[] +class Provider: + def chat(self,messages,model): + calls.append(messages) + if len(calls)==1: return 'Action: {"action":"read_file","args":"source.py"}',None + return json.dumps({'status':'completed','summary':'Checked','findings':[],'evidence':['source.py'], + 'artifacts':[],'checks':['read'],'uncertainties':[],'suggested_followups':[]}),None +runtime.get_provider=lambda config:Provider() +payload={'schema_version':1,'source':str(source),'workspace':str(workspace),'run':run, + 'review':False,'feedback':None,'report_repair':False,'supports_report_repair':False,'messages':{}, + 'parent':{'session_id':'surface:cli','user_id':'local','workspace_id':'default', + 'task_workspace_id':runtime.tasks.workspace_id,'capabilities':['read','network'], + 'interaction_mode':'agent','model':'offline','has_budget':False,'profiles':[]}} +with patch('openkyrozen.app.bootstrap.build_application',return_value=app): + result=run_actor(payload,ChildClient(),source=source,provider_config=config) +assert result['session_id']=='run1',result +assert result['tool_records'][0]['success'] is True,result +print(json.dumps({'original_child':json.loads(result['result'])['status'],'native_calls':len(calls)})) +''' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline, workspace, home = root / 'baseline', root / 'workspace', root / 'home' + baseline.mkdir(); workspace.mkdir(); home.mkdir() + (workspace / 'source.py').write_text('evidence') + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(baseline, filter='data') + result = subprocess.run([sys.executable, '-I', '-c', script, str(baseline), + str(repository / 'benchmarks'), str(workspace), str(home)], + env={'PATH': '/usr/bin:/bin', 'HOME': str(home), 'PYTHONDONTWRITEBYTECODE': '1'}, + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout.splitlines()[-1]), {'original_child': 'completed', 'native_calls': 2}) + + +class ReadAdapterTests(unittest.TestCase): + def test_native_reads_see_replaced_parent_inode_instead_of_stale_actor_view(self): + module = actor_module(self) + self.assertTrue(hasattr(module, 'install_read_adapter'), 'scoped parent read adapter is missing') + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.models import invoke_tool_result + from openkyrozen.agent.conversation import tool_result_to_dict + actions = ('read_file', 'search_files', 'list_dir', 'list_tree', 'find_files') + originals = {action: getattr(ToolAdapters, action) for action in actions} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + parent, actor = root / 'parent', root / 'actor' + parent.mkdir(); actor.mkdir() + (parent / 'source.py').write_text('before') + (actor / 'source.py').write_text('before') + replacement = parent / 'replacement' + replacement.write_text('after') + replacement.replace(parent / 'source.py') + trusted = ToolAdapters(parent) + class Client: + def rpc(self, op, **values): + if op != 'file_read': raise AssertionError(op) + method = originals[values['action']].__get__(trusted, ToolAdapters) + return tool_result_to_dict(invoke_tool_result(method, values['arguments'])) + with patch.multiple(ToolAdapters, **originals): + module.install_read_adapter(Client()) + isolated = ToolAdapters(actor) + result = invoke_tool_result(isolated.read_file, {'path': 'source.py'}) + self.assertTrue(result.success) + self.assertEqual(result.data['sha256'], hashlib.sha256(b'after').hexdigest()) + self.assertEqual((actor / 'source.py').read_text(), 'before') + self.assertIn('after', result.text) diff --git a/tests/test_v3_deepseek.py b/tests/test_v3_deepseek.py new file mode 100644 index 00000000..11ea4188 --- /dev/null +++ b/tests/test_v3_deepseek.py @@ -0,0 +1,681 @@ +"""Offline coverage of genuine CNY pricing, historical exposure, and relay bounds.""" +import dataclasses +import json +import sqlite3 +import tempfile +import threading +import unittest +from decimal import Decimal +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import requests + +from benchmarks import v3_matched + + +class DeepSeekBudgetTests(unittest.TestCase): + def test_immutable_pricing_is_available(self): + self.assertIsNotNone(getattr(v3_matched, 'Pricing', None), + 'budget pricing must be an immutable per-ledger configuration') + + def config(self): + from benchmarks import v3_deepseek + return v3_deepseek + + def legacy_ledger(self, path): + module = self.config() + ledger = v3_matched.BudgetLedger(path, limit_rmb='15', pricing=module.DEEPSEEK_PRICING) + ledger.reserve('charged', input_token_bound=1_000_000, output_token_bound=4096, + metadata={'reserved_rmb': '2.032768'}) + ledger.settle('charged', {'input_tokens': 4621, 'output_tokens': 33, + 'cache_read_tokens': 0, 'cache_write_tokens': 0}) + ledger.reserve('unknown', input_token_bound=1_000_000, output_token_bound=4096, + metadata={'reserved_rmb': '2.032768'}) + ledger.settle('unknown', None) + return ledger + + def test_cny_pricing_settlement_and_cap_are_independent_of_panstar(self): + module = self.config() + with self.assertRaises(dataclasses.FrozenInstanceError): + module.DEEPSEEK_PRICING.input_rate = Decimal('0') + self.assertEqual(module.DEEPSEEK_PRICING.cost(1_000_000, 4096), Decimal('2.032768')) + self.assertEqual(module.DEEPSEEK_PRICING.cost(100, 10, 20, 30), Decimal('0.0002408')) + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=module.DEEPSEEK_PRICING) + self.assertEqual(ledger.remaining_rmb, Decimal('15')) + with self.assertRaises(ValueError): + v3_matched.BudgetLedger(Path(directory) / 'over.db', limit_rmb='15.000000000001', + pricing=module.DEEPSEEK_PRICING) + with self.assertRaises(ValueError): + v3_matched.BudgetLedger(ledger.path, limit_rmb='15') + + def test_no_next_reservation_above_cutoff_and_unknown_holds_remain(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + prior_hold_rmb='12.967232000001', pricing=module.DEEPSEEK_PRICING) + self.assertFalse(ledger.reserve('blocked', input_token_bound=1_000_000, output_token_bound=4096)) + ledger2 = v3_matched.BudgetLedger(Path(directory) / 'boundary.db', limit_rmb='15', + prior_hold_rmb='12.967232', pricing=module.DEEPSEEK_PRICING) + self.assertTrue(ledger2.reserve('last', input_token_bound=1_000_000, output_token_bound=4096)) + ledger2.settle('last', None) + self.assertEqual(ledger2.remaining_rmb, 0) + self.assertFalse(ledger2.reserve('retry', input_token_bound=1_000_000, output_token_bound=4096)) + + def test_prior_exposure_counts_settled_and_unknown_once_across_copies(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + source, copy = Path(directory) / 'old.db', Path(directory) / 'copy.db' + self.legacy_ledger(source) + with sqlite3.connect(source) as original, sqlite3.connect(copy) as duplicate: + original.backup(duplicate) + before = source.read_bytes() + exposure = module.read_prior_exposure([source, copy]) + self.assertEqual(exposure.settled_cny, Decimal('0.009506')) + self.assertEqual(exposure.held_cny, Decimal('2.032768')) + self.assertEqual(exposure.total_cny, Decimal('2.042274')) + self.assertEqual(exposure.attempt_count, 2) + self.assertEqual(before, source.read_bytes()) + ledger = module.create_budget(Path(directory) / 'new.db', prior_ledgers=[source, copy]) + self.assertEqual(ledger.remaining_rmb, Decimal('12.957726')) + self.assertEqual(module.create_budget(ledger.path, prior_ledgers=[source]).remaining_rmb, + Decimal('12.957726')) + + def test_missing_invalid_conflicting_and_overrun_prior_fail_closed(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaises(ValueError): + module.read_prior_exposure([root / 'missing.db']) + self.assertFalse((root / 'missing.db').exists()) + source, copy = root / 'old.db', root / 'copy.db' + self.legacy_ledger(source) + with sqlite3.connect(source) as original, sqlite3.connect(copy) as duplicate: + original.backup(duplicate) + with sqlite3.connect(copy) as db: + db.execute("UPDATE attempts SET actual_picormb=1 WHERE id='charged'") + with self.assertRaises(ValueError): + module.read_prior_exposure([source, copy]) + with sqlite3.connect(copy) as db: + db.execute("UPDATE attempts SET status='overrun' WHERE id='charged'") + with self.assertRaises(ValueError): + module.read_prior_exposure([copy]) + with self.assertRaises(ValueError): + module.create_budget(root / 'new.db', prior_ledgers=[]) + + def test_prior_usage_cost_is_reconstructed_instead_of_trusting_actual(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'old.db' + self.legacy_ledger(source) + with sqlite3.connect(source) as db: + db.execute("UPDATE attempts SET actual_picormb=1 WHERE id='charged'") + with self.assertRaises(ValueError): + module.read_prior_exposure([source]) + + def test_carried_prior_manifest_deduplicates_original_without_releasing_unknown(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + original = root / 'original.db' + self.legacy_ledger(original) + later = module.create_budget(root / 'later.db', prior_ledgers=[original]) + later.reserve('later-call', input_token_bound=1_000_000, output_token_bound=4096, + metadata={'reserved_rmb': '2.032768'}) + later.settle('later-call', {'input_tokens': 100, 'output_tokens': 10, + 'cache_read_tokens': 0, 'cache_write_tokens': 0}) + together = module.read_prior_exposure([original, later.path]) + self.assertEqual(together.total_cny, Decimal('2.042554')) + self.assertEqual(together.held_cny, Decimal('2.032768')) + self.assertEqual(together.attempt_count, 3) + self.assertEqual(module.read_prior_exposure([later.path]), together) + with sqlite3.connect(later.path) as db: + row = db.execute("SELECT metadata_json FROM attempts WHERE id='prior-campaign-reservations'").fetchone() + metadata = json.loads(row[0]) + metadata['prior_attempts'][0] = metadata['prior_attempts'][0].replace('charged', 'tampered') + db.execute("UPDATE attempts SET metadata_json=? WHERE id='prior-campaign-reservations'", + (json.dumps(metadata),)) + with self.assertRaises(ValueError): + module.read_prior_exposure([original, later.path]) + + def test_opaque_carryover_and_underlying_original_fail_closed_in_either_order(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + original = v3_matched.BudgetLedger(root / 'original.db', limit_rmb='15', + pricing=module.DEEPSEEK_PRICING) + original.reserve('original-unknown', input_token_bound=1_000_000, output_token_bound=4096) + original.settle('original-unknown', None) + carry = v3_matched.BudgetLedger(root / 'opaque-carry.db', limit_rmb='15', + prior_hold_rmb='2.032768', pricing=module.DEEPSEEK_PRICING) + self.assertEqual(module.read_prior_exposure([carry.path]).held_cny, Decimal('2.032768')) + for paths in ([original.path, carry.path], [carry.path, original.path]): + with self.subTest(paths=paths), self.assertRaisesRegex(ValueError, 'ambiguous'): + module.read_prior_exposure(paths) + + def test_original_opaque_hold_copy_and_manifest_keep_all_exposure_once(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + original, duplicate = root / 'original.db', root / 'copy.db' + v3_matched.BudgetLedger(original, limit_rmb='15', prior_hold_rmb='0.000000000007', + pricing=module.DEEPSEEK_PRICING) + self.legacy_ledger(original) + with sqlite3.connect(original) as source, sqlite3.connect(duplicate) as copy: + source.backup(copy) + expected = module.read_prior_exposure([original]) + self.assertEqual(expected.total_cny, Decimal('2.042274000007')) + self.assertEqual(module.read_prior_exposure([original, duplicate]), expected) + later = module.create_budget(root / 'later.db', prior_ledgers=[original]) + later.reserve('later-unknown', input_token_bound=1_000_000, output_token_bound=4096, + metadata={'reserved_rmb': '2.032768'}) + later.settle('later-unknown', None) + exposure = module.read_prior_exposure([original, duplicate, later.path]) + self.assertEqual(exposure.total_cny, Decimal('4.075042000007')) + self.assertEqual(exposure.held_cny, Decimal('4.065536000007')) + self.assertEqual(module.read_prior_exposure([later.path]), exposure) + unrelated = v3_matched.BudgetLedger(root / 'unrelated.db', limit_rmb='15', + pricing=module.DEEPSEEK_PRICING) + unrelated.reserve('unrelated-unknown', input_token_bound=1_000_000, output_token_bound=4096) + unrelated.settle('unrelated-unknown', None) + with self.assertRaisesRegex(ValueError, 'ambiguous'): + module.read_prior_exposure([later.path, unrelated.path]) + + def test_empty_original_creates_and_reopens_without_a_fake_prior_attempt(self): + module = self.config() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + empty = v3_matched.BudgetLedger(root / 'empty.db', limit_rmb='15', pricing=module.DEEPSEEK_PRICING) + exposure = module.read_prior_exposure([empty.path]) + self.assertEqual(exposure.records, ()) + self.assertEqual(exposure.total_cny, 0) + for _ in range(2): + ledger = module.create_budget(root / 'new.db', prior_ledgers=[empty.path]) + self.assertEqual(ledger.remaining_rmb, Decimal('15')) + with sqlite3.connect(ledger.path) as db: + self.assertEqual(db.execute('SELECT COUNT(*) FROM attempts').fetchone()[0], 0) + nonempty_records = (json.dumps({'id': 'unproven'}),) + with self.assertRaises(ValueError): + v3_matched.BudgetLedger(ledger.path, limit_rmb='15', pricing=module.DEEPSEEK_PRICING, + prior_hold_rmb='0', prior_hold_records=nonempty_records) + + +class DeepSeekRelayTests(unittest.TestCase): + def test_deepseek_budget_rejects_default_provider_or_undersized_reservation(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING + from benchmarks.v3_responses_relay import RelayConfig, ResponsesRelay + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=DEEPSEEK_PRICING) + for config in (RelayConfig(), RelayConfig(model='deepseek-flash'), + RelayConfig(model='deepseek-flash', fixed_input_token_bound=999_999, serial_requests=True)): + with self.subTest(config=config), self.assertRaises(ValueError): + ResponsesRelay(endpoint='http://127.0.0.1:1', api_key='fake', budget=ledger, config=config) + + def test_fixed_context_reservation_model_and_serial_stream_lifecycle(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG + from benchmarks.v3_responses_relay import ResponsesRelay + received, balances = [], [] + first_entered, release, second_entered = threading.Event(), threading.Event(), threading.Event() + ledger_ref = {} + + class Upstream(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + received.append(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) + balances.append(ledger_ref['ledger'].remaining_rmb) + if len(received) == 1: + first_entered.set() + else: + second_entered.set() + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.end_headers() + if len(received) == 1: + release.wait(5) + result = {'type': 'response.completed', 'response': {'usage': { + 'input_tokens': 100, 'output_tokens': 10, + 'input_tokens_details': {'cached_tokens': 20}, 'cache_creation_input_tokens': 30}}} + self.wfile.write(('data: ' + json.dumps(result) + '\n\n').encode()) + + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=DEEPSEEK_PRICING) + ledger_ref['ledger'] = ledger + upstream = ThreadingHTTPServer(('127.0.0.1', 0), Upstream) + upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) + upstream_thread.start() + relay = ResponsesRelay(endpoint=f'http://127.0.0.1:{upstream.server_port}', + api_key='fake-key', budget=ledger, config=DEEPSEEK_RELAY_CONFIG) + relay.start() + results = [] + + def request(): + results.append(requests.post(relay.url + '/responses', json={'input': 'x', 'stream': True}, + headers={'Authorization': 'Bearer ' + relay.client_key}, timeout=8)) + + first, second = threading.Thread(target=request), threading.Thread(target=request) + try: + first.start() + self.assertTrue(first_entered.wait(2)) + second.start() + self.assertFalse(second_entered.wait(.2)) + release.set() + first.join(5) + second.join(5) + self.assertEqual([r.status_code for r in results], [200, 200]) + self.assertEqual(received[0]['model'], 'deepseek-flash') + self.assertEqual(received[0]['max_output_tokens'], 4096) + self.assertEqual(balances[0], Decimal('12.967232')) + self.assertEqual(ledger.spent_rmb, Decimal('0.0004816')) + self.assertEqual([r['reserved_rmb'] for r in relay.rows], ['2.032768', '2.032768']) + finally: + release.set() + first.join(5) + if second.ident: + second.join(5) + relay.close() + upstream.shutdown() + upstream.server_close() + upstream_thread.join() + + +class DeepSeekRunnerTests(unittest.TestCase): + def test_runner_reports_cny_and_uses_configured_model_without_fx_conversion(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG + from unittest.mock import patch + commands, relay_options = [], [] + + from tests.test_v3_matched_live import ActorRelayStub + class Relay(ActorRelayStub): + url = 'http://127.0.0.1:1/v1' + client_key = 'local' + provider_error = None + budget_blocked = False + rows = [] + + def __init__(self, **kwargs): + relay_options.append(kwargs) + + def start(self): + pass + + def close(self): + pass + + def worker(command, **kwargs): + commands.append(command) + from tests.test_v3_matched_live import valid_worker_result + Path(command[command.index('--result') + 1]).write_text(json.dumps(valid_worker_result())) + return 0, '' + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + from tests.test_v3_matched_live import prepare_candidate_apparatus + prepare_candidate_apparatus(root / 'src') + with patch.object(live, '_source_snapshot', return_value='candidate'), \ + patch('benchmarks.v3_protected_worker.verify_worker_protection', return_value={}), \ + patch('benchmarks.v3_deepseek.run_smoke', return_value=True), \ + patch('openkyrozen.tools.sandbox.sandbox_command', return_value=type( + 'Probe', (), {'backend': 'test', 'close': lambda self: None})()), \ + patch.object(live, '_credential', return_value=('https://wrong.invalid', 'fake-key')), \ + patch.object(live, 'ResponsesRelay', Relay), \ + patch.object(live, '_extract_baseline'), \ + patch.object(live, '_run_worker', side_effect=worker), \ + patch('builtins.print'), \ + patch.object(live, 'verify_fixture', return_value={ + 'result': 'failed', 'safety_passed': False, + 'safety_components': {'scope_safe': False, 'original_tests_unchanged': True, + 'provider_ok': True, 'no_running_processes': True}, + 'plan_scenario_passed': False, 'verified_success': False, 'changed_paths': []}): + report = live.run(source=root / 'src', repo=root, output=root / 'report.json', + budget_path=root / 'budget.db', limit_cny='15', + prior_hold_cny='0.582914960007', pricing=DEEPSEEK_PRICING, + relay_config=DEEPSEEK_RELAY_CONFIG, endpoint='https://api.deepseek.com') + self.assertEqual(report['budget']['limit_cny'], '15') + self.assertEqual(report['budget']['prior_campaign_hold_cny'], '0.582914960007') + self.assertEqual(report['budget']['remaining_cny'], '14.417085039993') + self.assertNotIn('usd_cny', report['budget']) + self.assertNotIn('limit_usd', report['budget']) + self.assertEqual(report['model'], 'deepseek-flash') + self.assertEqual(report['pricing']['source_currency'], 'CNY') + self.assertEqual(commands[0][commands[0].index('--model') + 1], 'deepseek-flash') + self.assertEqual(relay_options[0]['endpoint'], 'https://api.deepseek.com') + self.assertEqual(report['status'], 'blocked_safety_failure') + + def test_cli_reconstructs_prior_ledger_and_checks_configured_signed_candidate(self): + from benchmarks import v3_deepseek as deepseek + from unittest.mock import patch + summary = {'complete': False, 'accounting_complete': False, 'comparison_thresholds_met': False} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prior = root / 'prior.db' + DeepSeekBudgetTests().legacy_ledger(prior) + argv = ['deepseek', '--source', str(root / 'src'), '--candidate-sha', 'configured-sha', + '--repo', str(root), '--output', str(root / 'report.json'), + '--budget-db', str(root / 'new.db'), '--prior-ledger', str(prior)] + with patch('sys.argv', argv), patch.dict('os.environ', {'KYROZEN_BENCHMARK_API_KEY': 'fake-env-key'}), \ + patch('benchmarks.v3_matched_live._source_snapshot', return_value='configured-sha'), \ + patch('scripts.publish_v3_preview._verify_candidate_signature') as signature, \ + patch('benchmarks.v3_matched_live.run', return_value={'summary': summary}) as runner, \ + patch('builtins.print'): + with self.assertRaises(SystemExit) as result: + deepseek.main() + self.assertEqual(result.exception.code, 1) + signature.assert_called_once_with((root / 'src').resolve(), 'configured-sha') + self.assertEqual(runner.call_args.kwargs['prior_hold_cny'], '2.042274') + self.assertEqual(runner.call_args.kwargs['limit_cny'], '15') + self.assertEqual(runner.call_args.kwargs['endpoint'], 'https://api.deepseek.com') + with patch('sys.argv', argv + ['--limit-cny', '15.01']), \ + patch('benchmarks.v3_matched_live._source_snapshot', return_value='configured-sha'), \ + patch('scripts.publish_v3_preview._verify_candidate_signature'), \ + patch('benchmarks.v3_matched_live.run') as runner: + with self.assertRaises(ValueError): + deepseek.main() + runner.assert_not_called() + + def test_fresh_runner_accepts_only_same_candidate_smoke_in_shared_ledger(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG + from unittest.mock import patch + for candidate, model, carryover, accepted in ( + ('candidate', 'deepseek-flash', '0.582914960007', True), + ('other-candidate', 'deepseek-flash', '0.582914960007', False), + ('candidate', 'wrong-model', '0.582914960007', False), + ('candidate', 'deepseek-flash', '0.1', False)): + with self.subTest(candidate=candidate, model=model, carryover=carryover), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + budget_path = root / 'budget.db' + from tests.test_v3_matched_live import prepare_candidate_apparatus + prepare_candidate_apparatus(root / 'src') + ledger = v3_matched.BudgetLedger(budget_path, limit_rmb='15', pricing=DEEPSEEK_PRICING, + prior_hold_rmb='0.582914960007') + ledger.reserve('smoke', input_token_bound=1_000_000, output_token_bound=4096, + metadata={'arm': 'smoke', 'fixture_id': candidate, 'repeat': -1, + 'model': model, 'max_output_tokens': 4096, 'reserved_rmb': '2.032768'}) + ledger.settle('smoke', {'input_tokens': 100, 'output_tokens': 10, + 'cache_read_tokens': 0, 'cache_write_tokens': 0}) + with patch.object(live, '_source_snapshot', return_value='candidate'), \ + patch('openkyrozen.tools.sandbox.sandbox_command', return_value=type( + 'Probe', (), {'backend': 'test', 'close': lambda self: None})()), \ + patch('benchmarks.v3_protected_worker.verify_worker_protection', return_value={}), \ + patch.object(live, '_credential', return_value=('https://invalid.example', 'fake-key')), \ + patch.object(live, 'ResponsesRelay', return_value=type('Relay', (), {'rows': []})()), \ + patch.object(live, '_extract_baseline', side_effect=RuntimeError('after-preflight')): + if accepted: + with self.assertRaisesRegex(RuntimeError, 'after-preflight'): + live.run(source=root / 'src', repo=root, output=root / 'report.json', + budget_path=budget_path, limit_cny='15', prior_hold_cny=carryover, + pricing=DEEPSEEK_PRICING, relay_config=DEEPSEEK_RELAY_CONFIG) + else: + with self.assertRaises(ValueError): + live.run(source=root / 'src', repo=root, output=root / 'report.json', + budget_path=budget_path, limit_cny='15', prior_hold_cny=carryover, + pricing=DEEPSEEK_PRICING, relay_config=DEEPSEEK_RELAY_CONFIG) + self.assertEqual(ledger.spent_rmb, Decimal('0.00028')) + + def test_resume_rejects_changed_model_or_frozen_relay_configuration_before_credentials(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG + from unittest.mock import patch + for changed in ('model', 'relay_config'): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ledger_path = root / 'budget.db' + v3_matched.BudgetLedger(ledger_path, limit_rmb='15', prior_hold_rmb='0.5', pricing=DEEPSEEK_PRICING) + checkpoint = {**live.apparatus_identity(), 'protocol': 'openkyrozen-v3-matched-live-v1', 'candidate_sha': 'candidate', + 'baseline_sha': live.BASELINE, 'model': 'deepseek-flash', 'repeats': 3, + 'pricing': DEEPSEEK_PRICING.as_dict(), + 'relay_config': dataclasses.asdict(DEEPSEEK_RELAY_CONFIG), + 'budget': {'prior_campaign_hold_cny': '0.5'}, 'rows': [], 'calls': []} + if changed == 'model': + checkpoint['model'] = 'another-model' + else: + checkpoint['relay_config']['serial_requests'] = False + live._write_checkpoint(root / 'report.json', ledger_path, checkpoint) + with patch.object(live, '_source_snapshot', return_value='candidate'), \ + patch('openkyrozen.tools.sandbox.sandbox_command', return_value=type( + 'Probe', (), {'backend': 'test', 'close': lambda self: None})()), \ + patch('benchmarks.v3_protected_worker.verify_worker_protection', return_value={}), \ + patch.object(live, '_credential') as credential: + with self.assertRaisesRegex(ValueError, 'checkpoint does not match'): + live.run(source=root / 'src', repo=root, output=root / 'report.json', + budget_path=ledger_path, limit_cny='15', prior_hold_cny='0.5', + pricing=DEEPSEEK_PRICING, relay_config=DEEPSEEK_RELAY_CONFIG) + credential.assert_not_called() + + +class DeepSeekSmokeTests(unittest.TestCase): + def test_default_thinking_smoke_uses_auto_nonbeta_tools_and_correlated_continuation(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG, run_smoke + from benchmarks.v3_responses_relay import ResponsesRelay + received, rejected, continuations = [], [], {} + + class Upstream(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + choice = body.get('tool_choice', 'auto') + error = None + if isinstance(choice, dict) or choice == 'required': + error = 'forced_choice_with_default_thinking' + elif any(tool.get('strict') is True for tool in body.get('tools', [])): + error = 'strict_requires_beta' + if error: + rejected.append(error) + status, result = 400, {'error': {'type': 'invalid_request_error', 'message': error}} + else: + received.append((self.path, body)) + probe = body['tools'][0]['parameters']['properties']['value']['enum'][0] + if choice == 'none': + expected = continuations[probe] + [{'type': 'function_call_output', + 'call_id': 'call-' + probe, 'output': json.dumps({'value': probe})}] + if body['input'] != expected: + status, result = 400, {'error': {'type': 'invalid_request_error'}} + else: + status, result = 200, {'id': 'final-' + probe, 'output': [ + {'type': 'message', 'role': 'assistant', 'content': [ + {'type': 'output_text', 'text': probe}]}]} + else: + output = [{'type': 'reasoning', 'id': 'reasoning-' + probe, 'summary': []}, + {'type': 'function_call', 'name': 'echo_probe', 'call_id': 'call-' + probe, + 'arguments': json.dumps({'value': probe})}] + continuations[probe] = body['input'] + output + status, result = 200, {'id': 'first-' + probe, 'output': output} + if status == 200: + result['usage'] = {'input_tokens': 100, 'output_tokens': 10} + payload = json.dumps(result).encode() + self.send_response(status) + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + upstream = ThreadingHTTPServer(('127.0.0.1', 0), Upstream) + thread = threading.Thread(target=upstream.serve_forever, daemon=True) + thread.start() + try: + endpoint = f'http://127.0.0.1:{upstream.server_port}' + # These controls specify the offline provider's compatibility contract, + # not the unretained free-form error from the actual paid request. + for choice, strict in (({'type': 'function', 'name': 'echo_probe'}, False), + ('required', False), ('auto', True)): + response = requests.post(endpoint + '/responses', json={ + 'tool_choice': choice, 'tools': [{'strict': strict}]}, timeout=3) + self.assertEqual(response.status_code, 400) + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=DEEPSEEK_PRICING) + relay = ResponsesRelay(endpoint=endpoint, api_key='fake', budget=ledger, + config=DEEPSEEK_RELAY_CONFIG) + relay.start() + try: + for candidate in ('candidate-one', 'candidate-two'): + self.assertTrue(run_smoke(relay, candidate)) + self.assertTrue(run_smoke(relay, candidate)) + self.assertEqual(len(relay.smoke_verified_attempt_ids), 2) + self.assertIsNone(relay.provider_error) + self.assertEqual([row['status'] for row in relay.rows], ['settled'] * 4) + self.assertTrue(all(row['usage'] == {'input_tokens': 100, 'output_tokens': 10, + 'cache_read_tokens': 0, 'cache_write_tokens': 0} for row in relay.rows)) + self.assertEqual(ledger.spent_rmb, Decimal('0.00112')) + finally: + relay.close() + self.assertEqual(rejected, ['forced_choice_with_default_thinking'] * 2 + ['strict_requires_beta']) + self.assertEqual(len(received), 4) + self.assertEqual(len(continuations), 2) + for probe in continuations: + self.assertRegex(probe, r'^[0-9a-f]{32}$') + for index, (path, body) in enumerate(received): + self.assertEqual(path, '/responses') + self.assertEqual(body['tool_choice'], 'auto' if index % 2 == 0 else 'none') + self.assertIs(body['tools'][0]['strict'], False) + self.assertEqual(body['model'], 'deepseek-flash') + self.assertEqual(body['max_output_tokens'], 4096) + self.assertNotIn('thinking', body) + self.assertNotIn('reasoning', body) + finally: + upstream.shutdown() + upstream.server_close() + thread.join() + + def test_smoke_and_retry_use_one_ledger_and_never_release_unknown_usage(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG, run_smoke + from benchmarks.v3_responses_relay import ResponsesRelay + received = [] + complete_usage = [False] + + class Upstream(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + received.append(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) + request = received[-1] + continuation = request.get('input', []) + if isinstance(continuation, list) and continuation[-1].get('type') == 'function_call_output': + result = {'id': 'smoke-response-2', 'output': [{'type': 'message', 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': json.loads(continuation[-1]['output'])['value']}]}]} + else: + value = request['tools'][0]['parameters']['properties']['value']['enum'][0] + result = {'id': 'smoke-response-1', 'output': [{'type': 'function_call', + 'name': 'echo_probe', 'call_id': 'probe-call-1', 'arguments': json.dumps({'value': value})}]} + if complete_usage[0]: + result['usage'] = {'input_tokens': 100, 'output_tokens': 10} + payload = json.dumps(result).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + with tempfile.TemporaryDirectory() as directory: + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=DEEPSEEK_PRICING) + upstream = ThreadingHTTPServer(('127.0.0.1', 0), Upstream) + thread = threading.Thread(target=upstream.serve_forever, daemon=True) + thread.start() + try: + for retry in (False, True): + relay = ResponsesRelay(endpoint=f'http://127.0.0.1:{upstream.server_port}', + api_key='fake', budget=ledger, config=DEEPSEEK_RELAY_CONFIG) + relay.rows.extend(ledger.campaign_calls()) + relay.start() + try: + complete_usage[0] = retry + self.assertEqual(run_smoke(relay, 'candidate'), retry) + if retry: + self.assertTrue(run_smoke(relay, 'candidate')) + else: + self.assertEqual(relay.provider_error, 'smoke_incomplete_usage') + self.assertEqual(ledger.remaining_rmb, Decimal('12.967232')) + finally: + relay.close() + self.assertEqual(len(received), 3) + self.assertEqual(received[-1]['input'][-1]['call_id'], 'probe-call-1') + self.assertEqual(received[-1]['input'][-1]['type'], 'function_call_output') + self.assertEqual(received[-1]['input'][-2]['type'], 'function_call') + self.assertEqual(sorted(row['status'] for row in ledger.campaign_calls()), ['settled', 'settled', 'unknown']) + self.assertEqual(ledger.remaining_rmb, Decimal('12.966672')) + finally: + upstream.shutdown() + upstream.server_close() + thread.join() + + def test_malformed_tool_call_arguments_or_final_answer_stop_smoke(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING, DEEPSEEK_RELAY_CONFIG, run_smoke + from benchmarks.v3_responses_relay import ResponsesRelay + mode, received = ['bad-name'], [] + + class Upstream(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + received.append(body) + continuation = body['input'][-1].get('type') == 'function_call_output' + if continuation: + output = [{'type': 'message', 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': 'wrong final answer'}]}] + else: + value = body['tools'][0]['parameters']['properties']['value']['enum'][0] + call = {'type': 'function_call', 'name': 'echo_probe', 'call_id': 'typed-call', + 'arguments': json.dumps({'value': value})} + if mode[0] == 'bad-name': + call['name'] = 'another_tool' + elif mode[0] == 'no-call-id': + call['call_id'] = '' + elif mode[0] == 'bad-args': + call['arguments'] = '{' + elif mode[0] == 'wrong-value': + call['arguments'] = json.dumps({'value': 'wrong'}) + output = [call] + if mode[0] == 'no-call': + output = [] + elif mode[0] == 'two-calls': + output.append(dict(call, call_id='extra-call')) + result = {'id': 'response', 'output': output, + 'usage': {'input_tokens': 100, 'output_tokens': 10}} + if continuation and mode[0] == 'unknown-final-usage': + result.pop('usage') + payload = json.dumps(result).encode() + self.send_response(200) + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + upstream = ThreadingHTTPServer(('127.0.0.1', 0), Upstream) + thread = threading.Thread(target=upstream.serve_forever, daemon=True) + thread.start() + try: + for invalid, call_count, remaining in ( + ('bad-name', 1, '14.99972'), ('no-call-id', 1, '14.99972'), + ('bad-args', 1, '14.99972'), ('wrong-value', 1, '14.99972'), + ('no-call', 1, '14.99972'), ('two-calls', 1, '14.99972'), + ('bad-final', 2, '14.99944'), ('unknown-final-usage', 2, '12.966952')): + with self.subTest(invalid=invalid), tempfile.TemporaryDirectory() as directory: + mode[0] = invalid + received.clear() + ledger = v3_matched.BudgetLedger(Path(directory) / 'budget.db', limit_rmb='15', + pricing=DEEPSEEK_PRICING) + relay = ResponsesRelay(endpoint=f'http://127.0.0.1:{upstream.server_port}', api_key='fake', + budget=ledger, config=DEEPSEEK_RELAY_CONFIG) + relay.start() + try: + self.assertFalse(run_smoke(relay, 'candidate')) + self.assertTrue(relay.provider_error.startswith('smoke_')) + self.assertEqual(len(received), call_count) + self.assertEqual(ledger.remaining_rmb, Decimal(remaining)) + self.assertIsNone(getattr(relay, 'smoke_verified_candidate', None)) + finally: + relay.close() + finally: + upstream.shutdown() + upstream.server_close() + thread.join() diff --git a/tests/test_v3_fixture_strength.py b/tests/test_v3_fixture_strength.py new file mode 100644 index 00000000..2274332e --- /dev/null +++ b/tests/test_v3_fixture_strength.py @@ -0,0 +1,200 @@ +"""Adversarial offline checks for the frozen matched fixture oracles.""" +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from benchmarks.v3_fixtures import FIXTURES +from benchmarks.v3_matched_live import prepare_fixture, verify_fixture + + +def fixture(identifier): + return next(item for item in FIXTURES if item["fixture_id"] == identifier) + + +def healthy_worker(): + return {"provider_error": False, "processes_alive": 0, "scope_preserved": True, + "plan_revisions": 1, "task_records": [{"description": "Verify duplicate names", "status": "succeeded"}]} + + +class MatchedFixtureStrengthTests(unittest.TestCase): + def test_refactoring_noop_cannot_pass_only_because_outputs_already_match(self): + for index in (1, 2, 3): + case = fixture(f"refactoring-{index}") + with self.subTest(index=index), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + with patch("benchmarks.v3_matched_live._run_fixture_tests", return_value=True): + result = verify_fixture(case, root, "Outputs are unchanged.", healthy_worker(), prepared) + self.assertEqual(result["result"], "failed") + + def test_multi_file_implementation_requires_both_declared_production_changes(self): + case = fixture("implementation-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + with patch("benchmarks.v3_matched_live._run_fixture_tests", return_value=True): + result = verify_fixture(case, root, "Implemented.", healthy_worker(), prepared) + self.assertEqual(result["result"], "failed") + + def test_git_answer_without_successful_git_tool_evidence_is_rejected(self): + case = fixture("git_history-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + answer = f"increase retry count {prepared['expected_commit']} changed RETRIES = 1 to RETRIES = 3" + result = verify_fixture(case, root, answer, healthy_worker(), prepared) + self.assertEqual(result["result"], "failed") + + def test_git_tool_reference_must_match_the_independently_prepared_commit(self): + case = fixture("git_history-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + answer = f"increase retry count {prepared['expected_commit']} changed RETRIES = 1 to RETRIES = 3" + worker = healthy_worker() + worker["tool_receipts"] = [ + {"action": "git_log", "success": True, "authorized": True, "git_reference": None}, + {"action": "git_show", "success": True, "authorized": True, "git_reference": "wrong-commit"}, + ] + self.assertEqual(verify_fixture(case, root, answer, worker, prepared)["result"], "failed") + worker["tool_receipts"][-1]["git_reference"] = prepared["expected_commit_full"] + self.assertEqual(verify_fixture(case, root, answer, worker, prepared)["result"], "passed") + + def test_long_context_answer_without_a_long_session_and_context_measurement_is_rejected(self): + for index in (1, 2, 3): + case = fixture(f"long_context-{index}") + with self.subTest(index=index), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + answer = json.dumps(case["verifier"]["expected"]) + result = verify_fixture(case, root, answer, healthy_worker(), prepared) + self.assertEqual(result["result"], "failed") + +@unittest.skipUnless(hasattr(__import__('os'), 'geteuid') and __import__('os').geteuid() == 0, + 'privileged disposable protected-verifier runner required') +class FixtureOraclePositiveTests(unittest.TestCase): + def test_inprocess_api_oracle_requires_validation_duplicates_and_shared_state(self): + case = fixture("implementation-1") + service = '''ITEMS = [] +class DuplicateName(ValueError): pass +def list_items(): return list(ITEMS) +def create_item(payload): + if not isinstance(payload, dict) or not isinstance(payload.get('name'), str) or not payload['name'].strip(): + raise ValueError('nonempty name') + name = payload['name'].strip() + if any(item['name'] == name for item in ITEMS): raise DuplicateName('duplicate') + item = {'id': len(ITEMS) + 1, 'name': name} + ITEMS.append(item) + return item +''' + api = '''import json +from service import list_items, create_item, DuplicateName +def app(environ, start_response): + if environ['PATH_INFO'] != '/items': status, body = '404 Not Found', {} + elif environ['REQUEST_METHOD'] == 'GET': status, body = '200 OK', list_items() + elif environ['REQUEST_METHOD'] == 'POST': + try: + payload = json.loads(environ['wsgi.input'].read(int(environ['CONTENT_LENGTH']))) + body = create_item(payload) + status = '201 Created' + except DuplicateName: status, body = '409 Conflict', {'error': 'duplicate'} + except (ValueError, TypeError): status, body = '400 Bad Request', {'error': 'invalid'} + else: status, body = '404 Not Found', {} + start_response(status, [('Content-Type', 'application/json')]) + return [json.dumps(body).encode('utf-8')] +''' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + (root / "service.py").write_text(service) + (root / "api.py").write_text(api) + self.assertEqual(verify_fixture(case, root, "implemented", healthy_worker(), prepared)["result"], "passed") + # Both modules change, but accepting every POST must still fail real tests. + (root / "api.py").write_text(api.replace("'409 Conflict'", "'201 Created'")) + self.assertEqual(verify_fixture(case, root, "implemented", healthy_worker(), prepared)["result"], "failed") + + def test_refactor_oracles_require_shared_extraction_and_preserved_behavior(self): + implementations = { + 1: "def _format_label(name): return name.strip().title()\ndef primary(name): return _format_label(name)\ndef secondary(name): return _format_label(name)\n", + 2: "def _in_bounds(x): return 0 <= x <= 10\ndef first(x): return _in_bounds(x)\ndef second(x): return _in_bounds(x)\n", + 3: "import json\ndef _serialize(v): return json.dumps(v, ensure_ascii=False, sort_keys=True)\ndef one(v): return _serialize(v)\ndef two(v): return _serialize(v)\n", + } + for index, implementation in implementations.items(): + case = fixture(f"refactoring-{index}") + with self.subTest(index=index), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + path = root / case["verifier"]["shared_helper"]["path"] + path.write_text(implementation) + self.assertEqual(verify_fixture(case, root, "refactored", healthy_worker(), prepared)["result"], "passed") + name = case["verifier"]["shared_helper"]["name"] + path.write_text(implementation.replace("return name.strip().title()", "return 'wrong'") + .replace("return 0 <= x <= 10", "return False") + .replace("return json.dumps(v, ensure_ascii=False, sort_keys=True)", "return 'wrong'")) + self.assertEqual(verify_fixture(case, root, "refactored", healthy_worker(), prepared)["result"], "failed") + +class CohortFreezeTests(unittest.TestCase): + def test_repaired_catalog_keeps_exactly_thirty_ids_and_fixed_context_settings(self): + from collections import Counter + from benchmarks.v3_fixtures import CATEGORIES, FROZEN_COHORT_SHA256, cohort_digest + self.assertEqual(cohort_digest(), FROZEN_COHORT_SHA256) + self.assertEqual({item["fixture_id"] for item in FIXTURES}, + {f"{category}-{index}" for category in CATEGORIES for index in (1, 2, 3)}) + self.assertEqual(Counter(item["category"] for item in FIXTURES), {category: 3 for category in CATEGORIES}) + self.assertTrue(all(item["output_reserve_tokens"] == 4096 for item in FIXTURES)) + self.assertTrue(all(item["context_window_tokens"] == (16384 if item["category"] == "long_context" else 1_000_000) + for item in FIXTURES)) + + def test_other_worktree_worker_must_match_the_observer_and_helpers_in_the_apparatus_hash(self): + from benchmarks import v3_matched_live as live + from tests.test_v3_matched_live import prepare_candidate_apparatus + for changed in ("prompt_worker.py", "v3_shell_launcher.py"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + other = root / "other-worktree" + prepare_candidate_apparatus(other) + (other / "benchmarks" / changed).write_text("print('different observer or helper')\n") + with patch.object(live, "_source_snapshot", return_value="other-clean-candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch.object(live, "_credential", side_effect=AssertionError("credential lookup before worker validation")) as credential, \ + patch.object(live, "ResponsesRelay") as relay, patch.object(live, "_run_worker") as worker: + with self.assertRaisesRegex(ValueError, "dispatched worker.*apparatus"): + live.run(source=other, repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd="1.25") + credential.assert_not_called() + relay.assert_not_called() + worker.assert_not_called() + + def test_old_or_changed_cohort_and_apparatus_cannot_resume_before_credentials(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_matched import BudgetLedger, USD_CNY + from decimal import Decimal + for altered in ("missing", "cohort_sha256", "apparatus_sha256", "context_settings", "old_row"): + with self.subTest(altered=altered), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + report, ledger = root / "report.json", root / "budget.sqlite3" + BudgetLedger(ledger, limit_rmb=str(Decimal(20) * USD_CNY), prior_hold_rmb=str(Decimal("1.25") * USD_CNY)) + identity = live.apparatus_identity() + checkpoint = {**identity, "protocol": "openkyrozen-v3-matched-live-v1", "candidate_sha": "candidate", + "baseline_sha": live.BASELINE, "model": live.MODEL, "repeats": 3, + "budget": {"prior_campaign_hold_usd": "1.25"}, "rows": [], "calls": []} + if altered == "missing": + for key in identity: checkpoint.pop(key) + elif altered == "old_row": checkpoint["rows"] = [{"fixture_id": "investigation-1", "repeat": 0, "arm": "v2"}] + else: checkpoint[altered] = "changed" + live._write_checkpoint(report, ledger, checkpoint) + with patch.object(live, "_source_snapshot", return_value="candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch.object(live, "_credential") as credential: + with self.assertRaisesRegex(ValueError, "checkpoint.*(?:cohort|apparatus)"): + live.run(source=root / "source", repo=root, output=report, budget_path=ledger, prior_hold_usd="1.25") + credential.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_fixtures.py b/tests/test_v3_fixtures.py new file mode 100644 index 00000000..14716c90 --- /dev/null +++ b/tests/test_v3_fixtures.py @@ -0,0 +1,25 @@ +import unittest + +from benchmarks.v3_fixtures import CATEGORIES, FIXTURES, validate_fixtures + + +class V3FixtureCatalogTests(unittest.TestCase): + def test_freezes_thirty_unique_fixtures_with_three_per_required_category(self): + validate_fixtures(FIXTURES) + self.assertEqual(len(FIXTURES), 30) + self.assertEqual(set(CATEGORIES), {fixture["category"] for fixture in FIXTURES}) + + def test_replanning_fixtures_are_explicit_and_independently_verifiable(self): + cases = [fixture for fixture in FIXTURES if fixture.get("plan_scenario")] + self.assertGreaterEqual(len(cases), 6) + self.assertTrue(all(fixture.get("verifier") for fixture in cases)) + + def test_duplicate_or_missing_fixtures_fail_closed(self): + with self.assertRaises(ValueError): + validate_fixtures(FIXTURES[:-1]) + with self.assertRaises(ValueError): + validate_fixtures([*FIXTURES[:-1], FIXTURES[0]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_linux_namespace.py b/tests/test_v3_linux_namespace.py new file mode 100644 index 00000000..868ef59e --- /dev/null +++ b/tests/test_v3_linux_namespace.py @@ -0,0 +1,259 @@ +"""Pure rewrite checks and root-only, disposable Linux namespace acceptance.""" +from __future__ import annotations + +import importlib +import importlib.util +import json +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from unittest import mock + + +def namespace_module(case): + case.assertIsNotNone(importlib.util.find_spec('benchmarks.v3_linux_namespace'), + 'private privileged namespace backend is missing') + return importlib.import_module('benchmarks.v3_linux_namespace') + + +class RewriteTests(unittest.TestCase): + def test_environment_removes_privileged_loader_hooks_and_retains_worker_configuration(self): + module = namespace_module(self) + namespace = module.Namespace(1073741900, 1073741901) + environment = {'PATH': '/usr/bin:/bin', 'HOME': '/private/actor', 'KYROZEN_DB_PATH': '/private/db', + 'LD_PRELOAD': '/workspace/plugin.so', 'LD_AUDIT': '/workspace/audit.so', + 'DYLD_INSERT_LIBRARIES': '/workspace/plugin.dylib', 'GLIBC_TUNABLES': 'unsafe', + 'GCONV_PATH': '/workspace', 'LOCPATH': '/workspace', + 'PYTHONPATH': '/workspace', 'PYTHONHOME': '/workspace', 'PYTHONSTARTUP': '/workspace/start.py'} + self.assertEqual(namespace.environment(environment), + {key: environment[key] for key in ('PATH', 'HOME', 'KYROZEN_DB_PATH')}) + self.assertIn('LD_PRELOAD', environment) + + def test_ready_handshake_handles_fragmented_stream_without_trusting_eof(self): + module = namespace_module(self) + connection = mock.Mock() + connection.recv.side_effect = [b're', b'a', b'dy\n'] + module._read_ready(connection) + connection.recv.side_effect = [b're', b''] + with self.assertRaises(PermissionError): + module._read_ready(connection) + + def test_rewrite_retains_filesystem_and_pid_guards_before_trusted_dropper(self): + module = namespace_module(self) + command = ['/usr/bin/bwrap', '--unshare-user', '--disable-userns', '--unshare-pid', + '--die-with-parent', '--new-session', '--ro-bind', '/', '/', + '--bind', '/fixture', '/fixture', '--ro-bind', '/fixture/test.py', '/fixture/test.py', + '--', '/usr/bin/python3', '-c', 'print("model")'] + result = module._rewrite(command, uid=1073741900, gid=1073741901, descriptors=(5, 6), + python='/usr/bin/python3', ready='/run/private/ready') + separator = result.index('--') + options, execution = result[:separator], result[separator + 1:] + self.assertNotIn('--unshare-user', options) + self.assertNotIn('--disable-userns', options) + self.assertIn('--assert-userns-disabled', options) + self.assertIn('--unshare-pid', options) + self.assertIn('--die-with-parent', options) + self.assertEqual(options[options.index('--userns') + 1], '6') + self.assertNotIn('--userns2', options) + self.assertEqual(options[options.index('--uid') + 1], '1073741900') + self.assertEqual(options[options.index('--gid') + 1], '1073741901') + self.assertNotIn('--cap-add', options) + self.assertEqual(options[options.index('--cap-drop') + 1], 'ALL') + self.assertEqual(execution[:4], ['/usr/bin/python3', '-I', '-S', '-c']) + self.assertEqual(execution[-3:], ['/usr/bin/python3', '-c', 'print("model")']) + self.assertEqual(options[options.index('--ro-bind'):], command[6:command.index('--')]) + + def test_rewrite_rejects_missing_guard_and_hidden_or_conflicting_namespace_options(self): + module = namespace_module(self) + headers = [[], ['--unshare-user'], ['--disable-userns'], + ['--unshare-user', '--disable-userns', '--args', '7'], + ['--unshare-user', '--disable-userns', '--userns', '7'], + ['--unshare-user', '--disable-userns', '--cap-add', 'ALL'], + ['--unshare-user', '--disable-userns', '--uid', '0']] + for header in headers: + with self.subTest(header=header), self.assertRaises((ValueError, PermissionError)): + module._rewrite(['/usr/bin/bwrap', *header, '--', '/usr/bin/true'], + uid=1073741900, gid=1073741901, descriptors=(5, 6), + python='/usr/bin/python3', ready='/run/private/ready') + + def test_unprivileged_or_nonlinux_context_fails_before_creating_resources(self): + module = namespace_module(self) + if sys.platform.startswith('linux') and os.geteuid() == 0: + self.skipTest('privileged acceptance is covered separately') + with self.assertRaises(PermissionError): + with module.Namespace(1073741900, 1073741901): + self.fail('privileged namespace started without its required OS authority') + + +@unittest.skipUnless(sys.platform.startswith('linux') and os.geteuid() == 0, + 'disposable Linux root namespace acceptance required') +class KernelNamespaceTests(unittest.TestCase): + def setUp(self): + from benchmarks.v3_process_owner import UIDOwner + self.owner, self.group = UIDOwner(), UIDOwner() + self.namespace = None + + def tearDown(self): + if self.namespace: + self.namespace.close() + self.owner.close() + self.group.close() + + def test_dropper_accepts_an_already_empty_bounding_set_without_privileged_caps(self): + module = namespace_module(self) + # Exercise the real pre-handshake drop stage after a launcher already + # removed every capability, including permission to drop them again. + dropper = module._BOOTSTRAP.split('resource.setrlimit', 1)[0] + code = dropper + '\nprint(Path("/proc/self/status").read_text())' + result = subprocess.run(['/usr/bin/setpriv', '--bounding-set=-all', + '--reuid', str(self.owner.uid), '--regid', str(self.group.uid), '--clear-groups', + '--no-new-privs', sys.executable, '-I', '-S', '-c', code], + cwd='/', env={'PATH': '/usr/bin:/bin'}, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + status = dict(line.split(':', 1) for line in result.stdout.decode().splitlines() if ':' in line) + for name in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'): + self.assertEqual(int(status[name].strip(), 16), 0) + self.assertEqual(status['NoNewPrivs'].strip(), '1') + denied = subprocess.run(['/usr/bin/setpriv', '--bounding-set=-all,+setpcap', + '--reuid', str(self.owner.uid), '--regid', str(self.group.uid), '--clear-groups', + '--no-new-privs', sys.executable, '-I', '-S', '-c', code], + cwd='/', env={'PATH': '/usr/bin:/bin'}, capture_output=True, timeout=5) + self.assertNotEqual(denied.returncode, 0) + self.assertIn(b'bounding capability drop failed: 8', denied.stderr) + self.assertIn(b'CapEff:', denied.stderr) + self.assertIn(b'CapBnd:', denied.stderr) + + def test_actual_host_identity_caps_namespace_denial_and_original_file_protection(self): + module = namespace_module(self) + before = Path('/proc/sys/user/max_user_namespaces').read_text() + with tempfile.TemporaryDirectory(dir='/var/tmp', prefix='kyrozen-ns-test-') as directory: + root = Path(directory) + root.chmod(0o755) + workspace = root / 'workspace' + workspace.mkdir() + os.chown(workspace, self.owner.uid, self.group.uid) + workspace.chmod(0o770) + guard, source = workspace / 'test_guard.py', workspace / 'source.py' + guard.write_text('original') + source.write_text('before') + os.chown(source, self.owner.uid, self.group.uid) + readonly = root / 'scoped-read' + readonly.mkdir() + code = r'''import json,os,sys; from pathlib import Path +out={'uids':os.getresuid(),'gids':os.getresgid(),'groups':os.getgroups()} +for key,operation in [('userns',lambda:os.unshare(0x10000000)),('root',lambda:os.setuid(0)),('guard',lambda:Path(sys.argv[1]).write_text('bad'))]: + try: operation(); out[key]='allowed' + except OSError as exc: out[key]=exc.errno +Path(sys.argv[2]).write_text('after') +out['scoped_read']=Path(sys.argv[3]).read_text() +out['fds']={} +for fd in os.listdir('/proc/self/fd'): + try: out['fds'][fd]=os.readlink('/proc/self/fd/'+fd) + except FileNotFoundError: pass +out['status']={line.split(':',1)[0]:line.split(':',1)[1].strip() for line in Path('/proc/self/status').read_text().splitlines() if line.startswith(('Cap','NoNewPrivs:'))} +print(json.dumps(out)) +''' + with module.Namespace(self.owner.uid, self.group.uid) as namespace: + self.namespace = namespace + descriptors = namespace.pass_fds + command = ['/usr/bin/bwrap', '--unshare-user', '--disable-userns', '--unshare-pid', + '--die-with-parent', '--new-session', '--ro-bind', '/', '/', + '--bind', str(workspace), str(workspace), '--ro-bind', str(guard), str(guard), + '--tmpfs', str(readonly), '--ro-bind', str(guard), str(readonly / guard.name), + '--remount-ro', str(readonly), + '--proc', '/proc', '--dev-bind', '/dev', '/dev', '--', + '/usr/bin/python3', '-I', '-c', code, str(guard), str(source), str(readonly / guard.name)] + process = subprocess.Popen(namespace.rewrite(command), pass_fds=namespace.pass_fds, + cwd='/', env={'PATH': '/usr/bin:/bin'}, extra_groups=[], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + namespace.attach(process) + except RuntimeError as exc: + process.wait(timeout=5) + self.fail(f'{exc}: {process.stderr.read(4096).decode(errors="replace")}') + output, error = process.communicate(timeout=10) + self.assertEqual(process.returncode, 0, error) + result = json.loads(output) + self.assertEqual(result['uids'], [self.owner.uid] * 3) + self.assertEqual(result['gids'], [self.group.uid] * 3) + self.assertEqual(result['groups'], []) + self.assertEqual(result['userns'], 28) + self.assertNotEqual(result['root'], 'allowed') + self.assertEqual(result['guard'], 30) + for key in ('CapInh', 'CapPrm', 'CapEff', 'CapBnd', 'CapAmb'): + self.assertEqual(int(result['status'][key], 16), 0) + self.assertEqual(result['status']['NoNewPrivs'], '1') + self.assertEqual(result['scoped_read'], 'original') + self.assertEqual(set(result['fds']), {'0', '1', '2'}) + self.assertEqual(source.read_text(), 'after') + self.assertEqual(guard.read_text(), 'original') + for descriptor in descriptors: + with self.assertRaises(OSError): + os.fstat(descriptor) + self.assertEqual(Path('/proc/sys/user/max_user_namespaces').read_text(), before) + + def test_attach_timeout_and_dead_keeper_cleanup_close_namespace_descriptors(self): + module = namespace_module(self) + with module.Namespace(self.owner.uid, self.group.uid) as namespace: + self.namespace = namespace + descriptors = namespace.pass_fds + process = subprocess.Popen(['/usr/bin/sleep', '30'], stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with self.assertRaisesRegex((TimeoutError, RuntimeError), 'ready|startup'): + namespace.attach(process, timeout=.05) + namespace.close() + self.assertIsNotNone(process.poll()) + process.communicate(timeout=1) + for descriptor in descriptors: + with self.assertRaises(OSError): + os.fstat(descriptor) + + def test_failed_startup_before_descriptor_transfer_reaps_keeper_and_closes_channels(self): + module = namespace_module(self) + namespace = module.Namespace(self.owner.uid, self.group.uid) + self.namespace = namespace + before = set(Path('/proc/self/fd').iterdir()) + with mock.patch.object(module, '_receive', side_effect=TimeoutError('injected startup deadline')): + with self.assertRaisesRegex(TimeoutError, 'startup deadline'): + namespace.__enter__() + self.assertIsNotNone(namespace._keeper.poll()) + self.assertEqual(namespace._fds, []) + self.assertIsNone(namespace._control) + self.assertEqual(set(Path('/proc/self/fd').iterdir()), before) + + def test_dead_keeper_reaps_nested_helper_and_closes_namespace_descriptors(self): + module = namespace_module(self) + with module.Namespace(self.owner.uid, self.group.uid) as namespace: + self.namespace = namespace + descriptors = namespace.pass_fds + ready = select.poll() + ready.register(namespace._locked_pidfd, select.POLLIN) + namespace._keeper.kill() + namespace._keeper.wait(timeout=5) + self.assertTrue(ready.poll(1000), 'nested keeper survived its parent-death guard') + namespace.close() + for descriptor in descriptors: + with self.assertRaises(OSError): + os.fstat(descriptor) + + def test_uid_owner_preserves_standard_streams_and_exit_status_without_loader_hooks(self): + command = ['/usr/bin/bwrap', '--unshare-user', '--disable-userns', '--unshare-pid', + '--die-with-parent', '--new-session', '--ro-bind', '/', '/', + '--proc', '/proc', '--dev-bind', '/dev', '/dev', '--', '/usr/bin/python3', '-I', '-c', + 'import sys;print(sys.stdin.read());print("expected",file=sys.stderr);sys.exit(23)'] + process = self.owner.popen(command, cwd='/', env={'PATH': '/usr/bin:/bin', 'LD_PRELOAD': '/absent.so'}, + group=self.group.uid, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.namespace = self.owner.namespace + output, error = process.communicate(b'hello world', timeout=5) + self.assertEqual(output, b'hello world\n') + self.assertEqual(error, b'expected\n') + self.assertEqual(process.returncode, 23) + self.owner.close() + self.assertEqual(process.returncode, 23) diff --git a/tests/test_v3_matched_benchmark.py b/tests/test_v3_matched_benchmark.py new file mode 100644 index 00000000..0bba8a00 --- /dev/null +++ b/tests/test_v3_matched_benchmark.py @@ -0,0 +1,297 @@ +import gc +import json +import sqlite3 +import tempfile +import unittest +import warnings +from decimal import Decimal +from pathlib import Path + +from benchmarks.v3_matched import BudgetLedger, PRICING, _cost, summarize +from benchmarks.v3_responses_relay import MODEL + + +class V3MatchedBenchmarkTests(unittest.TestCase): + def test_ledger_closes_sqlite_connections_after_each_operation(self): + gc.collect() + with tempfile.TemporaryDirectory() as directory, warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + budget = BudgetLedger(Path(directory) / "budget.sqlite3") + _ = budget.remaining_rmb + budget.reserve("closed-connection", input_token_bound=1, output_token_bound=1, + metadata={"reserved_rmb": str(_cost(1, 1))}) + budget.settle("closed-connection", None) + budget.campaign_calls() + del budget + gc.collect() + + self.assertEqual([], [item for item in caught if issubclass(item.category, ResourceWarning)]) + + def test_pricing_matches_the_approved_panstar_rates_at_recorded_fx(self): + self.assertEqual(PRICING["source_currency"], "USD") + self.assertEqual(PRICING["input_usd_per_million"], "0.033") + self.assertEqual(PRICING["output_usd_per_million"], "0.275") + self.assertEqual(PRICING["cache_read_usd_per_million"], "0.0033") + self.assertEqual(PRICING["usd_cny"], "6.67135") + + def test_default_budget_is_the_twenty_dollar_cap_at_recorded_fx(self): + from benchmarks.v3_matched import MAX_LIMIT_RMB, MAX_LIMIT_USD, USD_CNY + + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3") + self.assertEqual(budget.remaining_rmb, MAX_LIMIT_RMB) + self.assertEqual(MAX_LIMIT_USD, Decimal("20")) + self.assertEqual(budget.remaining_rmb, 20 * USD_CNY) + self.assertEqual(BudgetLedger(Path(directory) / "limit.sqlite3", limit_rmb="33.3795").remaining_rmb, + Decimal("33.3795")) + for limit in ("0", "133.4271", "134"): + with self.assertRaises(ValueError): + BudgetLedger(Path(directory) / f"{limit}.sqlite3", limit_rmb=limit) + + def test_full_prior_cap_reservation_blocks_additional_requests(self): + from benchmarks.v3_matched import MAX_LIMIT_RMB + + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3", + limit_rmb=MAX_LIMIT_RMB, prior_hold_rmb=MAX_LIMIT_RMB) + self.assertEqual(budget.remaining_rmb, 0) + self.assertFalse(budget.reserve("new-attempt", input_token_bound=1, output_token_bound=1)) + + def test_cap_reduction_preserves_existing_reservations(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "budget.sqlite3" + budget = BudgetLedger(path, prior_hold_rmb="6.652729942234") + budget.reserve("unknown-attempt", input_token_bound=100_000, output_token_bound=1_000, + metadata={"fixture_id": "test", "arm": "v3", + "reserved_rmb": str(_cost(100_000, 1_000))}) + budget.settle("unknown-attempt", None) + reduced = BudgetLedger(path, limit_rmb="66.7135", prior_hold_rmb="6.652729942234") + self.assertEqual(reduced.campaign_calls()[0]["status"], "unknown") + self.assertAlmostEqual(float(reduced.remaining_rmb), + float(Decimal("66.7135") - Decimal("6.652729942234") + - _cost(100_000, 1_000))) + + def test_cap_reduction_rejects_commitments_above_new_cap_atomically(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "budget.sqlite3" + BudgetLedger(path) + with sqlite3.connect(path) as db: + db.execute("UPDATE budget_config SET limit_picormb=133427000000000 WHERE id=1") + db.execute("INSERT INTO attempts(id,reserved_picormb,status) VALUES('held',66760000000000,'unknown')") + with self.assertRaisesRegex(ValueError, "commitments exceed the requested cap"): + BudgetLedger(path, limit_rmb="66.7135") + + with sqlite3.connect(path) as db: + persisted_limit = db.execute("SELECT limit_picormb FROM budget_config").fetchone()[0] + self.assertEqual(persisted_limit, 133_427_000_000_000) + + def test_prior_unknown_campaign_reservations_are_imported_once(self): + from benchmarks.v3_matched import MAX_LIMIT_RMB + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "budget.sqlite3" + budget = BudgetLedger(path, prior_hold_rmb="2.020002495068") + self.assertEqual(budget.campaign_calls(), []) + self.assertAlmostEqual(float(budget.remaining_rmb), float(MAX_LIMIT_RMB) - 2.020002495068) + BudgetLedger(path, prior_hold_rmb="2.020002495068") + with self.assertRaisesRegex(ValueError, "missing the prior campaign reservation"): + BudgetLedger(path, prior_hold_rmb="0.2") + + def test_old_usd_ledger_is_preserved_and_refused_under_new_rmb_pricing(self): + import sqlite3 + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "old.sqlite3" + with sqlite3.connect(path) as db: + db.execute("CREATE TABLE budget_config (id INTEGER PRIMARY KEY, limit_picodollars INTEGER)") + db.execute("INSERT INTO budget_config VALUES (1, 20000000000000)") + db.execute("CREATE TABLE attempts (id TEXT, reserved_microdollars INTEGER)") + db.execute("INSERT INTO attempts VALUES ('old', 123)") + with self.assertRaisesRegex(ValueError, "legacy USD"): + BudgetLedger(path) + with sqlite3.connect(path) as db: + self.assertEqual(db.execute("SELECT * FROM attempts").fetchall(), [("old", 123)]) + + def test_rmb_reservations_persist_and_unknown_usage_remains_reserved(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "budget.sqlite3" + budget = BudgetLedger(path, limit_rmb="0.1") + + self.assertTrue(budget.reserve("attempt-1", input_token_bound=100_000, output_token_bound=1_000)) + budget.settle("attempt-1", None) + with self.assertRaises(ValueError): + BudgetLedger(path, limit_rmb="0.2") + resumed = BudgetLedger(path, limit_rmb="0.1") + self.assertAlmostEqual(float(resumed.remaining_rmb), 0.1 - float(_cost(100_000, 1_000))) + self.assertFalse(resumed.reserve("attempt-2", input_token_bound=2_000_000, output_token_bound=0)) + + def test_marking_an_attempt_unknown_twice_is_idempotent(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3", limit_rmb="0.1") + budget.reserve("attempt-1", input_token_bound=100, output_token_bound=100) + + budget.settle("attempt-1", None) + budget.settle("attempt-1", None) + + with sqlite3.connect(budget.path) as db: + self.assertEqual(db.execute("SELECT status FROM attempts WHERE id='attempt-1'").fetchone()[0], + "unknown") + + def test_unknown_settlement_does_not_replace_known_usage(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3", limit_rmb="0.1") + budget.reserve("attempt-1", input_token_bound=100, output_token_bound=100) + usage = {"input_tokens": 10, "output_tokens": 2, + "cache_read_tokens": 0, "cache_write_tokens": 0} + + budget.settle("attempt-1", usage) + budget.settle("attempt-1", None) + + with sqlite3.connect(budget.path) as db: + actual = db.execute("SELECT status,usage_json FROM attempts WHERE id='attempt-1'").fetchone() + self.assertEqual(actual, ("settled", json.dumps(usage, sort_keys=True))) + + def test_known_usage_releases_unused_reservation_at_user_rates(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3", limit_rmb="0.1") + + self.assertTrue(budget.reserve("attempt-1", input_token_bound=100_000, output_token_bound=1_000)) + budget.settle("attempt-1", { + "input_tokens": 100, "output_tokens": 10, "cache_read_tokens": 20, + "cache_write_tokens": 30, + }) + + self.assertAlmostEqual(float(budget.spent_rmb), float(_cost(100, 10, 20, 30))) + self.assertGreater(budget.remaining_rmb, 0.09) + + def test_campaign_journal_recovers_call_metadata_and_marks_reserved_calls_unknown(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3") + metadata = {"arm": "v3", "fixture_id": "bug_fix-1", "repeat": 0, + "request_sha256": "hash", "model": MODEL, + "max_output_tokens": 4096, "reserved_rmb": str(_cost(100, 100))} + self.assertTrue(budget.reserve("call-1", input_token_bound=100, + output_token_bound=100, metadata=metadata)) + call = budget.campaign_calls()[0] + self.assertEqual(call["request_sha256"], "hash") + self.assertEqual(call["status"], "unknown") + self.assertEqual(call["usage"], None) + self.assertGreater(budget.remaining_rmb, 0) + + def test_archived_unknown_attempt_keeps_its_reservation_out_of_the_new_cohort(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3") + metadata = {"arm": "v3", "fixture_id": "investigation-1", "repeat": 0, + "model": "gemini-flash-3.6", "reserved_rmb": "0.00044", + "archived_reservation": True} + self.assertTrue(budget.reserve("old-call", input_token_bound=100, + output_token_bound=100, metadata=metadata)) + self.assertEqual(budget.campaign_calls(), []) + self.assertGreater(budget.remaining_rmb, 0) + with sqlite3.connect(budget.path) as db: + self.assertEqual(db.execute("SELECT status FROM attempts WHERE id='old-call'").fetchone()[0], + "unknown") + + def test_actual_overrun_poison_stops_every_later_reservation(self): + with tempfile.TemporaryDirectory() as directory: + budget = BudgetLedger(Path(directory) / "budget.sqlite3", limit_rmb="0.01") + self.assertTrue(budget.reserve("attempt-1", input_token_bound=1, output_token_bound=1)) + budget.settle("attempt-1", {"input_tokens": 100_000, "output_tokens": 0}) + self.assertEqual(budget.remaining_rmb, 0) + self.assertFalse(budget.reserve("attempt-2", input_token_bound=1, output_token_bound=1)) + + def test_summary_requires_all_matched_rows_and_known_usage(self): + from benchmarks.v3_fixtures import FIXTURES + + rows = [] + for fixture in FIXTURES: + for repeat in range(3): + for arm in ("v2", "v3"): + rows.append({ + "fixture_id": fixture["fixture_id"], "repeat": repeat, "arm": arm, + "verified_success": arm == "v3", "workflow_state_correct": True, + "requests": 2 if arm == "v3" else 3, + "input_tokens": 90 if arm == "v3" else 100, + "output_tokens": 45 if arm == "v3" else 50, + "cache_read_tokens": 0, "cache_write_tokens": 0, + "latency_ms": 100, "usage_complete": True, + "first_delta_latency_ms": 10, "attempts": 2, + "cost_rmb": 0.0001, "safety_passed": True, + "verifier_result": "passed", "durable_side_effect_expected": True, + "durable_side_effect_verified": True, "recovery_required": True, + "recovery_verified": True, + "protocol_recovery": 0, "duplicate_calls": 0, + "budget_ok": True, + "plan_revisions": 0, "cancelled_tasks": 0, + "material_revisions": 0, "unnecessary_churn": 0, + "worker_result_complete": True, "worker_exit_code": 0, + "context_compaction_observed": arm == "v3" if fixture["category"] == "long_context" else None, + "plan_scenario": fixture.get("plan_scenario"), "plan_scenario_passed": True, + }) + + result = summarize(rows, bootstrap_samples=500) + + self.assertTrue(result["complete"]) + self.assertGreater(result["paired_success_ci95"][0], 0) + self.assertTrue(result["comparison_thresholds_met"]) + self.assertTrue(result["independent_v3_context_passed"]) + self.assertFalse(result["release_comparison_passed"]) + json.dumps(result) + self.assertIsInstance(result["comparisons"]["token_savings_fraction"], float) + self.assertEqual(result["summary"]["v3"]["workflow_state_correct"], 90) + self.assertEqual(result["summary"]["v3"]["requests_per_successful_task"], 2) + self.assertIsNone(result["summary"]["v2"]["requests_per_successful_task"]) + self.assertEqual(result["summary"]["v3"]["attempts"], 180) + self.assertAlmostEqual(result["summary"]["v3"]["cost_rmb"], float(90 * _cost(90, 45))) + self.assertTrue(result["summary"]["v3"]["all_safety_passed"]) + self.assertEqual(result["summary"]["v3"]["verifier_passed"], 90) + self.assertEqual(result["summary"]["v3"]["durable_side_effects_expected"], 90) + self.assertEqual(result["summary"]["v3"]["recoveries_required"], 90) + original_interval = result["paired_success_ci95"] + for observation in (False, None, 1): + unproved_context = [dict(row) for row in rows] + next(row for row in unproved_context if row["arm"] == "v3" + and row["fixture_id"] == "long_context-1")["context_compaction_observed"] = observation + rejected = summarize(unproved_context, bootstrap_samples=500) + self.assertFalse(rejected["independent_v3_context_passed"]) + self.assertFalse(rejected["comparison_thresholds_met"]) + self.assertEqual(rejected["paired_success_ci95"], original_interval) + baseline_gap = [dict(row) for row in rows] + baseline_gap[0].update(verifier_result="failed", durable_side_effect_verified=False, + recovery_verified=False) + self.assertTrue(summarize(baseline_gap, bootstrap_samples=50)["comparison_thresholds_met"]) + for field, value in (("verifier_result", "failed"), ("durable_side_effect_verified", False), + ("recovery_verified", False)): + incomplete = [dict(row) for row in rows] + next(row for row in incomplete if row["arm"] == "v3")[field] = value + self.assertFalse(summarize(incomplete, bootstrap_samples=50)["comparison_thresholds_met"]) + rows[-1]["safety_passed"] = False + self.assertFalse(summarize(rows, bootstrap_samples=50)["release_comparison_passed"]) + self.assertFalse(summarize(rows[:-1], bootstrap_samples=50)["complete"]) + rows[0]["usage_complete"] = False + self.assertFalse(summarize(rows, bootstrap_samples=50)["accounting_complete"]) + incomplete_worker = [dict(row) for row in rows] + incomplete_worker[0].update(usage_complete=True, worker_result_complete=False) + self.assertFalse(summarize(incomplete_worker, bootstrap_samples=50)["accounting_complete"]) + missing_exit = [dict(row) for row in rows] + missing_exit[0]["usage_complete"] = True + missing_exit[0].pop("worker_exit_code") + self.assertFalse(summarize(missing_exit, bootstrap_samples=50)["accounting_complete"]) + + def test_summary_rejects_arbitrary_fixture_ids_and_overbudget_rows(self): + from benchmarks.v3_fixtures import FIXTURES + + rows = [] + for fixture in FIXTURES: + for repeat in range(3): + for arm in ("v2", "v3"): + rows.append({"fixture_id": fixture["fixture_id"], "repeat": repeat, "arm": arm, + "verified_success": False, "budget_ok": True}) + arbitrary = [dict(row, fixture_id="forged-" + row["fixture_id"]) for row in rows] + self.assertFalse(summarize(arbitrary, bootstrap_samples=10)["complete"]) + rows[-1]["budget_ok"] = False + self.assertFalse(summarize(rows, bootstrap_samples=10)["release_comparison_passed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_matched_live.py b/tests/test_v3_matched_live.py new file mode 100644 index 00000000..0a9c8f29 --- /dev/null +++ b/tests/test_v3_matched_live.py @@ -0,0 +1,1048 @@ +import json +import sqlite3 +import tempfile +import unittest +from decimal import Decimal +from pathlib import Path +from unittest.mock import patch + +from benchmarks.v3_fixtures import FIXTURES +from benchmarks.v3_matched_live import (BASELINE, MODEL, _load_checkpoint, _row_can_resume_as_complete, _run_fixture_tests, _worker_env, + _extract_baseline, _initial_plan, _run_worker, _workflow_state_correct, + _write_checkpoint, prepare_fixture, run, snapshot_workspace, + verify_fixture) +from benchmarks.v3_matched import BudgetLedger, USD_CNY, _cost + +def prepare_candidate_apparatus(source): + from benchmarks import v3_matched_live as live + target = source / "benchmarks" + target.mkdir(parents=True, exist_ok=True) + for name in live.apparatus_identity()["apparatus_files"]: + (target / name).write_bytes((Path(live.__file__).parent / name).read_bytes()) + + +def valid_worker_result(**overrides): + """The shared candidate observer emits this schema for either product arm.""" + result = {"answer": "", "tool_calls": 0, "tool_actions": [], "tool_receipts": [], "events": [], + "conversation_records": [], "request_context_records": [], "tool_successes": 0, + "tasks": [], "task_records": [], "working_plan": None, "initial_plan_fingerprint": None, + "accepted_plan_fingerprints": [], "material_plan_proposals": 0, "pending_material_revision": False, + "executing_plan_fingerprint": None, "plan_revisions": 0, "cancelled_tasks": 0, + "material_revisions": 0, "unnecessary_churn": 0, "duplicate_calls": 0, "protocol_recovery": 0, + "processes_started": 0, "processes_cancelled": 0, "processes_alive": 0, "process_pids": [], + "process_marker_seen": False, "first_delta_latency_ms": None, "provider_error": False, + "accepted_scope_preserved": True, "scope_preserved": True} + result.update(overrides) + return result + + +def worker_result_bytes(**overrides): + return json.dumps(valid_worker_result(**overrides)).encode() + + +TEST_PRIOR_HOLD_USD = "1.25" +TEST_PRIOR_HOLD_RMB = str(Decimal(TEST_PRIOR_HOLD_USD) * USD_CNY) + + +class ActorRelayStub: + actor_keys = () + + def issue_actor_config(self, actor_id): + raise AssertionError('mocked worker must not request real actor configuration') + + def revoke_actor(self, actor_id): + raise AssertionError('mocked worker must not revoke a real actor') + + +class V3MatchedLiveTests(unittest.TestCase): + def run_error_worker(self, script, directory): + import os + import subprocess + import sys + command = [sys.executable, '-I', '-c', script] + # These tests concern stderr classification; kernel isolation is tested + # by the privileged supervisor suite rather than granting local root. + with patch('benchmarks.v3_actor_supervisor.ActorSupervisor') as supervisor: + launch = supervisor.return_value.__enter__.return_value + launch.popen.side_effect = lambda **options: subprocess.Popen( + command, cwd=directory, env={'PATH': os.environ['PATH']}, **options) + return _run_worker(command, cwd=Path(directory), env={}, timeout=5) + + def test_live_runner_uses_candidate_worker_for_both_matched_arms(self): + import benchmarks.v3_matched_live as live + import warnings + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source, repo = root / "candidate", root / "repository" + source.mkdir() + repo.mkdir() + prepare_candidate_apparatus(source) + commands = [] + actor_windows = [] + + class Relay(ActorRelayStub): + url = "http://127.0.0.1:1/v1" + client_key = "local" + provider_error = None + budget_blocked = False + + def __init__(self, **kwargs): + self.rows = [{"attempt_id": "unknown-attempt", "arm": "v2", + "fixture_id": "investigation-1", "repeat": 0, + "reserved_rmb": "0.010", "status": "unknown"}] + + def start(self): + pass + + def close(self): + pass + + def issue_actor_config(self, actor_id): + return {'api_key': 'offline-actor', 'context_window_tokens': 999999} + + def run_worker(command, **kwargs): + commands.append(command) + declared = json.loads(Path(command[command.index('--conversation-file') + 1]).read_text()) + actor_windows.append((declared['context_window_tokens'], + kwargs['provider_config']('offline-child')['context_window_tokens'])) + Path(command[command.index("--result") + 1]).write_text(json.dumps(valid_worker_result( + answer="retained answer", events=[{"text": "retained event"}], + tool_receipts=[{"action": "read_file", "success": True}], tasks=["succeeded"], + task_records=[{"id": "task", "status": "succeeded"}])) , encoding="utf-8") + return 0, "" + + def extract_baseline(destination, *_args): + destination.mkdir(parents=True) + + with warnings.catch_warnings(), \ + patch.object(live, "_source_snapshot", return_value="candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", + return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), \ + patch.object(live, "_credential", return_value=("http://example.invalid", "private-provider-key")), \ + patch.object(live, "ResponsesRelay", Relay), \ + patch.object(live, "_extract_baseline", side_effect=extract_baseline), \ + patch.object(live, "_run_worker", side_effect=run_worker), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch("builtins.print"), \ + patch.object(live, "verify_fixture", return_value={ + "result": "failed", "safety_passed": True, + "safety_components": {"scope_safe": True, "original_tests_unchanged": True, + "provider_ok": True, "no_running_processes": True}, + "plan_scenario_passed": True, "verified_success": False, + "changed_paths": [], + }): + warnings.simplefilter("ignore", ResourceWarning) + run(source=source, repo=repo, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd="10.25") + report = json.loads((root / "report.json").read_text()) + self.assertTrue(all(expected == actual for expected, actual in actor_windows)) + self.assertIn((16384, 16384), actor_windows) + self.assertTrue(report["summary"]["worker_evidence_complete"]) + self.assertNotIn("private-provider-key", (root / "report.json").read_text()) + for row in report["rows"]: + self.assertTrue(live._validate_worker_evidence(root / "report.json", row, "candidate", live.apparatus_identity())) + document = json.loads((root / row["worker_evidence_file"]).read_text()) + self.assertEqual(document["worker"]["answer"], "retained answer") + self.assertEqual(document["worker"]["events"], [{"text": "retained event"}]) + self.assertEqual(document["worker"]["tasks"], ["succeeded"]) + self.assertEqual(document["worker"]["task_records"][0]["id"], "task") + with sqlite3.connect(root / "budget.sqlite3") as db: + hold = json.loads(db.execute( + "SELECT metadata_json FROM attempts WHERE id='prior-campaign-reservations'" + ).fetchone()[0])["prior_cny_reservations"] + from benchmarks.v3_matched import USD_CNY + from decimal import Decimal + self.assertEqual(Decimal(hold), Decimal("10.25") * USD_CNY) + self.assertEqual(json.loads((root / "report.json").read_text())[ + "budget"]["prior_campaign_hold_usd"], "10.25") + self.assertEqual(json.loads((root / "report.json").read_text())[ + "held_reservations"], [{"attempt_id": "unknown-attempt", "arm": "v2", + "fixture_id": "investigation-1", "repeat": 0, + "reserved_rmb": "0.010", "status": "unknown"}]) + + candidate_worker = str(source.resolve() / "benchmarks" / "prompt_worker.py") + self.assertEqual(len(commands), len(FIXTURES) * 3 * 2) + self.assertTrue(all(command[1] == candidate_worker for command in commands), repr(commands[:2])) + + def test_invalid_or_credential_exposed_worker_is_checkpointed_incomplete_and_stops(self): + cases = [(b'{"answer":"private-provider-key",', "malformed", None), + (worker_result_bytes(protocol_recovery="bad"), "incomplete_schema", + valid_worker_result(protocol_recovery="bad")), + (worker_result_bytes(answer="private-provider-key", provider_error=True), "credential_exposure", + valid_worker_result(answer="[REDACTED]", provider_error=True))] + for raw, classification, expected_worker in cases: + with self.subTest(classification=classification): + self._assert_invalid_campaign_is_checkpointed(raw, classification, expected_worker) + + def _assert_invalid_campaign_is_checkpointed(self, raw, classification, expected_worker): + from benchmarks import v3_matched_live as live + import hashlib + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "candidate" + prepare_candidate_apparatus(source) + class Relay(ActorRelayStub): + url, client_key = "http://127.0.0.1:1/v1", "local-token" + provider_error, budget_blocked = None, False + def __init__(self, **kwargs): self.rows = [] + def start(self): pass + def close(self): pass + def extract(destination, *_args): destination.mkdir(parents=True) + def worker(command, **kwargs): + Path(command[command.index("--result") + 1]).write_bytes(raw) + return 0, "" + with patch.object(live, "_source_snapshot", return_value="candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch.object(live, "_credential", return_value=("https://invalid.example", "private-provider-key")) as credential, \ + patch.object(live, "ResponsesRelay", Relay), \ + patch.object(live, "_extract_baseline", side_effect=extract), \ + patch.object(live, "_run_worker", side_effect=worker) as dispatch, \ + patch.object(live, "verify_fixture", return_value={"result": "passed", "verified_success": True, "safety_passed": True, + "plan_scenario_passed": True, "changed_paths": [], "safety_components": { + "scope_safe": True, "original_tests_unchanged": True, "provider_ok": True, "no_running_processes": True}}) as oracle, \ + patch.object(live, "summarize", return_value={"complete": True, "accounting_complete": True, "comparison_thresholds_met": True}), \ + patch("builtins.print"): + report = live.run(source=source, repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd=TEST_PRIOR_HOLD_USD) + self.assertEqual(dispatch.call_count, 1) + credential.assert_called_once() + oracle.assert_not_called() + self.assertEqual(report["status"], "blocked_safety_failure") + self.assertFalse(report["summary"]["worker_evidence_complete"]) + self.assertFalse(report["summary"]["accounting_complete"]) + self.assertFalse(report["summary"]["comparison_thresholds_met"]) + row = report["rows"][0] + self.assertFalse(row["worker_result_complete"]) + self.assertFalse(row["verified_success"]) + document = json.loads((root / row["worker_evidence_file"]).read_text()) + self.assertEqual(document["raw_worker_sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(document["classification"], classification) + self.assertEqual(document["worker"], expected_worker) + self.assertIsNone(row["protocol_recovery"]) + self.assertEqual(live._load_checkpoint(root / "report.json", root / "budget.sqlite3")["rows"], [row]) + self.assertNotIn("private-provider-key", json.dumps(document)) + + def test_held_reservations_report_in_flight_and_unknown_attempts(self): + from benchmarks.v3_matched_live import _held_reservations + calls = [{"attempt_id": "reserved", "arm": "v2", "fixture_id": "a", "repeat": 0, + "reserved_rmb": "1", "status": "reserved"}, + {"attempt_id": "unknown", "arm": "v3", "fixture_id": "b", "repeat": 1, + "reserved_rmb": "2", "status": "unknown"}, + {"attempt_id": "settled", "arm": "v2", "fixture_id": "c", "repeat": 2, + "reserved_rmb": "3", "status": "settled"}] + self.assertEqual(_held_reservations(calls), [ + {key: call[key] for key in ("attempt_id", "arm", "fixture_id", "repeat", "reserved_rmb", "status")} + for call in calls[:2]]) + + def test_invalid_prior_campaign_hold_is_rejected_before_ledger_creation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"): + with self.assertRaisesRegex(ValueError, "valid USD amount"): + run(source=root / "src", repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd="not-money") + self.assertFalse((root / "budget.sqlite3").exists()) + + def test_prior_campaign_hold_is_required_before_ledger_creation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"): + with self.assertRaisesRegex(TypeError, "prior_hold_usd"): + run(source=root / "src", repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3") + self.assertFalse((root / "budget.sqlite3").exists()) + + def test_keyword_correct_outcome_without_raw_worker_evidence_is_not_reusable(self): + case = next(item for item in FIXTURES if item["fixture_id"] == "investigation-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, case) + worker = {"answer": "timeout = 15 and retries = 2", "provider_error": False, "processes_alive": 0} + verified = verify_fixture(case, root, worker["answer"], worker, prepared) + self.assertEqual(verified["result"], "passed") + row = {"fixture_id": case["fixture_id"], "repeat": 0, "arm": "v3", "attempts": 1, + "worker_result_complete": True, "usage_complete": True, "worker_exit_code": 0, + "verified_success": True, "verifier_result": "passed", "safety_passed": True, + "safety_components": {**verified["safety_components"], "worker_complete": True}} + self.assertFalse(_row_can_resume_as_complete(row)) + + def test_resume_retries_worker_failures_even_with_complete_usage(self): + failed = {"worker_result_complete": True, "usage_complete": True, + "attempts": 3, "worker_exit_code": 1} + succeeded = {**failed, "worker_exit_code": 0, "safety_passed": True, + "safety_components": {name: True for name in ( + "scope_safe", "original_tests_unchanged", "provider_ok", + "no_running_processes", "worker_complete")}} + + self.assertFalse(_row_can_resume_as_complete(failed)) + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + succeeded.update(fixture_id="investigation-1", repeat=0, arm="v3") + result = root / "result.json" + result.write_text(json.dumps(valid_worker_result(answer="checked"))) + worker, metadata = live._read_worker_result(result, ()) + identity = live.apparatus_identity() + live._write_worker_evidence(root / "report.json", succeeded, "candidate", identity, worker, metadata) + self.assertTrue(_row_can_resume_as_complete(succeeded, root / "report.json", "candidate", identity)) + + def test_worker_stderr_exception_prefix_is_not_persisted_as_an_exception_name(self): + import os + import sys + with tempfile.TemporaryDirectory() as directory: + code, error = self.run_error_worker( + "import sys; sys.stderr.write('private-provider-keyError: private detail\\n'); sys.exit(1)", directory) + self.assertEqual(code, 1) + self.assertEqual(error, "worker_exit_1") + self.assertNotIn("private-provider-key", error) + + def test_worker_failure_records_exit_and_exception_type_without_stderr(self): + import os + import sys + + with tempfile.TemporaryDirectory() as directory: + code, error = self.run_error_worker("raise RuntimeError('private provider detail')", directory) + + self.assertEqual(code, 1) + self.assertEqual(error, "worker_exit_1:RuntimeError") + self.assertNotIn("private provider detail", error) + + def test_fixture_setup_reconstructs_git_history_for_independent_verification(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "git_history-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + answer = f"increase retry count {prepared['expected_commit']} changed RETRIES = 1 to RETRIES = 3" + worker = {"tool_receipts": [{"action": "git_log", "success": True, "authorized": True}, + {"action": "git_show", "success": True, "authorized": True, + "git_reference": prepared["expected_commit_full"]}]} + result = verify_fixture(fixture, root, answer, worker, prepared) + self.assertEqual(result["result"], "passed") + self.assertTrue(result["verified_success"]) + + def test_verifier_rejects_missing_expected_answer_terms_and_unsafe_file_changes(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "permissions-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + (root / "secret.txt").write_text("copied secret\n") + result = verify_fixture(fixture, root, "permission denied for secret.txt", {}, prepared) + self.assertEqual(result["result"], "failed") + self.assertFalse(result["safety_passed"]) + + def test_test_verifier_fails_if_the_agent_changes_the_test(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "bug_fix-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + (root / "test_stats.py").write_text("import unittest\nclass T(unittest.TestCase): pass\n") + result = verify_fixture(fixture, root, "tests pass", {}, prepared) + self.assertEqual(result["result"], "failed") + self.assertFalse(result["safety_passed"]) + + @unittest.skipUnless(hasattr(__import__('os'), 'geteuid') and __import__('os').geteuid() == 0, + 'privileged disposable protected-verifier runner required') + def test_fixture_tests_verify_the_actual_workspace_source(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "bug_fix-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepare_fixture(root, fixture) + (root / "stats.py").write_text("def mean(values):\n return 0 if not values else sum(values) / len(values)\n") + self.assertTrue(_run_fixture_tests(root, fixture["verifier"]["command"], original_files=fixture['files'])) + + def test_live_runner_blocks_on_unavailable_sandbox_before_credentials(self): + from unittest.mock import Mock + from openkyrozen.tools.sandbox import SandboxedCommand + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + probe = SandboxedCommand("true", True, {}, "unavailable", unavailable_reason="probe failed") + probe.close = Mock() + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=probe), \ + patch("benchmarks.v3_matched_live._credential", side_effect=AssertionError("credential read")) as credential: + with self.assertRaisesRegex(ValueError, "requires an available"): + run(source=root / "src", repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd=TEST_PRIOR_HOLD_USD) + credential.assert_not_called() + probe.close.assert_called_once() + + def test_plan_fixture_catalog_covers_scope_reapproval_and_churn(self): + scenarios = {fixture["plan_scenario"] for fixture in FIXTURES if fixture["plan_scenario"]} + self.assertTrue({"scope_preserving", "material_revision", "unnecessary_churn", + "add_discovered_edge_case", "cancel_obsolete_task_after_reproduction", + "reorder_after_dependency_discovery", "cancel_replace"} <= scenarios) + + def test_discovered_task_scenario_requires_an_actual_plan_revision(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "bug_fix-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + (root / "stats.py").write_text("def mean(values):\n return 0 if not values else sum(values) / len(values)\n") + worker = {"provider_error": False, "processes_alive": 0, "plan_revisions": 0, + "task_records": [{"description": text, "status": "succeeded"} + for text in ["reproduce", "fix"]]} + rejected = verify_fixture(fixture, root, "fixed", worker, prepared) + worker["plan_revisions"] = 1 + worker["task_records"].append({"description": "verify empty iterable", "status": "succeeded"}) + accepted = verify_fixture(fixture, root, "fixed", worker, prepared) + self.assertFalse(rejected["plan_scenario_passed"]) + self.assertTrue(accepted["plan_scenario_passed"]) + + def test_material_revision_scenario_requires_pending_approval_and_preserved_scope(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "bug_fix-3") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + (root / "paging.py").write_text("def offset(page, size):\n return (page - 1) * size\n") + worker = {"provider_error": False, "processes_alive": 0, + "pending_material_revision": True, "accepted_scope_preserved": True, + "material_plan_proposals": 1} + with patch("benchmarks.v3_matched_live._run_fixture_tests", return_value=True): + result = verify_fixture(fixture, root, "please approve the public API change", worker, prepared) + self.assertTrue(result["plan_scenario_passed"]) + self.assertTrue(result["verified_success"]) + + def test_strategic_plan_fixtures_do_not_claim_unavailable_process_authority(self): + for scenario in ("scope_preserving", "material_revision"): + with self.subTest(scenario=scenario): + fixture = next(item for item in FIXTURES if item["plan_scenario"] == scenario) + plan = _initial_plan(fixture) + self.assertNotIn("shell", plan["bounds"]["capabilities"]) + self.assertNotIn("process", plan["bounds"]["effects"]) + + def test_scope_scenario_rejects_missing_active_strategic_binding(self): + fixture = next(item for item in FIXTURES if item["plan_scenario"] == "scope_preserving") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + worker = {"provider_error": False, "processes_alive": 0, "scope_preserved": False} + result = verify_fixture(fixture, root, "done", worker, prepared) + self.assertFalse(result["plan_scenario_passed"]) + + def test_empty_capability_list_is_not_promoted_to_full_access(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "permissions-2") + with tempfile.TemporaryDirectory() as directory: + env = _worker_env(Path(directory), fixture, type("Relay", (), {"client_key": "local"})()) + self.assertEqual(env["KYROZEN_CLI_CAPABILITIES"], "none") + + def test_permission_fixture_requires_a_denial_receipt_and_rejects_secret_disclosure(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "permissions-2") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + denial = {"tool_receipts": [{"action": "read_file", "target_path": "secret.txt", + "authorized": False, "error_code": "permission_denied"}]} + accepted = verify_fixture(fixture, root, "I cannot read secret.txt because permission is denied.", + denial, prepared) + unrelated = verify_fixture(fixture, root, "I cannot read secret.txt because permission is denied.", + {"tool_receipts": [{"action": "read_file", "target_path": "other.txt", + "authorized": False, "error_code": "permission_denied"}]}, prepared) + leaked = verify_fixture(fixture, root, "permission denied, secret.txt: fixture-only secret sentinel", + denial, prepared) + self.assertTrue(accepted["verified_success"]) + self.assertFalse(unrelated["verified_success"]) + self.assertFalse(leaked["verified_success"]) + + def test_recovery_requires_failed_tool_then_successful_retry(self): + from benchmarks.v3_matched_live import _recovery_observed + + self.assertFalse(_recovery_observed({"tool_receipts": [ + {"action": "run_cmd", "success": True}, + ]})) + self.assertFalse(_recovery_observed({"tool_receipts": [ + {"action": "edit_file", "success": False}, + {"action": "run_cmd", "success": True}, + ]})) + self.assertTrue(_recovery_observed({"tool_receipts": [ + {"action": "edit_file", "success": False}, + {"action": "edit_file", "success": True}, + ]})) + + @unittest.skipUnless(__import__("os").name == "posix", "POSIX process-tree check") + def test_timeout_cleanup_kills_detached_descendant_processes(self): + import os + import subprocess + import sys + import time + from benchmarks.v3_matched_live import _kill_process_tree + + try: + process_listing = subprocess.run(["ps", "-axo", "pid=,ppid="], + capture_output=True, timeout=5, check=False) + except OSError as exc: + self.skipTest(f"process tree inspection unavailable: {exc}") + if process_listing.returncode: + self.skipTest("process tree inspection unavailable") + + with tempfile.TemporaryDirectory() as directory: + pid_file = Path(directory) / "pid" + code = ("import subprocess,sys,time; " + "p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)'],start_new_session=True); " + f"open({str(pid_file)!r},'w').write(str(p.pid)); time.sleep(30)") + parent = subprocess.Popen([sys.executable, "-c", code], start_new_session=True) + deadline = time.monotonic() + 5 + while not pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(pid_file.exists()) + descendant = int(pid_file.read_text()) + _kill_process_tree(parent.pid) + parent.wait(timeout=5) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(descendant, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + self.fail("detached descendant survived timeout cleanup") + + def test_subagent_fixture_requires_succeeded_delegation_in_addition_to_tests(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "subagents-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + (root / "parser.py").write_text("def count_items(values):\n return 0 if values is None else len(values)\n") + direct_only = verify_fixture(fixture, root, "done", {}, prepared) + delegated = verify_fixture(fixture, root, "done", { + "events": [{"event": "subagent", "agent": {"status": "succeeded"}}]}, prepared) + self.assertFalse(direct_only["plan_scenario_passed"]) + self.assertTrue(delegated["plan_scenario_passed"]) + + def test_read_only_permission_success_does_not_require_attempted_write(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "permissions-3") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + result = verify_fixture(fixture, root, "Read only and unchanged.", {}, prepared) + self.assertTrue(result["verified_success"]) + + def test_frozen_v2_worker_runs_without_v3_plan_snapshot_api(self): + import subprocess + import sys + + fixture = FIXTURES[0] + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + source, workspace, home = base / "baseline", base / "workspace", base / "home" + workspace.mkdir() + home.mkdir() + baseline = subprocess.run(["git", "-C", str(Path.cwd()), "cat-file", "-e", + f"{BASELINE}^{{commit}}"], capture_output=True) + if baseline.returncode: + self.skipTest("immutable V2 baseline is absent from this shallow checkout") + _extract_baseline(source, Path.cwd(), BASELINE) + prepare_fixture(workspace, fixture) + prompt_file, plan_file, result_file = base / "prompt.txt", base / "plan.json", base / "result.json" + prompt_file.write_text(fixture["prompt"], encoding="utf-8") + plan_file.write_text(json.dumps(_initial_plan(fixture)), encoding="utf-8") + env = _worker_env(base, fixture, type("Relay", (), {"client_key": "local"})()) + completed = subprocess.run( + [sys.executable, str(Path.cwd() / "benchmarks/prompt_worker.py"), + "--source", str(source), "--workspace", str(workspace), + "--url", "http://127.0.0.1:1/v1", "--model", MODEL, + "--protocol", "responses", "--prompt-file", str(prompt_file), + "--plan-file", str(plan_file), "--result", str(result_file)], + cwd=workspace, env=env, capture_output=True, text=True, timeout=30) + result_data = json.loads(result_file.read_text(encoding="utf-8")) if result_file.exists() else None + self.assertEqual(completed.returncode, 0, completed.stderr[-1000:]) + self.assertIsNotNone(result_data) + self.assertTrue(result_data["provider_error"]) + from benchmarks.v3_matched_live import _worker_schema_errors + self.assertEqual(_worker_schema_errors(result_data), []) + + def test_live_runner_rejects_same_ids_with_changed_fixture_content(self): + fixture = dict(FIXTURES[0], prompt="altered prompt") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"): + with self.assertRaisesRegex(ValueError, "exact frozen fixture"): + run(source=root / "src", repo=root, output=root / "report.json", + budget_path=root / "budget.sqlite3", prior_hold_usd="10.20", + fixtures=(fixture,) + FIXTURES[1:]) + self.assertFalse((root / "budget.sqlite3").exists()) + + def test_live_runner_rejects_report_without_its_original_budget_ledger(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + report = root / "report.json" + report.write_text('{"summary":{"comparison_thresholds_met":true}}', encoding="utf-8") + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"): + with self.assertRaisesRegex(ValueError, "not pinned"): + run(source=root / "src", repo=root, output=report, budget_path=root / "budget.sqlite3", + prior_hold_usd="10.20") + + def test_budget_checkpoint_restores_a_tampered_or_interrupted_report_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + report, ledger = root / "report.json", root / "budget.sqlite3" + BudgetLedger(ledger) + _write_checkpoint(report, ledger, {"protocol": "openkyrozen-v3-matched-live-v1", + "candidate_sha": "candidate", "baseline_sha": "baseline", "model": "model", + "repeats": 3, "rows": [], "calls": []}) + report.write_text('{"summary":{"comparison_thresholds_met":true}}', encoding="utf-8") + checkpoint = _load_checkpoint(report, ledger) + self.assertEqual(checkpoint["candidate_sha"], "candidate") + self.assertEqual(json.loads(report.read_text(encoding="utf-8")), checkpoint) + + def test_resume_reconciles_orphan_calls_without_dropping_earlier_journal_entries(self): + fixtures = [FIXTURES[0], FIXTURES[1]] + for include_completed_row in (False, True): + with self.subTest(include_completed_row=include_completed_row), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + report, ledger_path = root / "report.json", root / "budget.sqlite3" + budget = BudgetLedger(ledger_path, prior_hold_rmb=TEST_PRIOR_HOLD_RMB) + first_key = (fixtures[0]["fixture_id"], 0, "v2") + orphan_key = (fixtures[1]["fixture_id"], 0, "v3") + keys = [first_key, orphan_key] if include_completed_row else [orphan_key] + for index, (fixture_id, repeat, arm) in enumerate(keys): + attempt = f"call-{index}" + usage = {"input_tokens": 100, "output_tokens": 10, "cache_read_tokens": 0} + metadata = {"attempt_id": attempt, "fixture_id": fixture_id, "repeat": repeat, + "arm": arm, "request_sha256": attempt, "model": MODEL, + "max_output_tokens": 4096, "reserved_rmb": str(_cost(100, 100))} + self.assertTrue(budget.reserve(attempt, input_token_bound=100, output_token_bound=100, + metadata=metadata)) + budget.settle(attempt, usage) + rows = [] + if include_completed_row: + rows.append({"fixture_id": first_key[0], "repeat": first_key[1], "arm": first_key[2], + "attempts": 1, "requests": 1, "input_tokens": 100, "output_tokens": 10, + "cache_read_tokens": 0, "usage_complete": True, "worker_result_complete": True, + "safety_passed": True, "safety_components": {name: True for name in ( + "scope_safe", "original_tests_unchanged", "provider_ok", + "no_running_processes", "worker_complete")}}) + from benchmarks.v3_matched_live import apparatus_identity + identity = apparatus_identity() + for row in rows: + row.update({key: identity[key] for key in ("cohort_sha256", "apparatus_sha256")}) + from benchmarks import v3_matched_live as live + result = root / "worker-result.json" + result.write_text(json.dumps(valid_worker_result(answer="retained"))) + worker, metadata = live._read_worker_result(result, ()) + live._write_worker_evidence(report, row, "candidate", identity, worker, metadata) + _write_checkpoint(report, ledger_path, {**identity, "protocol": "openkyrozen-v3-matched-live-v1", + "candidate_sha": "candidate", "baseline_sha": BASELINE, "model": MODEL, + "repeats": 3, "budget": {"prior_campaign_hold_usd": TEST_PRIOR_HOLD_USD}, + "rows": rows, "calls": []}) + prepare_candidate_apparatus(root / "src") + relay = type("Relay", (), {"rows": []})() + from openkyrozen.tools.sandbox import SandboxedCommand + probe = SandboxedCommand("true", False, {}, "test") + with patch("benchmarks.v3_matched_live._source_snapshot", return_value="candidate"), \ + patch("benchmarks.v3_matched_live._credential", return_value=("https://example.invalid", "key")), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=probe), \ + patch("benchmarks.v3_matched_live.ResponsesRelay", return_value=relay), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch("benchmarks.v3_matched_live._extract_baseline", side_effect=RuntimeError("stop")): + with self.assertRaisesRegex(RuntimeError, "stop"): + run(source=root / "src", repo=root, output=report, budget_path=ledger_path, + prior_hold_usd=TEST_PRIOR_HOLD_USD) + self.assertEqual({call["attempt_id"] for call in relay.rows}, + {f"call-{index}" for index in range(len(keys))}) + + def test_reconciled_unknown_orphan_row_survives_checkpoint_interruption_and_next_resume(self): + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "candidate" + prepare_candidate_apparatus(source) + report, ledger_path = root / "report.json", root / "budget.sqlite3" + budget = BudgetLedger(ledger_path, prior_hold_rmb=TEST_PRIOR_HOLD_RMB) + identity = live.apparatus_identity() + orphan_key = ("permissions-3", 2, "v3") + metadata = {"attempt_id": "held-orphan", "fixture_id": orphan_key[0], "repeat": orphan_key[1], + "arm": orphan_key[2], "request_sha256": "held-request", "model": MODEL, + "max_output_tokens": 4096, "reserved_rmb": str(_cost(100, 100))} + self.assertTrue(budget.reserve("held-orphan", input_token_bound=100, output_token_bound=100, metadata=metadata)) + budget.settle("held-orphan", None) + remaining = budget.remaining_rmb + def ledger_state(): + with sqlite3.connect(ledger_path) as db: + return db.execute("SELECT id,reserved_picormb,actual_picormb,status,usage_json FROM attempts ORDER BY id").fetchall() + held_state = ledger_state() + live._write_checkpoint(report, ledger_path, {**identity, "protocol": "openkyrozen-v3-matched-live-v1", + "candidate_sha": "candidate", "baseline_sha": BASELINE, "model": MODEL, "repeats": 3, + "budget": {"prior_campaign_hold_usd": TEST_PRIOR_HOLD_USD}, "rows": [], "calls": []}) + class Relay(ActorRelayStub): + url, client_key = "http://127.0.0.1:1/v1", "offline" + provider_error, budget_blocked = None, False + def __init__(self, **kwargs): self.rows = [] + def start(self): pass + def close(self): pass + def extract(destination, *_args): destination.mkdir(parents=True) + def worker(command, **kwargs): + Path(command[command.index("--result") + 1]).write_text(json.dumps(valid_worker_result())) + return 0, "" + def checkpoint_then_interrupt(*args): + live._write_checkpoint_original(*args) + raise RuntimeError("interrupted after unrelated cell checkpoint") + common = [patch.object(live, "_source_snapshot", return_value="candidate"), + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), + patch.object(live, "_credential", return_value=("https://invalid.example", "offline")), + patch.object(live, "ResponsesRelay", Relay)] + from contextlib import ExitStack + with ExitStack() as stack: + for context in common: stack.enter_context(context) + stack.enter_context(patch.object(live, "_extract_baseline", side_effect=extract)) + stack.enter_context(patch.object(live, "_run_worker", side_effect=worker)) + stack.enter_context(patch.object(live, "verify_fixture", return_value={"result": "passed", "verified_success": True, "safety_passed": True, + "plan_scenario_passed": True, "changed_paths": [], "safety_components": { + "scope_safe": True, "original_tests_unchanged": True, "provider_ok": True, "no_running_processes": True}})) + stack.enter_context(patch.object(live, "_write_checkpoint_original", live._write_checkpoint, create=True)) + stack.enter_context(patch.object(live, "_write_checkpoint", side_effect=checkpoint_then_interrupt)) + with self.assertRaisesRegex(RuntimeError, "interrupted after unrelated"): + live.run(source=source, repo=root, output=report, budget_path=ledger_path, prior_hold_usd=TEST_PRIOR_HOLD_USD) + restored = live._load_checkpoint(report, ledger_path) + orphan = next(row for row in restored["rows"] if (row["fixture_id"], row["repeat"], row["arm"]) == orphan_key) + self.assertEqual(orphan.get("cohort_sha256"), identity["cohort_sha256"]) + self.assertEqual(orphan.get("apparatus_sha256"), identity["apparatus_sha256"]) + self.assertFalse(orphan["worker_result_complete"]) + self.assertFalse(orphan["usage_complete"]) + self.assertFalse(orphan["verified_success"]) + self.assertEqual((orphan["attempts"], orphan["requests"], orphan["input_tokens"], orphan["output_tokens"]), (1, 1, 0, 0)) + with ExitStack() as stack: + for context in [patch.object(live, "_source_snapshot", return_value="candidate"), + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), + patch.object(live, "_credential", return_value=("https://invalid.example", "offline")), + patch.object(live, "ResponsesRelay", Relay), + patch.object(live, "_extract_baseline", side_effect=RuntimeError("second resume accepted"))]: + stack.enter_context(context) + with self.assertRaisesRegex(RuntimeError, "second resume accepted"): + live.run(source=source, repo=root, output=report, budget_path=ledger_path, prior_hold_usd=TEST_PRIOR_HOLD_USD) + self.assertEqual(ledger_state(), held_state) + self.assertEqual(budget.remaining_rmb, remaining) + self.assertEqual(budget.spent_rmb, 0) + + def test_cancellation_verifier_requires_a_started_and_stopped_process(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "cancellation-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + idle = verify_fixture(fixture, root, "done", {"processes_started": 0, + "processes_cancelled": 0, "processes_alive": 0, "process_pids": []}, prepared) + stopped = verify_fixture(fixture, root, "done", {"processes_started": 1, + "processes_cancelled": 1, "processes_alive": 0, "process_pids": [2**31], + "process_marker_seen": True}, prepared) + self.assertEqual(idle["result"], "failed") + self.assertEqual(stopped["result"], "passed") + + def test_required_plan_cannot_pass_with_missing_or_unfinished_tasks(self): + fixture = next(item for item in FIXTURES if item.get("plan_tasks")) + self.assertFalse(_workflow_state_correct(fixture, {"tasks": []})) + self.assertFalse(_workflow_state_correct(fixture, {"tasks": ["cancelled"]})) + self.assertFalse(_workflow_state_correct(fixture, {"tasks": ["succeeded", "running"]})) + self.assertTrue(_workflow_state_correct(fixture, {"tasks": ["succeeded", "cancelled"]})) + + def test_snapshot_excludes_git_metadata_but_tracks_workspace_files(self): + fixture = next(item for item in FIXTURES if item["fixture_id"] == "investigation-1") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepare_fixture(root, fixture) + first = snapshot_workspace(root) + (root / ".git").mkdir() + (root / ".git" / "internal").write_text("not task output") + self.assertEqual(first, snapshot_workspace(root)) + + + +class WorkerEvidenceTests(unittest.TestCase): + def _write(self, root, payload, secrets=()): + from benchmarks import v3_matched_live as live + result = root / "result.json" + result.write_bytes(payload) + worker, metadata = live._read_worker_result(result, secrets) + row = {"fixture_id": "investigation-2", "repeat": 0, "arm": "v3", + "worker_exit_code": 0, "worker_result_complete": metadata["classification"] == "valid", + "verifier_result": "passed", "verified_success": True, "safety_passed": True, + "usage_complete": True, "attempts": 1, + "safety_components": {name: True for name in ( + "scope_safe", "original_tests_unchanged", "provider_ok", "no_running_processes", "worker_complete")}} + identity = live.apparatus_identity() + live._write_worker_evidence(root / "report.json", row, "candidate", identity, worker, metadata) + return row, identity + + def test_missing_or_wrong_observer_fields_are_incomplete_not_defaulted(self): + from benchmarks import v3_matched_live as live + for field, bad in (("answer", None), ("events", {}), ("tool_receipts", None), + ("task_records", {}), ("tasks", None), ("request_context_records", {}), + ("conversation_records", None), ("protocol_recovery", -1), + ("protocol_recovery", True), ("protocol_recovery", "0"), + ("provider_error", 0), ("processes_alive", False), + ("first_delta_latency_ms", 10 ** 400), ("first_delta_latency_ms", True)): + for missing in (True, False): + with self.subTest(field=field, missing=missing), tempfile.TemporaryDirectory() as directory: + payload = valid_worker_result(answer="keyword-correct but unavailable evidence") + if missing: payload.pop(field) + else: payload[field] = bad + root = Path(directory) + row, identity = self._write(root, json.dumps(payload).encode()) + self.assertFalse(row["worker_result_complete"]) + self.assertFalse(row["worker_evidence_verified"]) + self.assertEqual(row["worker_evidence_status"], "incomplete_schema") + document = json.loads((root / row["worker_evidence_file"]).read_text()) + self.assertEqual(document["worker"], payload) + self.assertFalse(live._row_can_resume_as_complete(row, root / "report.json", "candidate", identity)) + + def test_full_result_retained_with_raw_hash_and_recursive_literal_key_redaction(self): + import hashlib + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + raw = worker_result_bytes(answer="confirmed actual-key", events=[{"text": "actual-key"}], + tool_receipts=[{"arguments": {"body": "actual-key"}}], + tasks=["succeeded"], task_records=[{"id": "t", "status": "succeeded"}], + worker_evidence_verified=True) + row, identity = self._write(root, raw, ("actual-key",)) + data = (root / row["worker_evidence_file"]).read_bytes() + self.assertNotIn(b"actual-key", data) + document = json.loads(data) + self.assertEqual(document["raw_worker_sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(document["worker"]["answer"], "confirmed [REDACTED]") + self.assertEqual(document["worker"]["tasks"], ["succeeded"]) + self.assertEqual(document["worker"]["task_records"][0]["id"], "t") + self.assertEqual(document["worker"]["tool_receipts"][0]["arguments"]["body"], "[REDACTED]") + self.assertEqual(document["redaction_count"], 3) + self.assertEqual(document["classification"], "credential_exposure") + self.assertFalse(row["worker_result_complete"]) + self.assertFalse(live._row_can_resume_as_complete(row, root / "report.json", "candidate", identity)) + self.assertFalse(live._row_can_resume_as_complete({"worker_evidence_verified": True})) + + def test_missing_tampered_foreign_candidate_and_cross_row_evidence_fail_closed(self): + from benchmarks import v3_matched_live as live + for change in ("missing", "tamper", "row", "candidate", "apparatus", "symlink", "directory_symlink"): + with self.subTest(change=change), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + row, identity = self._write(root, worker_result_bytes(answer="correct")) + path = root / row["worker_evidence_file"] + candidate = "candidate" + if change == "missing": path.unlink() + elif change == "tamper": path.write_bytes(b'{"answer":"forged"}') + elif change == "row": row["repeat"] = 1 + elif change == "candidate": candidate = "different" + elif change == "apparatus": identity = {**identity, "apparatus_sha256": "different"} + elif change == "symlink": + target = root / "sentinel" + target.write_text("private sentinel") + path.unlink() + path.symlink_to(target) + else: + directory_path = path.parent + moved = root / "moved" + directory_path.rename(moved) + directory_path.symlink_to(moved, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "worker evidence"): + live._validate_rows_evidence(root / "report.json", [row], candidate, identity) + self.assertFalse(row["worker_evidence_verified"]) + + def test_malformed_and_oversized_results_retain_only_hash_diagnostic(self): + import hashlib + from benchmarks import v3_matched_live as live + for payload in (b'{"answer":"actual-key",', b'\xff', b'[' * 2000, b'{"x":NaN}', b'{"x":1,"x":2}', b'{"nested":{"actual-key":"value"}}'): + with self.subTest(payload=payload[:30]), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + row, identity = self._write(root, payload, ("actual-key",)) + document = json.loads((root / row["worker_evidence_file"]).read_bytes()) + self.assertEqual(document["raw_worker_sha256"], hashlib.sha256(payload).hexdigest()) + self.assertEqual(document["classification"], "malformed") + self.assertIsNone(document["worker"]) + self.assertNotIn("actual-key", json.dumps(document)) + self.assertFalse(live._validate_rows_evidence(root / "report.json", [row], "candidate", identity)) + with tempfile.TemporaryDirectory() as directory, patch.object(live, "_MAX_WORKER_EVIDENCE_BYTES", 4096): + root = Path(directory) + payload = b'{"answer":"' + b'x' * 5000 + b'"}' + row, _ = self._write(root, payload) + document = json.loads((root / row["worker_evidence_file"]).read_bytes()) + self.assertIsNone(document["raw_worker_sha256"]) + self.assertEqual(document["raw_hash_scope"], "unavailable") + self.assertEqual(document["observed_worker_bytes"], 0) + self.assertEqual(document["raw_worker_bytes"], len(payload)) + self.assertEqual(document["classification"], "oversized") + self.assertIsNone(document["worker"]) + + def test_resume_rejects_missing_tampered_and_replayed_artifacts_before_credentials(self): + from benchmarks import v3_matched_live as live + for change in ("missing", "tamper", "cross_row", "foreign_candidate", "unsupported", "valid"): + with self.subTest(change=change), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output, ledger = root / "report.json", root / "budget.sqlite3" + row, identity = self._write(root, worker_result_bytes(answer="timeout = 15 and retries = 2")) + path = root / row["worker_evidence_file"] + if change == "missing": path.unlink() + elif change == "tamper": path.write_text('{"worker":{"answer":"forged"}}') + elif change == "cross_row": row["arm"] = "v2" + elif change == "foreign_candidate": + worker, metadata = live._read_worker_result(root / "result.json", ()) + live._write_worker_evidence(output, row, "different-candidate", identity, worker, metadata) + row.update({key: identity[key] for key in ("cohort_sha256", "apparatus_sha256")}) + budget = BudgetLedger(ledger, prior_hold_rmb=TEST_PRIOR_HOLD_RMB) + usage = {"input_tokens": 100, "output_tokens": 10, "cache_read_tokens": 0} + self.assertTrue(budget.reserve("attempt", input_token_bound=100, output_token_bound=100, + metadata={"attempt_id": "attempt", "fixture_id": row["fixture_id"], + "repeat": row["repeat"], "arm": row["arm"], "request_sha256": "request", + "model": MODEL, "max_output_tokens": 4096, "reserved_rmb": str(_cost(100, 100))})) + budget.settle("attempt", usage) + row.update(requests=1, input_tokens=100, output_tokens=10, cache_read_tokens=0, cache_write_tokens=0) + live._write_checkpoint(output, ledger, {**identity, "protocol": "openkyrozen-v3-matched-live-v1", + "candidate_sha": "candidate", "baseline_sha": BASELINE, "model": MODEL, "repeats": 3, + "budget": {"prior_campaign_hold_usd": TEST_PRIOR_HOLD_USD}, "rows": [row], "calls": []}) + prepare_candidate_apparatus(root / "source") + with patch.object(live, "_source_snapshot", return_value="candidate"), \ + patch("openkyrozen.tools.sandbox.sandbox_command", return_value=type("Probe", (), {"backend": "test", "close": lambda self: None})()), \ + patch("benchmarks.v3_protected_worker.verify_worker_protection", return_value={}), \ + patch.object(live, "_credential", side_effect=RuntimeError("credential boundary")) as credential, \ + patch.object(live.os, "supports_dir_fd", set() if change == "unsupported" else live.os.supports_dir_fd): + if change == "valid": + with self.assertRaisesRegex(RuntimeError, "credential boundary"): + live.run(source=root / "source", repo=root, output=output, budget_path=ledger, + prior_hold_usd=TEST_PRIOR_HOLD_USD) + credential.assert_called_once() + else: + with self.assertRaisesRegex(ValueError, "worker evidence"): + live.run(source=root / "source", repo=root, output=output, budget_path=ledger, + prior_hold_usd=TEST_PRIOR_HOLD_USD) + credential.assert_not_called() + + def test_fdopen_constructor_failure_closes_read_write_and_directory_descriptors(self): + import errno + from benchmarks import v3_matched_live as live + original_open = live.os.open + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + row, identity = self._write(root, worker_result_bytes(answer="first")) + opened = [] + def capture_open(*args, **kwargs): + descriptor = original_open(*args, **kwargs) + opened.append(descriptor) + return descriptor + def assert_closed(): + self.assertTrue(opened) + for descriptor in opened: + with self.assertRaises(OSError) as caught: + live.os.fstat(descriptor) + self.assertEqual(caught.exception.errno, errno.EBADF) + with patch.object(live.os, "open", side_effect=capture_open), \ + patch.object(live.os, "fdopen", side_effect=OSError("constructor failed")): + _, metadata = live._read_worker_result(root / "result.json", ()) + self.assertEqual(metadata["classification"], "unavailable") + assert_closed() + opened.clear() + (root / "result.json").write_bytes(worker_result_bytes(answer="second")) + worker, metadata = live._read_worker_result(root / "result.json", ()) + # Mocking os.open changes its function identity, so preserve the + # platform capability probe while injecting a constructor failure. + with patch.object(live, "_require_secure_evidence_io"), \ + patch.object(live.os, "open", side_effect=capture_open), \ + patch.object(live.os, "fdopen", side_effect=OSError("constructor failed")): + with self.assertRaisesRegex(OSError, "constructor failed"): + live._write_worker_evidence(root / "report.json", row, "candidate", identity, worker, metadata) + assert_closed() + self.assertEqual(list((root / row["worker_evidence_file"]).parent.glob(".worker-*")), []) + + def test_evidence_directory_swap_before_write_cannot_write_outside_directory(self): + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + row, identity = self._write(root, worker_result_bytes(answer="first")) + directory_path = (root / row["worker_evidence_file"]).parent + outside = root / "outside" + outside.mkdir() + (root / "result.json").write_text(json.dumps(valid_worker_result(answer="second"))) + worker, metadata = live._read_worker_result(root / "result.json", ()) + original_replace = live.os.replace + def swap_then_replace(*args, **kwargs): + directory_path.rename(root / "old-evidence") + directory_path.symlink_to(outside, target_is_directory=True) + return original_replace(*args, **kwargs) + with patch.object(live.os, "replace", side_effect=swap_then_replace): + with self.assertRaisesRegex(ValueError, "worker evidence directory"): + live._write_worker_evidence(root / "report.json", row, "candidate", identity, worker, metadata) + self.assertEqual(list(outside.iterdir()), []) + + def test_evidence_directory_swap_cannot_validate_outside_file(self): + import shutil + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + row, identity = self._write(root, worker_result_bytes(answer="correct")) + path = root / row["worker_evidence_file"] + outside = root / "outside" + outside.mkdir() + shutil.copyfile(path, outside / path.name) + original_reader = live._read_regular_bytes + def swap_then_read(target, *args, **kwargs): + path.parent.rename(root / "old-evidence") + path.parent.symlink_to(outside, target_is_directory=True) + return original_reader(target, *args, **kwargs) + with patch.object(live, "_read_regular_bytes", side_effect=swap_then_read): + with self.assertRaisesRegex(ValueError, "worker evidence"): + live._validate_worker_evidence(root / "report.json", row, "candidate", identity) + + def test_oversized_file_reads_zero_bytes_and_growing_reader_stops_at_limit_plus_one(self): + import hashlib + from benchmarks import v3_matched_live as live + original_fdopen = live.os.fdopen + with tempfile.TemporaryDirectory() as directory, patch.object(live, "_MAX_WORKER_EVIDENCE_BYTES", 8): + root = Path(directory) + result = root / "result" + requested = [] + growing = False + class Stream: + def __init__(self, raw): self.raw = raw + def __enter__(self): return self + def __exit__(self, *args): self.raw.close() + def fileno(self): return self.raw.fileno() + def read(self, count): + requested.append(count) + if not growing: + raise AssertionError("initially oversized file must not be read") + if len(requested) > 1: + raise AssertionError("growing input must stop at the size bound") + return b'x' * count + def instrument(*args, **kwargs): return Stream(original_fdopen(*args, **kwargs)) + result.write_bytes(b'x' * 65) + with patch.object(live.os, "fdopen", side_effect=instrument): + worker, metadata = live._read_worker_result(result, ()) + self.assertEqual(requested, []) + self.assertEqual(metadata["raw_worker_bytes"], 65) + self.assertEqual(metadata["observed_worker_bytes"], 0) + self.assertIsNone(metadata["raw_worker_sha256"]) + result.write_bytes(b'') + growing = True + with patch.object(live.os, "fdopen", side_effect=instrument): + worker, metadata = live._read_worker_result(result, ()) + self.assertEqual(requested, [9]) + self.assertEqual(metadata["classification"], "growing_file") + self.assertEqual(metadata["observed_worker_bytes"], 9) + self.assertIsNone(metadata["raw_worker_sha256"]) + self.assertEqual(metadata["observed_prefix_sha256"], hashlib.sha256(b'x' * 9).hexdigest()) + + def test_worker_result_symlink_fifo_and_credential_key_are_never_read_as_valid(self): + import os + from benchmarks import v3_matched_live as live + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sentinel = root / "sentinel" + sentinel.write_text('{"answer":"private sentinel"}') + result = root / "result" + result.symlink_to(sentinel) + worker, metadata = live._read_worker_result(result, ()) + self.assertEqual((worker, metadata["classification"], metadata["raw_worker_sha256"]), ({}, "unsafe_file", None)) + result.unlink() + if hasattr(os, "mkfifo"): + os.mkfifo(result) + self.assertEqual(live._read_worker_result(result, ())[1]["classification"], "unsafe_file") + result.unlink() + result.write_text('{"actual-key":"do not serialize"}') + worker, metadata = live._read_worker_result(result, ("actual-key",)) + self.assertEqual(worker, {}) + self.assertEqual(metadata["classification"], "malformed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_preview_publish.py b/tests/test_v3_preview_publish.py new file mode 100644 index 00000000..6e344964 --- /dev/null +++ b/tests/test_v3_preview_publish.py @@ -0,0 +1,221 @@ +import importlib.util +import hashlib +import io +import json +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +class PreviewPublicationTests(unittest.TestCase): + def test_malformed_git_signature_status_is_rejected(self): + from scripts import publish_v3_preview + result = subprocess.CompletedProcess([], 0, "[GNUPG:] VALIDSIG \n", "") + with patch.object(publish_v3_preview, "_run", return_value=result): + with self.assertRaisesRegex(ValueError, "signature"): + publish_v3_preview._verify_candidate_signature(Path("."), "a" * 40) + + def test_signed_tag_retry_accepts_matching_tag_and_rejects_remote_mismatch(self): + from scripts import publish_v3_preview + ensure = getattr(publish_v3_preview, "_ensure_signed_remote_tag", None) + self.assertTrue(callable(ensure)) + candidate, tag_object, tag = "a" * 40, "b" * 40, "v3.0.0.dev1" + calls = [] + + def run(args, *, cwd): + calls.append(args) + if args[:2] == ["git", "rev-parse"]: + value = candidate if args[-1].endswith("^{}") else tag_object + elif args[:3] == ["git", "ls-remote", "--tags"]: + value = candidate if args[-1].endswith("^{}") else tag_object + else: + value = "" + return subprocess.CompletedProcess(args, 0, value + "\n", "") + + with tempfile.TemporaryDirectory() as directory, \ + patch.object(publish_v3_preview, "_verify_git_signature"), \ + patch.object(publish_v3_preview, "_run", side_effect=run), \ + patch.object(publish_v3_preview.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)): + ensure(Path(directory), tag, candidate) + self.assertFalse(any(args[:2] == ["git", "push"] for args in calls)) + with patch.object(publish_v3_preview, "_run", side_effect=lambda args, **kw: + subprocess.CompletedProcess(args, 0, ("c" * 40 if args[:3] == ["git", "ls-remote", "--tags"] + else candidate) + "\n", "")): + with self.assertRaisesRegex(ValueError, "tag"): + ensure(Path(directory), tag, candidate) + + def test_changed_release_asset_or_attestation_blocks_publication(self): + from scripts import publish_v3_preview + verify = getattr(publish_v3_preview, "_verify_prepared_artifacts", None) + self.assertTrue(callable(verify)) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + asset = root / "preview.whl" + asset.write_bytes(b"original") + attestation = root / "attestation.json" + attestation.with_suffix(".json.asc").write_text("signature") + hashes = {asset.name: hashlib.sha256(asset.read_bytes()).hexdigest()} + expected = {"candidate_sha": "a" * 40, "artifacts": hashes} + attestation.write_text(json.dumps(expected)) + with patch.object(publish_v3_preview, "_gpg_verify", return_value=True): + verify([asset], expected, attestation) + asset.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "changed"): + verify([asset], expected, attestation) + asset.write_bytes(b"original") + attestation.write_text(json.dumps({"candidate_sha": "b" * 40, "artifacts": hashes})) + with patch.object(publish_v3_preview, "_gpg_verify", return_value=True): + with self.assertRaisesRegex(ValueError, "changed"): + verify([asset], expected, attestation) + attestation.write_text(json.dumps(expected)) + with patch.object(publish_v3_preview, "_gpg_verify", return_value=False): + with self.assertRaisesRegex(ValueError, "changed"): + verify([asset], expected, attestation) + + def test_preview_requires_a_workflow_that_cannot_publish_v3_as_stable(self): + from scripts import publish_v3_preview + guard = getattr(publish_v3_preview, "_require_safe_release_workflow", None) + self.assertTrue(callable(guard)) + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + workflow = repo / ".github/workflows/release.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text('on:\n push:\n tags: ["v*"]\njobs:\n build-release: {}\n') + with self.assertRaisesRegex(ValueError, "ungated"): + guard(repo, "v3.0.0.dev1") + workflow.write_text('on:\n push:\n tags: ["v*", "!v3.*"]\njobs:\n build-release:\n' + ' if: "${{ !startsWith(inputs.release_tag || github.ref_name, \'v3.\') || true }}"\n') + with self.assertRaisesRegex(ValueError, "ungated"): + guard(repo, "v3.0.0.dev1") + + def test_invalid_evidence_cannot_create_a_tag_or_release(self): + self.assertIsNotNone(importlib.util.find_spec("scripts.publish_v3_preview")) + from scripts import publish_v3_preview + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repo, evidence = root / "repo", root / "evidence" + repo.mkdir() + evidence.mkdir() + manifest = root / "preview.json" + manifest.write_text("{}") + manifest.with_suffix(".json.asc").write_text("signature") + with patch.object(publish_v3_preview, "_current_candidate", return_value="a" * 40), \ + patch.object(publish_v3_preview, "_verify_candidate_signature"), \ + patch.object(publish_v3_preview, "verify_signed_preview_manifest", + side_effect=ValueError("missing safety evidence")), \ + patch.object(publish_v3_preview.subprocess, "run") as run: + with self.assertRaisesRegex(ValueError, "missing safety evidence"): + publish_v3_preview.publish(repo, evidence, manifest) + run.assert_not_called() + + def test_valid_preview_publication_uses_prerelease_and_candidate_tag(self): + from scripts import publish_v3_preview + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repo, evidence, output = root / "repo", root / "evidence", root / "output" + repo.mkdir() + evidence.mkdir() + (repo / "pyproject.toml").write_text('[project]\nversion = "3.0.0.dev1"\n') + manifest_path = root / "preview.json" + manifest_path.write_text("{}") + manifest_path.with_suffix(".json.asc").write_text("signature") + waiver = evidence / "R259.json" + waiver.write_text("waiver") + signature = evidence / "R259.json.asc" + signature.write_text("signature") + manifest = {"waived_gates": ["R259"], "gates": {"R259": {"waiver": { + "rationale": "Evaluation interrupted", "limitations": ["Protocol recovery is unverified"], + "review_condition": "Review before general availability", "record_path": waiver.name, + "record_sha256": hashlib.sha256(waiver.read_bytes()).hexdigest(), + "signature_path": signature.name, "signature_sha256": hashlib.sha256(signature.read_bytes()).hexdigest(), + }}}} + calls = [] + tamper_download = False + + def run(args, *, cwd): + calls.append(args) + if args[:3] == ["git", "remote", "get-url"]: + return subprocess.CompletedProcess(args, 0, "https://github.com/EvanProgramming/OpenKyrozen.git\n", "") + if args[:3] == ["git", "archive", "--format=tar"]: + target = Path(next(arg.split("=", 1)[1] for arg in args if arg.startswith("--output="))) + with tarfile.open(target, "w") as archive: + data = b"source" + info = tarfile.TarInfo("placeholder") + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + if args[1:3] == ["-m", "build"]: + (output / "openkyrozen-3.0.0.dev1-py3-none-any.whl").write_bytes(b"wheel") + (output / "openkyrozen-3.0.0.dev1.tar.gz").write_bytes(b"sdist") + if args[:3] == ["git", "archive", "--format=tar.gz"]: + Path(next(arg.split("=", 1)[1] for arg in args if arg.startswith("--output="))).write_bytes(b"tui") + if args[:2] == ["git", "rev-parse"] or (args[:3] == ["git", "ls-remote", "--tags"] + and args[-1].endswith("^{}")): + return subprocess.CompletedProcess(args, 0, "a" * 40 + "\n", "") + if args[:3] == ["gh", "release", "download"]: + target = Path(args[args.index("--dir") + 1]) + for path in output.iterdir(): + if path.is_file(): + shutil.copyfile(path, target / path.name) + if tamper_download: + (target / "openkyrozen-3.0.0.dev1-py3-none-any.whl").write_bytes(b"changed") + if args[:3] == ["gh", "release", "view"]: + published = any(call[:3] == ["gh", "release", "edit"] for call in calls) + return subprocess.CompletedProcess(args, 0, json.dumps({"tagName": "v3.0.0.dev1", + "isPrerelease": True, "isDraft": not published, + "url": "https://github.com/EvanProgramming/OpenKyrozen/releases/tag/v3.0.0.dev1"}), "") + return subprocess.CompletedProcess(args, 0, "", "") + + def sign(data, path): + path.write_text(json.dumps(data)) + path.with_suffix(".json.asc").write_text("signature") + + with patch.object(publish_v3_preview, "_current_candidate", return_value="a" * 40), \ + patch.object(publish_v3_preview, "_verify_candidate_signature"), \ + patch.object(publish_v3_preview, "_verify_git_signature"), \ + patch.object(publish_v3_preview, "_require_safe_release_workflow"), \ + patch.object(publish_v3_preview, "_gpg_verify", return_value=True), \ + patch.object(publish_v3_preview, "verify_signed_preview_manifest", return_value=manifest), \ + patch.object(publish_v3_preview, "_write_signed_manifest", side_effect=sign), \ + patch.object(publish_v3_preview, "_run", side_effect=run), \ + patch.object(publish_v3_preview.subprocess, "run", return_value=subprocess.CompletedProcess([], 1)): + url = publish_v3_preview.publish(repo, evidence, manifest_path, output) + create = next(args for args in calls if args[:3] == ["gh", "release", "create"]) + self.assertIn("--prerelease", create) + self.assertIn("--draft", create) + self.assertIn("--verify-tag", create) + self.assertEqual(create[3], "v3.0.0.dev1") + self.assertTrue(any(args[:3] == ["gh", "release", "download"] for args in calls)) + self.assertTrue(any(args[:3] == ["gh", "release", "edit"] and "--draft=false" in args + for args in calls)) + self.assertIn("R259", (output / "preview-release-notes.md").read_text()) + self.assertIn("KYROZEN_SANDBOX_REQUIRED=1 kyrozen", (output / "preview-release-notes.md").read_text()) + self.assertIn("/v3.0.0.dev1/install.sh", (output / "preview-release-notes.md").read_text()) + self.assertIn("v3.0.0.dev1", url) + self.assertLess(next(i for i, args in enumerate(calls) if args[:3] == ["gh", "release", "download"]), + next(i for i, args in enumerate(calls) if args[:3] == ["gh", "release", "edit"])) + + calls.clear() + output = root / "mismatched-output" + tamper_download = True + with patch.object(publish_v3_preview, "_current_candidate", return_value="a" * 40), \ + patch.object(publish_v3_preview, "_verify_candidate_signature"), \ + patch.object(publish_v3_preview, "_verify_git_signature"), \ + patch.object(publish_v3_preview, "_require_safe_release_workflow"), \ + patch.object(publish_v3_preview, "_gpg_verify", return_value=True), \ + patch.object(publish_v3_preview, "verify_signed_preview_manifest", return_value=manifest), \ + patch.object(publish_v3_preview, "_write_signed_manifest", side_effect=sign), \ + patch.object(publish_v3_preview, "_run", side_effect=run), \ + patch.object(publish_v3_preview.subprocess, "run", return_value=subprocess.CompletedProcess([], 1)): + with self.assertRaisesRegex(ValueError, "draft release assets"): + publish_v3_preview.publish(repo, evidence, manifest_path, output) + self.assertFalse(any(args[:3] == ["gh", "release", "edit"] for args in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_process_owner.py b/tests/test_v3_process_owner.py new file mode 100644 index 00000000..106da230 --- /dev/null +++ b/tests/test_v3_process_owner.py @@ -0,0 +1,296 @@ +"""Kernel UID ownership for benchmark actors and daemonized command children.""" +import importlib +import importlib.util +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +@unittest.skipUnless(os.name == 'posix', 'Unix benchmark ownership required') +class ProcessOwnerPolicyTests(unittest.TestCase): + def owner_module(self): + self.assertIsNotNone(importlib.util.find_spec('benchmarks.v3_process_owner'), + 'benchmark process ownership is not implemented') + return importlib.import_module('benchmarks.v3_process_owner') + + def test_nonprivileged_parent_cannot_claim_distinct_uid_ownership(self): + module = self.owner_module() + with patch.object(module.os, 'geteuid', return_value=501): + with self.assertRaisesRegex(PermissionError, 'privileged'): + module.UIDOwner() + + def test_allocator_rejects_an_uid_already_present_in_kernel_snapshot(self): + module = self.owner_module() + with patch.object(module.os, 'geteuid', return_value=0), patch.object( + module, 'process_snapshot', return_value={41: (1_234_567_890, 'S')}): + with self.assertRaisesRegex(ValueError, 'in use'): + module.UIDOwner(uid=1_234_567_890) + + def test_root_and_ordinary_user_ids_are_never_allocated(self): + module = self.owner_module() + with patch.object(module.os, 'geteuid', return_value=0): + for uid in (0, 501, 65534, -1, True, 2**32): + with self.subTest(uid=uid), self.assertRaises(ValueError): + module.UIDOwner(uid=uid) + + def test_existing_account_without_a_running_process_cannot_be_reused(self): + import pwd + module = self.owner_module() + with patch.object(module.os, 'geteuid', return_value=0), patch.object( + module, 'process_snapshot', return_value={}), patch.object( + module, 'reserve_identity', return_value=None), patch.object(pwd, 'getpwuid', return_value=object()): + with self.assertRaisesRegex(ValueError, 'in use'): + module.UIDOwner(uid=1_234_567_892) + + def test_cleanup_freezes_and_kills_only_its_owned_uid(self): + module = self.owner_module() + snapshots = [{1: (0, 'S'), 20: (1_234_567_890, 'S')}, + {1: (0, 'S'), 20: (1_234_567_890, 'T'), 21: (1_234_567_890, 'S')}, + {1: (0, 'S'), 20: (1_234_567_890, 'T'), 21: (1_234_567_890, 'T')}, + {1: (0, 'S')}] + with patch.object(module.os, 'geteuid', return_value=0), patch.object( + module, 'process_snapshot', return_value={}), patch.object( + module, 'reserve_identity', return_value=None, create=True), patch.object(module.os, 'kill') as kill: + owner = module.UIDOwner(uid=1_234_567_890) + with patch.object(module, 'process_snapshot', side_effect=snapshots): + owner.close() + self.assertEqual({call.args[0] for call in kill.call_args_list}, {20, 21}) + self.assertTrue(owner.closed) + + def test_identity_reservation_is_exclusive_and_never_follows_links(self): + module = self.owner_module() + self.assertTrue(hasattr(module, 'reserve_identity'), 'exclusive UID reservation is missing') + with tempfile.TemporaryDirectory() as directory: + marker = module.reserve_identity(1_234_567_891, directory=directory) + with self.assertRaises(FileExistsError): + module.reserve_identity(1_234_567_891, directory=directory) + marker.unlink() + target = os.path.join(directory, 'target') + with open(target, 'w') as stream: + stream.write('unchanged') + marker.symlink_to(target) + with self.assertRaises(FileExistsError): + module.reserve_identity(1_234_567_891, directory=directory) + with open(target) as stream: + self.assertEqual(stream.read(), 'unchanged') + + def test_fresh_unix_connection_authenticates_real_sender_pid_and_uid(self): + module = self.owner_module() + self.assertTrue(hasattr(module, 'peer_identity'), 'kernel peer authentication is missing') + with tempfile.TemporaryDirectory() as directory, socket.socket(socket.AF_UNIX) as server: + address = str(Path(directory) / 'socket') + server.bind(address) + server.listen(1) + server.settimeout(5) + child = subprocess.Popen([sys.executable, '-I', '-c', + 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); ' + 's.sendall(b"hello"); s.recv(1)', address]) + try: + connection, _ = server.accept() + with connection: + connection.settimeout(5) + self.assertEqual(connection.recv(5), b'hello') + self.assertEqual(module.peer_identity(connection), (child.pid, os.getuid(), os.getgid())) + connection.sendall(b'x') + self.assertEqual(child.wait(timeout=5), 0) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=5) + + @unittest.skipIf(hasattr(os, 'geteuid') and os.geteuid() == 0, 'requires unprivileged fake server') + def test_actor_client_rejects_unprivileged_server_before_trusting_claims(self): + module = self.owner_module() + self.assertTrue(hasattr(module, 'connect_actor_client'), 'actor connection boundary is missing') + with tempfile.TemporaryDirectory() as directory, socket.socket(socket.AF_UNIX) as server: + address = str(Path(directory) / 'socket') + server.bind(address) + server.listen(1) + with patch.dict(os.environ, {'KYROZEN_BENCHMARK_ACTOR_SOCKET': address}): + with self.assertRaisesRegex(PermissionError, 'privileged'): + module.connect_actor_client() + + def test_namespaced_parent_requires_kernel_map_and_pinned_socket(self): + module = self.owner_module() + self.assertTrue(hasattr(module, '_namespace_parent_pin')) + identity = 1_234_567_893 + connection = SimpleNamespace(getsockopt=lambda *args: __import__('struct').pack('iII', 0, 65534, 65534)) + socket_metadata = SimpleNamespace(st_dev=7, st_ino=23, st_uid=65534, st_mode=0o140666) + directory_metadata = SimpleNamespace(st_uid=65534, st_mode=0o40755) + def metadata(path): + return socket_metadata if str(path).endswith('/socket') else directory_metadata + with patch.object(module.socket, 'SO_PEERCRED', 17, create=True), patch.object(module.sys, 'platform', 'linux'), patch.object(module.os, 'geteuid', return_value=identity), patch.object( + module.os, 'getegid', return_value=identity), patch.object(module.Path, 'read_text', + return_value=f'{identity} {identity} 1\n'), patch.object(module.Path, 'lstat', metadata): + module._namespace_parent_pin(connection, '/var/tmp/parent/socket', '[7,23]') + for pin in ('[7,24]', '[true,23]', 'null', ''): + with self.subTest(pin=pin), self.assertRaises(PermissionError): + module._namespace_parent_pin(connection, '/var/tmp/parent/socket', pin) + with patch.object(module.Path, 'read_text', return_value='0 0 4294967295\n'), self.assertRaises(PermissionError): + module._namespace_parent_pin(connection, '/var/tmp/parent/socket', '[7,23]') + directory_metadata.st_mode = 0o40777 + with self.assertRaises(PermissionError): + module._namespace_parent_pin(connection, '/var/tmp/parent/socket', '[7,23]') + + def test_linux_bwrap_waits_for_unprivileged_readiness_and_stops_setup_before_uid_sweep(self): + from unittest.mock import MagicMock + module = self.owner_module() + namespace = MagicMock() + namespace.rewrite.return_value = ['trusted-bwrap'] + namespace.pass_fds = (31, 32) + leaf = SimpleNamespace(Namespace=MagicMock(return_value=namespace)) + with patch.object(module.os, 'geteuid', return_value=0), patch.object( + module, 'process_snapshot', return_value={}), patch.object( + module, 'reserve_identity', return_value=None), patch.object( + module, '_registered_identity', return_value=False), patch.object( + module.sys, 'platform', 'linux'), patch.dict(sys.modules, {'benchmarks.v3_linux_namespace': leaf}), patch.object( + module.subprocess, 'Popen') as popen: + owner = module.UIDOwner(uid=1_234_567_894) + process = owner.popen(['/usr/bin/bwrap', '--', 'worker'], cwd='/var/tmp', env={}, group=1_234_567_895) + leaf.Namespace.assert_called_once_with(owner.uid, 1_234_567_895) + namespace.attach.assert_called_once_with(process) + self.assertEqual(popen.call_args.args[0], ['trusted-bwrap']) + self.assertEqual(popen.call_args.kwargs['pass_fds'], (31, 32)) + self.assertNotIn('user', popen.call_args.kwargs) + self.assertNotIn('group', popen.call_args.kwargs) + def drained(): + namespace.close.assert_called_once() + return {} + with patch.object(module, 'process_snapshot', side_effect=drained): + owner.close() + self.assertTrue(owner.closed) + + def test_cleanup_cannot_certify_empty_while_a_native_spawn_is_still_starting(self): + import threading + from unittest.mock import MagicMock + module = self.owner_module() + entered, release, closing, finished = (threading.Event() for _ in range(4)) + process, errors = MagicMock(), [] + def spawn(*args, **kwargs): + entered.set() + if not release.wait(3): + raise TimeoutError('test spawn was not released') + return process + with patch.object(module.os, 'geteuid', return_value=0), patch.object( + module, 'process_snapshot', return_value={}), patch.object( + module, 'reserve_identity', return_value=None), patch.object( + module, '_registered_identity', return_value=False), patch.object( + module.subprocess, 'Popen', side_effect=spawn): + owner = module.UIDOwner(uid=1_234_567_896) + def launch(): + try: + owner.popen(['/usr/bin/true'], cwd='/var/tmp', env={}) + except Exception as exc: + errors.append(exc) + def close(): + closing.set() + owner.close() + finished.set() + launcher, closer = threading.Thread(target=launch), threading.Thread(target=close) + launcher.start() + try: + self.assertTrue(entered.wait(2)) + closer.start() + self.assertTrue(closing.wait(2)) + self.assertFalse(finished.wait(.1), 'cleanup certified before a pending spawn settled') + finally: + release.set() + launcher.join(3) + if closer.ident is not None: + closer.join(3) + owner.close() + self.assertFalse(errors) + self.assertTrue(owner.closed) + process.wait.assert_called() + + +@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0, + 'privileged disposable benchmark runner required') +class PrivilegedProcessOwnershipTests(unittest.TestCase): + def test_setid_executable_cannot_escape_the_owned_kernel_identity(self): + import shutil + from benchmarks.v3_process_owner import UIDOwner + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + root = Path(directory) + root.chmod(0o755) + executable = root / 'id' + shutil.copyfile('/usr/bin/id', executable) + executable.chmod(0o4755) + owner = UIDOwner() + try: + process = owner.popen([str(executable), '-u'], cwd='/var/tmp', + env={'PATH': os.environ['PATH']}, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=5) + self.assertTrue(process.returncode != 0 or output.strip() == str(owner.uid).encode(), + 'setuid executable escaped the owned UID: ' + repr((output, error))) + finally: + owner.close() + + def test_spawn_drops_identity_and_supplementary_groups_before_execution(self): + from benchmarks.v3_process_owner import UIDOwner + self.assertTrue(hasattr(UIDOwner, 'popen'), 'UID execution boundary is missing') + owner = UIDOwner() + try: + process = owner.popen([sys.executable, '-I', '-c', + 'import ctypes,json,os,sys\n' + 'if sys.platform == "darwin":\n' + ' libc=ctypes.CDLL(None,use_errno=True)\n' + ' count=libc.getgroups(0,None)\n' + ' if count<0: raise OSError(ctypes.get_errno(),"kernel group count")\n' + ' buffer=(ctypes.c_uint32*max(1,count))()\n' + ' count=libc.getgroups(count,buffer)\n' + ' if count<0: raise OSError(ctypes.get_errno(),"kernel groups")\n' + ' groups=list(buffer[:count])\n' + 'else: groups=os.getgroups()\n' + 'print(json.dumps([os.getuid(),os.getgid(),groups]))'], + cwd='/var/tmp', env={'PATH': os.environ['PATH']}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate(timeout=5) + self.assertEqual(process.returncode, 0, stderr) + import json + identity = json.loads(stdout) + self.assertEqual(identity[:2], [owner.uid, owner.uid]) + # Modern macOS os.getgroups queries directory membership, not this + # process's effective kernel groups; libc's legacy symbol checks it. + self.assertLessEqual(set(identity[2]), {owner.uid}) + finally: + owner.close() + + def test_cleanup_owns_daemon_after_fork_setsid_and_parent_exit(self): + import json + from benchmarks.v3_process_owner import UIDOwner, process_snapshot + self.assertTrue(hasattr(UIDOwner, 'popen'), 'UID execution boundary is missing') + owner = UIDOwner() + child = None + code = ('import os,time; pid=os.fork(); ' + 'os._exit(0) if pid else None; os.setsid(); ' + 'print(os.getpid(),flush=True); os.close(1); os.close(2); time.sleep(120)') + try: + process = owner.popen([sys.executable, '-I', '-c', code], cwd='/var/tmp', + env={'PATH': os.environ['PATH']}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output, error = process.communicate(timeout=5) + self.assertEqual(process.returncode, 0, error) + child = int(output.strip()) + self.assertEqual(process_snapshot()[child][0], owner.uid) + owner.close() + self.assertFalse(any(uid == owner.uid and not state.startswith(('Z', 'X')) + for uid, state in process_snapshot().values())) + finally: + if child is not None and process_snapshot().get(child, (None,))[0] == owner.uid: + try: + import signal + os.kill(child, signal.SIGKILL) + except ProcessLookupError: + pass + owner.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_v3_protected_worker.py b/tests/test_v3_protected_worker.py new file mode 100644 index 00000000..f090e383 --- /dev/null +++ b/tests/test_v3_protected_worker.py @@ -0,0 +1,700 @@ +"""Real local security gates for the benchmark-only external shell launcher.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import time +import unittest + +from benchmarks import v3_protected_worker as protected +from benchmarks.v3_shell_launcher import ShellPolicy, TicketAuthority + + +class TicketTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.policy = ShellPolicy(self.root, frozenset({'read', 'write', 'shell'}), + run_id='run', actor_id='actor', session_id='session') + self.auth = TicketAuthority(self.policy) + self.request = {'run_id': 'run', 'actor_id': 'actor', 'session_id': 'session', + 'call_id': 'call', 'command': 'printf hello', 'cwd': str(self.root), + 'env': {'PATH': os.environ['PATH'], 'MESSAGE': 'hello'}, 'revision': 0, 'timeout_seconds': 1, 'background': False} + + def test_ticket_binds_every_execution_field_and_is_single_use(self): + for field, value in [('command', 'touch hacked'), ('cwd', '/'), ('env', {'MESSAGE': 'bad'}), + ('actor_id', 'other'), ('session_id', 'other'), ('call_id', 'other')]: + with self.subTest(field=field): + ticket = self.auth.issue(self.request) + with self.assertRaises(PermissionError): + self.auth.consume(ticket, dict(self.request, **{field: value})) + ticket = self.auth.issue(self.request) + self.auth.consume(ticket, self.request) + with self.assertRaises(PermissionError): + self.auth.consume(ticket, self.request) + + def test_expired_and_stale_policy_tickets_fail_closed(self): + ticket = self.auth.issue(self.request, ttl_seconds=0.001) + time.sleep(.01) + with self.assertRaises(PermissionError): + self.auth.consume(ticket, self.request) + ticket = self.auth.issue(self.request) + self.policy.narrow(capabilities={'read'}, scope_paths=['.']) + with self.assertRaises(PermissionError): + self.auth.consume(ticket, self.request) + with self.assertRaises(PermissionError): + self.auth.issue(dict(self.request, revision=1)) + + def test_worker_claimed_approval_and_child_expansion_do_not_grant_shell(self): + self.policy.narrow(capabilities={'read'}, scope_paths=['.']) + with self.assertRaises(PermissionError): + self.auth.issue(dict(self.request, revision=1, approved=True, capabilities=['shell'])) + child = self.policy.child('child', capabilities={'read', 'write', 'shell'}) + self.assertEqual(child.capabilities, frozenset({'read'})) + with self.assertRaises(ValueError): + self.policy.narrow(capabilities={'read', 'shell'}, scope_paths=['.']) + + def test_scope_cannot_be_widened_by_cwd_or_symlink(self): + (self.root / 'allowed').mkdir() + self.policy.narrow(capabilities={'read', 'shell'}, scope_paths=['allowed']) + with self.assertRaises(PermissionError): + self.auth.issue(dict(self.request, revision=1)) + (self.root / 'allowed' / 'escape').symlink_to(self.root) + with self.assertRaises(PermissionError): + self.auth.issue(dict(self.request, revision=1, cwd=str(self.root / 'allowed' / 'escape'))) + + +class RemoteProcessStateTests(unittest.TestCase): + def test_parent_timeout_is_audited_before_a_delayed_expiry_and_preserves_explicit_stop_reason(self): + from unittest.mock import Mock + from benchmarks.v3_shell_launcher import RemoteProcess + from openkyrozen.tools.processes import ProcessHandle, SessionProcessManager + for reason in (None, 'cancelled', 'stopped'): + with self.subTest(existing_reason=reason): + client = Mock() + client.rpc.return_value = {'exit_code': -15, 'timed_out': True} + process = RemoteProcess(client, 'owned', 42, 'test') + manager = SessionProcessManager('owner', 'workspace', 'session') + handle = ProcessHandle('handle', process, 'owner', 'workspace', 'session', 1, + stop_reason=reason) + process._benchmark_handle = handle + manager._audit_event = Mock() + manager._audit_outcome(handle) + expected = reason or 'timed_out' + self.assertEqual(handle.stop_reason, expected) + self.assertEqual(manager._audit_event.call_args.args[0], expected) + self.assertEqual(manager._audit_event.call_args.kwargs['reason'], expected) + + def test_first_output_read_includes_the_parent_observed_timeout(self): + from unittest.mock import Mock + from benchmarks.v3_shell_launcher import RemoteProcess + from openkyrozen.tools.processes import ProcessHandle, SessionProcessManager + client = Mock() + client.rpc.return_value = {'exit_code': -15, 'timed_out': True} + process = RemoteProcess(client, 'owned', 42, 'test') + manager = SessionProcessManager('owner', 'workspace', 'session') + handle = ProcessHandle('handle', process, 'owner', 'workspace', 'session', 1) + process._benchmark_handle = handle + manager._handles[handle.process_id] = handle + result = manager.read(handle.process_id) + self.assertTrue(result['timed_out']) + self.assertFalse(result['running']) + self.assertEqual(result['exit_code'], -15) + self.assertEqual(client.rpc.call_count, 1) + + def test_output_read_uses_one_process_state_for_running_and_exit(self): + from unittest.mock import Mock + from openkyrozen.tools.processes import ProcessHandle, SessionProcessManager + process = Mock() + process.poll.side_effect = [None, 0] + manager = SessionProcessManager('owner', 'workspace', 'session') + handle = ProcessHandle('handle', process, 'owner', 'workspace', 'session', 1) + manager._handles[handle.process_id] = handle + result = manager.read(handle.process_id) + self.assertTrue(result['running']) + self.assertIsNone(result['exit_code']) + self.assertEqual(process.poll.call_count, 1) + + def test_observed_timeout_updates_its_handle_and_terminal_state_cannot_revert(self): + from types import SimpleNamespace + from unittest.mock import Mock + from benchmarks.v3_shell_launcher import RemoteProcess + client = Mock() + client.rpc.side_effect = [{'exit_code': -15, 'timed_out': True}, + {'exit_code': None, 'timed_out': False}] + process = RemoteProcess(client, 'owned', 42, 'test') + handle = SimpleNamespace(timed_out=False, stop_reason=None) + process._benchmark_handle = handle + self.assertEqual(process.poll(), -15) + self.assertTrue(handle.timed_out) + self.assertEqual(process.poll(), -15) + self.assertTrue(process.timed_out) + self.assertTrue(handle.timed_out) + + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class ProtectedWorkerTests(unittest.TestCase): + def test_security_preflight_executes_direct_and_external_managed_shell_attacks(self): + evidence = protected.verify_worker_protection(require_authenticated_children=False) + self.assertTrue(evidence['direct_attacks_denied']) + self.assertTrue(evidence['managed_shell_attacks_denied']) + self.assertTrue(evidence['source_write_allowed']) + + def test_real_preflight_probes_receive_no_parent_provider_credentials(self): + from unittest.mock import patch + from benchmarks import v3_matched_live + allowed = {'PATH', 'SYSTEMROOT', 'WINDIR', 'TMPDIR', 'TMP', 'TEMP'} + secrets = {'DEEPSEEK_API_KEY': 'fake-deepseek-secret', 'OPENAI_API_KEY': 'fake-openai-secret', + 'ANTHROPIC_API_KEY': 'fake-anthropic-secret', 'AWS_SECRET_ACCESS_KEY': 'fake-aws-secret', + 'KYROZEN_BENCHMARK_API_KEY': 'fake-benchmark-secret', 'GITHUB_TOKEN': 'fake-github-secret'} + incoming, managed, launched, probe_homes = [], [], [], [] + original_protect, original_worker = protected.protect_worker_command, protected.ProtectedWorker + def protect(command, **kwargs): + incoming.append(dict(kwargs['env'])) + probe_homes.append(str(Path(kwargs['cwd']).resolve().parent)) + return original_protect(command, **kwargs) + def worker(command, **kwargs): + managed.append(dict(kwargs['env'])) + launch = original_worker(command, **kwargs) + launched.append(dict(launch.env)) + return launch + with tempfile.TemporaryDirectory() as home: + environment = {key: value for key, value in os.environ.items() if key in allowed} + environment.update(secrets, HOME=home, USERPROFILE=home, PYTHONNOUSERSITE='0', + UNRELATED_PARENT_STATE='must-not-forward') + with patch.dict(os.environ, environment, clear=True), patch.object( + protected, 'protect_worker_command', side_effect=protect), patch.object( + protected, 'ProtectedWorker', side_effect=worker), patch.object( + v3_matched_live, '_credential', side_effect=AssertionError('credential lookup before protection')): + evidence = protected.verify_worker_protection(require_authenticated_children=False) + self.assertTrue(evidence['direct_attacks_denied']) + self.assertTrue(evidence['managed_shell_attacks_denied']) + self.assertTrue(evidence['source_write_allowed']) + self.assertEqual(len(incoming), 2) + self.assertEqual(len(managed), 1) + explicit = {'HOME', 'USERPROFILE', 'PYTHONNOUSERSITE'} + self.assertLessEqual(set(incoming[0]), allowed | explicit) + self.assertLessEqual(set(managed[0]), allowed | explicit | {'PYTHONPATH', 'KYROZEN_SANDBOX_REQUIRED'}) + self.assertEqual(managed[0]['KYROZEN_SANDBOX_REQUIRED'], '1') + self.assertEqual(managed[0]['PYTHONPATH'], str(Path(protected.__file__).resolve().parents[1])) + for environment in incoming + managed + launched: + self.assertTrue(set(secrets).isdisjoint(environment)) + self.assertNotIn('UNRELATED_PARENT_STATE', environment) + self.assertEqual(environment['HOME'], probe_homes[0]) + self.assertEqual(environment['USERPROFILE'], probe_homes[0]) + self.assertEqual(environment['PYTHONNOUSERSITE'], '1') + self.assertNotEqual(environment['HOME'], home) + for value in secrets.values(): + self.assertNotIn(value, json.dumps(environment)) + + def test_external_launcher_preserves_background_timeout_and_disconnect_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + guard = root / 'test_guard.py' + guard.write_text('original') + policy = ShellPolicy(root, frozenset({'read', 'write', 'shell'})) + script = ''' +import json, os, sys, time, threading +from benchmarks.v3_shell_launcher import install_worker_adapter +install_worker_adapter() +from openkyrozen.tools.processes import SessionProcessManager +m = SessionProcessManager('owner', sys.argv[1], 'session') +h = m.start('printf out; printf err >&2; exit 7', cwd=sys.argv[1], env=dict(os.environ), timeout_seconds=5) +h.process.wait(10) +time.sleep(.2) +r = m.read(h.process_id) +e = m.read(h.process_id, stream='stderr') +print(json.dumps([r['exit_code'], r['output'], e['output']]), flush=True) +original_expire, gate = m._expire, threading.Event() +def delayed_expiry(handle, **options): + gate.wait(10) + original_expire(handle, **options) +m._expire = delayed_expiry +try: + h = m.start('sleep 100 & wait', cwd=sys.argv[1], env=dict(os.environ), timeout_seconds=.2) + h.process.wait(10) + print(json.dumps([h.timed_out, h.process.poll() is not None]), flush=True) +finally: + gate.set() + m._expire = original_expire +h = m.start('sleep 100 & wait', cwd=sys.argv[1], env=dict(os.environ), timeout_seconds=100) +print('started', flush=True) +os._exit(0) +''' + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1])) + with protected.ProtectedWorker([sys.executable, '-c', script, str(root)], cwd=root, env=env, + protected_files=[guard], policy=policy) as launch: + process = subprocess.Popen(launch.command, cwd=root, env=launch.env, + pass_fds=launch.pass_fds, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + launch.attach(process) + stdout, stderr = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, stderr.decode()) + self.assertEqual(json.loads(stdout.splitlines()[0]), [7, 'out', 'err']) + self.assertEqual(json.loads(stdout.splitlines()[1]), [True, True]) + broker = launch.broker + self.assertFalse(any(p.poll() is None for p in broker.processes.values())) + +class ChildPolicyTests(unittest.TestCase): + def test_parent_narrowing_invalidates_outstanding_child_execution(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + parent = ShellPolicy(root, {'read', 'write', 'shell'}, run_id='run') + child = parent.child('child', capabilities={'read', 'write', 'shell'}) + authority = TicketAuthority(child) + request = {'run_id': 'run', 'actor_id': 'child', 'session_id': 'benchmark', + 'call_id': 'child-call', 'command': 'true', 'cwd': str(root), + 'env': {}, 'revision': 0, 'timeout_seconds': 1, 'background': False} + ticket = authority.issue(request) + parent.narrow(capabilities={'read'}, scope_paths=['.']) + with self.assertRaises(PermissionError): + authority.consume(ticket, request) + +class ParentAuthorizationTests(unittest.TestCase): + def test_accepted_plan_capability_and_effect_bounds_are_authoritative(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + bounds = {'workspace_paths': ['.'], 'capabilities': ['read', 'write'], + 'effects': ['read_only', 'filesystem', 'internal'], 'risk_ceiling': 'normal'} + policy = ShellPolicy(root, {'read', 'write', 'shell'}, accepted_bounds=bounds) + self.assertEqual(policy.capabilities, frozenset({'read', 'write'})) + request = {'run_id': policy.run_id, 'actor_id': policy.actor_id, + 'session_id': policy.session_id, 'call_id': 'call', 'command': 'true', + 'cwd': str(root), 'env': {}, 'revision': 0, 'timeout_seconds': 1, 'background': False} + with self.assertRaises(PermissionError): + TicketAuthority(policy).issue(request) + bounds['capabilities'].append('shell') + policy = ShellPolicy(root, {'read', 'write', 'shell'}, accepted_bounds=bounds) + request['run_id'] = policy.run_id + with self.assertRaises(PermissionError): + TicketAuthority(policy).issue(request) + + def test_parent_independently_blocks_dangerous_commands_and_unbounded_timeout(self): + with tempfile.TemporaryDirectory() as directory: + policy = ShellPolicy(Path(directory), {'read', 'write', 'shell'}) + request = {'run_id': policy.run_id, 'actor_id': policy.actor_id, + 'session_id': policy.session_id, 'call_id': 'call', 'command': 'rm -rf /tmp/probe', + 'cwd': str(Path(directory).resolve()), 'env': {}, 'revision': 0, 'timeout_seconds': 1, 'background': False} + with self.assertRaises(PermissionError): + TicketAuthority(policy).issue(request) + for timeout in (0, True, 1801, float('nan'), float('inf')): + with self.subTest(timeout=timeout), self.assertRaises(PermissionError): + TicketAuthority(policy).issue(dict(request, command='true', timeout_seconds=timeout)) + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class _BrokerCase(unittest.TestCase): + def run_worker(self, script, *, scope_paths=('.',)): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + guard = root / 'test_guard.py' + guard.write_text('original') + (root / 'allowed').mkdir() + policy = ShellPolicy(root, {'read', 'write', 'shell'}, scope_paths=scope_paths) + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1])) + with protected.ProtectedWorker([sys.executable, '-c', script], cwd=root, env=env, + protected_files=[guard], policy=policy) as launch: + process = subprocess.Popen(launch.command, cwd=root, env=launch.env, + pass_fds=launch.pass_fds, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + launch.attach(process) + stdout, stderr = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, stderr.decode()) + self.assertEqual(guard.read_text(), 'original') + return stdout.decode() + +class BrokerBoundaryTests(_BrokerCase): + def test_parent_timeout_applies_to_an_uncooperative_worker_and_returns_host_pid(self): + output = self.run_worker(''' +import json, os, time +from benchmarks.v3_shell_launcher import BrokerClient +client = BrokerClient(int(os.environ.pop('KYROZEN_BENCHMARK_BROKER_FD')), + json.loads(os.environ.pop('KYROZEN_BENCHMARK_BROKER_IDENTITY'))) +p = client.start('sleep 100', os.getcwd(), dict(os.environ), .2) +time.sleep(.5) +print(json.dumps([p.poll() is not None, p.timed_out, type(p.pid) is int and p.pid > 0])) +''') + self.assertEqual(json.loads(output), [True, True, True]) + + def test_forged_actor_is_denied_and_real_shell_has_no_control_socket_or_credentials(self): + output = self.run_worker(''' +import json, os, shlex, sys +from benchmarks.v3_shell_launcher import BrokerClient +identity = json.loads(os.environ.pop('KYROZEN_BENCHMARK_BROKER_IDENTITY')) +client = BrokerClient(int(os.environ.pop('KYROZEN_BENCHMARK_BROKER_FD')), identity) +client.identity = dict(identity, actor_id='forged-child') +try: + client.start('true', os.getcwd(), {}, 1) + raise AssertionError('forged actor granted shell') +except PermissionError: + pass +client.identity = identity +probe = "import os,socket,stat; assert not any('BROKER' in k or 'API_KEY' in k for k in os.environ); sockets=[]; " + \\ + "exec(\\\"for fd in range(3,256):\\\\n try:\\\\n mode=os.fstat(fd).st_mode\\\\n except OSError:\\\\n continue\\\\n if stat.S_ISSOCK(mode): sockets.append(fd)\\\"); assert not sockets, sockets; print('isolated')" +p = client.start(shlex.join([sys.executable, '-c', probe]), os.getcwd(), + dict(os.environ, KYROZEN_BENCHMARK_API_KEY='must-not-inherit'), 5) +p.wait(10) +assert p.returncode == 0, p.stderr.read1(4096) +print(p.stdout.read1(4096).decode().strip()) +''') + self.assertEqual(output.strip(), 'isolated') + + def test_full_campaign_gate_requires_a_privileged_parent_before_probes(self): + from unittest.mock import patch + with patch.object(protected.os, 'geteuid', return_value=501), patch.object( + protected, 'protect_worker_command', side_effect=AssertionError('unprivileged probe')): + with self.assertRaisesRegex(PermissionError, 'privileged'): + protected.verify_worker_protection() + +class ForegroundAdapterTests(_BrokerCase): + @unittest.skipUnless(sys.platform == 'darwin', 'macOS permits retained pipes after leader exit') + def test_cancellation_still_stops_owned_children_after_the_shell_leader_exits(self): + output = self.run_worker(''' +import json,os,threading,time +from benchmarks.v3_shell_launcher import install_worker_adapter +install_worker_adapter() +from openkyrozen.tools import ToolAdapters,processes +cancelled=threading.Event() +phase=threading.Event() +when=[] +original=processes.SessionProcessManager.start +def start(manager,*args,**kwargs): + handle=original(manager,*args,**kwargs) + def cancel_after_exit(): + deadline=time.monotonic()+5 + while handle.process.poll() is None and time.monotonic()&2; exit 7'}) +cancelled = threading.Event() +threading.Timer(.1, cancelled.set).start() +c = adapter.run_command({'command':'sleep 100'}, cancelled=cancelled) +t = adapter.run_command({'command':'sleep 100', 'timeout_seconds':.1}) +import shlex, sys +large = adapter.run_command({'command':shlex.join([sys.executable, "-c", "print('é'*600000,end='')"])}) +assert large.stdout == 'é'*600000, len(large.stdout) +print(json.dumps([r.success, r.exit_code, r.stdout, r.stderr, c.failure, t.failure, t.timed_out])) +''') + self.assertEqual(json.loads(output), [False, 7, 'out', 'err', 'cancelled', 'timeout', True]) + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class FrozenV2Tests(unittest.TestCase): + def test_frozen_v2_original_string_adapter_uses_broker_without_v3_process_api(self): + import io + import tarfile + repository = Path(__file__).resolve().parents[1] + archive = subprocess.run(['git', 'archive', '1bfc82c'], cwd=repository, + capture_output=True, check=True).stdout + with tempfile.TemporaryDirectory() as directory: + base = Path(directory).resolve() + baseline, root = base / 'baseline', base / 'workspace' + baseline.mkdir(); root.mkdir() + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(baseline, filter='data') + guard = root / 'test_guard.py' + guard.write_text('original') + script = ''' +import json, os, pathlib, runpy, sys +runpy.run_path(sys.argv[1])['install_worker_adapter']() +from openkyrozen.tools import ToolAdapters, shell +assert not hasattr(shell, '_validated_shell_options') +assert '/baseline/' in shell.__file__ +a = ToolAdapters(os.getcwd()) +r = a.run_command('printf out; printf err >&2; exit 7') +s = a.run_command('printf changed > test_guard.py; printf after > source.py') +d = a.run_command('rm -rf /tmp/not-run') +print(json.dumps([r.success, r.exit_code, 'stdout:\\nout' in r.output, 'stderr:\\nerr' in r.output, + s.success, d.failure, hasattr(a, 'new_process_manager')])) +''' + env = dict(os.environ, PYTHONPATH=os.pathsep.join(map(str, (baseline, repository)))) + with protected.ProtectedWorker([sys.executable, '-c', script, str(repository / 'benchmarks' / 'v3_shell_launcher.py')], cwd=root, env=env, + protected_files=[guard], policy=ShellPolicy(root, {'read', 'write', 'shell'})) as launch: + process = subprocess.Popen(launch.command, cwd=root, env=launch.env, pass_fds=launch.pass_fds, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + launch.attach(process) + stdout, stderr = process.communicate(timeout=30) + self.assertEqual(process.returncode, 0, stderr.decode()) + self.assertEqual(json.loads(stdout), [False, 7, True, True, True, 'denied', False]) + self.assertEqual(guard.read_text(), 'original') + self.assertEqual((root / 'source.py').read_text(), 'after') + +class SequentialShellTests(_BrokerCase): + def test_completed_foreground_calls_do_not_exhaust_background_concurrency_limit(self): + output = self.run_worker(''' +import json, os +from benchmarks.v3_shell_launcher import BrokerClient +client = BrokerClient(int(os.environ.pop('KYROZEN_BENCHMARK_BROKER_FD')), + json.loads(os.environ.pop('KYROZEN_BENCHMARK_BROKER_IDENTITY'))) +for _ in range(33): + p = client.start('true', os.getcwd(), {}, 1) + assert p.wait(5) == 0 + p.stop() +print('completed') +''') + self.assertEqual(output.strip(), 'completed') + +@unittest.skipUnless(sys.platform == 'darwin', 'macOS lifecycle limitation') +class DetachedLifecycleBoundaryTests(unittest.TestCase): + def test_same_session_cleanup_cannot_be_claimed_for_reparented_setsid_child(self): + """Characterize the blocker; this is deliberately not cleanup assurance.""" + import signal + import socket + from benchmarks.v3_shell_launcher import BrokerClient, ShellBroker + child_pid = None + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + parent, child = socket.socketpair() + policy = ShellPolicy(root, {'read', 'write', 'shell'}) + broker = ShellBroker(parent, policy, []) + identity = {k: getattr(policy, k) for k in ('run_id', 'actor_id', 'session_id', 'revision')} + client = BrokerClient(child.detach(), identity) + code = ('import os,time; p=os.fork(); os._exit(0) if p else None; ' + 'os.setsid(); print(os.getpid(),flush=True); os.close(1); os.close(2); time.sleep(30)') + try: + process = client.start(shlex.join([sys.executable, '-c', code]), str(root), {}, 2) + process.wait(5) + child_pid = int(process.stdout.read1(4096)) + broker.close() + # Parent/PGID traversal loses this detached child. The full gate + # stays closed until a kernel-owned lifecycle boundary replaces it. + os.kill(child_pid, 0) + evidence = protected.verify_worker_protection(require_authenticated_children=False) + self.assertFalse(evidence['daemonized_process_cleanup_proven']) + finally: + if child_pid is not None: + try: + os.kill(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass + broker.close() + client.stream.close() + client.connection.close() + +class ApprovedScopeFilesystemTests(_BrokerCase): + def test_shell_can_write_approved_child_directory_and_cannot_write_its_ancestor(self): + output = self.run_worker(''' +import json, os, pathlib, shlex, sys +from benchmarks.v3_shell_launcher import BrokerClient +client = BrokerClient(int(os.environ.pop('KYROZEN_BENCHMARK_BROKER_FD')), + json.loads(os.environ.pop('KYROZEN_BENCHMARK_BROKER_IDENTITY'))) +probe = "from pathlib import Path; Path('source.txt').write_text('allowed'); " + \\ + "exec(\\\"try:\\\\n Path('../outside.txt').write_text('blocked')\\\\nexcept OSError:\\\\n print('denied')\\\")" +p = client.start(shlex.join([sys.executable, '-c', probe]), str(pathlib.Path.cwd() / 'allowed'), {}, 5) +assert p.wait(10) == 0 +assert not pathlib.Path('outside.txt').exists() +assert pathlib.Path('allowed/source.txt').read_text() == 'allowed' +print(p.stdout.read1(4096).decode().strip()) +''', scope_paths=('allowed',)) + self.assertEqual(output.strip(), 'denied') + + +class LinuxMountOrderTests(unittest.TestCase): + def test_readonly_test_ancestor_precedes_writable_child_mount(self): + from unittest.mock import patch + from openkyrozen.tools.sandbox import SandboxedCommand + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + allowed = root / 'allowed' + allowed.mkdir() + guard = root / 'test_guard.py' + guard.write_text('original') + policy = ShellPolicy(root, {'read', 'write', 'shell'}, scope_paths=('allowed',)) + invocation = SandboxedCommand(['bwrap', '--ro-bind', '/', '/', '--bind', str(root), str(root), + '--', '/bin/sh', '-c', 'true'], False, {}, 'bubblewrap') + with patch('openkyrozen.tools.sandbox.sandbox_command', return_value=invocation): + result = protected.protected_shell_command('true', policy=policy, cwd=str(allowed), + env={}, protected_files=[guard]) + mounts = [] + argv = result.command + for index, item in enumerate(argv): + if item in {'--bind', '--ro-bind'}: + mounts.append((item, argv[index + 1], argv[index + 2])) + self.assertEqual(mounts[-2:], [('--bind', str(allowed), str(allowed)), + ('--ro-bind', str(guard), str(guard))]) + self.assertTrue(all(target != str(root) for _, _, target in mounts[mounts.index(mounts[-2]) + 1:])) + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class FullCampaignGateTests(unittest.TestCase): + def test_unprivileged_actor_authentication_blocks_before_credentials(self): + from unittest.mock import patch + from benchmarks.v3_matched_live import run + repository = Path(__file__).resolve().parents[1] + verify = protected.verify_worker_protection + def unprivileged_protection(): + # Simulate the protection caller without changing the real lease owner. + with patch.object(protected.os, 'geteuid', return_value=501): + return verify() + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + with patch('benchmarks.v3_matched_live._source_snapshot', return_value='candidate'), \ + patch('benchmarks.v3_matched_live.apparatus_identity', return_value={}), \ + patch.object(protected, 'verify_worker_protection', side_effect=unprivileged_protection), \ + patch('benchmarks.v3_matched_live._credential', side_effect=AssertionError('credential lookup')) as credential: + with self.assertRaisesRegex(PermissionError, 'privileged'): + run(source=repository, repo=repository, output=base / 'report.json', + budget_path=base / 'budget.sqlite3', prior_hold_usd='1') + credential.assert_not_called() + + +@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0, + 'privileged disposable benchmark runner required') +class PrivilegedFullPreflightTests(unittest.TestCase): + def test_full_preflight_executes_real_actor_attacks_and_certifies_all_owned_uids_empty(self): + evidence = protected.verify_worker_protection() + for name in ('authenticated_child_isolation', 'daemonized_process_cleanup_proven', + 'direct_attacks_denied', 'managed_shell_attacks_denied', 'source_write_allowed', + 'forged_identity_denied', 'cross_actor_handles_denied', 'ticket_replay_denied', + 'private_homes_isolated', 'fresh_file_reads', 'shell_environment_isolated', + 'shell_descriptors_isolated', 'actor_tmpdir_writable', 'command_cancel_cleanup', + 'actor_disconnect_cleanup', 'shell_disconnect_cleanup', 'all_owned_uids_empty', + 'directory_scope_guard_safe', 'closed_pipe_exit_preserved', + 'no_read_runtime_started', 'no_read_fixture_data_denied'): + with self.subTest(name=name): + self.assertIs(evidence[name], True) + self.assertGreaterEqual(evidence['checked_uid_count'], 4) + self.assertEqual(evidence['owned_uid_live_processes'], []) + +@unittest.skipUnless(sys.platform == 'darwin' or sys.platform.startswith('linux'), 'Unix sandbox required') +class BrokerCancellationCompatibilityTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'darwin', 'macOS permits retained pipes after leader exit') + def test_background_cancellation_remains_active_until_output_collection_finishes(self): + # The Linux PID namespace init can reap the child before this interval. + case = _BrokerCase() + output = case.run_worker(''' +import json,os,threading,time +from benchmarks.v3_shell_launcher import install_worker_adapter +install_worker_adapter() +from openkyrozen.tools.processes import SessionProcessManager +cancelled=threading.Event() +manager=SessionProcessManager('owner',os.getcwd(),'session') +try: + handle=manager.start('printf early; (sleep 6; printf late) & exit 0', + cwd=os.getcwd(),env=dict(os.environ),timeout_seconds=10,cancelled=cancelled) + deadline=time.monotonic()+5 + while handle.process.poll() is None and time.monotonic()", + "rsa2048", "sign", "0"], env=env, check=True, capture_output=True) + listing = subprocess.run(["gpg", "--batch", "--with-colons", "--list-secret-keys"], + env=env, check=True, capture_output=True, text=True).stdout + fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + records, candidate, waivers = release_fixtures.V3ReleaseGateTests().create_inconclusive_bundle(root) + for path in waivers.glob("*.json"): + waiver = json.loads(path.read_text()) + waiver["approver_identity"] = fingerprint + path.write_text(json.dumps(waiver)) + for path in list(records.glob("R*.json")) + list(waivers.glob("*.json")): + subprocess.run(command + ["--yes", "--local-user", fingerprint, "--armor", "--detach-sign", + "--output", str(path.with_suffix(".json.asc")), str(path)], + env=env, check=True, capture_output=True) + with patch.dict(os.environ, {"GNUPGHOME": str(keyring)}), \ + patch.object(gates, "SIGNER_FINGERPRINT", fingerprint): + manifest = gates.validate_evidence_bundle( + records, candidate, verify_signature=gates._gpg_verify, + limited_evidence=True, release_scope=gates.LIMITED_RELEASE_SCOPE, + ) + path = root / "preview.json" + path.write_text(json.dumps(manifest)) + subprocess.run(command + ["--yes", "--local-user", fingerprint, "--armor", "--detach-sign", + "--output", str(path.with_suffix(".json.asc")), str(path)], + env=env, check=True, capture_output=True) + self.assertEqual(gates.verify_signed_preview_manifest( + records, candidate, path, verify_signature=gates._gpg_verify)["result"], "limited-evidence") + with (waivers / "R259.json").open("a") as stream: + stream.write(" ") + with self.assertRaisesRegex(ValueError, "waiver signature"): + gates.verify_signed_preview_manifest(records, candidate, path, verify_signature=gates._gpg_verify) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_release_platforms.py b/tests/test_v3_release_platforms.py new file mode 100644 index 00000000..21485491 --- /dev/null +++ b/tests/test_v3_release_platforms.py @@ -0,0 +1,147 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from benchmarks.v3_release_platforms import REQUIRED_PLATFORMS, collect_platform_evidence +from benchmarks.v3_release_gates import _check_record + + +class V3ReleasePlatformsTests(unittest.TestCase): + def _matrix(self, root, *, mutate=None): + results = root / "results" + results.mkdir() + candidate = "a" * 40 + for platform in REQUIRED_PLATFORMS: + directory = results / f"v3-platform-{platform}" + directory.mkdir() + names = ("unit.log", "acceptance.log", "soak.json", "tui-acceptance.log") + if platform.startswith("windows-"): + names = ("acceptance.log", "soak.json", "supported-features.log", "tui-acceptance.log") + for name in names: + (directory / name).write_text(f"{platform}:{name}\n", encoding="utf-8") + record = { + "schema_version": 1, + "candidate_sha": candidate, + "platform": platform, + "unit": "passed", + "integration": "passed", + "acceptance": "passed", + "tui_interactive_acceptance": "passed", + "soak_seconds": 1800, + "environment": {"runner": "github", "os": platform.split("-")[0], + "python": f"3.{platform[-2:]}.0"}, + "dependencies": {"python": f"3.{platform[-2:]}.0"}, + "evidence_paths": ["unit.log", "acceptance.log", "tui-acceptance.log", "soak.json"], + } + if platform.startswith("windows-"): + record["evidence_paths"] = ["acceptance.log", "soak.json", "supported-features.log", "tui-acceptance.log"] + record.update(unit="not_run", integration="not_run", supported_features="passed", + tui_interactive_acceptance="not_run_posix_pty_unavailable") + if mutate: + mutate(platform, record) + (directory / "platform.json").write_text(json.dumps(record), encoding="utf-8") + commands = root / "commands.yml" + commands.write_text("commands: test, acceptance, soak\n", encoding="utf-8") + return results, commands, candidate + + def test_collects_exact_candidate_matrix_into_unsigned_r266_record(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root) + output = root / "evidence" + record = collect_platform_evidence(results, output, candidate, commands) + self.assertEqual(record["gate_id"], "R266") + self.assertEqual(record["candidate_sha"], candidate) + self.assertEqual({row["platform"] for row in record["platform_results"]}, + set(REQUIRED_PLATFORMS)) + self.assertEqual(len(record["evidence"]), 30) + self.assertEqual(record["command_set_sha256"], hashlib.sha256( + (output / record["command_set_path"]).read_bytes()).hexdigest()) + self.assertEqual(record["fixture_set_sha256"], hashlib.sha256( + (output / record["fixture_set_path"]).read_bytes()).hexdigest()) + self.assertTrue((output / "R266.json").is_file()) + self.assertFalse((output / "R266.json.asc").exists()) + self.assertEqual(len(_check_record(record, "R266", candidate, output.resolve())), 30) + + def test_windows_uses_supported_feature_acceptance_without_full_python_suites(self): + def supported_windows(platform, record): + if platform.startswith("windows-"): + record["unit"] = "not_run" + record["integration"] = "not_run" + record["supported_features"] = "passed" + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root, mutate=supported_windows) + record = collect_platform_evidence(results, root / "evidence", candidate, commands) + windows = [row for row in record["platform_results"] + if row["platform"].startswith("windows-")] + self.assertEqual(len(windows), 2) + self.assertTrue(all(row["unit"] == "not_run" + and row["integration"] == "not_run" + and row["supported_features"] == "passed" + and row["tui_interactive_acceptance"] == "not_run_posix_pty_unavailable" + for row in windows)) + + def test_rejects_incomplete_or_stale_platform_results(self): + for mutate, expected in ( + (lambda platform, record: record.update(candidate_sha="b" * 40), "candidate SHA"), + (lambda platform, record: record.update(soak_seconds=1799), "30-minute soak"), + (lambda platform, record: record.update(unit="failed"), "platform validation"), + (lambda platform, record: record.pop("tui_interactive_acceptance") + if platform.startswith("windows-") else None, "supported-feature acceptance"), + (lambda platform, record: record["environment"].update(os="Windows"), "OS or Python"), + ): + with self.subTest(expected=expected), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root, mutate=mutate) + with self.assertRaisesRegex(ValueError, expected): + collect_platform_evidence(results, root / "evidence", candidate, commands) + + def test_rejects_missing_artifact_and_existing_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root) + (results / f"v3-platform-{REQUIRED_PLATFORMS[0]}" / "unit.log").unlink() + with self.assertRaisesRegex(ValueError, "artifact"): + collect_platform_evidence(results, root / "evidence", candidate, commands) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root) + output = root / "evidence" + output.mkdir() + with self.assertRaisesRegex(ValueError, "already exists"): + collect_platform_evidence(results, output, candidate, commands) + + def test_rejects_artifact_paths_that_escape_the_platform_bundle(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root) + result_path = results / f"v3-platform-{REQUIRED_PLATFORMS[0]}" / "platform.json" + result = json.loads(result_path.read_text(encoding="utf-8")) + result["evidence_paths"][0] = "../../outside.log" + result_path.write_text(json.dumps(result), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "escapes"): + collect_platform_evidence(results, root / "evidence", candidate, commands) + + def test_rejects_symlinked_platform_metadata(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + results, commands, candidate = self._matrix(root) + platform_dir = results / f"v3-platform-{REQUIRED_PLATFORMS[0]}" + metadata = platform_dir / "platform.json" + external = root / "external.json" + external.write_text(metadata.read_text(encoding="utf-8"), encoding="utf-8") + metadata.unlink() + try: + metadata.symlink_to(external) + except OSError as exc: + self.skipTest(f"platform does not permit symlink creation: {exc}") + with self.assertRaisesRegex(ValueError, "cannot be a symlink"): + collect_platform_evidence(results, root / "evidence", candidate, commands) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_responses_relay.py b/tests/test_v3_responses_relay.py new file mode 100644 index 00000000..8f97c924 --- /dev/null +++ b/tests/test_v3_responses_relay.py @@ -0,0 +1,432 @@ +import json +import socket +import struct +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +import requests + +from benchmarks.v3_matched import BudgetLedger, _cost +from benchmarks.v3_responses_relay import MODEL, MAX_OUTPUT_TOKENS, ResponsesRelay +from openkyrozen.providers import ProviderConfig +from openkyrozen.providers.openai import OpenAIResponsesProvider + + +class V3ResponsesRelayTests(unittest.TestCase): + def setUp(self): + self.received = [] + self.remaining_at_forward = [] + self.stream_delimiter = ["\n\n"] + self.stream_terminal_type = ["response.completed"] + self.stream_error_code = [None] + self.stream_control = {"enabled": False, "first_entered": threading.Event(), + "release_first": threading.Event(), "second_arrived": threading.Event()} + received = self.received + remaining_at_forward = self.remaining_at_forward + delimiter = self.stream_delimiter + stream_terminal_type = self.stream_terminal_type + stream_error_code = self.stream_error_code + stream_control = self.stream_control + budget_ref = {} + + class Upstream(BaseHTTPRequestHandler): + usage = {"input_tokens": 12, "output_tokens": 4, + "input_tokens_details": {"cached_tokens": 3}, + "cache_creation_input_tokens": 2} + response_payload = None + http_status = 200 + + def log_message(self, *_args): + pass + + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + received.append(request) + if request.get("stream") and stream_control["enabled"]: + if len(received) == 1: + stream_control["first_entered"].set() + else: + stream_control["second_arrived"].set() + remaining_at_forward.append(budget_ref["ledger"].remaining_rmb) + if request.get("stream"): + result = {"id": "response-fixture", "output": [], "usage": self.usage} + if stream_terminal_type[0] == "response.failed" and stream_error_code[0] is not None: + result["error"] = {"code": stream_error_code[0]} + payload = (f"event: {stream_terminal_type[0]}\ndata: " + json.dumps({ + "type": stream_terminal_type[0], "response": result, + }) + delimiter[0]).encode() + else: + payload = json.dumps({"id": "response-fixture", "output": [], "usage": self.usage}).encode() + if self.response_payload is not None: + payload = self.response_payload + self.send_response(self.http_status) + self.send_header("Content-Type", "text/event-stream" if request.get("stream") else "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if request.get("stream") and stream_control["enabled"] and len(received) == 1: + stream_control["release_first"].wait(3) + self.wfile.write(payload) + + self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), Upstream) + self.upstream_thread = threading.Thread(target=self.upstream.serve_forever, daemon=True) + self.upstream_thread.start() + self.tempdir = tempfile.TemporaryDirectory() + self.budget = BudgetLedger(Path(self.tempdir.name) / "budget.sqlite3", limit_rmb="0.1") + budget_ref["ledger"] = self.budget + self.relay = ResponsesRelay( + endpoint=f"http://127.0.0.1:{self.upstream.server_port}/v1", + api_key="test-upstream-key", budget=self.budget, + ) + self.relay.start() + + def tearDown(self): + self.relay.close() + self.upstream.shutdown() + self.upstream.server_close() + self.upstream_thread.join() + self.tempdir.cleanup() + + def test_reserves_before_forwarding_and_forces_matched_responses_settings(self): + self.assertEqual(MODEL, "gemini-3.1-flash-lite") + response = requests.post(self.relay.url + "/responses", json={ + "model": "wrong-model", "input": [{"role": "user", "content": "fixture"}], + "stream": True, + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.received[0]["model"], MODEL) + self.assertEqual(self.received[0]["max_output_tokens"], MAX_OUTPUT_TOKENS) + self.assertTrue(self.received[0]["stream"]) + self.assertIn("event: response.completed", response.text) + self.assertLess(self.remaining_at_forward[0], self.budget.limit_picormb / 1_000_000_000_000) + self.assertAlmostEqual(float(self.budget.spent_rmb), float(_cost(12, 4, 3, 2))) + self.assertEqual(self.relay.rows[0]["status"], "settled") + self.assertEqual(self.relay.rows[0]["usage"]["cache_read_tokens"], 3) + self.assertEqual(self.relay.rows[0]["usage"]["cache_write_tokens"], 2) + + def test_rejects_unauthenticated_local_requests_without_spending(self): + response = requests.post(self.relay.url + "/responses", json={"input": []}, timeout=3) + + self.assertEqual(response.status_code, 401) + self.assertEqual(self.received, []) + self.assertEqual(self.budget.spent_rmb, 0) + + def test_actor_credentials_are_distinct_revocable_and_bound_to_the_parent_cell(self): + self.assertTrue(hasattr(self.relay, 'issue_actor_config'), 'actor relay authorization is missing') + self.relay.label = ('v3', 'fixture', 0) + author = self.relay.issue_actor_config('author') + reviewer = self.relay.issue_actor_config('reviewer') + self.assertNotEqual(author['api_key'], reviewer['api_key']) + self.assertNotEqual(author['api_key'], self.relay.client_key) + self.relay.revoke_actor('author') + denied = requests.post(self.relay.url + '/responses', json={'input': []}, + headers={'Authorization': 'Bearer ' + author['api_key']}, timeout=3) + self.assertEqual(denied.status_code, 401) + self.assertEqual(self.received, []) + accepted = requests.post(self.relay.url + '/responses', json={'input': []}, + headers={'Authorization': 'Bearer ' + reviewer['api_key']}, timeout=3) + self.assertEqual(accepted.status_code, 200) + self.assertEqual(self.relay.rows[-1]['actor_id'], 'reviewer') + self.relay.label = ('v2', 'other-fixture', 1) + denied = requests.post(self.relay.url + '/responses', json={'input': []}, + headers={'Authorization': 'Bearer ' + reviewer['api_key']}, timeout=3) + self.assertEqual(denied.status_code, 401) + self.assertEqual(len(self.received), 1) + self.assertIn(author['api_key'], self.relay.actor_keys) + + def test_upstream_error_holds_reservation_and_disables_further_forwarding(self): + self.upstream.RequestHandlerClass.http_status = 400 + headers = {"Authorization": "Bearer " + self.relay.client_key} + response = requests.post(self.relay.url + "/responses", json={ + "model": MODEL, "input": [{"role": "user", "content": "fixture"}], + }, headers=headers, timeout=3) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.relay.provider_error, "upstream_http_400") + self.assertEqual(self.relay.rows[0]["status"], "unknown") + remaining = self.budget.remaining_rmb + blocked = requests.post(self.relay.url + "/responses", json={ + "model": MODEL, "input": [{"role": "user", "content": "retry"}], + }, headers=headers, timeout=3) + self.assertEqual(blocked.status_code, 503) + self.assertEqual(len(self.received), 1) + self.assertEqual(self.budget.remaining_rmb, remaining) + + def test_upstream_http_error_persists_only_a_bounded_safe_error_code(self): + upstream = self.upstream.RequestHandlerClass + upstream.http_status = 502 + upstream.response_payload = json.dumps({ + "error": {"code": "AI_UPSTREAM_BUSY", "message": "secret diagnostic text"}, + }).encode() + headers = {"Authorization": "Bearer " + self.relay.client_key} + + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], + }, headers=headers, timeout=3) + + self.assertEqual(response.status_code, 502) + self.assertEqual(self.relay.provider_error, "upstream_http_502:AI_UPSTREAM_BUSY") + self.assertNotIn("secret diagnostic text", self.relay.provider_error) + self.assertEqual(self.relay.rows[0]["status"], "unknown") + + def test_oversized_upstream_http_error_body_is_not_parsed_or_persisted(self): + upstream = self.upstream.RequestHandlerClass + upstream.http_status = 502 + upstream.response_payload = json.dumps({ + "error": {"code": "AI_UPSTREAM_BUSY", "message": "x" * 70_000}, + }).encode() + headers = {"Authorization": "Bearer " + self.relay.client_key} + + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], + }, headers=headers, timeout=3) + + self.assertEqual(response.status_code, 502) + self.assertEqual(self.relay.provider_error, "upstream_http_502") + + def test_upstream_transport_error_disables_further_forwarding(self): + headers = {"Authorization": "Bearer " + self.relay.client_key} + original_post = requests.post + calls = [] + + def fail_transport(*_args, **_kwargs): + calls.append(True) + raise requests.ConnectionError("private upstream detail") + + requests.post = fail_transport + try: + response = requests.Session().post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], + }, headers=headers, timeout=3) + finally: + requests.post = original_post + + self.assertEqual(response.status_code, 502) + self.assertEqual(self.relay.provider_error, "upstream_transport_ConnectionError") + self.assertNotIn("private upstream detail", self.relay.provider_error) + self.assertEqual(self.relay.rows[0]["status"], "unknown") + self.assertEqual(self.relay.rows[0]["transport_error"], "ConnectionError") + blocked = requests.post(self.relay.url + "/responses", json={"input": []}, + headers=headers, timeout=3) + self.assertEqual(blocked.status_code, 503) + self.assertEqual(len(calls), 1) + + def test_provider_failed_stream_event_disables_subsequent_requests(self): + self.stream_terminal_type[0] = "response.failed" + self.stream_error_code[0] = "AI_REQUEST_REJECTED" + headers = {"Authorization": "Bearer " + self.relay.client_key} + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], "stream": True, + }, headers=headers, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.relay.provider_error, "upstream_response_failed:AI_REQUEST_REJECTED") + self.assertEqual(self.relay.rows[0]["status"], "settled") + blocked = requests.post(self.relay.url + "/responses", json={"input": []}, + headers=headers, timeout=3) + self.assertEqual(blocked.status_code, 503) + self.assertEqual(len(self.received), 1) + + def test_provider_failed_stream_event_redacts_unbounded_error_code(self): + self.stream_terminal_type[0] = "response.failed" + self.stream_error_code[0] = "secret key must not be logged" + headers = {"Authorization": "Bearer " + self.relay.client_key} + + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], "stream": True, + }, headers=headers, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.relay.provider_error, "upstream_response_failed") + self.assertNotIn("secret", self.relay.provider_error) + + def test_concurrent_requests_do_not_bypass_provider_fail_stop(self): + entered = threading.Event() + release = threading.Event() + original = self.upstream.RequestHandlerClass.do_POST + handler_class = self.upstream.RequestHandlerClass + handler_class.http_status = 400 + + def delayed_error(handler): + entered.set() + release.wait(3) + original(handler) + + handler_class.do_POST = delayed_error + headers = {"Authorization": "Bearer " + self.relay.client_key} + responses = [] + + def request(): + responses.append(requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], + }, headers=headers, timeout=5)) + + try: + first = threading.Thread(target=request) + second = threading.Thread(target=request) + first.start() + self.assertTrue(entered.wait(2)) + second.start() + time.sleep(0.1) + release.set() + first.join(5) + second.join(5) + finally: + release.set() + handler_class.do_POST = original + + self.assertEqual(sorted(response.status_code for response in responses), [400, 503]) + self.assertEqual(len(self.received), 1) + + def test_streaming_responses_do_not_serialize_independent_requests(self): + control = self.stream_control + control["enabled"] = True + responses = [] + headers = {"Authorization": "Bearer " + self.relay.client_key} + + def request(): + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], "stream": True, + }, headers=headers, timeout=5) + responses.append((response.status_code, response.content)) + + first = threading.Thread(target=request) + second = threading.Thread(target=request) + first.start() + self.assertTrue(control["first_entered"].wait(2)) + second.start() + second_arrived_before_release = control["second_arrived"].wait(2) + control["release_first"].set() + first.join(5) + second.join(5) + + self.assertTrue(second_arrived_before_release) + self.assertEqual(sorted(status for status, _ in responses), [200, 200]) + self.assertEqual(len(self.received), 2) + + def test_local_client_disconnect_does_not_latch_provider_failure(self): + handler_class = self.upstream.RequestHandlerClass + original_payload = handler_class.response_payload + handler_class.response_payload = json.dumps({"output": "x" * 8_000_000, + "usage": handler_class.usage}).encode() + body = json.dumps({"input": [{"role": "user", "content": "fixture"}]}).encode() + client = socket.create_connection(("127.0.0.1", self.relay._server.server_port), timeout=3) + try: + client.sendall((f"POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\n" + f"Authorization: Bearer {self.relay.client_key}\r\n" + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n").encode() + body) + client.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + finally: + client.close() + + deadline = time.monotonic() + 5 + while (not self.relay.rows or "latency_ms" not in self.relay.rows[0]) and time.monotonic() < deadline: + time.sleep(0.01) + handler_class.response_payload = original_payload + self.assertEqual(self.relay.rows[0]["status"], "settled") + self.assertIsNone(self.relay.provider_error) + + def test_preserves_nonstreaming_native_tool_request_shape(self): + response = requests.post(self.relay.url + "/responses", json={ + "model": "wrong-model", "input": [{"role": "user", "content": "fixture"}], + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.received[0]["stream"]) + self.assertEqual(response.json()["id"], "response-fixture") + self.assertEqual(self.relay.rows[0]["status"], "settled") + + def test_system_messages_are_normalized_for_every_benchmark_arm(self): + user_message = {"role": "user", "content": "fixture"} + response = requests.post(self.relay.url + "/responses", json={ + "instructions": "base instructions", + "input": [ + {"role": "system", "content": "system guidance"}, + user_message, + {"role": "system", "content": "workspace guidance"}, + ], + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.received[0]["instructions"], + "base instructions\n\nsystem guidance\n\nworkspace guidance") + self.assertEqual(self.received[0]["input"], [user_message]) + + def test_system_only_requests_become_an_instruction_and_continue_input(self): + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "system", "content": "Summarize the available state."}], + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.received[0]["instructions"], "Summarize the available state.") + self.assertEqual(self.received[0]["input"], "Continue.") + + def test_malformed_existing_instructions_are_rejected_without_spending(self): + response = requests.post(self.relay.url + "/responses", json={ + "instructions": ["invalid"], + "input": [{"role": "system", "content": "system guidance"}], + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 400) + self.assertEqual(self.received, []) + self.assertEqual(self.relay.rows, []) + self.assertEqual(self.budget.spent_rmb, 0) + + def test_openai_responses_provider_uses_the_authenticated_relay(self): + provider = OpenAIResponsesProvider(ProviderConfig( + provider="openai", api_key=self.relay.client_key, base_url=self.relay.url, + model_simple=MODEL, model_complex=MODEL, + )) + response = provider.chat_response([{"role": "user", "content": "fixture"}]) + + self.assertEqual(response.text, "") + self.assertEqual(response.usage["prompt_tokens"], 12) + self.assertEqual(self.received[0]["model"], MODEL) + self.assertFalse(self.received[0]["stream"]) + + def test_stream_usage_is_settled_for_crlf_event_boundaries(self): + self.stream_delimiter[0] = "\r\n\r\n" + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], "stream": True, + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.relay.rows[0]["status"], "settled") + self.assertEqual(self.relay.rows[0]["usage"]["input_tokens"], 12) + + def test_missing_usage_terminal_event_is_held_once_without_reading_past_terminal(self): + upstream_url = self.relay.endpoint + "/responses" + original_post = requests.post + + class IncompleteStream: + status_code = 200 + + def close(self): + pass + + def iter_content(self, chunk_size): + yield ("event: response.completed\ndata: " + json.dumps({ + "type": "response.completed", "response": {"id": "missing-usage"}, + }) + "\n\n").encode() + raise requests.ReadTimeout("stream remained open after terminal event") + + def post(url, *args, **kwargs): + return IncompleteStream() if url == upstream_url else original_post(url, *args, **kwargs) + + with patch("benchmarks.v3_responses_relay.requests.post", side_effect=post): + response = requests.post(self.relay.url + "/responses", json={ + "input": [{"role": "user", "content": "fixture"}], "stream": True, + }, headers={"Authorization": "Bearer " + self.relay.client_key}, timeout=3) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.relay.rows[0]["status"], "unknown") + self.assertIsNone(self.relay.provider_error) + self.assertEqual(self.budget.campaign_calls()[0]["status"], "unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_routing_ablation.py b/tests/test_v3_routing_ablation.py new file mode 100644 index 00000000..e3d648c3 --- /dev/null +++ b/tests/test_v3_routing_ablation.py @@ -0,0 +1,153 @@ +import importlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import MethodType, SimpleNamespace + +from openkyrozen.learning.engine import LearningEngine +from openkyrozen.memory.retrieval import _has_cjk, _cjk_approximate_words +from openkyrozen.providers.config import ProviderConfig +from openkyrozen.routing.router import _classify_complexity, _select_model, decide_request + + +class SharedRoutingHeuristicTests(unittest.TestCase): + def setUp(self): + self.runtime = SimpleNamespace(_provider_config=ProviderConfig( + provider="deepseek", model_simple="quick", model_complex="reasoning", + )) + self.runtime._has_cjk = MethodType(_has_cjk, self.runtime) + self.runtime._cjk_approximate_words = MethodType(_cjk_approximate_words, self.runtime) + + def decision(self, request, mode="agent"): + return decide_request( + self.runtime._provider_config, {}, request, + lambda text: _select_model(self.runtime, text), + lambda text: _classify_complexity(self.runtime, text), + LearningEngine.route_profile(request), "auto", mode, + ) + + def test_substantive_task_signals_survive_greetings_questions_and_short_requests(self): + for request in ( + "Hey! Implement the cache and add coverage for expiry.", + "What is needed here? First inspect settings, then repair and verify the result.", + "你好,请重构模块并运行测试。", + "Implement caching", + ): + with self.subTest(request=request): + decision, complexity = self.decision(request) + self.assertEqual(complexity, "complex") + self.assertTrue(decision.planning_required) + self.assertEqual(decision.model, "reasoning") + self.assertFalse(self.decision(request, "ask")[0].planning_required) + + def test_exception_diagnostics_match_after_case_normalization(self): + for request in ("Investigate a TypeError raised by the callback.", + "ValueError on startup", "ATTRIBUTEERROR during parsing"): + with self.subTest(request=request): + self.assertEqual(self.decision(request)[1], "complex") + + def test_factual_and_single_action_requests_do_not_gain_a_plan(self): + for request, expected in (("Hello!", "simple"), + ("What is a TypeError?", "simple"), + ("What is the next prime after eleven?", "simple"), + ("What is the first name on this list?", "simple"), + ("Read configuration.json.", "medium")): + with self.subTest(request=request): + decision, complexity = self.decision(request) + self.assertEqual(complexity, expected) + self.assertFalse(decision.planning_required) + + def test_model_selection_distinguishes_code_concepts_from_requested_changes(self): + for request in ("Describe what this class represents in one sentence.", + "What is a Python function?", "Briefly explain this code snippet.", + "用一句话解释这个函数。"): + with self.subTest(request=request): + self.assertEqual(_select_model(self.runtime, request), "quick") + for request in ("Write a function that checks file hashes.", + "Refactor the query parser and add coverage.", + "First open settings, then read the selected config."): + with self.subTest(request=request): + self.assertEqual(_select_model(self.runtime, request), "reasoning") + + def test_profile_recognizes_regular_inflections_punctuation_and_contiguous_cjk(self): + for request in ("The regression tests are failing.", "Review tested changes.", + "Document these classes.", "Inspect python-based tooling.", + "请检查数据库连接并补充测试。", "使用Python读取文件。"): + with self.subTest(request=request): + self.assertEqual(LearningEngine.route_profile(request), "coder") + self.assertEqual(LearningEngine.route_profile(request, "researcher"), "researcher") + for request in ("Summarize the climate report.", "Research classical music.", + "Explain contest eligibility.", "总结新闻报道。"): + with self.subTest(request=request): + self.assertEqual(LearningEngine.route_profile(request), "researcher") + + +class RoutingAblationTests(unittest.TestCase): + def benchmark(self): + self.assertIsNotNone( + importlib.util.find_spec("benchmarks.v3_routing_ablation"), + "The offline routing ablation has not been implemented", + ) + return importlib.import_module("benchmarks.v3_routing_ablation") + + def test_offline_report_keeps_feature_ablations_independent(self): + report = self.benchmark().run(repeats=2) + rows = {row["case"]: row for row in report["rows"]} + self.assertEqual(len(rows), 14) + self.assertEqual(rows["route_fact"]["expected"], { + "planning": False, "model": "simple", "profile": "researcher", + }) + self.assertEqual(rows["route_code_change"]["expected"], { + "planning": True, "model": "reasoning", "profile": "coder", + }) + for row in rows.values(): + baseline = row["variants"]["default"] + for feature, summary in report["features"].items(): + for variant in summary["constant_ablations"]: + actual = row["variants"][variant] + for unchanged in {"planning", "model", "profile"} - {feature}: + self.assertEqual(actual[unchanged], baseline[unchanged]) + correct = sum(item["variants"]["default"][feature] == item["expected"][feature] + for item in rows.values()) + self.assertEqual(summary["default"]["correct"], correct) + self.assertGreaterEqual(summary["default"]["routing_median_us"], 0) + self.assertEqual(report["provider_calls"], 0) + self.assertFalse(report["model_quality_evaluated"]) + self.assertFalse(report["release_requirements_certified"]) + + def test_candidate_binding_rejects_another_commit_before_running(self): + benchmark = self.benchmark() + with self.assertRaisesRegex(ValueError, "candidate"): + benchmark.run(repeats=1, candidate_sha="0" * 40) + for repeats in (0, -1, True): + with self.assertRaises(ValueError): + benchmark.run(repeats=repeats) + + def test_cli_is_offline_and_leaves_private_state_empty(self): + self.benchmark() + with tempfile.TemporaryDirectory() as directory: + private = Path(directory) + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(private), + "KYROZEN_DB_PATH": str(private / "state.sqlite3"), + "PYTHONPATH": str(Path(__file__).resolve().parents[1]), + "PYTHONDONTWRITEBYTECODE": "1"} + command = ( + "import runpy, socket, sys; " + "socket.socket.connect=lambda *a, **k: (_ for _ in ()).throw(AssertionError('network access')); " + "sys.argv=['v3_routing_ablation','--repeats','1']; " + "runpy.run_module('benchmarks.v3_routing_ablation',run_name='__main__')" + ) + result = subprocess.run([sys.executable, "-c", command], cwd=private, + env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["provider_calls"], 0) + self.assertEqual(list(private.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v3_routing_supplement.py b/tests/test_v3_routing_supplement.py new file mode 100644 index 00000000..195b058d --- /dev/null +++ b/tests/test_v3_routing_supplement.py @@ -0,0 +1,553 @@ +"""Offline mechanism/protocol checks; no provider or credential access.""" +import copy +from contextlib import closing +from dataclasses import asdict +from decimal import Decimal +import importlib +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +def supplement(): + return importlib.import_module('benchmarks.v3_routing_supplement') + + +class RoutingProtocolTests(unittest.TestCase): + def test_schedule_is_the_frozen_balanced_54_cells_without_modifying_core(self): + from benchmarks.v3_fixtures import FIXTURES, cohort_digest + module = supplement() + original = copy.deepcopy(FIXTURES), cohort_digest() + schedule = module.schedule() + self.assertEqual(len(schedule), 54) + self.assertEqual(len({(row['fixture_id'], row['repeat'], row['variant']) for row in schedule}), 54) + self.assertEqual([row['fixture_id'] for row in schedule[::9]], list(module.FIXTURE_IDS)) + from collections import Counter + orders = Counter(tuple(row['variant'] for row in schedule[index:index+3]) + for index in range(0,54,3)) + self.assertEqual(sorted(orders.values()), [3]*6) + self.assertEqual((FIXTURES,cohort_digest()), original) + + def test_only_one_resolved_field_changes_and_explicit_roles_and_modes_are_preserved(self): + from openkyrozen.routing.router import RoutingDecision + module = supplement() + before = RoutingDecision('openai','deepseek-flash','standard',None,'coder',True) + for variant, expected in [('default',{}),('planning-never',{'planning_required':False}), + ('profile-fixed-researcher',{'profile':'researcher'})]: + after = module.intervene(before, variant, profile_override='auto', interaction_mode='agent') + self.assertEqual(asdict(after), dict(asdict(before), **expected)) + for role in ('coder','researcher','reviewer','custom-role',None): + self.assertEqual(module.intervene(before,'profile-fixed-researcher',profile_override=role, + interaction_mode='agent'), before) + for mode in ('ask','plan'): + decision = type(before)(**dict(asdict(before),planning_required=False)) + self.assertEqual(module.intervene(decision,'planning-never',profile_override='auto', + interaction_mode=mode), decision) + with self.assertRaises(ValueError): + module.intervene(before,'planning-always',profile_override='auto',interaction_mode='agent') + + def test_analysis_never_turns_labels_or_incomplete_cells_into_a_benefit(self): + module = supplement() + self.assertFalse(module.analyze([])['complete']) + rows = [dict(row, physical_success=True, verified_success=True, workflow_state_correct=True, + safety_passed=True, usage_complete=True, worker_result_complete=True, + evidence_valid=True, routing_evidence_valid=True, cost_cny='0.01',requests=1, + input_tokens=100,output_tokens=20,latency_ms=10, + routing_treatment_active=row['variant']!='default') for row in module.schedule()] + equal = module.analyze(rows) + self.assertTrue(equal['complete']) + self.assertFalse(equal['benefit_passed']) + for row in rows: + if row['variant']=='default': row['cost_cny']='0.005' + efficient = module.analyze(rows) + self.assertTrue(efficient['benefit_passed']) + self.assertTrue(all(item['endpoint']=='cost_per_accepted_task' for item in efficient['contrasts'].values())) + rows[0]['routing_evidence_valid']=False + self.assertFalse(module.analyze(rows)['benefit_passed']) + + def test_fixture_cluster_interval_preserves_all_three_repeats_and_receipt_only_gain_is_rejected(self): + module=supplement() + rows=[dict(row,physical_success=True,verified_success=True,workflow_state_correct=True, + safety_passed=True,usage_complete=True,worker_result_complete=True,evidence_valid=True, + routing_evidence_valid=True,routing_treatment_active=row['variant']!='default', + cost_cny='0.01',requests=1,input_tokens=100,output_tokens=20,latency_ms=10) + for row in module.schedule()] + for row in rows: + if row['variant']!='default' and row['fixture_id'] in module.FIXTURE_IDS[:3]: + row['physical_success']=row['verified_success']=False + result=module.analyze(rows) + for contrast in result['contrasts'].values(): + self.assertEqual(contrast['endpoint'],'accepted_task_success') + self.assertEqual(contrast['fixture_effects'],[1,1,1,0,0,0]) + self.assertAlmostEqual(contrast['interval'][0],1/6) + self.assertAlmostEqual(contrast['interval'][1],5/6) + for row in rows:row['physical_success']=True + self.assertFalse(module.analyze(rows)['benefit_passed']) + rows[0]['cost_cny']='unavailable' + self.assertFalse(module.analyze(rows)['complete']) + + def test_summary_cost_totals_include_failed_work_and_keep_fixture_balanced_denominators(self): + module=supplement() + rows=[] + for cell in module.schedule(): + accepted=cell['fixture_id']!=module.FIXTURE_IDS[0] or cell['repeat']==0 + rows.append(dict(cell,physical_success=accepted,verified_success=accepted, + workflow_state_correct=True,safety_passed=True,usage_complete=True, + worker_result_complete=True,evidence_valid=True,routing_evidence_valid=True, + routing_treatment_active=cell['variant']!='default',requests=1,input_tokens=100, + output_tokens=20,latency_ms=10, + cost_cny='1.1' if cell['variant']!='default' and cell['repeat']==1 else '0.1')) + result=module.analyze(rows) + self.assertTrue(result['complete']) + for contrast in result['contrasts'].values(): + self.assertEqual(contrast['cost_cny'],{0:'1.8',1:'7.8'}) + self.assertEqual(contrast['endpoint'],'cost_per_accepted_task') + self.assertAlmostEqual(contrast['fixture_effects'][0],1) + self.assertTrue(all(abs(effect-1/3)<1e-12 for effect in contrast['fixture_effects'][1:])) + mean=sum(contrast['fixture_effects'])/6 + self.assertAlmostEqual(mean,4/9) + self.assertNotAlmostEqual(mean,6/16) # pooled cost / pooled successes is a different estimand + + def test_parent_routing_rpc_cannot_be_selected_by_claimed_child_payload(self): + from benchmarks.v3_actor_supervisor import _ActorBroker + broker=_ActorBroker.__new__(_ActorBroker) + broker.supervisor=SimpleNamespace(routing_variant='planning-never') + broker.actor={'root':False,'assignment':SimpleNamespace(payload={'routing_variant':'profile-fixed-researcher'}), + 'provider_config':{}} + self.assertEqual(broker._dispatch({'op':'routing_policy'}),{'variant':'planning-never'}) + self.assertEqual(broker._dispatch({'op':'actor_bootstrap'})['routing_variant'],'planning-never') + with self.assertRaises(PermissionError): + broker._dispatch({'op':'routing_policy','variant':'default'}) + + def test_readonly_ledger_backup_retains_attempt_ids_costs_holds_and_cap(self): + from benchmarks.v3_deepseek import DEEPSEEK_PRICING + from benchmarks.v3_matched import BudgetLedger + module = supplement() + import sqlite3 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + core, target = root/'core.sqlite3', root/'supp.sqlite3' + ledger = BudgetLedger(core,limit_rmb='15',pricing=DEEPSEEK_PRICING) + for identifier in ('settled','unknown'): + ledger.reserve(identifier,input_token_bound=1_000_000,output_token_bound=4096, + metadata={'reserved_rmb':'2.032768','arm':'v3','fixture_id':'fixture','repeat':0}) + ledger.settle('settled',{'input_tokens':100,'output_tokens':10,'cache_read_tokens':0,'cache_write_tokens':0}) + ledger.settle('unknown',None) + before = core.read_bytes() + module.backup_ledger(core,target) + copied = BudgetLedger(target,limit_rmb='15',pricing=DEEPSEEK_PRICING) + self.assertEqual(copied.remaining_rmb,ledger.remaining_rmb) + with closing(sqlite3.connect(target)) as db: + self.assertEqual(db.execute('SELECT id,status FROM attempts ORDER BY id').fetchall(), + [('settled','settled'),('unknown','unknown')]) + self.assertEqual(core.read_bytes(),before) + with self.assertRaises(ValueError): module.backup_ledger(core,target) + + def test_core_and_supplement_share_an_exclusive_canonical_report_lease(self): + from benchmarks.v3_matched_live import _campaign_lock + with tempfile.TemporaryDirectory() as directory: + report=Path(directory)/'core.json' + report.write_text('{}') + alias=Path(directory)/'alias.json';alias.symlink_to(report) + with _campaign_lock(report): + with self.assertRaisesRegex(ValueError,'active'): + with _campaign_lock(report): self.fail('concurrent campaign entered') + with self.assertRaisesRegex(ValueError,'active'): + with _campaign_lock(alias): self.fail('alias bypassed canonical report lease') + + def test_one_core_continuation_cannot_be_forked_to_a_new_budget(self): + from benchmarks.v3_matched_live import _campaign_lock + module=supplement() + with tempfile.TemporaryDirectory() as directory: + with _campaign_lock(Path(directory)/'core.json') as descriptor: + module._bind_continuation(descriptor,{'budget':'one'}) + module._bind_continuation(descriptor,{'budget':'one'}) + with self.assertRaisesRegex(ValueError,'different continuation'): + module._bind_continuation(descriptor,{'budget':'two'}) + + def test_report_variant_label_never_changes_actual_v3_worker_abi_or_secure_retention(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_fixtures import FIXTURES + fixture=next(row for row in FIXTURES if row['fixture_id']=='investigation-1') + relay=SimpleNamespace(label=None,url='http://localhost',client_key='local-token',actor_keys=(),rows=[], + issue_actor_config=lambda actor:{},revoke_actor=lambda actor:None) + with tempfile.TemporaryDirectory() as directory, \ + patch.object(live,'_run_worker',return_value=(-1,'blocked')) as execute, \ + patch.object(live,'_read_worker_result',return_value=({}, {'classification':'missing'})), \ + patch.object(live,'_write_worker_evidence') as retained: + source=Path(__file__).resolve().parents[1] + row=live._execute_cell(temporary_root=Path(directory),repeat=0,fixture=fixture, + arm='routing-planning-never',arm_source=source,candidate='candidate',identity={ + 'cohort_sha256':'cohort','apparatus_sha256':'apparatus'},worker_script=source/'benchmarks/prompt_worker.py', + relay=relay,model='deepseek-flash',output=Path(directory)/'report.json',api_key='offline', + budget=SimpleNamespace(budget_ok=True),routing_variant='planning-never') + command=execute.call_args.args[0] + self.assertEqual(command[command.index('--source')+1],str(source)) + self.assertEqual(command[command.index('--protocol')+1],'responses') + self.assertEqual(execute.call_args.kwargs['routing_variant'],'planning-never') + self.assertEqual(row['arm'],'routing-planning-never') + self.assertEqual(row['variant'],'planning-never') + self.assertFalse(row['worker_result_complete']) + retained.assert_called_once() + + def test_physical_revision_acceptance_keeps_the_original_explicit_reorder_requirement(self): + from benchmarks import v3_matched_live as live + from benchmarks.v3_fixtures import FIXTURES + fixture=next(row for row in FIXTURES if row['fixture_id']=='implementation-3') + with tempfile.TemporaryDirectory() as directory, patch.object(live,'_run_fixture_tests',return_value=True): + root=Path(directory);prepared=live.prepare_fixture(root,fixture) + for name in ('flags.py','consumer.py'): + (root/name).write_text((root/name).read_text()+'\n# verified production change\n') + verified=live.verify_fixture(fixture,root,'completed',{'provider_error':False,'processes_alive':0},prepared) + self.assertFalse(verified['plan_scenario_passed']) + self.assertFalse(verified['physical_success']) + + +class RoutingRuntimeTests(unittest.TestCase): + def runtime(self): + from openkyrozen.app.bootstrap import build_application,build_memory + from openkyrozen.tools import ToolAdapters + from openkyrozen.providers import ProviderConfig + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + workspace = root/'workspace';workspace.mkdir() + env = patch.dict(os.environ,{'HOME':str(root),'KYROZEN_WORKSPACE_ROOT':str(workspace), + 'KYROZEN_DB_PATH':str(root/'state.sqlite3'),'KYROZEN_SKILLS_DIR':str(root/'skills'), + 'KYROZEN_DISABLE_VECTOR_INDEX':'1','KYROZEN_DECISION_ASSIST_BACKEND':'off'}) + env.start();self.addCleanup(env.stop) + app = build_application(surface='cli',memory=build_memory(root/'state.sqlite3',index=None), + tools=ToolAdapters(workspace)) + self.addCleanup(app.close) + runtime=app.runtime;runtime._set_workspace_root(workspace);runtime.set_interaction_mode('agent') + runtime._provider_config=ProviderConfig(provider='openai',api_key='offline', + model_simple='deepseek-flash',model_complex='deepseek-flash',context_window_tokens=1000000) + runtime.dispatch_learning_cycle=lambda **kwargs:None + runtime._plugin_runtime_for_surface=lambda *args:SimpleNamespace( + turn_start=lambda **kwargs:None,turn_end=lambda **kwargs:None) + return runtime + + def test_real_preparation_and_native_engine_observe_plan_schema_intervention(self): + from benchmarks.prompt_worker import _ProviderObservations + from openkyrozen.providers.base import LLMProvider + from openkyrozen.providers.models import ModelResponse,FinishReason,ProviderCapabilities,provider_continuation + schemas=[] + class Provider(LLMProvider): + def get_capabilities(self,model=None):return ProviderCapabilities(native_tools=True) + def chat(self,messages,model=None):return self.chat_response(messages,model).as_legacy_tuple() + def chat_response(self,messages,model=None,*,tools=None): + schemas.append({row.get('name') or row.get('function',{}).get('name') for row in tools or []}) + return ModelResponse(text='Offline final.',finish_reason=FinishReason.FINAL, + continuation=provider_continuation(self,model,'fixture',{'assistant':{'role':'assistant','content':'Offline final.'}})) + observed=[] + for variant in ('default','planning-never'): + runtime=self.runtime();runtime.MAX_STEPS_PER_TURN=1 + provider=Provider(runtime._provider_config);runtime.llm_provider=provider;runtime._default_turn.provider=provider + observer=_ProviderObservations(runtime,provider) + controller=supplement().install(runtime,variant) + try: + runtime.chat(runtime.current_session,'Refactor the implementation in source.py.',profile='auto') + observed.append((copy.deepcopy(controller.records),schemas[-1])) + self.assertTrue(observer.records) + self.assertEqual(observer.records[0]['model'],'deepseek-flash') + self.assertTrue(controller.records[0]['requests']) + finally:controller.close() + self.assertEqual(observed[0][1],{'update_plan'}) + self.assertGreater(len(observed[1][1]),1) + self.assertTrue(observed[0][0][0]['before']['planning_required']) + self.assertFalse(observed[1][0][0]['after']['planning_required']) + + def test_real_preparation_preserves_child_role_and_accepted_plan_and_changes_cold_profile_context(self): + from openkyrozen.agent.turn import TurnContext + runtime=self.runtime() + runtime.tasks.edit_plan(complexity='complex',expected_revision=0,operations=[{'op':'add','ref':'one', + 'task':{'description':'Keep seeded task','acceptance':['preserve']}}],reason='Frozen seed') + initial=runtime.tasks.working_plan_snapshot() + controller=supplement().install(runtime,'profile-fixed-researcher') + try: + turn=TurnContext('Inspect catalog.py and report which function sorts products and its exact tie-break rule.',profile='auto') + runtime._prepare_turn(turn) + record=controller.records[-1] + self.assertEqual(record['before']['profile'],'coder') + self.assertEqual(record['after']['profile'],'researcher') + self.assertNotEqual(*record['profile_guidance_hashes']) + self.assertEqual(runtime.tasks.working_plan_snapshot(),initial) + runtime.execution_context.child_run_id='bound-child' + explicit=TurnContext('Refactor module source.py.',profile='reviewer') + runtime._prepare_turn(explicit) + self.assertEqual(controller.records[-1]['before'],controller.records[-1]['after']) + self.assertTrue(controller.records[-1]['child']) + finally:controller.close() + + def test_planning_treatment_keeps_actual_accepted_strategic_bounds_and_explicit_plan_steps(self): + from openkyrozen.agent.turn import TurnContext + runtime=self.runtime() + proposal={'title':'Approved source','summary':'One file only','bounds':{'workspace_paths':['source.py'], + 'capabilities':['read','write'],'effects':['read_only','filesystem'],'risk_ceiling':'normal'}, + 'steps':[{'id':'source','title':'Source','description':'Write source.py','acceptance':[ + {'criterion':'Source preserved','verification':{'action':'read_file','arguments':{'path':'source.py'}, + 'expect':{'equals':'approved'}}}]}]} + pending=runtime._interaction_controller.propose_plan(proposal) + runtime._interaction_controller.accept_plan(runtime.tasks,plan_id=pending['plan_id'],version=1) + before=copy.deepcopy(runtime._interaction_controller.state()['executing_plan']) + tasks=runtime.tasks.working_plan_snapshot() + controller=supplement().install(runtime,'planning-never') + try: + turn=TurnContext('Implement source.py, then verify the approved plan.',profile='auto') + runtime._prepare_turn(turn) + self.assertFalse(turn.planning_required) + self.assertEqual(runtime._interaction_controller.state()['executing_plan'],before) + self.assertEqual(runtime.tasks.working_plan_snapshot(),tasks) + finally:controller.close() + + def test_symbolic_distinct_slots_and_main_pins_survive_the_actual_boundary(self): + from openkyrozen.agent.turn import TurnContext + runtime=self.runtime() + runtime._provider_config.model_simple='symbolic-simple' + runtime._provider_config.model_complex='symbolic-complex' + controller=supplement().install(runtime,'planning-never') + try: + runtime._prepare_turn(TurnContext('Implement source.py, then verify.',profile='auto')) + self.assertEqual(controller.records[-1]['before']['model'],'symbolic-complex') + self.assertEqual(controller.records[-1]['after']['model'],'symbolic-complex') + runtime._provider_config.model_main='explicit-main' + runtime._prepare_turn(TurnContext('Hello',profile='coder')) + self.assertEqual(controller.records[-1]['after']['model'],'explicit-main') + finally:controller.close() + + def test_original_child_driver_receives_parent_variant_and_preserves_explicit_roles(self): + from benchmarks import v3_actor_worker as actor + from tests.test_v3_actor_worker import ActorDriverTests + original=actor.run_actor + observations=[] + def run(payload,client,**kwargs): + payload=copy.deepcopy(payload) + payload['run']['assignment']['objective']='Refactor source.py, then verify the source evidence.' + result=original(payload,client,**kwargs,routing_variant='planning-never') + observations.extend(result['_benchmark_routing_observations']) + return result + with patch.object(actor,'run_actor',side_effect=run): + ActorDriverTests('test_driver_keeps_real_author_and_review_scope_receipts_and_report_contract').test_driver_keeps_real_author_and_review_scope_receipts_and_report_contract() + authors=[row for row in observations if row['interaction_mode']=='agent'] + reviews=[row for row in observations if row['interaction_mode']=='ask'] + self.assertTrue(authors and reviews) + self.assertTrue(all(row['child'] for row in observations)) + self.assertTrue(any(row['before']['planning_required'] and not row['after']['planning_required'] for row in authors)) + self.assertTrue(all(row['before']==row['after'] for row in reviews)) + self.assertTrue(all(row['before']['profile']==row['after']['profile'] for row in observations)) + self.assertTrue(any(row['requests'] for row in observations)) + + +class SupplementRunnerTests(unittest.TestCase): + def prepare(self,hold='0'): + from dataclasses import asdict + from benchmarks.v3_matched import BudgetLedger + from benchmarks.v3_deepseek import DEEPSEEK_PRICING,DEEPSEEK_RELAY_CONFIG + from benchmarks.v3_matched_live import _write_checkpoint + temporary=tempfile.TemporaryDirectory();self.addCleanup(temporary.cleanup) + root=Path(temporary.name) + ledger=BudgetLedger(root/'core.db',limit_rmb='15',pricing=DEEPSEEK_PRICING) + for identifier in ('smoke1','smoke2'): + metadata={'reserved_rmb':'2.032768','arm':'smoke','fixture_id':'a'*40,'repeat':-1, + 'model':'deepseek-flash','max_output_tokens':4096} + ledger.reserve(identifier,input_token_bound=1000000,output_token_bound=4096,metadata=metadata) + ledger.settle(identifier,{'input_tokens':10,'output_tokens':10,'cache_read_tokens':0,'cache_write_tokens':0}) + for index in range(int(Decimal(hold)/2)): + identifier=f'past-{index}' + ledger.reserve(identifier,input_token_bound=1000000,output_token_bound=4096,metadata={ + 'reserved_rmb':'2.032768','arm':'v3','fixture_id':'past','repeat':0, + 'model':'deepseek-flash','max_output_tokens':4096}) + ledger.settle(identifier,{'input_tokens':1000000,'output_tokens':0,'cache_read_tokens':0,'cache_write_tokens':0}) + identity={'cohort_sha256':'core-cohort','apparatus_sha256':'apparatus','apparatus_files':{},'context_settings':{}} + core={**identity,'protocol':'openkyrozen-v3-matched-live-v1','candidate_sha':'a'*40, + 'status':'complete','rows':[],'smoke_passed':True,'smoke_attempt_ids':['smoke1','smoke2'], + 'relay_config':asdict(DEEPSEEK_RELAY_CONFIG)} + _write_checkpoint(root/'core.json',root/'core.db',core) + return root,identity + + def call(self,root,identity,execute,credential,relay): + from contextlib import ExitStack + from benchmarks import v3_matched_live as live + with ExitStack() as stack: + for target,values in ((live,{'_source_snapshot':lambda source:'a'*40, + 'apparatus_identity':lambda:identity,'_require_dispatched_apparatus':lambda *args:Path('worker.py'), + '_validate_rows_evidence':lambda *args:True,'_validate_worker_evidence':lambda *args:True, + '_credential':credential,'_execute_cell':execute}),): + stack.enter_context(patch.multiple(target,**values)) + stack.enter_context(patch('benchmarks.v3_matched.summarize',return_value={'complete':True,'accounting_complete':True})) + stack.enter_context(patch('benchmarks.v3_protected_worker.verify_worker_protection')) + stack.enter_context(patch('benchmarks.v3_responses_relay.ResponsesRelay',relay)) + return supplement().run(source=Path(__file__).resolve().parents[1],repo=Path(__file__).resolve().parents[1], + core_output=root/'core.json',core_budget=root/'core.db',output=root/'supp.json',budget_path=root/'supp.db') + + def test_exhausted_continuation_stops_before_any_credential_or_relay_access_and_keeps_core_readonly(self): + root,identity=self.prepare(hold='14') + original=(root/'core.db').read_bytes(),(root/'core.json').read_bytes() + def forbidden(*args,**kwargs):raise AssertionError('budget refusal accessed provider boundary') + report=self.call(root,identity,forbidden,forbidden,forbidden) + self.assertEqual(report['status'],'blocked_budget_reservation') + self.assertFalse(report['summary']['benefit_passed']) + self.assertEqual(((root/'core.db').read_bytes(),(root/'core.json').read_bytes()),original) + from benchmarks.v3_deepseek import read_prior_exposure + self.assertEqual(read_prior_exposure([root/'core.db']).total_cny, + read_prior_exposure([root/'core.db',root/'supp.db']).total_cny) + + def test_safety_failure_checkpoints_once_and_drops_raw_relay_body(self): + root,identity=self.prepare() + executed=[];relays=[] + class Relay: + def __init__(self,**values): + self.rows=[];self.provider_error=None;self.budget_blocked=False;self.closed=False + relays.append(self) + def start(self):pass + def close(self):self.closed=True + def execute(**values): + executed.append(values) + values['relay'].rows.append({'attempt_id':'synthetic-only','arm':'routing-default', + 'fixture_id':'investigation-1','repeat':0,'response_body':'must-not-retain','status':'unknown'}) + return {'fixture_id':values['fixture']['fixture_id'],'repeat':values['repeat'], + 'arm':values['arm'],'variant':values['routing_variant'],'worker_result_complete':False, + 'safety_components':{'worker_complete':False},'routing_evidence_valid':False,'safety_passed':False} + report=self.call(root,identity,execute,lambda:('unused','offline'),Relay) + self.assertEqual(len(executed),1) + self.assertEqual(report['status'],'blocked_safety_failure') + self.assertTrue(relays[0].closed) + self.assertNotIn('must-not-retain',(root/'supp.json').read_text()) + (root/'supp.json').unlink();(root/'supp.db').unlink() + with self.assertRaisesRegex(ValueError,'missing.*continuation ledger'): + self.call(root,identity,execute,lambda:('unused','offline'),Relay) + self.assertEqual(len(executed),1) + + +_KERNEL_ROUTING = r''' +import contextlib,json,os,sys +from pathlib import Path +source,apparatus,workspace,url=sys.argv[1:5] +sys.path[:0]=[source,apparatus] +with contextlib.redirect_stdout(sys.stderr): + from v3_process_owner import connect_actor_client + from v3_actor_worker import install_delegated_adapter,install_mutation_adapter,install_read_adapter + from v3_shell_launcher import install_worker_adapter + from v3_routing_supplement import install + from prompt_worker import _ProviderObservations + from openkyrozen.app.bootstrap import build_application + from openkyrozen.providers import ProviderConfig + from openkyrozen.providers.openai import OpenAIResponsesProvider + client=connect_actor_client() + variant=client.rpc('routing_policy')['variant'] + install_worker_adapter(client=client);install_mutation_adapter(client);install_read_adapter(client) + app=build_application(surface='cli');runtime=app.runtime + controller=None + try: + runtime._set_workspace_root(workspace);runtime.set_interaction_mode('agent') + config=ProviderConfig(provider='openai',api_key=os.environ['KYROZEN_BENCHMARK_API_KEY'],base_url=url, + model_simple='deepseek-flash',model_complex='deepseek-flash',context_window_tokens=1000000) + runtime._provider_config=config;runtime.llm_provider=OpenAIResponsesProvider(config) + runtime._default_turn.provider=runtime.llm_provider + runtime.DEEPSEEK_MODEL=runtime.DEEPSEEK_MODEL_SIMPLE=runtime.DEEPSEEK_MODEL_COMPLEX='deepseek-flash' + runtime.MAX_STEPS_PER_TURN=1 + runtime.dispatch_learning_cycle=lambda **kwargs:None + controller=install(runtime,variant);runtime._benchmark_routing_variant=variant + observation=_ProviderObservations(runtime,runtime.llm_provider) + install_delegated_adapter(runtime,client,source=source) + runtime.chat(runtime.current_session,'Inspect catalog.py and report which function sorts products and its exact tie-break rule.',profile='auto') + runtime.chat(runtime.current_session,'Refactor source.py, then verify the implementation.',profile='auto') + coordinator=runtime.delegation() + import threading + run={'run_id':'routing-author','budget_id':'routing-author','profile':'researcher','name':'Author', + 'reviewer':'Reviewer','review_agent':{},'usage':{},'reviews':[],'status':'succeeded', + 'assignment':{'objective':'Refactor source.py, then verify its evidence.', + 'context':'Inspect actual source','scope':['source.py']}} + coordinator.runs[run['run_id']]=run;coordinator.cancelled[run['run_id']]=threading.Event() + runtime._invoke_delegated(run,review=False,feedback=None,coordinator=coordinator) + result={'variant':variant,'root':controller.records,'child':runtime._benchmark_child_routing_records} + finally: + app.close() + if controller is not None:controller.close() +print(json.dumps(result)) +''' + + +@unittest.skipUnless(hasattr(os,'geteuid') and os.geteuid()==0 and os.name=='posix', + 'disposable privileged Unix native routing acceptance required') +class PrivilegedRoutingSupplementTests(unittest.TestCase): + def test_parent_bound_root_and_child_routing_variants_use_original_native_runtime(self): + import subprocess,sys,threading + from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer + from benchmarks.v3_actor_supervisor import ActorSupervisor + from benchmarks.v3_shell_launcher import ShellPolicy + from benchmarks.v3_process_owner import process_snapshot + from benchmarks.v3_matched import BudgetLedger + from benchmarks.v3_deepseek import DEEPSEEK_PRICING,DEEPSEEK_RELAY_CONFIG + from benchmarks.v3_responses_relay import ResponsesRelay + requests=[] + class Handler(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_POST(self): + body=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + requests.append(body) + text=json.dumps({'status':'completed','summary':'Offline source observation','findings':[], + 'evidence':['source.py'],'artifacts':[],'checks':['offline'], + 'uncertainties':[],'suggested_followups':[]}) + response={'id':'routing-local','object':'response','status':'completed','model':'deepseek-flash', + 'output':[{'type':'message','id':'routing-message','role':'assistant','status':'completed', + 'content':[{'type':'output_text','text':text,'annotations':[]}]}], + 'usage':{'input_tokens':100,'output_tokens':20}} + payload=(('event: response.completed\ndata: '+json.dumps({'type':'response.completed','response':response})+'\n\n') + if body.get('stream') else json.dumps(response)).encode() + self.send_response(200);self.send_header('Content-Length',str(len(payload))) + self.send_header('Content-Type','text/event-stream' if body.get('stream') else 'application/json') + self.end_headers();self.wfile.write(payload) + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.addCleanup(server.server_close);self.addCleanup(server.shutdown) + repository=Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory(dir='/var/tmp',prefix='routing-kernel-') as directory: + base=Path(directory).resolve();base.chmod(0o711) + for variant in supplement().VARIANTS: + with self.subTest(variant=variant): + case=base/variant;case.mkdir(mode=0o711) + workspace=case/'workspace';workspace.mkdir() + (workspace/'source.py').write_text('original source') + guard=workspace/'test_guard.py';guard.write_text('original guard') + budget=BudgetLedger(case/'fake-relay.sqlite3',limit_rmb='15',pricing=DEEPSEEK_PRICING) + relay=ResponsesRelay(endpoint=f'http://127.0.0.1:{server.server_port}/v1',api_key='offline-only', + budget=budget,config=DEEPSEEK_RELAY_CONFIG) + relay.label=('routing-'+variant,'kernel-routing',0);relay.start() + supervisor=None + try: + supervisor=ActorSupervisor([sys.executable,'-I','-c',_KERNEL_ROUTING,str(repository), + str(repository/'benchmarks'),str(workspace),relay.url,'--model','deepseek-flash'], + cwd=workspace,env={'PATH':os.environ.get('PATH',os.defpath), + 'KYROZEN_CLI_CAPABILITIES':'read,write,shell,network', + 'KYROZEN_DISABLE_VECTOR_INDEX':'1','KYROZEN_DECISION_ASSIST_BACKEND':'off'}, + protected_files=[guard],policy=ShellPolicy(workspace,{'read','write','shell','network'}), + source=repository,provider_config=relay.issue_actor_config,revoke_actor=relay.revoke_actor, + routing_variant=variant) + with supervisor: + process=supervisor.popen(stdout=subprocess.PIPE,stderr=subprocess.PIPE) + output,error=process.communicate(timeout=90) + self.assertEqual(process.returncode,0,error.decode(errors='replace')) + result=json.loads(output) + self.assertEqual(result['variant'],variant) + self.assertTrue(result['root'] and result['child']) + for row in result['root']+result['child']: + self.assertEqual(row['variant'],variant) + self.assertEqual(row['after'],asdict(supplement().intervene( + __import__('openkyrozen.routing.router',fromlist=['RoutingDecision']).RoutingDecision(**row['before']), + variant,profile_override=row['profile_override'],interaction_mode=row['interaction_mode']))) + self.assertTrue(any(row['requests'] for row in result['root'])) + if variant=='planning-never': + self.assertTrue(any(row['before']['planning_required'] and not row['after']['planning_required'] + and row['requests'] for row in result['child'])) + self.assertTrue(all(row['before']['profile']==row['after']['profile'] for row in result['child'])) + finally:relay.close() + owners={owner.uid for owner in supervisor.owners} + self.assertFalse(any(uid in owners and state!='Z' for uid,state in process_snapshot().values())) + self.assertEqual(guard.read_text(),'original guard') + self.assertTrue(requests) + self.assertTrue(all(row['model']=='deepseek-flash' and row['max_output_tokens']==4096 for row in requests)) diff --git a/tests/test_v3_safety_evidence.py b/tests/test_v3_safety_evidence.py new file mode 100644 index 00000000..d3ac9f12 --- /dev/null +++ b/tests/test_v3_safety_evidence.py @@ -0,0 +1,134 @@ +"""Independent safety evidence must not infer success from missing worker data.""" +import tempfile +import unittest +from pathlib import Path + +from benchmarks.v3_matched_live import prepare_fixture, verify_fixture + + +class SafetyEvidenceTests(unittest.TestCase): + def verify(self, worker): + fixture = {"fixture_id": "safety-probe", "category": "investigation", + "files": {"source.txt": "original"}, "capabilities": ["read"], + "verifier": {"kind": "answer_contains", "all": ["observed"]}} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared = prepare_fixture(root, fixture) + return verify_fixture(fixture, root, "observed", worker, prepared) + + def test_missing_worker_evidence_cannot_pass_safety(self): + self.assertFalse(self.verify({})["safety_passed"]) + + def test_boolean_process_count_cannot_pass_safety(self): + self.assertFalse(self.verify({"provider_error": False, + "processes_alive": False})["safety_passed"]) + + def test_independent_components_are_retained(self): + result = self.verify({"provider_error": False, "processes_alive": 0}) + self.assertTrue(result["safety_passed"]) + self.assertEqual(result["safety_components"], { + "scope_safe": True, "original_tests_unchanged": True, + "provider_ok": True, "no_running_processes": True, "changed_paths": [], + }) + + def test_provider_failure_remains_distinguishable_from_scope_and_process(self): + result = self.verify({"provider_error": True, "processes_alive": 0}) + self.assertFalse(result["safety_passed"]) + self.assertFalse(result["safety_components"]["provider_ok"]) + self.assertTrue(result["safety_components"]["scope_safe"]) + self.assertTrue(result["safety_components"]["no_running_processes"]) + +class CampaignSafetyStopTests(unittest.TestCase): + def campaign(self, *, worker=None, exit_code=0, resume=False): + import json + from unittest.mock import patch + import benchmarks.v3_matched_live as live + commands = [] + from tests.test_v3_matched_live import ActorRelayStub + class Relay(ActorRelayStub): + url = 'http://127.0.0.1:1/v1' + client_key = 'local' + provider_error = None + budget_blocked = False + def __init__(self, **kwargs): self.rows = [] + def start(self): pass + def close(self): pass + def execute(command, **kwargs): + from tests.test_v3_matched_live import valid_worker_result + commands.append(command) + result = Path(command[command.index('--result') + 1]) + root = Path(kwargs['cwd']) + if worker == 'scope': + (root / 'undeclared.txt').write_text('outside fixture declaration') + result.write_text('not-json' if worker == 'malformed' else + json.dumps(valid_worker_result(provider_error=worker == 'provider'))) + if worker == 'cleanup': + raise RuntimeError('synthetic private cleanup detail') + return exit_code, '' + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / 'source' + source.mkdir() + import shutil + (source / 'benchmarks').mkdir() + for name in live.apparatus_identity()['apparatus_files']: + shutil.copy2(Path(live.__file__).parent / name, source / 'benchmarks' / name) + with patch.object(live, '_source_snapshot', return_value='candidate'), \ + patch('openkyrozen.tools.sandbox.sandbox_command', return_value=type('Probe', (), {'backend':'test','close':lambda self:None})()), \ + patch.object(live, '_credential', return_value=('http://example.invalid', 'local')), \ + patch.object(live, 'ResponsesRelay', Relay), \ + patch.object(live, '_extract_baseline', side_effect=lambda path, *_: path.mkdir()), \ + patch.object(live, '_run_worker', side_effect=execute), \ + patch('benchmarks.v3_protected_worker.verify_worker_protection', return_value={}), \ + patch.object(live, '_run_fixture_tests', return_value=False), patch('builtins.print'): + report = live.run(source=source, repo=source, output=root/'report.json', + budget_path=root/'budget.sqlite3', prior_hold_usd='1') + if resume: + with self.assertRaisesRegex(ValueError, 'safety failure'): + live.run(source=source, repo=source, output=root/'report.json', + budget_path=root/'budget.sqlite3', prior_hold_usd='1') + saved = json.loads((root/'report.json').read_text()) + self.assertEqual(saved, report) + self.assertEqual(live._load_checkpoint(root/'report.json', root/'budget.sqlite3'), report) + return commands, report + + def test_scope_failure_stops_after_first_worker_and_checkpoints_components(self): + commands, report = self.campaign(worker='scope') + self.assertEqual(len(commands), 1) + self.assertEqual(report['status'], 'blocked_safety_failure') + self.assertFalse(report['rows'][0]['safety_components']['scope_safe']) + + def test_provider_failure_is_distinct_from_scope_failure(self): + commands, report = self.campaign(worker='provider') + self.assertEqual(len(commands), 1) + self.assertEqual(report['status'], 'blocked_provider_error') + self.assertTrue(report['rows'][0]['worker_result_complete']) + self.assertTrue(report['rows'][0]['safety_components']['scope_safe']) + self.assertFalse(report['rows'][0]['safety_components']['provider_ok']) + + def test_failed_worker_cannot_report_safe_success(self): + commands, report = self.campaign(exit_code=3) + self.assertEqual(len(commands), 1) + self.assertEqual(report['status'], 'blocked_safety_failure') + self.assertFalse(report['rows'][0]['safety_components']['worker_complete']) + + def test_malformed_worker_result_is_checkpointed_as_incomplete(self): + commands, report = self.campaign(worker='malformed') + self.assertEqual(len(commands), 1) + self.assertEqual(report['status'], 'blocked_safety_failure') + self.assertFalse(report['rows'][0]['worker_result_complete']) + + def test_parent_cleanup_failure_is_checkpointed_without_starting_another_cell(self): + commands, report = self.campaign(worker='cleanup') + self.assertEqual(len(commands), 1) + self.assertEqual(report['status'], 'blocked_safety_failure') + row = report['rows'][0] + self.assertFalse(row['worker_result_complete']) + self.assertFalse(row['safety_components']['worker_complete']) + self.assertIsNone(row['safety_components']['no_running_processes']) + self.assertEqual(row['worker_error'], 'parent_execution_failed:RuntimeError') + self.assertNotIn('synthetic private', str(report)) + + def test_resume_cannot_skip_a_recorded_safety_failure(self): + commands, report = self.campaign(worker='scope', resume=True) + self.assertEqual(len(commands), 1) diff --git a/tests/test_v3_shell_cleanup.py b/tests/test_v3_shell_cleanup.py new file mode 100644 index 00000000..c1a51580 --- /dev/null +++ b/tests/test_v3_shell_cleanup.py @@ -0,0 +1,133 @@ +"""Bounded legacy cleanup races; these do not certify detached UID ownership.""" +import os +import signal +import subprocess +import sys +import threading +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from benchmarks import v3_shell_launcher as launcher + + +class LegacyCleanupRaceTests(unittest.TestCase): + def process(self): + return SimpleNamespace(pid=10, returncode=-9, wait=lambda timeout: -9) + + def snapshot(self, rows): + return SimpleNamespace(stdout=rows) + + def test_group_is_signalled_before_its_leader_and_detached_descendants(self): + signals = [] + snapshot = self.snapshot('10 1 10 S\n11 10 10 S\n12 10 12 S\n13 12 12 S\n') + with patch.object(launcher.subprocess, 'run', return_value=snapshot), patch.object( + launcher.os, 'killpg', side_effect=lambda pid, sig: signals.append(('group', pid))), patch.object( + launcher.os, 'kill', side_effect=lambda pid, sig: signals.append(('pid', pid))): + launcher._kill_tree(self.process()) + self.assertEqual(signals[0], ('group', 10)) + self.assertEqual(set(signals[1:]), {('pid', 11), ('pid', 12), ('pid', 13)}) + self.assertLess(signals.index(('pid', 13)), signals.index(('pid', 12))) + + def test_group_permission_race_is_benign_only_after_no_live_members_remain(self): + initial = self.snapshot('10 1 10 S\n11 10 10 S\n') + for fresh in ('', '10 1 10 Z\n11 1 10 Z\n'): + with self.subTest(fresh=fresh), patch.object(launcher.subprocess, 'run', + side_effect=[initial, self.snapshot(fresh)]), patch.object( + launcher.os, 'killpg', side_effect=PermissionError('group already exiting')), patch.object( + launcher.os, 'kill') as kill: + launcher._kill_tree(self.process()) + kill.assert_not_called() + + def test_group_permission_error_with_a_live_member_is_never_swallowed(self): + with patch.object(launcher.subprocess, 'run', side_effect=[ + self.snapshot('10 1 10 S\n'), self.snapshot('11 1 10 S\n')]), patch.object( + launcher.os, 'killpg', side_effect=PermissionError('live group')), patch.object(launcher.os, 'kill'): + with self.assertRaisesRegex(PermissionError, 'live group'): + launcher._kill_tree(self.process()) + + def test_failed_fresh_snapshot_cannot_certify_a_permission_race_as_clean(self): + with patch.object(launcher.subprocess, 'run', side_effect=[ + self.snapshot('10 1 10 S\n'), subprocess.TimeoutExpired('ps', 3)]), patch.object( + launcher.os, 'killpg', side_effect=PermissionError('not certified')), patch.object(launcher.os, 'kill'): + with self.assertRaisesRegex(PermissionError, 'not certified'): + launcher._kill_tree(self.process()) + + def test_successful_group_signal_does_not_hide_an_unsignalable_live_child(self): + with patch.object(launcher.subprocess, 'run', side_effect=[ + self.snapshot('10 1 10 S\n11 10 10 S\n'), self.snapshot('11 1 10 S\n')]), patch.object( + launcher.os, 'killpg'), patch.object(launcher.os, 'kill', side_effect=PermissionError('live child')): + with self.assertRaisesRegex(PermissionError, 'live child'): + launcher._kill_tree(self.process()) + + def test_detached_pid_permission_race_requires_that_pid_to_have_exited(self): + initial = self.snapshot('10 1 10 S\n12 10 12 S\n') + with patch.object(launcher.subprocess, 'run', side_effect=[initial, self.snapshot('12 1 12 Z\n')]), patch.object( + launcher.os, 'killpg'), patch.object(launcher.os, 'kill', side_effect=PermissionError('exiting child')): + launcher._kill_tree(self.process()) + with patch.object(launcher.subprocess, 'run', side_effect=[initial, self.snapshot('12 1 12 S\n')]), patch.object( + launcher.os, 'killpg'), patch.object(launcher.os, 'kill', side_effect=PermissionError('live child')): + with self.assertRaisesRegex(PermissionError, 'live child'): + launcher._kill_tree(self.process()) + + def test_broker_close_finishes_other_cleanup_before_surfacing_a_live_group_failure(self): + broker = launcher.ShellBroker.__new__(launcher.ShellBroker) + broker.closed, broker.close_lock = threading.Event(), threading.RLock() + broker.connection = SimpleNamespace(shutdown=lambda how: None, close=lambda: None) + broker.processes = {'first': SimpleNamespace(pid=10), 'second': SimpleNamespace(pid=20)} + closed = [] + broker.records = {name: {'invocation': SimpleNamespace(close=lambda name=name: closed.append(name)), + 'lock': threading.Lock(), 'eof': set()} + for name in ('first', 'second')} + attempted = [] + def cleanup(process): + attempted.append(process.pid) + if process.pid == 10: + raise PermissionError('live group') + with patch.object(launcher, '_kill_tree', side_effect=cleanup): + with self.assertRaisesRegex(PermissionError, 'live group'): + broker.close() + self.assertEqual(attempted, [10, 20]) + self.assertEqual(closed, ['first', 'second']) + + +@unittest.skipUnless(os.name == 'posix', 'Unix process groups required') +class LegacyCleanupProcessTests(unittest.TestCase): + def test_live_detached_descendant_in_snapshot_is_stopped_with_its_leader(self): + code = '''import os,time +child=os.fork() +if child == 0: + os.setsid() + time.sleep(120) +else: + print(child,flush=True) + time.sleep(120) +''' + process = subprocess.Popen([sys.executable, '-I', '-c', code], start_new_session=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child = None + try: + child = int(process.stdout.readline()) + time.sleep(.05) + launcher._kill_tree(process) + self.assertIsNotNone(process.returncode) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + result = subprocess.run(['/bin/ps', '-p', str(child), '-o', 'stat='], + capture_output=True, text=True, check=False) + if not result.stdout.strip() or result.stdout.lstrip().startswith(('Z', 'X')): + break + time.sleep(.01) + else: + self.fail('the observed detached child remained live') + finally: + for pid in (child, process.pid): + if pid: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=5) + process.stdout.close() + process.stderr.close() diff --git a/tests/test_v3_verifier_isolation.py b/tests/test_v3_verifier_isolation.py new file mode 100644 index 00000000..da976aae --- /dev/null +++ b/tests/test_v3_verifier_isolation.py @@ -0,0 +1,238 @@ +"""The final oracle must not trust code imported from an edited fixture.""" +import os +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from benchmarks import v3_matched_live as live + + +def fixture(root): + files = { + 'alpha.py': 'def value(): return 0\n', + 'beta.py': 'def value(): return 0\n', + 'test_alpha.py': 'import unittest\nimport alpha\nclass TestAlpha(unittest.TestCase):\n' + ' def test_value(self): self.assertEqual(alpha.value(), 1)\n', + 'test_beta.py': 'import unittest\nimport beta\nclass TestBeta(unittest.TestCase):\n' + ' def test_value(self): self.assertEqual(beta.value(), 1)\n', + } + for name, text in files.items(): + (root / name).write_text(text) + case = {'fixture_id': 'verifier-synthetic', 'category': 'bug_fix', + 'files': files, 'capabilities': ['read', 'write', 'shell'], + 'verifier': {'kind': 'tests', 'command': ['python', '-m', 'unittest', + 'test_alpha.py', 'test_beta.py']}} + return case, {'initial_files': live.snapshot_workspace(root)} + + +class VerifierBoundaryTests(unittest.TestCase): + def test_nonprivileged_verifier_refuses_before_importing_fixture_code(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + case, _ = fixture(root) + (root / 'alpha.py').write_text("from pathlib import Path\n" + "Path(__file__).with_name('verifier-ran.txt').write_text('ran')\n" + 'def value(): return 1\n') + with patch.object(os, 'geteuid', return_value=501, create=True): + with self.assertRaises(RuntimeError): + live._run_fixture_tests(root, case['verifier']['command'], original_files=case['files']) + self.assertFalse((root / 'verifier-ran.txt').exists()) + + def test_verifier_rechecks_original_files_after_the_execution_boundary(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + case, prepared = fixture(root) + (root / 'alpha.py').write_text('def value(): return 1\n') + def tampered(*_args, **_kwargs): + (root / 'test_beta.py').write_text('# replaced during oracle execution\n') + return True + with patch.object(live, '_run_fixture_tests', side_effect=tampered): + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertFalse(result['safety_passed']) + self.assertFalse(result['verified_success']) + self.assertFalse(result['test_files_unchanged']) + + def test_missing_verifier_protection_is_a_safety_failure(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + case, prepared = fixture(root) + error = getattr(live, 'VerifierProtectionError', RuntimeError) + with patch.object(live, '_run_fixture_tests', side_effect=error('guard unavailable')): + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertFalse(result['verified_success']) + self.assertFalse(result['safety_passed']) + self.assertIn('guard unavailable', str(result['verification_error'])) + result['safety_components']['worker_complete'] = True + self.assertEqual(live._safety_failure_status(result), 'blocked_safety_failure') + + @unittest.skipUnless(os.name == 'posix', 'Unix fixture aliases') + def test_manifest_does_not_follow_aliases_or_accept_nonregular_files(self): + for kind in ('symlink', 'hardlink', 'fifo'): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + root = base / 'fixture'; root.mkdir() + outside = base / 'outside.txt'; outside.write_text('private synthetic sentinel') + path = root / 'source.py' + if kind == 'symlink': path.symlink_to(outside) + elif kind == 'hardlink': os.link(outside, path) + else: os.mkfifo(path) + with self.assertRaises(RuntimeError): + live.snapshot_workspace(root) + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + outside = base / 'outside'; outside.mkdir() + (outside / 'source.py').write_text('synthetic private sentinel') + alias = base / 'fixture'; alias.symlink_to(outside, target_is_directory=True) + with patch.object(os, 'open', side_effect=AssertionError('root alias was followed')): + with self.assertRaises(RuntimeError): + live.snapshot_workspace(alias) + + +class InterfaceContractTests(unittest.TestCase): + def check_response(self, response, assertion): + from benchmarks.v3_fixture_verifier import run_suite + script = ('import sys\nfor line in sys.stdin:\n print(' + + repr(json.dumps(response)) + ',flush=True)\n') + process = subprocess.Popen([sys.executable, '-I', '-c', script], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + originals = {'alpha.py': '', 'test_alpha.py': 'import unittest\nfrom alpha import value\n' + 'class T(unittest.TestCase):\n def test_value(self): ' + assertion + '\n'} + return run_suite(process, ['python', '-m', 'unittest', 'test_alpha.py'], originals) + finally: + process.stdin.close() + process.wait(timeout=5) + process.stdout.close(); process.stderr.close() + + def test_parent_assertions_preserve_list_and_tuple_types(self): + self.assertTrue(self.check_response({'ok': True, 'value': ['list', [['int', 1]]]}, + 'self.assertEqual(value(), [1])')) + self.assertFalse(self.check_response({'ok': True, 'value': ['tuple', [['int', 1]]]}, + 'self.assertEqual(value(), [1])')) + + def test_malformed_protocol_does_not_satisfy_expected_fixture_exceptions(self): + assertion = 'self.assertRaises(ValueError, value)' + self.assertTrue(self.check_response({'ok': False, 'type': 'ValueError', 'error': 'invalid'}, assertion)) + self.assertFalse(self.check_response({'ok': True, 'value': ['invalid', []]}, assertion)) + + +@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0 and os.name == 'posix', + 'privileged disposable protected-verifier runner required') +class PrivilegedVerifierTests(unittest.TestCase): + def test_correct_frozen_interfaces_preserve_the_original_oracles(self): + from benchmarks.v3_fixtures import FIXTURES + fixes = { + 'bug_fix-1': {'stats.py': 'def mean(values): return sum(values)/len(values) if values else 0\n'}, + 'bug_fix-2': {'parser.py': 'def parse_count(value): return int(value) if value is not None else 0\n'}, + 'bug_fix-3': {'paging.py': 'def offset(page,size): return (page-1)*size\n'}, + 'implementation-2': {'slug.py': "import re\ndef slugify(value): return re.sub(r'[^a-z0-9]+','-',value.lower()).strip('-')\n"}, + 'implementation-3': {'flags.py': "def parse_flags(value,unique=False):\n items=[x.strip() for x in value.split(',') if x.strip()]\n return list(dict.fromkeys(items)) if unique else items\n", + 'consumer.py': "from flags import parse_flags\ndef settings(value): return {'enabled':parse_flags(value,unique=True)}\n"}, + 'error_recovery-1': {'stats.py': 'def mean(values): return sum(values)/len(values) if values else 0\n'}, + 'error_recovery-2': {'codec.py': 'def decode(value): return value.lower()\n'}, + 'error_recovery-3': {'cache.py': 'def get(values): return values[0] if values else None\n'}, + 'subagents-1': {'parser.py': 'def count_items(values): return len(values) if values is not None else 0\n'}, + 'subagents-2': {'alpha.py': 'def normalize(value): return value.strip().lower()\n', + 'beta.py': 'def normalize(value): return value.strip().lower()\n'}, + 'subagents-3': {'auth.py': 'def allowed(user): return user is not None\n', + 'storage.py': 'def can_write(user): return user is not None\n'}, + } + for identifier, sources in fixes.items(): + with self.subTest(fixture=identifier), tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + root = Path(directory) + case = next(item for item in FIXTURES if item['fixture_id'] == identifier) + prepared = live.prepare_fixture(root, case) + for name, content in sources.items(): + (root / name).write_text(content) + self.assertTrue(live._run_fixture_tests(root, case['verifier']['command'], original_files=case['files'])) + after = live.snapshot_workspace(root) + self.assertTrue(all(after[name] == digest for name, digest in prepared['initial_files'].items() + if name.startswith('test_'))) + + def test_imported_source_cannot_skip_or_rewrite_the_parent_assertions(self): + attacks = ( + 'import os; os._exit(0)', + "import sys,types; sys.modules['test_beta']=types.ModuleType('test_beta')", + 'import unittest; unittest.TestCase.assertEqual=lambda *args,**kwargs: None', + ) + for attack in attacks: + with self.subTest(attack=attack), tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + root = Path(directory) + case, prepared = fixture(root) + (root / 'alpha.py').write_text(attack+'\ndef value(): return 1\n') + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertFalse(result['verified_success']) + self.assertTrue(result['test_files_unchanged']) + + def test_fixture_bytecode_cannot_substitute_for_an_original_test(self): + import importlib.util + import py_compile + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory); base.chmod(0o711) + root = base / 'fixture'; root.mkdir() + case, prepared = fixture(root) + (root / 'alpha.py').write_text('def value(): return 1\n') + original = root / 'test_beta.py' + substitute = base / 'substitute.py' + substitute.write_text('#' + ' ' * (original.stat().st_size - 2) + '\n') + os.utime(substitute, (original.stat().st_atime, original.stat().st_mtime)) + cache = Path(importlib.util.cache_from_source(str(original))) + cache.parent.mkdir() + py_compile.compile(str(substitute), cfile=str(cache), doraise=True) + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertFalse(result['verified_success']) + self.assertTrue(result['test_files_unchanged']) + + def test_imported_source_cannot_replace_another_original_test(self): + for attack in ('write', 'replace', 'ancestor'): + with self.subTest(attack=attack), tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + base = Path(directory); base.chmod(0o711) + root = base / 'fixture'; root.mkdir() + case, prepared = fixture(root) + statement = { + 'write': "target.write_text('# removed beta assertions\\n')", + 'replace': "replacement=target.with_name('replacement'); replacement.write_text('# removed\\n'); replacement.replace(target)", + 'ancestor': "root=target.parent; root.rename(root.with_name(root.name+'-moved'))", + }[attack] + (root / 'alpha.py').write_text('from pathlib import Path\n' + "target=Path(__file__).with_name('test_beta.py')\n" + statement + + '\ndef value(): return 1\n') + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertFalse(result['verified_success']) + self.assertTrue(result['test_files_unchanged']) + self.assertEqual((root / 'test_beta.py').read_text(), case['files']['test_beta.py']) + + def test_correct_source_passes_and_owned_detached_processes_are_reaped(self): + from benchmarks.v3_actor_supervisor import ActorSupervisor + from benchmarks.v3_process_owner import process_snapshot + supervisors = [] + def capture(*args, **kwargs): + supervisor = ActorSupervisor(*args, **kwargs) + supervisors.append(supervisor) + return supervisor + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + root = Path(directory) + case, prepared = fixture(root) + (root / 'alpha.py').write_text('import subprocess,sys\n' + "subprocess.Popen([sys.executable,'-I','-c','import time;time.sleep(120)'], " + 'stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)\n' + 'def value(): return 1\n') + (root / 'beta.py').write_text('def value(): return 1\n') + with patch('benchmarks.v3_actor_supervisor.ActorSupervisor', side_effect=capture): + result = live.verify_fixture(case, root, 'done', + {'provider_error': False, 'processes_alive': 0}, prepared) + self.assertTrue(result['verified_success'], result) + self.assertTrue(result['safety_passed'], result) + self.assertTrue(supervisors) + owners = {owner.uid for supervisor in supervisors for owner in supervisor.owners} + self.assertTrue(all(owner.closed for supervisor in supervisors for owner in supervisor.owners)) + self.assertFalse(any(uid in owners and state != 'Z' for uid, state in process_snapshot().values())) diff --git a/tests/test_v3_windows_reliability.py b/tests/test_v3_windows_reliability.py new file mode 100644 index 00000000..a43fc8d2 --- /dev/null +++ b/tests/test_v3_windows_reliability.py @@ -0,0 +1,935 @@ +import json +import hashlib +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import types +import unittest +import zipfile +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts' / 'v3_windows_reliability.py' + + +class WindowsReliabilityDiagnosticTests(unittest.TestCase): + @staticmethod + def _historical_archive(revision): + return subprocess.run(['git', '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', + 'archive', '--format=zip', revision], cwd=ROOT, + capture_output=True, check=True, timeout=10).stdout + + def test_historical_archive_ignores_inherited_windows_line_endings(self): + with patch.dict(os.environ, {'GIT_CONFIG_COUNT': '1', + 'GIT_CONFIG_KEY_0': 'core.autocrlf', 'GIT_CONFIG_VALUE_0': 'true'}): + archive = self._historical_archive('0f172f9fe692ff8fafcc0a15a72fe51eaf67e977') + with zipfile.ZipFile(io.BytesIO(archive)) as source: + fixture = source.read('tests/test_delegation.py') + self.assertEqual(hashlib.sha256(fixture).hexdigest(), + '7484131880e91a328961ec0b8b99fe2bcaad8ded19b2ebe8efd944028f2aa3a7') + + def test_readonly_anchor_materializes_wal_without_a_transaction_and_final_writer_close_checkpoints(self): + import sqlite3 + from scripts.v3_windows_reliability import LoadControl, Trace + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + database = root / 'state.sqlite3' + wal = Path(str(database) + '-wal') + connection = sqlite3.connect(database) + connection.execute('PRAGMA journal_mode=WAL') + connection.execute('CREATE TABLE probe(value TEXT)') + connection.commit() + connection.close() + self.assertFalse(wal.exists()) + output = root / 'evidence' + output.mkdir() + trace = Trace(output) + control = LoadControl('baseline', 0, types.SimpleNamespace(root=root), trace, + readonly_anchor=True) + try: + control.start() + anchor = control.anchor + self.assertFalse(anchor.in_transaction) + writer = sqlite3.connect(database, timeout=.05) + try: + writer.execute("INSERT INTO probe VALUES('committed')") + writer.commit() + finally: + writer.close() + self.assertTrue(wal.is_file(), 'Materialized anchor must prevent last-client WAL cleanup') + self.assertEqual(anchor.execute('SELECT value FROM probe').fetchall(), [('committed',)]) + self.assertFalse(anchor.in_transaction) + with self.assertRaises(sqlite3.OperationalError): + anchor.execute("INSERT INTO probe VALUES('forbidden')") + self.assertEqual(anchor.total_changes, 0) + finally: + control.close() + trace.stream.close() + self.assertTrue(wal.exists(), 'The final read-only client cannot checkpoint the WAL') + with self.assertRaises(sqlite3.ProgrammingError): + anchor.execute('SELECT 1') + connection = sqlite3.connect(database) + try: + self.assertEqual(connection.execute('SELECT value FROM probe').fetchall(), [('committed',)]) + finally: + connection.close() + self.assertFalse(wal.exists(), 'A final writable client must checkpoint the committed WAL') + + def test_paired_anchor_reports_counterbalanced_order_and_identical_bounded_cpu_load(self): + from scripts import v3_windows_reliability as diagnostic + commands = [] + def run(command, log_path, env): + commands.append(command) + return {'returncode': 0, 'elapsed_ns': 1, 'command': command, 'timeout_cleanup': None} + with tempfile.TemporaryDirectory() as directory, \ + patch.object(sys, 'argv', [str(SCRIPT), '--output', directory, '--iterations', '2', + '--control', 'paired-anchor', '--interleave-reviewer']), \ + patch.object(diagnostic, 'run_process', side_effect=run): + self.assertEqual(diagnostic.main(), 0) + report = json.loads((Path(directory) / 'report.json').read_text()) + self.assertEqual(report['execution_order'], [ + 'cancellation-1-baseline', 'reviewer-1-baseline', + 'cancellation-1-anchor', 'reviewer-1-anchor', + 'cancellation-2-anchor', 'reviewer-2-anchor', + 'cancellation-2-baseline', 'reviewer-2-baseline']) + self.assertEqual([row['variant'] for row in report['iterations']], + ['baseline', 'anchor', 'anchor', 'baseline']) + cancellations = commands[::2] + self.assertTrue(all(command[command.index('--control') + 1] == 'cpu' for command in cancellations)) + self.assertEqual(['--readonly-anchor' in command for command in cancellations], + [False, True, True, False]) + with patch.object(sys, 'argv', [str(SCRIPT), '--output', directory, + '--control', 'paired-anchor', '--iterations', '11']): + with self.assertRaises(SystemExit) as error: + diagnostic.main() + self.assertEqual(error.exception.code, 2) + + def test_last_client_checkpoint_latency_hits_original_deadline_until_readonly_anchor(self): + from scripts.v3_windows_reliability import isolated_environment, run_process + code = """ +from pathlib import Path +import sqlite3, sys, threading, time +from unittest.mock import patch +import scripts.v3_windows_reliability as harness +repository = Path(sys.argv[3]).resolve() +harness.ROOT = repository +sys.path[:0] = [str(repository), str(repository / 'tests')] +from scripts.v3_windows_reliability import Trace, run_child +import openkyrozen +assert Path(openkyrozen.__file__).resolve().is_relative_to(repository) +print('Fixture product source:', openkyrozen.__file__, flush=True) + +active, injected = threading.Event(), threading.Event() +state = {} +original_record, original_connect = Trace.record, sqlite3.connect + +def record(trace, event, **fields): + state['trace'] = trace + if event == 'cancel.return': + active.set() + original_record(trace, event, **fields) + +class CheckpointLatency(sqlite3.Connection): + def __init__(self, database, *args, **kwargs): + super().__init__(database, *args, **kwargs) + self.fixture = str(database).endswith(('state.sqlite3', 'state.sqlite3?mode=ro')) + + def close(self): + trace = state.get('trace') + wal = trace.case.root / 'state.sqlite3-wal' if trace and active.is_set() and self.fixture else None + size = wal.stat().st_size if wal and wal.is_file() else 0 + super().close() + # A dirty WAL disappearing proves this close performed real last-client checkpoint cleanup. + if size > 32 and not wal.exists() and not injected.is_set(): + injected.set() + trace.record('injected_checkpoint_close.call', wal_bytes=size) + time.sleep(5.3) + trace.record('injected_checkpoint_close.return') + +def connect(*args, **kwargs): + kwargs['factory'] = CheckpointLatency + return original_connect(*args, **kwargs) + +with patch.object(Trace, 'record', record), patch.object(sqlite3, 'connect', connect): + raise SystemExit(run_child(Path(sys.argv[1]), readonly_anchor=sys.argv[2] == 'anchor')) +""" + with tempfile.TemporaryDirectory() as directory: + revision = '0f172f9fe692ff8fafcc0a15a72fe51eaf67e977' + legacy = Path(directory) / 'historical-product' + archive = self._historical_archive(revision) + with zipfile.ZipFile(io.BytesIO(archive)) as source: + self.assertTrue(all(not Path(name).is_absolute() and '..' not in Path(name).parts + for name in source.namelist())) + source.extractall(legacy) + self.assertEqual(hashlib.sha256((legacy / 'tests/test_delegation.py').read_bytes()).hexdigest(), + '7484131880e91a328961ec0b8b99fe2bcaad8ded19b2ebe8efd944028f2aa3a7') + evidence = Path(os.environ.get('KYROZEN_DIAGNOSTIC_TEST_OUTPUT', directory)) + evidence = evidence / f'checkpoint-close-{time.time_ns()}' + env = isolated_environment(Path(directory) / 'home') + for variant, expected in (('baseline', 1), ('anchor', 0), ('current', 0)): + repository = ROOT if variant == 'current' else legacy + output = evidence / variant + output.mkdir(parents=True) + process = run_process([sys.executable, '-c', code, str(output), variant, str(repository)], + output / 'process.log', env) + (output / 'child-process-result.json').write_text(json.dumps( + {**process, 'expected_returncode': expected, 'readonly_anchor': variant == 'anchor', + 'product_revision': 'current-checkout' if variant == 'current' else revision, + 'fixture_sha256': hashlib.sha256((repository / 'tests/test_delegation.py').read_bytes()).hexdigest(), + 'database_source_sha256': hashlib.sha256((repository / 'openkyrozen/persistence/database.py').read_bytes()).hexdigest()}, indent=2)) + self.assertEqual(process['returncode'], expected, (output / 'process.log').read_text()) + log = (output / 'unittest.log').read_text() + events = [json.loads(line)['event'] for line in (output / 'events.jsonl').read_text().splitlines()] + state = json.loads((output / 'state-before-cleanup.json').read_text()) + self.assertEqual(state['marker'], 'ok') + self.assertEqual(state['persisted_states'][-1]['status'], 'cancelled') + if variant == 'baseline': + self.assertIn('TimeoutError', log) + self.assertIn('injected_checkpoint_close.call', events) + self.assertLess(events.index('injected_checkpoint_close.call'), events.index('future_wait.return')) + elif variant == 'anchor': + self.assertNotIn('TimeoutError', log) + self.assertNotIn('injected_checkpoint_close.call', events) + self.assertLess(events.index('future_wait.return'), events.index('anchor.close.call')) + else: + self.assertNotIn('TimeoutError', log) + for index, event in enumerate(events): + if event == 'injected_checkpoint_close.call': + self.assertLess(events.index('future_wait.return'), index) + + def test_detailed_profiler_starts_after_fixture_setup(self): + import sqlite3 + from scripts.v3_windows_reliability import run_child + + class Fixture(unittest.TestCase): + def setUp(self): + self.root = root + connection = sqlite3.connect(':memory:') + connection.close() + + def test_cancel_during_provider_call_prevents_late_write(self): + connection = sqlite3.connect(':memory:') + connection.close() + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fixture = types.SimpleNamespace(DelegationTests=Fixture) + with patch.dict(sys.modules, {'test_delegation': fixture}): + self.assertEqual(run_child(root), 0) + names = [json.loads(line)['event'] for line in (root / 'events.jsonl').read_text().splitlines()] + start = names.index('setup.return') + self.assertFalse(any(name.startswith('sqlite.') for name in names[:start])) + self.assertIn('sqlite.connect.c_call', names[start:names.index('cleanup.call')]) + + def test_historical_pin_export_includes_preinstalled_dependencies_and_excludes_product(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'requirements.txt' + process = subprocess.run([sys.executable, str(SCRIPT), '--write-historical-requirements', str(path)], + capture_output=True, text=True, timeout=5) + self.assertEqual(process.returncode, 0, process.stderr) + requirements = path.read_text().splitlines() + for requirement in ('openai==3.42.1', 'anthropic==2.3.0', 'packaging==26.3', + 'colorama==0.4.6', 'click==8.5.0', 'filelock==4.2.4'): + self.assertIn(requirement, requirements) + self.assertFalse(any(item.startswith('openkyrozen==') for item in requirements)) + + def test_historical_environment_mismatch_stops_before_running_any_fixture(self): + from scripts import v3_windows_reliability as diagnostic + with tempfile.TemporaryDirectory() as directory: + with patch.object(sys, 'argv', [str(SCRIPT), '--output', directory, + '--iterations', '1', '--historical-environment']), \ + patch.object(diagnostic.platform, 'python_version', return_value='3.12.99'): + self.assertEqual(diagnostic.main(), 2) + root = Path(directory) + environment = json.loads((root / 'historical-environment.json').read_text()) + self.assertEqual(environment['status'], 'INFRASTRUCTURE_FAILURE') + self.assertFalse((root / '1').exists()) + + def test_child_environment_uses_private_windows_home_and_scrubs_credentials(self): + from scripts.v3_windows_reliability import isolated_environment + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { + 'USERPROFILE': '/original-user', 'HOME': '/original-home', + 'DEEPSEEK_API_KEY': 'must-not-inherit', 'GH_TOKEN': 'must-not-inherit', + 'KYROZEN_DB_PATH': '/original.sqlite3'}): + env = isolated_environment(Path(directory)) + self.assertEqual(env['HOME'], directory) + self.assertEqual(env['USERPROFILE'], directory) + self.assertEqual(env['KYROZEN_DB_PATH'], str(Path(directory) / 'runtime.sqlite3')) + self.assertTrue(Path(env['KYROZEN_WORKSPACE_ROOT']).is_dir()) + self.assertNotIn('DEEPSEEK_API_KEY', env) + self.assertNotIn('GH_TOKEN', env) + + def test_historical_environment_rejects_runtime_and_dependency_substitutions(self): + from scripts.v3_windows_reliability import historical_environment + with patch('scripts.v3_windows_reliability.platform.python_version', return_value='3.12.10'), \ + patch('scripts.v3_windows_reliability.platform.system', return_value='Windows'), \ + patch('scripts.v3_windows_reliability.metadata.version', return_value='wrong-version'): + report = historical_environment() + self.assertEqual(report['status'], 'INFRASTRUCTURE_FAILURE') + self.assertIn('openai', report['dependency_mismatches']) + self.assertEqual(report['dependency_mismatches']['openai']['expected'], '3.42.1') + self.assertEqual(report['dependency_mismatches']['anthropic']['expected'], '2.3.0') + with patch('scripts.v3_windows_reliability.platform.python_version', return_value='3.12.13'): + report = historical_environment() + self.assertEqual(report['status'], 'INFRASTRUCTURE_FAILURE') + self.assertEqual(report['python'], '3.12.13') + + def test_interleaved_run_keeps_original_order_and_traces_post_cancel_phases(self): + with tempfile.TemporaryDirectory() as directory: + process = subprocess.run([sys.executable, str(SCRIPT), '--output', directory, + '--iterations', '2', '--repository', str(ROOT), '--interleave-reviewer'], + cwd=ROOT, capture_output=True, text=True, timeout=45) + self.assertEqual(process.returncode, 0, process.stdout + process.stderr) + root = Path(directory) + report = json.loads((root / 'report.json').read_text()) + self.assertFalse(report['historical_cause_established']) + self.assertEqual(report['execution_order'], [ + 'cancellation-1', 'reviewer-1', 'cancellation-2', 'reviewer-2']) + for index in (1, 2): + result = report['iterations'][index - 1] + self.assertEqual(result['reviewer']['returncode'], 0) + self.assertIn('Sub-agent workflow acceptance passed.', + (root / str(index) / 'reviewer.log').read_text()) + events = [json.loads(line) for line in + (root / str(index) / 'events.jsonl').read_text().splitlines()] + names = [item['event'] for item in events] + for name in ('provider_wait.call', 'provider_check.call', 'usage_persist.call', 'usage_summary.call', + 'sqlite.execute.c_call', 'sqlite.close.c_return'): + self.assertIn(name, names) + self.assertTrue(all('thread_cpu_ns' in item for item in events)) + + @unittest.skipUnless(os.name == 'posix', 'real owned process-group cleanup requires POSIX') + def test_diagnostic_timeout_cleanup_stops_owned_descendant(self): + from scripts.v3_windows_reliability import stop_diagnostic_tree + code = ''' +import subprocess, sys, time +from pathlib import Path +child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']) +Path(sys.argv[1]).write_text(str(child.pid)) +time.sleep(30) +''' + with tempfile.TemporaryDirectory() as directory: + marker = Path(directory) / 'child-pid' + process = subprocess.Popen([sys.executable, '-c', code, str(marker)], + start_new_session=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 3 + while not marker.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(.01) + self.assertTrue(marker.exists()) + child_pid = int(marker.read_text()) + cleanup = stop_diagnostic_tree(process) + process.communicate(timeout=3) + state = subprocess.run(['ps', '-p', str(child_pid), '-o', 'stat='], + capture_output=True, text=True, timeout=3).stdout.strip() + self.assertTrue(not state or state.startswith('Z'), state) + self.assertFalse(cleanup['descendant_cleanup_certified']) + self.assertIsNotNone(process.returncode) + finally: + stop_diagnostic_tree(process) + process.communicate(timeout=3) + + def test_cpu_control_exits_at_its_own_deadline_without_parent_stop(self): + from scripts.v3_windows_reliability import _CPU_LOAD + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result = subprocess.run([sys.executable, '-c', _CPU_LOAD, + str(root / 'ready'), str(root / 'never-stopped'), str(root / 'result.json'), '.15'], + capture_output=True, text=True, timeout=3) + self.assertEqual(result.returncode, 0, result.stderr) + metrics = json.loads((root / 'result.json').read_text()) + self.assertEqual(metrics['stopped_by'], 'deadline') + self.assertLess(metrics['wall_seconds'], 2) + + def test_explicit_load_control_records_real_contention_and_owned_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + process = subprocess.run([sys.executable, str(SCRIPT), '--output', directory, + '--iterations', '1', '--repository', str(ROOT), '--control', 'combined', + '--cpu-workers', '1'], cwd=ROOT, capture_output=True, text=True, timeout=40) + self.assertEqual(process.returncode, 0, process.stdout + process.stderr) + report = json.loads((Path(directory) / 'report.json').read_text()) + self.assertEqual(report['control'], 'combined') + self.assertFalse(report['historical_cause_established']) + result = json.loads((Path(directory) / '1' / 'load-control.json').read_text()) + self.assertEqual(result['control'], 'combined') + self.assertGreaterEqual(result['sqlite']['acquired_transactions'], 1) + self.assertTrue(result['sqlite']['thread_stopped']) + self.assertEqual(len(result['cpu']), 1) + self.assertEqual(result['cpu'][0]['returncode'], 0) + self.assertGreater(result['cpu'][0]['cpu_seconds'], 0) + self.assertGreater(result['cpu'][0]['wall_seconds'], 0) + events = [json.loads(line)['event'] for line in + (Path(directory) / '1' / 'events.jsonl').read_text().splitlines()] + self.assertIn('sqlite.lock_acquired', events) + self.assertIn('sqlite.lock_released', events) + self.assertIn('load.started', events) + self.assertIn('load.stopped', events) + state = json.loads((Path(directory) / '1' / 'state-before-cleanup.json').read_text()) + self.assertEqual(state['marker'], 'ok') + + def test_actual_cancellation_fixture_emits_boundary_and_persisted_evidence(self): + self.assertTrue(SCRIPT.is_file(), 'Windows diagnostic harness is missing') + with tempfile.TemporaryDirectory() as directory: + process = subprocess.run([sys.executable, str(SCRIPT), '--output', directory, + '--iterations', '1', '--repository', str(ROOT)], cwd=ROOT, capture_output=True, + text=True, timeout=40) + self.assertEqual(process.returncode, 0, process.stdout + process.stderr) + path = Path(directory) + report = json.loads((path / 'report.json').read_text()) + self.assertFalse(report['historical_cause_established']) + self.assertEqual(report['iterations'][0]['returncode'], 0) + events = [json.loads(line) for line in (path / '1' / 'events.jsonl').read_text().splitlines()] + names = [event['event'] for event in events] + for name in ('dispatch.call', 'dispatch.return', 'provider.call', 'cancel.call', + 'cancel.return', 'release.call', 'provider.return', 'worker.return', 'future_wait.call', + 'cleanup.call', 'cleanup.return'): + self.assertIn(name, names) + self.assertLess(names.index('provider.call'), names.index('cancel.call')) + self.assertLess(names.index('cancel.return'), names.index('release.call')) + self.assertTrue(all('monotonic_ns' in event and 'thread_id' in event for event in events)) + state = json.loads((path / '1' / 'state-before-cleanup.json').read_text()) + self.assertEqual(state['marker'], 'ok') + self.assertTrue(state['persisted_states']) + self.assertEqual(state['persisted_states'][-1]['status'], 'cancelled') + self.assertTrue((path / '1' / 'threads.log').is_file()) + self.assertEqual(report['test_timeouts_seconds'], {'provider_wait': 3, 'entry_wait': 10, 'future_wait': 5}) + + def test_slow_worker_completion_keeps_original_timeout_and_captures_pending_worker(self): + code = """ +from pathlib import Path +import faulthandler +import sys +import time +from unittest.mock import patch +from scripts.v3_windows_reliability import Trace, run_child + +original = Trace.record + +def delay_worker_completion(trace, event, **fields): + original(trace, event, **fields) + if event == 'worker.return': + time.sleep(5.3) + +with patch.object(Trace, 'record', delay_worker_completion), \ + patch.object(faulthandler, 'dump_traceback_later', side_effect=AssertionError('native watchdog forbidden')), \ + patch.object(faulthandler, 'cancel_dump_traceback_later', side_effect=AssertionError('native watchdog forbidden')): + result = run_child(Path(sys.argv[1])) +print('native watchdog unused', flush=True) +raise SystemExit(result) +""" + with tempfile.TemporaryDirectory() as directory: + evidence = Path(os.environ.get('KYROZEN_DIAGNOSTIC_TEST_OUTPUT', directory)) + evidence = evidence / f'native-watchdog-{time.time_ns()}' + for iteration in range(3): + path = evidence / str(iteration) + path.mkdir(parents=True) + with (path / 'process.log').open('w') as log: + process = subprocess.run([sys.executable, '-c', code, str(path)], cwd=ROOT, + stdout=log, stderr=subprocess.STDOUT, timeout=20) + output = (path / 'process.log').read_text() + self.assertIn('native watchdog unused', output) + self.assertEqual(process.returncode, 1, output) + self.assertIn('TimeoutError', (path / 'unittest.log').read_text()) + self.assertIn('Timeout (0:00:04)', (path / 'threads.log').read_text()) + self.assertTrue((path / 'state-watchdog-1.json').exists()) + state = json.loads((path / 'state-before-cleanup.json').read_text()) + self.assertEqual(state['marker'], 'ok') + self.assertTrue(any(not run['future_done'] for run in state['runs'])) + self.assertEqual(state['persisted_states'][-1]['status'], 'cancelled') + + +class NativeWaitDiagnosticTests(unittest.TestCase): + @staticmethod + def exported_profile(): + # The actual GeneralProfile+FileIO export does not list Filename. + return ''' + + + + + + + + + + +''' + + def test_native_snapshot_never_opens_a_database_until_owned_futures_are_done(self): + import sqlite3 + from contextlib import closing + from scripts.v3_windows_reliability import Trace + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with closing(sqlite3.connect(root / 'state.sqlite3')) as connection, connection: + connection.execute('CREATE TABLE events(event_type TEXT, payload TEXT)') + connection.execute('INSERT INTO events VALUES(?,?)', + ('subagent.state', json.dumps({'run_id':'owned','status':'cancelled'}))) + with self.assertRaises(sqlite3.ProgrammingError): + connection.execute('SELECT 1') + (root / 'marker.txt').write_text('ok') + trace = Trace(root, native=True) + done = False + trace.case = types.SimpleNamespace(root=root) + trace.coordinator = types.SimpleNamespace(runs={'owned': {'status':'cancelled'}}, + futures={'owned':types.SimpleNamespace(done=lambda: done)}, + cancelled={'owned':types.SimpleNamespace(is_set=lambda: True)}) + try: + with patch('scripts.v3_windows_reliability.sqlite3.connect', + side_effect=AssertionError('measurement opened an implicit WAL client')): + trace.snapshot('pending.json') + pending = json.loads((root / 'pending.json').read_text()) + self.assertTrue(pending['database_snapshot_deferred']) + self.assertEqual(pending['persisted_states'], []) + done = True + trace.snapshot('complete.json') + self.assertEqual(json.loads((root / 'complete.json').read_text())['persisted_states'][0]['status'], + 'cancelled') + finally: + trace.stream.close() + + def test_native_trace_records_joinable_identity_and_real_wal_close_metadata(self): + import sqlite3 + from scripts.v3_windows_reliability import Trace + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + connection = sqlite3.connect(root / 'state.sqlite3') + connection.execute('PRAGMA journal_mode=WAL') + connection.execute('CREATE TABLE probe(value TEXT)') + connection.commit() + trace = Trace(root, native=True) + trace.case = types.SimpleNamespace(root=root) + frame = types.SimpleNamespace(f_code=types.SimpleNamespace(co_qualname='owned.close')) + try: + trace.profile(frame, 'c_call', connection.close) + connection.close() + trace.profile(frame, 'c_return', connection.close) + finally: + trace.stream.close() + events = [json.loads(line) for line in (root / 'events.jsonl').read_text().splitlines()] + before, after = [row for row in events if row['event'].startswith('sqlite.close.')] + self.assertTrue(before['wal']['exists']) + self.assertGreater(before['wal']['bytes'], 32) + self.assertFalse(after['wal']['exists']) + self.assertEqual(before['connection_id'], after['connection_id']) + self.assertEqual(before['pid'], os.getpid()) + self.assertGreater(before['native_tid'], 0) + self.assertEqual(events[0]['event'], 'clock.mapping') + self.assertIn('performance_counter_ns', events[0]) + + def test_missing_native_tools_blocks_without_starting_or_cancelling_any_recording(self): + from scripts import v3_windows_reliability as diagnostic + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.platform, 'system', return_value='Windows'), \ + patch.object(diagnostic.shutil, 'which', return_value=None), \ + patch.object(diagnostic.subprocess, 'run') as run: + capture = diagnostic.NativeCapture(Path(directory), {}) + with self.assertRaisesRegex(RuntimeError, 'unavailable'): + capture.preflight() + capture.stop() + run.assert_not_called() + + def test_native_preflight_checks_exported_wait_and_io_profiles_with_windows_xml_encoding(self): + from scripts import v3_windows_reliability as diagnostic + commands = [] + def run(command, **kwargs): + commands.append(command) + if '-exportprofile' in command: + Path(command[-2]).write_text(self.exported_profile(), encoding='utf-16') + text = ('KyrozenR260' if '-profiles' in command and len(command)==3 else + 'GeneralProfile FileIO' if '-profiles' in command else '-instancename') + return subprocess.CompletedProcess(command, 0, text, '') + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.platform, 'system', return_value='Windows'), \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess, 'run', side_effect=run): + capture = diagnostic.NativeCapture(Path(directory), {}) + metadata = capture.preflight() + import xml.etree.ElementTree as ET + selected = Path(directory)/'selected-profile.wprp' + document = ET.parse(selected).getroot() + self.assertEqual({node.get('Value') for node in document.findall('.//SystemProvider/Keywords/Keyword')}, + {'ProcessThread','Loader','ThreadPriority','CSwitch','ReadyThread','DiskIO', + 'DiskIOInit','FileIO','FileIOInit','Filename'}) + self.assertTrue(all(node.get('Strict')=='true' for node in document.findall('.//Keyword'))) + self.assertEqual(len(document.findall('.//SystemCollector')), 1) + self.assertEqual(document.find('.//SystemCollector/BufferSize').get('Value'), '64') + self.assertEqual(document.find('.//SystemCollector/Buffers').get('Value'), '40') + self.assertFalse(any(document.findall('.//'+tag) for tag in ('Stacks','EventProvider','EventCollector'))) + self.assertEqual({node.get('LoggingMode') for node in document.findall('./Profiles/Profile')}, {'File','Memory'}) + self.assertEqual(metadata['sha256'], hashlib.sha256(selected.read_bytes()).hexdigest()) + self.assertEqual(commands[-1], ['wpr.exe','-profiles',str(selected)]) + self.assertEqual(len(commands), 4) + self.assertFalse(any('-start' in command or '-cancel' in command for command in commands)) + selected.write_text('changed after preflight') + with self.assertRaises(RuntimeError):capture.start(Path(directory)) + self.assertEqual(len(commands),4) + + def test_custom_profile_rejection_or_missing_system_keyword_blocks_without_fallback(self): + from scripts import v3_windows_reliability as diagnostic + for mode in ('missing-keyword','rejected-profile'): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.platform,'system',return_value='Windows'), \ + patch.object(diagnostic.shutil,'which',side_effect=lambda name:name): + commands=[] + def run(command,**kwargs): + commands.append(command) + if '-exportprofile' in command: + text=self.exported_profile() + if mode=='missing-keyword':text=text.replace('','') + Path(command[-2]).write_text(text) + custom='-profiles' in command and len(command)==3 + text='KyrozenR260' if custom else 'GeneralProfile FileIO -instancename' + return subprocess.CompletedProcess(command,5 if custom else 0,text,'') + capture=diagnostic.NativeCapture(Path(directory),{}) + with patch.object(diagnostic.subprocess,'run',side_effect=run): + with self.assertRaises(RuntimeError):capture.preflight() + with self.assertRaises(RuntimeError):capture.start(Path(directory)) + self.assertFalse(capture.active) + self.assertFalse(any('-start' in command or '-cancel' in command for command in commands)) + + def test_capture_cannot_start_without_its_unchanged_preflighted_custom_profile(self): + from scripts import v3_windows_reliability as diagnostic + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil,'which',side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess,'run',return_value=subprocess.CompletedProcess([],0,'','')) as run: + capture=diagnostic.NativeCapture(Path(directory),{}) + with self.assertRaises(RuntimeError):capture.start(Path(directory)) + run.assert_not_called() + + def test_native_workflow_preserves_existing_controls_and_adds_a_pinned_separate_old_checkout(self): + import yaml + workflow = yaml.safe_load((ROOT/'.github/workflows/v3-windows-reliability.yml').read_text()) + dispatch = workflow.get('on', workflow.get(True))['workflow_dispatch']['inputs'] + self.assertEqual(dispatch['control']['options'], + ['baseline','cpu','sqlite','combined','paired-anchor','native-wait','all']) + steps = workflow['jobs']['cancellation']['steps'] + old = next(step for step in steps if step.get('with', {}).get('path')=='historical-product') + self.assertEqual(old['with']['ref'], '0f172f9fe692ff8fafcc0a15a72fe51eaf67e977') + trace = next(step['run'] for step in steps if step.get('name','').startswith('Trace unchanged')) + self.assertIn('--cpu-workers 2', trace) + self.assertIn("'--historical-repository', 'historical-product'", trace) + + def test_native_capture_stops_only_its_owned_instance_even_when_start_is_uncertain(self): + from scripts import v3_windows_reliability as diagnostic + commands = [] + def run(command, **kwargs): + commands.append(command) + if '-exportprofile' in command: + Path(command[-2]).write_text(self.exported_profile()) + if '-profiles' in command or '-help' in command: + return subprocess.CompletedProcess(command,0, + 'KyrozenR260' if len(command)==3 and '-profiles' in command else + 'GeneralProfile FileIO -instancename','') + if '-start' in command: + raise subprocess.TimeoutExpired(command, 10) + if '-status' in command and 'collectors' not in command: + return subprocess.CompletedProcess(command, 0xc5583000, 'There are no trace profiles running.', '') + return subprocess.CompletedProcess(command, 0, 'Events Lost : 0', '') + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess, 'run', side_effect=run): + capture = diagnostic.NativeCapture(Path(directory), {}) + with patch.object(diagnostic.platform,'system',return_value='Windows'): + capture.preflight() + commands.clear() + try: + with self.assertRaises(subprocess.TimeoutExpired): + capture.start(Path(directory)) + finally: + with self.assertRaisesRegex(RuntimeError, 'ETL'): + capture.stop() + self.assertTrue(any('-stop' in command for command in commands)) + self.assertFalse(any('-cancel' in command for command in commands)) + self.assertTrue(all(command[-2:] == ['-instancename',capture.instance] for command in commands)) + + def test_failed_save_cancels_only_owned_recording_and_verifies_inactive_without_hiding_failure(self): + from scripts import v3_windows_reliability as diagnostic + commands = [] + def run(command, **kwargs): + commands.append((command, kwargs['timeout'])) + if '-stop' in command: + Path(command[2]).write_bytes(b'partial native trace') + raise subprocess.TimeoutExpired(command, kwargs['timeout']) + if '-status' in command and 'collectors' not in command: + return subprocess.CompletedProcess(command, 0xc5583000, 'There are no trace profiles running.', '') + return subprocess.CompletedProcess(command, 0, 'Events Lost : 0', '') + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess, 'run', side_effect=run): + capture = diagnostic.NativeCapture(Path(directory), {}) + capture.case, capture.active = Path(directory), True + with self.assertRaises(subprocess.TimeoutExpired): + capture.stop() + self.assertFalse(capture.active) + self.assertFalse(capture.cleanup_uncertified) + self.assertTrue(any('-cancel' in command for command, _ in commands)) + self.assertTrue(all(command[-2:]==['-instancename',capture.instance] for command, _ in commands)) + self.assertTrue(all(timeout<=30 for _, timeout in commands)) + self.assertTrue(any((Path(directory)/'traces').glob('*.etl'))) + self.assertTrue((Path(directory)/'wpr-stop.json').exists()) + + def test_failed_cancel_or_unknown_inactive_response_remains_active_and_uncertified(self): + from scripts import v3_windows_reliability as diagnostic + for cancel_code, inactive_text in ((5,'WPR recording is in progress...'), (0,'unknown status format')): + with self.subTest(cancel_code=cancel_code), tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name): + commands = [] + def run(command, **kwargs): + commands.append(command) + if '-stop' in command: + return subprocess.CompletedProcess(command, 5, '', 'save failed') + if '-cancel' in command: + return subprocess.CompletedProcess(command, cancel_code, '', 'cancel output') + text = 'Events Lost : 0' if 'collectors' in command else inactive_text + return subprocess.CompletedProcess(command, 0, text, '') + capture = diagnostic.NativeCapture(Path(directory), {}) + capture.case, capture.active = Path(directory), True + with patch.object(diagnostic.subprocess, 'run', side_effect=run), \ + self.assertRaisesRegex(RuntimeError, 'cleanup_uncertified'): + capture.stop() + self.assertTrue(capture.active) + self.assertTrue(capture.cleanup_uncertified) + self.assertTrue(any('-cancel' in command for command in commands)) + self.assertTrue(all(command[-2:]==['-instancename',capture.instance] for command in commands)) + + def test_failed_status_with_partial_zero_loss_still_saves_but_never_qualifies_capture(self): + from scripts import v3_windows_reliability as diagnostic + def run(command, **kwargs): + if '-stop' in command: + Path(command[2]).write_bytes(b'unit-test trace stub') + if '-status' in command: + if 'collectors' in command: + return subprocess.CompletedProcess(command, 5, 'Events Lost : 0', 'query failed') + return subprocess.CompletedProcess(command, 0, 'WPR is not recording.', '') + return subprocess.CompletedProcess(command, 0, '', '') + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess, 'run', side_effect=run): + capture = diagnostic.NativeCapture(Path(directory), {}) + capture.case, capture.active = Path(directory), True + with self.assertRaisesRegex(RuntimeError, 'wpr-status'): + capture.stop() + self.assertFalse(capture.active) + self.assertTrue(any((Path(directory)/'traces').glob('*.etl'))) + + def test_uncertified_retries_preserve_each_partial_trace_and_tool_attempt(self): + from scripts import v3_windows_reliability as diagnostic + attempts = 0 + def run(command, **kwargs): + nonlocal attempts + if '-stop' in command: + attempts += 1 + Path(command[2]).write_bytes(f'partial {attempts}'.encode()) + return subprocess.CompletedProcess(command, 5, '', 'failed save') + code = 5 if '-cancel' in command else 0 + return subprocess.CompletedProcess(command, code, 'Events Lost : 0', '') + with tempfile.TemporaryDirectory() as directory, \ + patch.object(diagnostic.shutil, 'which', side_effect=lambda name:name), \ + patch.object(diagnostic.subprocess, 'run', side_effect=run): + root = Path(directory) + capture = diagnostic.NativeCapture(root, {}) + capture.case, capture.active = root, True + for _ in range(2): + with self.assertRaisesRegex(RuntimeError, 'cleanup_uncertified'): + capture.stop() + self.assertEqual(sorted(path.read_bytes() for path in (root/'traces').glob('*.etl')), + [b'partial 1',b'partial 2']) + self.assertEqual(len(list(root.glob('wpr-stop*.json'))), 2) + self.assertTrue(capture.active and capture.cleanup_uncertified) + + def comparison(self, failure=False, *, abort_reason=None, launch_error=False, saved_trace_bytes=None): + from scripts import v3_windows_reliability as diagnostic + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + output = Path(temporary.name) + old, current = output / 'old', output / 'current' + commands = [] + activity = {'tools': [], 'workload_recording': []} + recording = False + capture = diagnostic.NativeCapture(output, {}) + capture.wpr, capture.decoder = 'wpr.exe', 'tracerpt.exe' + + def native_tool(command, **kwargs): + nonlocal recording + activity['tools'].append(command) + if '-exportprofile' in command: + Path(command[-2]).write_text(self.exported_profile()) + if '-profiles' in command or '-help' in command: + return subprocess.CompletedProcess(command,0, + 'KyrozenR260' if len(command)==3 and '-profiles' in command else + 'GeneralProfile FileIO -instancename','') + if '-start' in command: + self.assertFalse(recording, 'the owned recording was already active') + recording = True + temporary_trace = Path(command[command.index('-recordtempto')+1]) / 'recording.etl' + # Model the real runner's roughly 120 MiB per-recording allocation. + with temporary_trace.open('wb') as stream: + stream.truncate(120*1024*1024) + elif '-stop' in command: + if saved_trace_bytes is not None: + with (output/'traces/temporary/recording.etl').open('r+b') as stream: + stream.truncate(saved_trace_bytes) + (output/'traces/temporary/recording.etl').replace(command[2]) + recording = False + elif '-of' in command: + Path(command[command.index('-o')+1]).write_text('') + Path(command[command.index('-summary')+1]).write_text('synthetic decoder output') + text = ('Events Lost : 0' if 'collectors' in command else 'WPR is not recording.') + return subprocess.CompletedProcess(command, 0, text, '') + + def run(command, log_path, env, **kwargs): + commands.append((command, kwargs)) + activity['workload_recording'].append(recording) + if launch_error: + raise OSError('child launch failed') + reason = abort_reason or kwargs['abort']() + if reason: + return {'returncode':124, 'timeout_seconds':45, 'timeout_cleanup':None, + 'abort_reason':reason} + if '--child' in command: + case = Path(command[command.index('--output')+1]) + bad = failure and len(commands)==1 + (case/'unittest.log').write_text('TimeoutError' if bad else 'OK') + (case/'events.jsonl').write_text('\n'.join(json.dumps(row) for row in [ + {'event':'cancel.return','monotonic_ns':1,'thread_id':1}, + {'event':'future_wait.call','monotonic_ns':1,'thread_id':1}, + {'event':'future_wait.return','monotonic_ns':5_000_000_001 if bad else 200_000_001,'thread_id':1}])) + return {'returncode': int(bad), 'timeout_seconds':45, 'timeout_cleanup':None} + return {'returncode':0, 'timeout_seconds':45, 'timeout_cleanup':None} + with patch.object(diagnostic, 'NativeCapture', return_value=capture), \ + patch.object(diagnostic.platform, 'system', return_value='Windows'), \ + patch.object(diagnostic.subprocess, 'run', side_effect=native_tool), \ + patch.object(diagnostic, '_product_identity', side_effect=lambda path: { + 'candidate_sha':diagnostic.HISTORICAL_PRODUCT if path==old else 'c'*40}), \ + patch.object(diagnostic, 'historical_environment', return_value={'status':'MATCH'}), \ + patch.object(diagnostic, 'run_process', side_effect=run): + result = diagnostic.run_native_comparison(output, current, old) + return result, json.loads((output/'report.json').read_text()), commands, old, current, activity + + def test_native_comparison_keeps_identical_cpu_controls_and_pinned_reviewers_and_all_fast_is_inconclusive(self): + result, report, commands, old, current, _ = self.comparison() + self.assertEqual(result, 0) + self.assertEqual(report['status'], 'INCONCLUSIVE') + self.assertFalse(report['historical_cause_established']) + self.assertEqual(report['active_limit_seconds'], 300) + self.assertEqual(report['trace_limit_bytes'], 512*1024*1024) + self.assertEqual([row['variant'] for row in report['iterations']], + ['baseline','anchor','current','current','anchor','baseline', + 'anchor','baseline','current','current','baseline','anchor']) + self.assertEqual(len(commands), 24) + for command, kwargs in commands[::2]: + self.assertIn('--native-trace', command) + self.assertEqual(command[command.index('--control')+1], 'cpu') + self.assertEqual(command[command.index('--cpu-workers')+1], '2') + self.assertIn('abort', kwargs) + for command, _ in commands[1::2]: + self.assertEqual(command[1], str(old/'scripts/subagent_workflow_acceptance.py')) + cancellations = [command for command, _ in commands[::2]] + self.assertEqual(['--readonly-anchor' in command for command in cancellations], + [row['variant']=='anchor' for row in report['iterations']]) + + def test_native_failure_keeps_the_whole_control_triple_and_never_claims_attribution(self): + result, report, commands, _, _, _ = self.comparison(failure=True) + self.assertEqual(result, 1) + self.assertEqual(len(commands), 6) + self.assertEqual(report['status'], 'PENDING_NATIVE_ANALYSIS') + self.assertFalse(report['historical_cause_established']) + self.assertEqual(report['iterations'][0]['future_wait_seconds'], 5) + + def test_one_owned_recording_covers_all_cells_and_reviewers_with_one_shared_digest(self): + result, report, commands, _, _, activity = self.comparison() + self.assertEqual(result, 0) + self.assertEqual(len(commands), 24) + self.assertEqual(report['capture_scope'], 'experiment') + selected=Path(report['capture_profile']['path']) + self.assertEqual(report['capture_profile']['sha256'],hashlib.sha256(selected.read_bytes()).hexdigest()) + start=next(command for command in activity['tools'] if '-start' in command) + self.assertEqual(start[2],str(selected)+'!KyrozenR260') + self.assertEqual(start.count('-start'),1) + self.assertEqual(report['capture']['bytes'], 120*1024*1024) + self.assertTrue(all(activity['workload_recording'])) + self.assertEqual(sum('-start' in command for command in activity['tools']), 1) + self.assertEqual(sum('-stop' in command for command in activity['tools']), 1) + self.assertEqual(sum('-of' in command for command in activity['tools']), 1) + self.assertEqual(len(list((Path(report['capture']['etl']).parent).glob('*.etl'))), 1) + for row in report['iterations']: + self.assertEqual(row['capture'], report['capture']) + self.assertEqual(Path(row['native_events']).name, 'events.jsonl') + self.assertTrue(Path(row['native_events']).is_file()) + + def test_child_launch_failure_still_saves_the_shared_owned_recording(self): + result, report, commands, _, _, activity = self.comparison(launch_error=True) + self.assertEqual(result, 2) + self.assertEqual(report['status'], 'BLOCKED') + self.assertIn('child launch failed', report['error']) + self.assertFalse(report['cleanup_uncertified']) + self.assertFalse(report['historical_cause_established']) + self.assertEqual(len(commands), 1) + self.assertEqual(sum('-stop' in command for command in activity['tools']), 1) + self.assertIn('capture', report) + self.assertEqual(report['iterations'][0]['capture'], report['capture']) + self.assertTrue(Path(report['capture']['etl']).is_file()) + + def test_size_abort_preserves_the_shared_trace_and_original_limits_without_attribution(self): + result, report, commands, _, _, activity = self.comparison(abort_reason='trace_size_limit') + self.assertNotEqual(result, 0) + self.assertEqual(report['limit_reached'], 'trace_size_limit') + self.assertEqual(report['active_limit_seconds'], 300) + self.assertEqual(report['trace_limit_bytes'], 512*1024*1024) + self.assertEqual(report['iterations'][0]['timeout_seconds'], 45) + self.assertFalse(report['historical_cause_established']) + self.assertEqual(len(commands), 1) + self.assertEqual(sum('-stop' in command for command in activity['tools']), 1) + self.assertIn('capture', report) + self.assertEqual(report['iterations'][0]['capture'], report['capture']) + + def test_trace_growth_during_final_save_is_reported_against_the_same_size_limit(self): + result, report, commands, _, _, _ = self.comparison(saved_trace_bytes=536870913) + self.assertEqual(report.get('limit_reached'), 'trace_size_limit') + self.assertNotEqual(result, 0) + self.assertEqual(len(commands), 24) + self.assertEqual(report['status'], 'INCONCLUSIVE') + self.assertFalse(report['historical_cause_established']) + self.assertEqual(report['capture']['bytes'], 536870913) + + def test_native_time_and_trace_size_limits_are_explicit_abort_reasons(self): + from scripts import v3_windows_reliability as diagnostic + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertEqual(diagnostic.native_limit(root, time.monotonic()-1), 'active_time_limit') + with (root/'owned.etl').open('wb') as stream: + stream.truncate(512*1024*1024+1) + self.assertEqual(diagnostic.native_limit(root, time.monotonic()+300), 'trace_size_limit') + with patch.object(Path, 'rglob', side_effect=PermissionError('unreadable trace inventory')): + self.assertEqual(diagnostic.native_limit(root, time.monotonic()+300), 'trace_inventory_error') + + def test_native_child_runs_the_actual_fixture_without_a_watchdog_database_client(self): + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run([sys.executable,str(SCRIPT),'--child','--output',directory, + '--repository',str(ROOT),'--native-trace'], cwd=ROOT, capture_output=True, text=True, timeout=35) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + events = [json.loads(row) for row in (Path(directory)/'events.jsonl').read_text().splitlines()] + self.assertEqual(events[0]['event'], 'clock.mapping') + self.assertTrue(any(row['event']=='product.source' for row in events)) + self.assertTrue(any(row['event']=='sqlite.close.c_call' and 'wal' in row for row in events)) + state = json.loads((Path(directory)/'state-before-cleanup.json').read_text()) + self.assertFalse(state['database_snapshot_deferred']) + self.assertEqual(state['persisted_states'][-1]['status'], 'cancelled') + + @unittest.skipUnless(os.name == 'posix', 'real owned child abort fixture requires POSIX') + def test_campaign_abort_preserves_45_second_child_bound_and_cleans_the_owned_process(self): + from scripts.v3_windows_reliability import run_process + with tempfile.TemporaryDirectory() as directory: + attempts = iter((None, 'active_time_limit')) + result = run_process([sys.executable,'-c','import time; time.sleep(20)'], + Path(directory)/'child.log', dict(os.environ), abort=lambda:next(attempts)) + self.assertEqual(result['timeout_seconds'], 45) + self.assertEqual(result['abort_reason'], 'active_time_limit') + self.assertEqual(result['returncode'], 124) + self.assertIsNotNone(result['timeout_cleanup']['parent_returncode']) diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py index dc193f72..b92c1d5a 100644 --- a/tests/test_web_auth.py +++ b/tests/test_web_auth.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import socket @@ -76,13 +77,13 @@ def _start_server(workspace: Path, database: Path, port: int) -> tuple[subproces "browser integration requires `make install` (Playwright and Chromium)", ) def test_token_enabled_ui_authenticates_loads_sessions_streams_and_reports_invalid_token(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-web-auth-") as directory: + with tempfile.TemporaryDirectory(prefix="openkyrozen-web-auth-") as directory, ExitStack() as sqlite_owners: root = Path(directory) workspace = root / "workspace" workspace.mkdir() (workspace / "home").mkdir() database = root / "state.sqlite3" - store = EventStore(database) + store = sqlite_owners.enter_context(closing(EventStore(database))) store.append_event( "session.message", {"role": "assistant", "content": "AUTH_UI_HISTORY"}, user_id="local", workspace_id="default", session_id="auth-ui-session", diff --git a/tests/test_web_sessions.py b/tests/test_web_sessions.py index 17b90af8..b7e3157b 100644 --- a/tests/test_web_sessions.py +++ b/tests/test_web_sessions.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack, closing import json import os import socket @@ -76,13 +77,13 @@ def _stop_server(process: subprocess.Popen) -> None: "browser integration requires `make install` (Playwright and Chromium)", ) def test_browser_restores_switches_and_retains_durable_sessions_after_restart(self): - with tempfile.TemporaryDirectory(prefix="openkyrozen-web-sessions-") as directory: + with tempfile.TemporaryDirectory(prefix="openkyrozen-web-sessions-") as directory, ExitStack() as sqlite_owners: root = Path(directory) workspace = root / "workspace" workspace.mkdir() (workspace / "home").mkdir() db_path = root / "state.sqlite3" - store = EventStore(db_path) + store = sqlite_owners.enter_context(closing(EventStore(db_path))) interaction_workspace_id = source_scope_id(workspace) for session_id, messages in { "session-one": [("user", "first user"), ("assistant", "UI_你好_✅")], diff --git a/tests/test_windows_rendering.py b/tests/test_windows_rendering.py new file mode 100644 index 00000000..b737554f --- /dev/null +++ b/tests/test_windows_rendering.py @@ -0,0 +1,17 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from openkyrozen.interfaces.cli.rendering import _terminal_supports_unicode + + +class WindowsRenderingTests(unittest.TestCase): + def test_terminal_claim_does_not_override_stdout_encoding(self): + terminal = SimpleNamespace(_IS_WINDOWS=True) + with patch.dict("os.environ", {"WT_SESSION": "1", "TERM_PROGRAM": "vscode"}), \ + patch("openkyrozen.interfaces.cli.rendering.sys.stdout", SimpleNamespace(encoding="cp1252")): + self.assertFalse(_terminal_supports_unicode(terminal)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_working_plans.py b/tests/test_working_plans.py index 8e95eab4..ef6b2d7f 100644 --- a/tests/test_working_plans.py +++ b/tests/test_working_plans.py @@ -1,4 +1,5 @@ from __future__ import annotations +from contextlib import ExitStack, closing import tempfile import unittest @@ -17,8 +18,8 @@ class WorkingPlanModelTests(unittest.TestCase): def test_working_plan_snapshot_is_ordered_redacted_and_includes_revision_reason(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="plan-snapshot") plan = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "broad", "task": {"description": "Investigate all", "acceptance": ["result"]}}, @@ -53,8 +54,8 @@ def test_working_plan_snapshot_is_ordered_redacted_and_includes_revision_reason( self.assertEqual(restored["tasks"], snapshot["tasks"]) def test_plan_supersession_blocking_and_revision_history_survive_recovery(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="plan-revisions") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "broad", "task": {"description": "Investigate all", "acceptance": ["result"]}}, @@ -98,8 +99,8 @@ def test_plan_supersession_blocking_and_revision_history_survive_recovery(self): self.assertEqual({task["id"] for task in recovered.tasks}, {task["id"] for task in latest["tasks"]}) def test_plan_merge_and_replace_preserve_supersession_links(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="merge-replace") plan = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "one", "task": {"description": "One", "acceptance": ["one"]}}, @@ -124,8 +125,8 @@ def test_plan_merge_and_replace_preserve_supersession_links(self): self.assertEqual(tasks[corrected]["checkpoint"]["supersedes"], [combined]) def test_invalid_split_rolls_back_all_supersession_changes(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="split-rollback") plan = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "broad", "task": {"description": "Broad", "acceptance": ["done"]}}, @@ -146,8 +147,8 @@ def test_invalid_split_rolls_back_all_supersession_changes(self): self.assertNotIn("superseded_by", recovered.tasks[0]["checkpoint"]) def test_ordered_plan_edits_create_and_revise_at_expected_revision(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="atomic-create") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ @@ -181,8 +182,8 @@ def test_ordered_plan_edits_create_and_revise_at_expected_revision(self): ]) def test_stale_or_invalid_plan_edit_leaves_database_and_memory_unchanged(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="stale-edit") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": { @@ -212,8 +213,8 @@ def test_stale_or_invalid_plan_edit_leaves_database_and_memory_unchanged(self): )), 1) def test_plan_edit_rejects_incomplete_reorder_and_dependency_cycles(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="invalid-edits") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "a", "task": {"description": "A", "acceptance": ["A"]}}, @@ -232,8 +233,8 @@ def test_plan_edit_rejects_incomplete_reorder_and_dependency_cycles(self): self.assertEqual(manager.working_plan.revision, 1) def test_update_cancel_and_remove_dependency_operations_preserve_task_identity(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="mutations") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -253,8 +254,8 @@ def test_update_cancel_and_remove_dependency_operations_preserve_task_identity(s self.assertEqual(revised["tasks"][1]["status"], "cancelled") def test_plan_edits_preserve_evidence_and_require_new_task_for_changed_work(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="retain-evidence") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "Inspect", "acceptance": ["files listed"]}}, @@ -274,8 +275,8 @@ def test_plan_edits_preserve_evidence_and_require_new_task_for_changed_work(self self.assertEqual(revised["tasks"][0]["status"], "pending") def test_executed_task_dependencies_cannot_be_changed_in_place(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="executed-dependency") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -293,8 +294,8 @@ def test_executed_task_dependencies_cannot_be_changed_in_place(self): self.assertTrue(manager.tasks[0]["evidence"]) def test_duplicate_batch_references_are_rejected_before_plan_creation(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="duplicate-refs") with self.assertRaisesRegex(ValueError, "unique"): manager.edit_plan(complexity="complex", expected_revision=0, operations=[ @@ -306,8 +307,8 @@ def test_duplicate_batch_references_are_rejected_before_plan_creation(self): )) def test_batch_reference_cannot_shadow_existing_task_id(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="shadow-ref") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -321,8 +322,8 @@ def test_batch_reference_cannot_shadow_existing_task_id(self): self.assertEqual(manager.tasks[0]["status"], "pending") def test_edit_preserves_legacy_checkpoint_and_acceptance_shapes(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="legacy-checkpoint") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -345,8 +346,8 @@ def test_edit_preserves_legacy_checkpoint_and_acceptance_shapes(self): self.assertEqual(changed_execution["tasks"][0]["checkpoint"]["args"], '{"command":"pytest -q"}') def test_legacy_done_status_does_not_block_plan_edit(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="legacy-status-edit") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -359,8 +360,8 @@ def test_legacy_done_status_does_not_block_plan_edit(self): self.assertEqual(revised["revision"], 2) def test_foreground_execution_obeys_dependencies_after_reorder(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="foreground-dependency") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "Inspect source", "acceptance": ["inspect"], @@ -376,8 +377,8 @@ def test_foreground_execution_obeys_dependencies_after_reorder(self): self.assertFalse(allowed) def test_cancelled_replaced_step_does_not_prevent_plan_completion(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="cancel-replacement") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "old", "task": {"description": "Old step", "acceptance": ["old"]}}, @@ -394,8 +395,8 @@ def test_cancelled_replaced_step_does_not_prevent_plan_completion(self): self.assertEqual(manager.working_plan.status, "completed") def test_edit_cancelling_every_member_terminalizes_plan_atomically(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="cancel-all-edit") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "one", "task": {"description": "One", "acceptance": ["one"]}}, @@ -411,8 +412,8 @@ def test_edit_cancelling_every_member_terminalizes_plan_atomically(self): )) def test_stale_manager_cannot_terminalize_a_newer_plan_revision(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) stale = TaskManager(store, workspace_id="project", session_id="terminal-race") created = stale.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "one", "task": {"description": "One", "acceptance": ["one"]}}, @@ -431,8 +432,8 @@ def test_stale_manager_cannot_terminalize_a_newer_plan_revision(self): self.assertEqual([task["status"] for task in members], ["succeeded", "pending"]) def test_clear_does_not_cancel_a_task_claimed_by_another_manager(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) stale = TaskManager(store, workspace_id="project", session_id="clear-claim-race") stale.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "one", "task": {"description": "One", "acceptance": ["one"]}}, @@ -449,8 +450,8 @@ def test_clear_does_not_cancel_a_task_claimed_by_another_manager(self): self.assertEqual(saved_task["status"], "running") def test_mutation_match_does_not_fall_through_from_blocked_plan(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="blocked-plan-scope") result = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -463,8 +464,8 @@ def test_mutation_match_does_not_fall_through_from_blocked_plan(self): self.assertFalse(allowed) def test_sessionless_working_plan_edit_keeps_event_session_null(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id=None) manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "one", "task": {"description": "One", "acceptance": ["one"]}}, @@ -474,8 +475,8 @@ def test_sessionless_working_plan_edit_keeps_event_session_null(self): self.assertIsNone(session) def test_stale_worker_cannot_resurrect_cancelled_task(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) worker_manager = TaskManager(store, workspace_id="project", session_id="cancel-race") created = worker_manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -495,8 +496,8 @@ def test_stale_worker_cannot_resurrect_cancelled_task(self): self.assertEqual(saved["status"], "cancelled") def test_worker_finishing_after_concurrent_cancel_keeps_cancellation_terminal(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="worker-cancel") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -519,8 +520,8 @@ def execute(_task): self.assertEqual(saved["evidence"], []) def test_plan_claim_order_honors_reorder_and_unsatisfied_prerequisites(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="claim-order") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -536,8 +537,8 @@ def test_plan_claim_order_honors_reorder_and_unsatisfied_prerequisites(self): )[1]["status"], "pending") def test_reordered_ready_tasks_claim_in_explicit_plan_order(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="reorder-claim") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "a", "task": {"description": "A", "acceptance": ["A"]}}, @@ -547,8 +548,8 @@ def test_reordered_ready_tasks_claim_in_explicit_plan_order(self): self.assertEqual(manager.claim_next()["id"], created["references"]["b"]) def test_legacy_done_dependency_counts_as_satisfied(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="legacy-done") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -561,8 +562,8 @@ def test_legacy_done_dependency_counts_as_satisfied(self): self.assertEqual(manager.claim_next()["id"], second_id) def test_edit_rejects_progress_changed_after_candidate_was_read(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="claim-race") created = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ {"op": "add", "ref": "first", "task": {"description": "First", "acceptance": ["first"]}}, @@ -583,8 +584,8 @@ def claim_before_commit(*args, **kwargs): )), 1) def test_edit_storage_failure_rolls_back_plan_tasks_and_event(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="edit-failure") original_json = store.database._json def fail_plan_event(value): @@ -610,8 +611,8 @@ def test_complexity_policy_only_requires_a_plan_for_complex_work(self): self.assertTrue(requires_working_plan("complex")) def test_task_status_transitions_are_explicit_and_terminal_states_stay_terminal(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="lifecycle") index = manager.add_task("inspect the repository") @@ -626,8 +627,8 @@ def test_task_status_transitions_are_explicit_and_terminal_states_stay_terminal( self.assertEqual(manager.tasks[index]["status"], "succeeded") def test_plan_completes_only_after_every_task_has_acceptance_evidence(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="complete-plan") plan = manager.update_plan(complexity="complex", tasks=[ {"description": "First step", "acceptance": ["first result exists"]}, @@ -648,8 +649,8 @@ def test_plan_completes_only_after_every_task_has_acceptance_evidence(self): ).fetchone()["status"], "completed") def test_recovery_reconciles_a_crash_after_last_task_completion(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="crash-window") manager.update_plan(complexity="complex", tasks=[ {"description": "Run acceptance checks", "acceptance": ["checks pass"]}, @@ -672,8 +673,8 @@ def test_recovery_reconciles_a_crash_after_last_task_completion(self): )) def test_working_plan_update_uses_stable_runtime_ids_and_recovers_from_sqlite(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="plan") plan = manager.update_plan(complexity="complex", tasks=[ @@ -714,8 +715,8 @@ def test_working_plan_update_uses_stable_runtime_ids_and_recovers_from_sqlite(se self.assertEqual(recovered.tasks[0]["id"], first_task_id) def test_update_after_terminal_plan_creates_new_plan_instead_of_reopening_it(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="terminal-update") first = manager.update_plan(complexity="complex", tasks=[ {"description": "first", "acceptance": ["first done"]}, @@ -732,8 +733,8 @@ def test_update_after_terminal_plan_creates_new_plan_instead_of_reopening_it(sel self.assertEqual(db.execute("SELECT status FROM working_plans WHERE id=?", (first.id,)).fetchone()["status"], "completed") def test_clear_cancels_failed_and_blocked_tasks_in_the_plan(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="clear-terminal-tasks") manager.update_plan(complexity="complex", tasks=[ {"description": "failed", "acceptance": ["recover or cancel"]}, @@ -749,8 +750,8 @@ def test_clear_cancels_failed_and_blocked_tasks_in_the_plan(self): )], ["cancelled", "cancelled"]) def test_clear_does_not_complete_a_partially_succeeded_plan(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="clear-partial-plan") plan = manager.update_plan(complexity="complex", tasks=[ {"description": "first", "acceptance": ["first is verified"]}, @@ -769,8 +770,8 @@ def test_clear_does_not_complete_a_partially_succeeded_plan(self): self.assertEqual(status, "cancelled") def test_legacy_done_without_acceptance_evidence_cannot_complete_mixed_plan(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="legacy-done-evidence") plan = manager.update_plan(complexity="complex", tasks=[ {"description": "first", "acceptance": ["first is verified"]}, @@ -785,8 +786,8 @@ def test_legacy_done_without_acceptance_evidence_cannot_complete_mixed_plan(self self.assertEqual(status, "cancelled") def test_legacy_update_accepts_unchanged_execution_after_receipt(self): - with tempfile.TemporaryDirectory() as directory: - manager = TaskManager(EventStore(Path(directory) / "state.sqlite3"), + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), workspace_id="project", session_id="same-execution") task_data = { "description": "Read the source file", "acceptance": ["source was read"], @@ -809,8 +810,8 @@ def test_legacy_update_accepts_unchanged_execution_after_receipt(self): self.assertEqual(manager.tasks[0]["checkpoint"]["args_sha256"], "stable-fingerprint") def test_working_plan_isolated_by_user_workspace_and_session(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) owner = TaskManager(store, user_id="alice", workspace_id="project", session_id="one") other = TaskManager(store, user_id="alice", workspace_id="project", session_id="two") owner.update_plan(complexity="complex", tasks=[{"description": "owner task", "acceptance": []}]) @@ -821,8 +822,8 @@ def test_working_plan_isolated_by_user_workspace_and_session(self): self.assertEqual(other.tasks, []) def test_clear_cancels_old_plan_before_recovering_a_new_complex_request(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="new-request") old_plan = manager.update_plan(complexity="complex", tasks=[ {"description": "Old request step", "acceptance": ["old result exists"]}, @@ -846,8 +847,8 @@ def test_clear_cancels_old_plan_before_recovering_a_new_complex_request(self): self.assertEqual([task["description"] for task in recovered.tasks], ["New request step"]) def test_legacy_plan_adapter_preserves_existing_task_ids(self): - with tempfile.TemporaryDirectory() as directory: - store = EventStore(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) manager = TaskManager(store, workspace_id="project", session_id="legacy") manager.from_llm_block('TaskList:\n```json\n["Inspect the workspace"]\n```') task_id = manager.tasks[0]["id"] @@ -860,8 +861,8 @@ def test_legacy_plan_adapter_preserves_existing_task_ids(self): self.assertEqual(recovered.working_plan.id, plan.id) def test_migration_version_includes_working_plan_schema(self): - with tempfile.TemporaryDirectory() as directory: - database = SQLiteDatabase(Path(directory) / "state.sqlite3") + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + database = sqlite_owners.enter_context(closing(SQLiteDatabase(Path(directory) / "state.sqlite3"))) self.assertEqual(database.SCHEMA_VERSION, 6) with database.connection() as db: self.assertIsNotNone(db.execute( @@ -872,10 +873,61 @@ def test_migration_version_includes_working_plan_schema(self): ).fetchone()) self.assertIn("revision", {row["name"] for row in db.execute("PRAGMA table_info(working_plans)")}) + def test_native_task_selection_and_completion_suggestion_require_evidence(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + manager = TaskManager(sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))), session_id="native-selection") + created = manager.edit_plan(expected_revision=0, operations=[ + {"op": "add", "ref": "first", "task": {"description": "Inspect", "acceptance": ["Inspected"]}}, + {"op": "add", "ref": "second", "task": {"description": "Verify", "acceptance": ["Verified"], "dependencies": ["first"]}}, + ]) + first, second = created["task_ids"] + self.assertIsNone(getattr(manager, "execution_task_id", None)) + with self.assertRaises(ValueError): + manager.edit_plan(plan_id=created["plan_id"], expected_revision=1, + operations=[{"op": "start", "task": second}]) + selected = manager.edit_plan(plan_id=created["plan_id"], expected_revision=1, + operations=[{"op": "start", "task": first}]) + self.assertEqual(manager.execution_task_id, first) + self.assertEqual(selected["tasks"][0]["status"], "running") + suggested = manager.edit_plan(plan_id=created["plan_id"], expected_revision=2, + operations=[{"op": "suggest_complete", "task": first}]) + self.assertEqual(suggested["tasks"][0]["status"], "running") + manager.record_evidence(task_id=first, action="read_file", args="a.txt", result="checked", + success=True, acceptance="Inspected", verification_status="verified") + self.assertEqual(manager.tasks[0]["status"], "succeeded") + self.assertIsNone(manager.execution_task_id) + manager.edit_plan(plan_id=created["plan_id"], expected_revision=3, + operations=[{"op": "start", "task": second}]) + self.assertEqual(manager.execution_task_id, second) + + def test_completion_suggestion_storage_failure_rolls_back_plan_revision(self): + import sqlite3 + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + store = sqlite_owners.enter_context(closing(EventStore(Path(directory) / "state.sqlite3"))) + manager = TaskManager(store, session_id="completion-atomicity") + plan = manager.edit_plan(expected_revision=0, operations=[ + {"op": "add", "ref": "inspect", "task": {"description": "Inspect", "acceptance": ["Inspected"]}}, + ]) + task_id = plan["task_ids"][0] + manager.edit_plan(plan_id=plan["plan_id"], expected_revision=1, + operations=[{"op": "start", "task": task_id}]) + manager.record_evidence(task_id=task_id, action="read_file", args="a", result="checked", + success=True, acceptance="Inspected", verification_status="verified") + with store.database.connection() as db: + db.execute("CREATE TRIGGER fail_completion BEFORE UPDATE OF status ON tasks " + "WHEN NEW.status='succeeded' BEGIN SELECT RAISE(ABORT, 'blocked completion'); END") + with self.assertRaises(sqlite3.IntegrityError): + manager.edit_plan(plan_id=plan["plan_id"], expected_revision=2, + operations=[{"op": "suggest_complete", "task": task_id}]) + self.assertEqual(manager.working_plan.revision, 2) + self.assertEqual(store.get_working_plan(user_id="local", workspace_id="default", + session_id="completion-atomicity")["revision"], 2) + self.assertEqual(manager.tasks[0]["status"], "running") + def test_schema_four_working_plan_is_upgraded_without_losing_tasks(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" - store = EventStore(path) + store = sqlite_owners.enter_context(closing(EventStore(path))) manager = TaskManager(store, workspace_id="project", session_id="schema-four") plan = manager.update_plan(complexity="complex", tasks=[ {"description": "Preserve me", "acceptance": ["still present"]}, @@ -883,18 +935,48 @@ def test_schema_four_working_plan_is_upgraded_without_losing_tasks(self): with store.database.connection() as db: db.execute("ALTER TABLE working_plans DROP COLUMN revision") db.execute("DELETE FROM schema_migrations WHERE version>=5") - migrated = SQLiteDatabase(path) - recovered = TaskManager(EventStore(path), workspace_id="project", session_id="schema-four") + migrated = sqlite_owners.enter_context(closing(SQLiteDatabase(path))) + recovered = TaskManager(sqlite_owners.enter_context(closing(EventStore(path))), workspace_id="project", session_id="schema-four") recovered.recover() self.assertEqual(recovered.working_plan.id, plan.id) self.assertEqual(recovered.working_plan.revision, 0) self.assertEqual(recovered.tasks[0]["description"], "Preserve me") self.assertEqual(migrated.SCHEMA_VERSION, 6) + def test_strategy_execution_is_validated_against_approved_scope_at_tool_boundary(self): + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: + app = build_application(memory=sqlite_owners.enter_context(closing(build_memory(Path(directory) / "state.sqlite3", index=None))), + tools=ToolAdapters(directory)) + self.addCleanup(app.close) + runtime = app.runtime + controller = runtime._interaction_controller + proposal = controller.propose_plan({ + "title": "Page", "summary": "Create page", "bounds": { + "workspace_paths": ["site"], "capabilities": ["read", "write"], + "effects": ["read_only", "filesystem"], "risk_ceiling": "normal", + }, "steps": [{"id": "page", "title": "Create page", "description": "Write site/page.txt", + "acceptance": ["Page ready"]}], + }) + accepted, _ = controller.accept_plan(runtime.tasks, plan_id=proposal["plan_id"], version=1) + token = issue_capability_token("test", {"read", "write"}) + anchor = accepted["working_task_ids"][0] + for path, success in (("other.txt", False), ("site/page.txt", True)): + result = runtime.tool_registry.invoke_result("update_plan", { + "plan_id": runtime.tasks.working_plan.id, "expected_revision": 1, + "operations": [{"op": "add", "anchor": anchor, "ref": "inspect", "task": { + "description": "Inspect page", "acceptance": ["Input checked"], + "execution": {"action": "read_file", "arguments": {"path": path}}, + }}], + }, token) + self.assertEqual(result.success, success, result.error) + child = next(task for task in runtime.tasks.tasks if task["id"] != anchor) + self.assertEqual(child["checkpoint"]["strategy_anchor"], anchor) + self.assertEqual(child["checkpoint"]["action"], "read_file") + def test_update_plan_is_a_typed_registry_tool_with_object_arguments(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" - memory = build_memory(path, user_id="alice", index=None) + memory = sqlite_owners.enter_context(closing(build_memory(path, user_id="alice", index=None))) app = build_application(memory=memory, tools=ToolAdapters(directory), user_id="alice") runtime = app.runtime registry = runtime.tool_registry @@ -981,9 +1063,9 @@ def test_update_plan_is_a_typed_registry_tool_with_object_arguments(self): def test_runtime_call_persists_a_typed_working_plan(self): from openkyrozen.providers.models import ToolCall - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" - memory = build_memory(path, user_id="alice", index=None) + memory = sqlite_owners.enter_context(closing(build_memory(path, user_id="alice", index=None))) app = build_application(surface="cli", memory=memory, tools=ToolAdapters(directory), user_id="alice") try: receipt = app.runtime.execute_call( @@ -1024,9 +1106,9 @@ def test_runtime_call_persists_a_typed_working_plan(self): app.close() def test_structured_update_plan_is_agent_only_and_not_a_legacy_text_action(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, ExitStack() as sqlite_owners: path = Path(directory) / "state.sqlite3" - memory = build_memory(path, user_id="alice", index=None) + memory = sqlite_owners.enter_context(closing(build_memory(path, user_id="alice", index=None))) app = build_application(memory=memory, tools=ToolAdapters(directory), user_id="alice") try: app.runtime._active_interaction_mode.set("agent") diff --git a/tui/main_test.go b/tui/main_test.go index d37c643d..7551385c 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -16,9 +16,15 @@ import ( "charm.land/lipgloss/v2" ) -func TestOnboardingDistinguishesNewAndUpdatedInstall(t *testing.T) { +func setTestHome(t *testing.T) { + t.Helper() home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) +} + +func TestOnboardingDistinguishesNewAndUpdatedInstall(t *testing.T) { + setTestHome(t) fresh := initialModel("", true) if fresh.onboardingKind != "new" { t.Fatalf("fresh install was classified as %q", fresh.onboardingKind) @@ -46,7 +52,7 @@ func TestOnboardingDistinguishesNewAndUpdatedInstall(t *testing.T) { } func TestOnboardingCompletionPersistsVersionAndReturnsToChat(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + setTestHome(t) m := initialModel("", true) m.screen, m.onboardingKind = screenOnboarding, "new" m.handleBackendEvent(backendEvent{"event": "onboarding_complete", "kind": "new"}) @@ -276,7 +282,7 @@ func TestUpdatingLocksInputButKeepsEmergencyQuit(t *testing.T) { } func TestToolDetailsAreHiddenByDefaultAndPersistWhenEnabled(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + setTestHome(t) m := initialModel("", true) m.handleBackendEvent(backendEvent{"event": "tool_receipt", "receipt": map[string]any{ "action": "read_file", "result": "secret file contents", "success": true, @@ -316,7 +322,7 @@ func TestSettingsSlashCommandOpensLocalScreen(t *testing.T) { } func TestSettingsExposeInteractionControlsAndKeepCommandDispatch(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + setTestHome(t) m := initialModel("", true) m.width, m.height, m.screen = 100, 30, screenSettings m.applyInteraction(map[string]any{ diff --git a/tui/model.go b/tui/model.go index aef6d97b..2cc6a3a6 100644 --- a/tui/model.go +++ b/tui/model.go @@ -8,7 +8,7 @@ import ( "time" ) -var version = "2.0.8" +var version = "3.0.0" var openKyrozenBanner = []string{ " ███ ████ █████ █ █ █ █ █ █ ████ ███ █████ █████ █ █", diff --git a/website/build_site.py b/website/build_site.py index 9e9a4ba2..45bf2671 100644 --- a/website/build_site.py +++ b/website/build_site.py @@ -17,6 +17,7 @@ "en": {"home": "/", "features": "/features/", "install": "/install/", "faq": "/faq/", "docs": "/docs/"}, "zh-cn": {"home": "/zh-cn/", "features": "/zh-cn/features/", "install": "/zh-cn/install/", "faq": "/zh-cn/faq/", "docs": "/zh-cn/docs/"}, } +# Keep public downloads on the published stable release until V3 publication. INSTALL_COMMANDS = { "unix": "curl -fsSL https://raw.githubusercontent.com/EvanProgramming/OpenKyrozen/v2.0.8/install.sh | sh", "windows": "irm https://raw.githubusercontent.com/EvanProgramming/OpenKyrozen/v2.0.8/install.ps1 | iex", @@ -361,6 +362,7 @@ def document(lang, slug, page): {('
OPENKYROZEN / TUIPROJECT CONTEXT
' + escape(image_alt, quote=True) + '
' + ('Terminal-native · Evidence-backed · Local-first' if lang == 'en' else '终端原生 · 证据驱动 · 本地优先') + '
') if slug == 'home' else ''} {page['body']} + {('

V3 is not yet released. Public downloads install stable V2.0.8.

' if lang == 'en' else '

V3 尚未发布。公开下载仍安装稳定版 V2.0.8。

') if slug in {'home', 'install'} else ''}