diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index b36b2bf76f..81e84efdc3 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -34,7 +34,10 @@ permissions: concurrency: # A merged PR's closed event uses main as github.ref; keep its original PR lane. group: python-tests-${{ github.event_name }}-${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('pr-{0}', github.event.number) || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'push' }} + # Let active main qualification finish; the default single pending slot keeps + # only its latest successor. PR updates still retire that PR's obsolete head. + queue: single + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} env: LOOPX_USAGE_PING: "0" @@ -105,8 +108,20 @@ jobs: retention-days: 7 chat-bundle: - needs: changes - if: needs.changes.outputs.core_tests == 'true' || needs.changes.outputs.python_tests == 'true' || needs.changes.outputs.presentation_tests == 'true' + needs: [changes, kernel-static-checks] + # This is the sole admission path to expensive qualification. A client-only + # exemption requires the static lane to be skipped; failed full candidates + # continue only in the existing scheduled/manual diagnostic runs. + if: >- + ${{ !cancelled() && needs.changes.result == 'success' && + (needs.changes.outputs.core_tests == 'true' || + needs.changes.outputs.python_tests == 'true' || + needs.changes.outputs.presentation_tests == 'true') && + ((needs.changes.outputs.backend_tests == 'false' && needs.kernel-static-checks.result == 'skipped') || + (needs.changes.outputs.backend_tests == 'true' && + (needs.kernel-static-checks.result == 'success' || + ((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && + needs.kernel-static-checks.result == 'failure')))) }} runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -132,7 +147,7 @@ jobs: chat-bundle-browser: needs: [changes, chat-bundle] - if: needs.changes.outputs.core_tests == 'true' + if: needs.changes.outputs.core_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: ubuntu-latest # The packaged scenario catalog outgrew one 15-minute runner, so the # catalog is partitioned like dashboard-browser and the companion smokes @@ -194,9 +209,43 @@ jobs: node examples/zcode-persistent-browser-smoke.mjs kernel-static-checks: - needs: [changes, chat-bundle] + # Reject cheap repository failures before any frontend build or test matrix. + needs: changes if: needs.changes.outputs.backend_tests == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + + - name: Install test dependencies + run: python -m pip install --disable-pip-version-check -e ".[test]" + + - name: Check repository invariants before long tests + run: >- + python -m pytest -q --no-cov + tests/test_stage2c_dependency_lock.py + tests/architecture/test_top_level_module_budget.py + + - name: Lint test suite + run: >- + python -m ruff check + tests + loopx/canary + loopx/control_plane + loopx/domain_packs + loopx/presentation + + kernel-contracts: + needs: [changes, chat-bundle] + if: needs.changes.outputs.backend_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' + runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Check out repository @@ -225,12 +274,6 @@ jobs: - name: Install test dependencies run: python -m pip install --disable-pip-version-check -e ".[test]" - - name: Check repository invariants before long tests - run: >- - python -m pytest -q --no-cov - tests/test_stage2c_dependency_lock.py - tests/architecture/test_top_level_module_budget.py - - name: Verify file authority on the core runtime run: >- node --no-warnings --experimental-strip-types --test @@ -238,15 +281,6 @@ jobs: tests/control_plane_ts/local_authority_runtime.test.ts tests/control_plane_ts/sqlite_runtime_admission.test.ts - - name: Lint test suite - run: >- - python -m ruff check - tests - loopx/canary - loopx/control_plane - loopx/domain_packs - loopx/presentation - - name: Type-check kernel contracts run: python -m mypy @@ -261,7 +295,7 @@ jobs: typescript-core: name: typescript-core (${{ matrix.shard }}/3) needs: [changes, chat-bundle] - if: needs.changes.outputs.backend_tests == 'true' + if: needs.changes.outputs.backend_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: ubuntu-latest timeout-minutes: 30 strategy: @@ -317,7 +351,7 @@ jobs: typescript-coverage: needs: [changes, typescript-core] - if: needs.changes.outputs.backend_tests == 'true' + if: needs.changes.outputs.backend_tests == 'true' && !cancelled() && needs.typescript-core.result == 'success' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -356,7 +390,7 @@ jobs: dashboard-browser: needs: [changes, chat-bundle] - if: needs.changes.outputs.core_tests == 'true' + if: needs.changes.outputs.core_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: ubuntu-latest timeout-minutes: 25 strategy: @@ -431,7 +465,7 @@ jobs: dashboard-acceptance: needs: [changes, dashboard-browser] - if: needs.changes.outputs.core_tests == 'true' + if: needs.changes.outputs.core_tests == 'true' && !cancelled() && needs.dashboard-browser.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -458,7 +492,7 @@ jobs: # Preserve the required check name while exposing independent failure lanes. # Cancelled main pushes must not retain runner-backed aggregate work. if: always() && needs.changes.outputs.core_tests == 'true' && (github.event_name != 'push' || !cancelled()) - needs: [changes, kernel-static-checks, typescript-coverage, dashboard-acceptance, chat-bundle-browser] + needs: [changes, kernel-static-checks, kernel-contracts, typescript-coverage, dashboard-acceptance, chat-bundle-browser] runs-on: ubuntu-latest timeout-minutes: 2 steps: @@ -472,8 +506,8 @@ jobs: run: python scripts/ci/review_gate.py verify-core node-minimum-compatibility: - needs: changes - if: needs.changes.outputs.backend_tests == 'true' + needs: [changes, chat-bundle] + if: needs.changes.outputs.backend_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: ubuntu-latest # The full suite runs under this exact Node version in typescript-core. timeout-minutes: 10 @@ -497,10 +531,10 @@ jobs: tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts node-forward-compatibility: - needs: changes + needs: [changes, chat-bundle] # Probe the future runtime once per night or on demand, outside the PR/main # critical path. The supported runtime still runs on every full candidate. - if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.changes.outputs.backend_tests == 'true' + if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.changes.outputs.backend_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' continue-on-error: true runs-on: ubuntu-latest # This runs the full core conformance suite. Allow forward-runtime/runner @@ -520,14 +554,14 @@ jobs: npm run test:control-plane test-shard: - # Keep known-red PRs out of the long-test queue. Non-PR runs still collect - # complete Python diagnostics after checks finish, even when checks fail. - needs: [changes, chat-bundle, checks] + # The bundle admits only statically qualified candidates or explicit + # diagnostics. Python can now overlap TS/browser qualification while its + # six repository-wide slots still bound long-test concurrency. + needs: [changes, chat-bundle] if: >- ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.python_tests == 'true' && - needs.chat-bundle.result == 'success' && - (github.event_name != 'pull_request' || needs.checks.result == 'success') }} + needs.chat-bundle.result == 'success' }} runs-on: ubuntu-latest # Share six long-test lanes across current PRs and other workflow runs. # Do not replace another candidate's pending shard or cancel its active head. @@ -667,7 +701,7 @@ jobs: stage2c-suite: needs: [changes, chat-bundle] - if: needs.changes.outputs.stage2c_tests == 'true' + if: needs.changes.outputs.stage2c_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' name: stage2c (${{ matrix.suite }} ${{ matrix.shard }}) runs-on: ubuntu-latest timeout-minutes: 30 @@ -766,7 +800,7 @@ jobs: windows-powershell: needs: [changes, chat-bundle] - if: needs.changes.outputs.python_tests == 'true' + if: needs.changes.outputs.python_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: windows-latest timeout-minutes: 30 steps: @@ -812,7 +846,7 @@ jobs: python -m pytest -q tests/control_plane/test_cli_output_budget.py tests/control_plane/test_cli_output_differential.py - -k "stable_budget_fixture or probe_process_failure_preserves_utf8_diagnostics" + -k "stable_budget_fixture or long_path_budget or probe_process_failure_preserves_utf8_diagnostics" - name: Qualify the native Windows CLI output differential timeout-minutes: 12 @@ -941,7 +975,7 @@ jobs: presentation: needs: [changes, chat-bundle] - if: needs.changes.outputs.presentation_tests == 'true' + if: needs.changes.outputs.presentation_tests == 'true' && !cancelled() && needs.chat-bundle.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/docs/development/ci-impact-selection.md b/docs/development/ci-impact-selection.md index 3d6fef84a8..1d257804a8 100644 --- a/docs/development/ci-impact-selection.md +++ b/docs/development/ci-impact-selection.md @@ -61,27 +61,43 @@ No tests are removed. Without timing history the splitter uses equal weights; six shards do not establish perfect duration balancing. Each numbered shard shares its queue across workflow runs without replacing another current head. -Long Python shards now wait for the existing `checks` aggregate. An ordinary -PR enters those queues only after core qualification succeeds, so a known-red -candidate does not occupy the long-test lanes. This changes scheduling, not -qualification: skipped shards after a failed prerequisite still fail `pytest` -and `merge-gate`; a passing full candidate still requires every shard and the -same coverage floor. Main, scheduled, manual and merge-queue runs retain full -Python diagnostics after `checks` finishes, even if it failed, provided the -classification and source bundle succeeded and the workflow was not cancelled. -The tradeoff is extra serial latency for a healthy run in an idle queue, in -exchange for less work behind known failures under contention. `ci:full` still -forces the full profile; use a manual run for complete diagnostics of a failing -PR revision. This does not cancel previously queued runs of older workflows. - -长 Python 分片现在先等待现有 `checks` 汇总;普通 PR 仅在核心资格检查成功后 -进入长队列,避免已知失败的候选继续占用分片。前置失败导致的跳过仍使 `pytest` -和 `merge-gate` 失败,完整候选仍须通过全部分片与原覆盖率门槛。主干、定时、 -手动和 merge queue 在汇总结束后仍执行完整 Python 诊断,即使该汇总失败; -分类和源码构建必须成功,取消的 workflow 不再启动分片。代价是空闲队列中健康 -候选的串行等待增加;收益是拥塞时不再给已知失败的 PR 排长测试。`ci:full` 仍强制 -完整 profile;需要失败候选的全部诊断时可手动运行其分支。旧 workflow 已排队的 -任务不会因此自动取消。 +The existing `kernel-static-checks` lane now follows classification directly: +the dependency lock, top-level module budget and existing Ruff scope run before +the Chat build or any heavy matrix. Full PR, main-push and merge-queue candidates +must pass that lane before the source bundle admits downstream work. A client-only +exemption requires its static lane to be explicitly skipped. Scheduled and manual +full runs may continue after a static failure to collect complete diagnostics; +cancelled, missing or unexpectedly skipped prerequisites never grant admission. +The downstream jobs retain explicit cancellation predicates so GitHub's implicit +`success()` does not discard requested diagnostics after a failed ancestor. + +Python shards start after the admitted bundle, alongside TypeScript and browser +qualification. The six repository-wide slots and two workers per shard remain. +File-authority tests, mypy and CLI output budgets move to `kernel-contracts` and +remain required by `checks`, as does the static lane. The existing `pytest` and +`merge-gate` still reject any required failure, skip or missing coverage. This +removes the full browser/TypeScript wait from Python's critical path, but allows +up to six already-bounded Python jobs to overlap those lanes; a later browser +failure may now coexist with running Python tests. It neither reserves hosted +capacity nor proves a throughput improvement. `ci:full` selects all qualification, +without overriding failed admission; use a manual run for complete diagnostics +of a failing revision. Older queued workflow versions keep their own policy. + +既有 `kernel-static-checks` 现在直接等待分类:依赖锁、顶层模块预算和原有 Ruff +范围在 Chat 构建及全部重型矩阵之前检查。全量 PR、main push 和 merge queue +必须先通过此通道,源码 bundle 才准入下游;纯前端豁免要求静态通道明确 skipped。 +定时与手动全量运行可在静态 failure 后继续收集完整诊断;取消、缺失或意外 skipped +均不放行。下游显式保留取消条件,避免 GitHub 隐含的 `success()` 因祖先失败丢弃 +本次请求的诊断。 + +Python 分片在已准入的 bundle 完成后即可与 TypeScript/浏览器并行,仍受六个 +仓库级槽位、每片两个 worker 限制。File authority、mypy 与 CLI 输出预算移到 +`kernel-contracts`,与静态通道一起继续作为 `checks` 必过项。既有 `pytest` 和 +`merge-gate` 仍拒绝必需项失败、跳过或 coverage 缺失。这样缩短 Python 等待全套 +前端/TS 的关键路径,但最多六个已有上限的 Python job 会与这些通道重叠;稍后 +发生浏览器失败时,Python 可能已经运行。这不预留 hosted 容量,也不直接证明吞吐 +改善。`ci:full` 选择全部资格检查,不绕过准入失败;失败版本的完整诊断仍使用手动 +运行。旧版 workflow 已排队的任务保留其原有规则。 The shard command explicitly loads `scripts.ci.pytest_timing`. Its controller streams pytest's setup/call/teardown durations to Actions logs as each report @@ -240,13 +256,21 @@ to one and background shard occupancy from five runners to two; the complete sweep may take longer. The existing fleet-health readback still checks receipts. Python Tests also runs daily at `20:17 UTC`. Its event-scoped concurrency keeps -that full run separate from main pushes and PRs. New PR heads and main pushes -cancel superseded runs; scheduled, manual and merge-queue qualification is not -cancelled by that policy. Release-artifact, desktop-artifact, signed-desktop, +that full run separate from main pushes and PRs. New PR heads cancel their +superseded runs. Active main qualification finishes; `queue: single` replaces +only the pending main successor with the latest push instead of interrupting +the running candidate or accumulating every intermediate commit. Scheduled, +manual and merge-queue qualification is not cancelled by that policy. +Release-artifact, desktop-artifact, signed-desktop, DCO and optional Ark PR checks cancel only superseded validation for the same PR. Signed-desktop PRs use separate groups from each other and from channel publication. Release publication remains serialized and is never cancelled by a PR update. +Python Tests 每日 `20:17 UTC` 的完整运行与 main push、PR 保持不同并发组。 +PR 新 head 仍取消本 PR 的旧运行;正在执行的 main 资格验证会完成,`queue: single` +仅用最新 push 替换等待中的主干后继,不中断运行中的候选,也不积压每个中间提交。 +该策略不取消定时、手动和 merge queue 的资格验证;发布仍保持原有串行规则。 + Trusted obsolete-PR cleanup runs in the separate **CI Maintenance** workflow on `pull_request_target` close/synchronize events. It never checks out PR content and retains the same PR/repository/head checks before cancellation. A green diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 127cebc773..64e6bd66e9 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -749,18 +749,27 @@ pull requests. It intentionally excludes provider-backed evaluation and the full smoke catalog, so ordinary iteration does not depend on credentials, network latency, provider availability, or a two-hour matrix. -The existing `kernel-static-checks` job first checks the Stage 2C dependency -lock and top-level module budget. A failed invariant makes the existing -`checks` preflight fail, so PR long-test shards do not enter their shared queue. -These tests also remain in the complete six-shard collection. +The existing `kernel-static-checks` job checks the Stage 2C dependency lock, +top-level module budget and the existing Ruff scope immediately after change +classification, before the Chat build or any heavy matrix. Ordinary PR, main-push +and merge-queue qualification requires this static admission to succeed. Client-only +work retains its explicit backend exemption. Scheduled/manual full runs may +continue after a static failure for diagnostics, but cancelled, missing or +unexpectedly skipped prerequisites cannot admit work. The invariant tests also +remain in the complete six-shard collection. File-authority tests, mypy and CLI +output budgets live in `kernel-contracts`; both kernel lanes remain required by +the existing `checks` aggregate. `.github/workflows/python-tests.yml` 会在相关 Python PR 上运行这条快速通道。 它刻意不包含真实模型调用和 full smoke catalog,因此普通迭代不依赖凭证、网络 时延、模型服务可用性或两小时级测试矩阵。 -既有 `kernel-static-checks` 作业先检查 Stage 2C 依赖锁和顶层模块预算。不变量失败 -会使既有 `checks` 前置门失败,阻止 PR 的长测试分片进入共享队列。这些测试仍保留 -在六分片的完整 collection 中。 +既有 `kernel-static-checks` 在变更分类后立即检查 Stage 2C 依赖锁、顶层模块预算 +和原有 Ruff 范围,先于 Chat 构建与全部重型矩阵。普通 PR、main push 和 merge +queue 必须通过静态准入;纯前端仍保留明确的后端豁免。定时/手动全量运行可在 +静态失败后继续诊断,但取消、缺失或意外跳过均不能准入。不变量测试仍保留在六分片 +完整 collection 中。File authority、mypy 和 CLI 输出预算移到 `kernel-contracts`; +两个 kernel 通道仍由既有 `checks` 汇总要求通过。 The Linux suite uses six hosted runners with two xdist workers each. `pytest-split` partitions the complete collection using `least_duration`; @@ -828,11 +837,13 @@ PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token Full Python shards share six repository-wide job concurrency groups, one per `matrix.shard`, across PR, main, merge-queue and manual runs carrying this -workflow version. Shards wait for `checks`; ordinary PRs enter only after it -succeeds. Non-PR runs still collect Python diagnostics after failed checks when -classification and the source bundle succeeded and the workflow is not cancelled. -Skipped required shards remain a qualification failure. This avoids long-test -work for known-red PRs but adds serial latency when capacity is idle; see the +workflow version. Shards wait for the statically admitted source bundle, then +overlap the remaining TypeScript, browser and kernel-contract checks. Scheduled +and manual runs may collect diagnostics after static failure; ordinary PR, +main-push and merge-queue candidates may not. Skipped required shards and failed +static admission remain qualification failures. The six-slot bound is unchanged, +but those jobs can now overlap the other matrices; a later failure in those +checks may coexist with Python execution. See the [admission tradeoff](ci-impact-selection.md#six-complete-python-shards). Each group admits one running job and uses `queue: max` to retain up to 100 pending jobs instead of replacing another current @@ -858,10 +869,11 @@ retaining workflow-level cancellation and the required gate. See GitHub's [concurrency queue contract](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency). 全量 Python 分片按 `matrix.shard` 共用六个仓库级 job concurrency 组,适用于 -携带本版 workflow 的 PR、main、merge queue 和手动运行。分片先等待 `checks`, -普通 PR 仅在其成功后进入队列;非 PR 在分类、源码构建成功且未取消时仍保留失败后 -的完整 Python 诊断。必需分片跳过仍判失败。此规则减少已知失败 PR 的长测试占用, -但会增加空闲队列中的串行延迟。每组允许一个运行中 job, +携带本版 workflow 的 PR、main、merge queue 和手动运行。分片等待静态准入后的 +源码 bundle,再与余下 TypeScript、浏览器及 kernel-contract 检查并行。仅定时和 +手动运行可在静态失败后继续诊断;普通 PR、main push 与 merge queue 不可。 +必需分片跳过、静态准入失败仍判失败。六槽上限不变,但现在会与其他矩阵重叠, +这些检查稍后失败时 Python 可能已在运行。每组允许一个运行中 job, 用 `queue: max` 保留至多 100 个等待 job,不替换其他有效候选。workflow 级取消 请求清退同 PR 的旧 head;可信 base 分支的 `synchronize` 清理还会取消遗留 旧 run,包括普通取消后仍继续的 `always()` 汇总。清理要求事件中的当前 head、 diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index 9b62204528..b5b98b46a4 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -238,7 +238,7 @@ def main(): parser.error(str(exc)) if not (assets / "index.html").is_file(): parser.error("Build the packaged Chat assets first or supply --assets-dir") - raw = json.loads(args.sources.read_text()) + raw = json.loads(args.sources.read_text(encoding="utf-8")) if not isinstance(raw, list) or not 1 <= len(raw) <= 12: parser.error("Supply 1–12 scoped sources") sources = [ContainerSource.parse(item) for item in raw] diff --git a/loopx/usage_goal.py b/loopx/usage_goal.py index e49e209b6e..ca3d6af5b3 100644 --- a/loopx/usage_goal.py +++ b/loopx/usage_goal.py @@ -111,7 +111,10 @@ def observe_quota_cycle(*, registry_path: Path, runtime_root: Path, goal_id: str "goal": goal_id, "agent": agent_id, "turn": turn_id, "phase": phase, "at": at, "host": host, "path": str(usage_ping.state_path()), "generation": state["generation"]}, - command=[sys.executable, "-m", "loopx.usage_goal"]) + # Detachment uses the OS temp directory as cwd. Its + # unrelated loopx cache/package must not shadow this + # interpreter's installed observer, including editable installs. + command=[sys.executable, "-P", "-m", "loopx.usage_goal"]) except Exception: pass diff --git a/scripts/ci/qualification_summary.test.ts b/scripts/ci/qualification_summary.test.ts index 79f415e7ba..47a9769a08 100644 --- a/scripts/ci/qualification_summary.test.ts +++ b/scripts/ci/qualification_summary.test.ts @@ -36,6 +36,20 @@ test("coverage or aggregation failures remain visible even without a failing tes assert.match(result, /Gate step: failure/); }); +test("static and runtime contract failures remain execution failures ahead of checks", () => { + for (const name of ["kernel-static-checks", "kernel-contracts"]) { + const result = renderQualificationSummary(input({jobs: [ + job("checks", "failure", {id: 11}), job(name, "failure", { + steps: [{name: "Qualify owned contract", conclusion: "failure"}], + }), job("pytest", "failure", {id: 12}), + ]})); + assert.ok(result.indexOf(name) < result.indexOf("Aggregate failures")); + assert.match(result, /Failing jobs[\s\S]*Qualify owned contract/); + assert.match(result, /Aggregate failures[\s\S]*checks[\s\S]*pytest/); + assert.match(result, /Gate step: failure/); + } +}); + test("exempt checks and unavailable evidence cannot become passing test claims", () => { const docs = renderQualificationSummary(input({gateResult: "success", needs: { changes: {result: "success", outputs: {change_kind: "docs"}}, pytest: {result: "skipped"}, diff --git a/scripts/ci/review_gate.py b/scripts/ci/review_gate.py index cae9d38713..b38da94d9f 100644 --- a/scripts/ci/review_gate.py +++ b/scripts/ci/review_gate.py @@ -17,6 +17,7 @@ } CHECK_OUTPUT = { "kernel-static-checks": "backend_tests", + "kernel-contracts": "backend_tests", "typescript-coverage": "backend_tests", "dashboard-acceptance": "core_tests", "chat-bundle-browser": "core_tests", diff --git a/scripts/ci/test_review_gate.py b/scripts/ci/test_review_gate.py index edfc84e056..928d30491e 100644 --- a/scripts/ci/test_review_gate.py +++ b/scripts/ci/test_review_gate.py @@ -24,7 +24,7 @@ "docs": set(), "presentation": {"checks", "presentation", "dashboard-acceptance", "chat-bundle-browser"}, "full": {"checks", "pytest", "node-minimum-compatibility", "stage2c-correctness-e2e", - "windows-powershell", "kernel-static-checks", "typescript-coverage", + "windows-powershell", "kernel-static-checks", "kernel-contracts", "typescript-coverage", "dashboard-acceptance", "chat-bundle-browser"}, } @@ -88,8 +88,10 @@ def lane(action, **kwargs): for name in ("changes", "merge-gate"): self.assertEqual(bool(expression(jobs[name]["if"], action)), action != "closed") for event in ("push", "schedule", "workflow_dispatch", "merge_group"): + self.assertFalse(expression(concurrency["cancel-in-progress"], "", event=event)) for name in ("changes", "merge-gate"): self.assertTrue(expression(jobs[name]["if"], "", event=event)) + self.assertEqual(concurrency["queue"], "single") self.assertEqual([name for name, job in jobs.items() if not job.get("needs")], ["changes"]) for name in ("changes", "merge-gate"): @@ -356,47 +358,101 @@ def script(self, job, step): return next(row["run"] for row in self.workflow["jobs"][job]["steps"] if row.get("name") == step) - def test_pr_shards_wait_for_core_qualification_without_waiving_failures(self): - job = self.workflow["jobs"]["test-shard"] - self.assertEqual(job["needs"], ["changes", "chat-bundle", "checks"]) - # Evaluate the shipped expression over independent admission cases. - # The explicit status predicate is required to retain non-PR diagnostic - # traversal after failed checks; GitHub otherwise adds success(). + def admitted(self, name, event, results, *, kind="full", cancelled=False, classification="success"): + job = self.workflow["jobs"][name] + dependencies = job.get("needs", []) + if isinstance(dependencies, str): + dependencies = [dependencies] + values = {dep: SimpleNamespace(result=results.get(dep, "skipped")) for dep in dependencies} + values["changes"] = SimpleNamespace(result=classification, outputs=SimpleNamespace( + **{key: str(value).lower() for key, value in PROFILE_FLAGS[kind].items()})) source = job["if"].strip().removeprefix("${{").removesuffix("}}") - self.assertIn("!cancelled()", source) - source = source.replace("!cancelled()", "not cancelled()") - source = source.replace("&&", " and ").replace("||", " or ") - source = source.replace("needs.chat-bundle", "needs.chat_bundle") - source = " ".join(source.split()) - for event in ("pull_request", "push", "schedule", "workflow_dispatch", "merge_group"): - for checks in ("success", "failure", "cancelled", "skipped", ""): - for ready in (True, False): - for cancelled in (True, False): - context = { - "github": SimpleNamespace(event_name=event), - "needs": SimpleNamespace( - changes=SimpleNamespace(result="success", outputs=SimpleNamespace(python_tests="true")), - chat_bundle=SimpleNamespace(result="success" if ready else "failure"), - checks=SimpleNamespace(result=checks), - ), - "cancelled": lambda: cancelled, - } - expected = ready and not cancelled and (event != "pull_request" or checks == "success") - with self.subTest(event=event, checks=checks, ready=ready, cancelled=cancelled): - self.assertEqual(eval(source, {"__builtins__": {}}, context), expected) # noqa: S307 - # Failed classification and exempt lanes never acquire - # a long-test slot, even if other prerequisites passed. - for changes_result, selected in (("failure", "true"), ("skipped", "true"), ("success", "false"), ("success", "")): - context["needs"].changes = SimpleNamespace( - result=changes_result, outputs=SimpleNamespace(python_tests=selected)) - self.assertFalse(eval(source, {"__builtins__": {}}, context)) # noqa: S307 - # No cycle may make the prerequisite aggregate wait for its consumer. + # A status predicate is necessary to traverse a failed ancestor during + # explicit diagnostics. Do not silently model GitHub's success() default + # as always(): ordinary missing/failed direct prerequisites must block. + successful = all(value.result == "success" for value in values.values()) + if not any(function in source for function in ("always()", "success()", "failure()", "cancelled()")) and not successful: + return False + source = re.sub(r"needs\.([\w-]+)", lambda match: f"needs[{match[1]!r}]", source) + source = re.sub(r"!(?!=)", " not ", source).replace("&&", " and ").replace("||", " or ") + context = {"github": SimpleNamespace(event_name=event), "needs": values, + "cancelled": lambda: cancelled, "always": lambda: True, + "success": lambda: successful, "failure": lambda: not successful} + return bool(eval(" ".join(source.split()), {"__builtins__": {}}, context)) # noqa: S307 + + def test_only_explicit_diagnostics_can_admit_a_failed_static_candidate(self): + bundle = self.workflow["jobs"]["chat-bundle"] + self.assertEqual(bundle["needs"], ["changes", "kernel-static-checks"]) + self.assertIn("!cancelled()", bundle["if"]) + for event in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"): + for kind in PROFILE_FLAGS: + for state in ("success", "failure", "cancelled", "skipped", ""): + for cancelled in (False, True): + expected = not cancelled and ( + (kind == "presentation" and state == "skipped") or + (kind == "full" and (state == "success" or + state == "failure" and event in {"schedule", "workflow_dispatch"}))) + with self.subTest(event=event, kind=kind, state=state, cancelled=cancelled): + self.assertEqual(self.admitted("chat-bundle", event, + {"kernel-static-checks": state}, kind=kind, cancelled=cancelled), expected) + for classification in ("failure", "cancelled", "skipped", ""): + self.assertFalse(self.admitted("chat-bundle", event, + {"kernel-static-checks": state}, kind=kind, classification=classification)) + # Diagnostics still fail qualification even when every later job passed. + diagnosed = needs(jobs=CHECK_OUTPUT) + diagnosed["kernel-static-checks"]["result"] = "failure" + with self.assertRaisesRegex(ValueError, "kernel-static-checks must be success"): + verify_checks(diagnosed) + + def test_every_heavy_lane_uses_the_admitted_bundle_and_retains_its_scope(self): + lanes = {"kernel-contracts": "backend_tests", "typescript-core": "backend_tests", + "chat-bundle-browser": "core_tests", "dashboard-browser": "core_tests", + "test-shard": "python_tests", "stage2c-suite": "stage2c_tests", + "windows-powershell": "python_tests", "presentation": "presentation_tests", + "node-minimum-compatibility": "backend_tests", "node-forward-compatibility": "backend_tests"} + for name, output in lanes.items(): + job = self.workflow["jobs"][name] + self.assertEqual(job["needs"], ["changes", "chat-bundle"]) + # Keep diagnostic traversal after the failed static ancestor; a + # default success() would silently discard that requested evidence. + self.assertIn("!cancelled()", job["if"]) + for event in ("pull_request", "push", "merge_group", "schedule", "workflow_dispatch"): + for kind, flags in PROFILE_FLAGS.items(): + for state in ("success", "failure", "cancelled", "skipped", ""): + for cancelled in (False, True): + expected = flags[output] and state == "success" and not cancelled + if name == "node-forward-compatibility": + expected = expected and event in {"schedule", "workflow_dispatch"} + with self.subTest(job=name, event=event, kind=kind, state=state, cancelled=cancelled): + self.assertEqual(self.admitted(name, event, {"chat-bundle": state}, + kind=kind, cancelled=cancelled), expected) + for name, prerequisite in (("typescript-coverage", "typescript-core"), + ("dashboard-acceptance", "dashboard-browser")): + self.assertIn("!cancelled()", self.workflow["jobs"][name]["if"]) + for state in ("success", "failure", "cancelled", "skipped", ""): + self.assertEqual(self.admitted(name, "schedule", {prerequisite: state}), state == "success") + + def test_static_admission_precedes_builds_without_serializing_python_on_browser_checks(self): + static = self.workflow["jobs"]["kernel-static-checks"] + self.assertEqual(static["needs"], "changes") + self.assertNotIn("download-artifact", json.dumps(static)) + invariants = self.script("kernel-static-checks", "Check repository invariants before long tests") + for test in ("tests/test_stage2c_dependency_lock.py", "tests/architecture/test_top_level_module_budget.py"): + self.assertIn(test, invariants) + self.assertIn("python -m ruff check", self.script("kernel-static-checks", "Lint test suite")) + self.assertEqual(CHECK_OUTPUT["kernel-static-checks"], "backend_tests") + self.assertEqual(CHECK_OUTPUT["kernel-contracts"], "backend_tests") + for step in ("Verify file authority on the core runtime", "Type-check kernel contracts", "Qualify agent-facing CLI output"): + self.assertTrue(self.script("kernel-contracts", step)) + def ancestors(name): dependencies = self.workflow["jobs"][name].get("needs", []) if isinstance(dependencies, str): dependencies = [dependencies] return set(dependencies).union(*(ancestors(dep) for dep in dependencies)) + self.assertEqual(ancestors("test-shard"), {"changes", "kernel-static-checks", "chat-bundle"}) self.assertNotIn("test-shard", ancestors("checks")) + self.assertEqual(self.workflow["jobs"]["pytest"]["needs"], ["changes", "checks", "test-shard"]) def test_required_python_gate_rejects_cancelled_overflow_and_missing_shards(self): script = self.script("pytest", "Require every upstream check") diff --git a/scripts/ci/test_windows_lane_coverage.py b/scripts/ci/test_windows_lane_coverage.py index a5ccf5b990..4b384b5de4 100644 --- a/scripts/ci/test_windows_lane_coverage.py +++ b/scripts/ci/test_windows_lane_coverage.py @@ -37,7 +37,10 @@ ROOT = Path(__file__).resolve().parents[2] WORKFLOW = ROOT / ".github/workflows/python-tests.yml" -ACTIVATION = "needs.changes.outputs.python_tests == 'true'" +ACTIVATION = ( + "needs.changes.outputs.python_tests == 'true' && !cancelled() " + "&& needs.chat-bundle.result == 'success'" +) NODE_OPTION_DENIAL = "NODE_OPTIONS could turn the suite command into a no-op" QUALIFY_COMMAND = ( "node", diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 9004078a0d..bb3894b31e 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -1749,12 +1749,18 @@ def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: # A reproducible 128-character absolute root, independent of pytest's # ever-growing temp/worker prefix. Keep full routes and this workload; # presentation-budget changes require matched base/head cost evidence. - # Reuse the other budget fixtures' short namespace. A canary's nested - # TMPDIR can already exceed 128 characters before we create this root. - parent = Path("/tmp").resolve() + # Use an existing native temp namespace on Windows; a drive-root /tmp + # need not exist. POSIX keeps the short namespace used by socket fixtures. + parent = Path(tempfile.gettempdir() if os.name == "nt" else "/tmp").resolve() + prefix = "loopx-brief-" + # A nested runner temp can exceed the workload itself. Use its nearest + # existing ancestor that leaves room for our owned, random child. + while len(str(parent / prefix)) + 8 > 128: + assert parent != parent.parent, "No temporary ancestor fits the 128-character workload" + parent = parent.parent # tempfile contributes an eight-character random suffix. Hold input size - # constant across Linux /tmp and macOS's longer temporary-directory root. - prefix = "loopx-brief-".ljust(128 - len(str(parent)) - 1 - 8, "p") + # constant even when the selected parent is a drive or share root. + prefix += "p" * (128 - len(str(parent / prefix)) - 8) with tempfile.TemporaryDirectory(prefix=prefix, dir=parent) as directory: root = Path(directory).resolve() assert len(str(root)) == 128 @@ -1764,13 +1770,40 @@ def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: def test_long_path_budget_is_independent_of_runner_temp_prefix( tmp_path: Path, monkeypatch ) -> None: - parent = tmp_path / ("nested-canary-" + "p" * 128) + # Remain longer than the workload without adding 141 characters to an + # arbitrary runner prefix and crossing Windows directory limits. + prefix = "nested-canary-".ljust(160 - len(str(tmp_path.resolve())) - 1, "p") + parent = tmp_path / prefix parent.mkdir() assert len(str(parent.resolve())) > 128 monkeypatch.setattr(tempfile, "tempdir", str(parent)) test_brief_budget_retains_full_commands_on_real_long_paths() +@pytest.mark.parametrize("nested_temp", [False, True]) +def test_long_path_budget_uses_native_temp_without_a_drive_root_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, nested_temp: bool, +) -> None: + from types import SimpleNamespace + + # Model a Windows drive with no /tmp while keeping real filesystem and + # CLI reads. Do not change the host's pathlib or product platform checks. + missing_drive_tmp = tmp_path / "missing-drive-tmp" + path_type = Path + + def native_path(value): + return missing_drive_tmp if value == "/tmp" else path_type(value) + + monkeypatch.setitem(globals(), "Path", native_path) + monkeypatch.setitem(globals(), "os", SimpleNamespace(name="nt", environ=os.environ)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + if nested_temp: + test_long_path_budget_is_independent_of_runner_temp_prefix(tmp_path, monkeypatch) + else: + test_brief_budget_retains_full_commands_on_real_long_paths() + assert not missing_drive_tmp.exists() + + def test_todo_list_explicit_limit_stays_bounded_and_default_path_unchanged( tmp_path: Path, ) -> None: diff --git a/tests/test_ci_resource_policy.py b/tests/test_ci_resource_policy.py index e6b29d80f8..7714d020e6 100644 --- a/tests/test_ci_resource_policy.py +++ b/tests/test_ci_resource_policy.py @@ -25,7 +25,8 @@ def event(name: str, *, pr: int = 101, tag: str = "", backend: bool = True) -> d event=SimpleNamespace(action="opened", number=pr, pull_request=SimpleNamespace(number=pr), release=SimpleNamespace(tag_name=tag)), ), "inputs": SimpleNamespace(tag=tag), - "needs": SimpleNamespace(changes=SimpleNamespace(outputs=SimpleNamespace(**{ + "cancelled": lambda: False, + "needs": SimpleNamespace(chat_bundle=SimpleNamespace(result="success"), changes=SimpleNamespace(outputs=SimpleNamespace(**{ key: str(backend).lower() for key in ("backend_tests", "core_tests", "python_tests", "stage2c_tests") }))), } @@ -37,6 +38,7 @@ def expression(value: str | bool, context: dict) -> str | bool: return value def evaluate(source: str): + source = source.replace("needs.chat-bundle", "needs.chat_bundle") return eval( # noqa: S307 - repository-owned expressions, no builtins. re.sub(r"!(?!=)", " not ", source.strip()).replace("&&", " and ").replace("||", " or "), {"__builtins__": {}, "format": lambda template, *args: template.format(*args)}, @@ -74,7 +76,7 @@ def test_signed_desktop_prs_do_not_share_either_publication_channel() -> None: assert expression(concurrency["group"], event("schedule")) == expression(concurrency["group"], event("workflow_dispatch")) -def test_main_supersession_does_not_cancel_nightly_or_manual_full_qualification() -> None: +def test_main_finishes_active_qualification_and_keeps_only_the_latest_pending_successor() -> None: policy = workflow("python-tests") assert "schedule" in policy[True] concurrency = policy["concurrency"] @@ -83,7 +85,8 @@ def test_main_supersession_does_not_cancel_nightly_or_manual_full_qualification( if context["github"].event_name != "pull_request": del context["github"].event.number assert len({expression(concurrency["group"], context) for context in contexts}) == 5 - assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [True, True, False, False, False] + assert [expression(concurrency["cancel-in-progress"], context) for context in contexts] == [False, True, False, False, False] + assert concurrency["queue"] == "single" @pytest.mark.parametrize("job_id", ["checks", "pytest", "stage2c-correctness-e2e", "merge-gate"]) diff --git a/tests/test_inbox_receipt_recovery.py b/tests/test_inbox_receipt_recovery.py index 94efb68c35..f280edc292 100644 --- a/tests/test_inbox_receipt_recovery.py +++ b/tests/test_inbox_receipt_recovery.py @@ -115,6 +115,7 @@ def test_cli_reports_the_gap_without_rewriting_an_immutable_result(exchange): def test_original_app_conversation_retains_readback_and_recovers_over_real_http(tmp_path, damaged_lane): from loopx.capabilities.manager_context import deliver from loopx.capabilities.manager_context.roundtrip import drain + from loopx.chat_runtime import ChatRuntimeController from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_store import ChatSessionStore @@ -144,6 +145,8 @@ def test_original_app_conversation_retains_readback_and_recovers_over_real_http( path.write_text("{damaged") server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.chat_store, server.runtime_root, server.registry_path = store, tmp_path, registry + server.runtime_controller = ChatRuntimeController( + store=store, codex_bin="missing-codex", registry_path=registry) server.verbose = False thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/test_opencode2_goal_worker_cli.py b/tests/test_opencode2_goal_worker_cli.py index 91b9ea413b..95373aad33 100644 --- a/tests/test_opencode2_goal_worker_cli.py +++ b/tests/test_opencode2_goal_worker_cli.py @@ -3,13 +3,25 @@ import argparse import json import os +import tempfile from types import SimpleNamespace +import pytest + from loopx.cli_commands.opencode2_goal_worker import ( handle_opencode2_goal_worker_command, ) +@pytest.fixture(autouse=True) +def _isolated_worker_cache(tmp_path, monkeypatch): + # Mocked process launches still materialize the real worker/cache files. + # Keep those files out of later tests' detached working directories. + for name in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(name, str(tmp_path)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + class FakeProcess: """Minimal subprocess.Popen stand-in for CLI handler contract tests.""" diff --git a/tests/test_python_ci_workflow.py b/tests/test_python_ci_workflow.py index 886f5fbdfe..c1dc0f5376 100644 --- a/tests/test_python_ci_workflow.py +++ b/tests/test_python_ci_workflow.py @@ -54,7 +54,7 @@ def test_dashboard_acceptance_and_kernel_checks_run_independently() -> None: assert "NEEDS_JSON: ${{ toJSON(needs) }}" in aggregate assert "scripts/ci/review_gate.py verify-core" in aggregate assert ( - "needs: [changes, kernel-static-checks, typescript-coverage, " + "needs: [changes, kernel-static-checks, kernel-contracts, typescript-coverage, " "dashboard-acceptance, chat-bundle-browser]" ) in aggregate @@ -79,6 +79,7 @@ def test_checks_aggregate_requires_every_planned_parallel_lane( "stage2c_tests": str(backend).lower(), "presentation_tests": str(not backend).lower(), }}, "kernel-static-checks": {"result": kernel}, + "kernel-contracts": {"result": kernel}, "typescript-coverage": {"result": typescript}, "dashboard-acceptance": {"result": dashboard}, "chat-bundle-browser": {"result": browser}, @@ -233,12 +234,12 @@ def test_merge_gate_runs_on_all_prs_and_checks_every_core_aggregate() -> None: job = WORKFLOW.split(f" {name}:\n", 1)[1].split(" steps:", 1)[0] if name == "checks": assert ( - "needs: [changes, kernel-static-checks, typescript-coverage, " + "needs: [changes, kernel-static-checks, kernel-contracts, typescript-coverage, " "dashboard-acceptance, chat-bundle-browser]" ) in job assert "if: always() && needs.changes.outputs.core_tests == 'true'" in job elif name == "test-shard": - assert "needs: [changes, chat-bundle, checks]" in job + assert "needs: [changes, chat-bundle]" in job assert f"needs.changes.outputs.{output} == 'true'" in job else: assert "needs: [changes, chat-bundle]" in job @@ -265,7 +266,7 @@ def test_presentation_exemption_retains_real_frontend_checks_and_force_full() -> < browser.index("npm run smoke:chat-upgrade") ) assert "continue-on-error" not in browser - for name in ("kernel-static-checks", "typescript-core", "typescript-coverage", "node-minimum-compatibility"): + for name in ("kernel-static-checks", "kernel-contracts", "typescript-core", "typescript-coverage", "node-minimum-compatibility"): header = WORKFLOW.split(f" {name}:\n", 1)[1].split(" steps:", 1)[0] assert "if: needs.changes.outputs.backend_tests == 'true'" in header dashboard = WORKFLOW.split(" dashboard-acceptance:\n", 1)[1].split(" steps:", 1)[0] @@ -311,6 +312,17 @@ def test_windows_lifecycle_suite_references_existing_tests() -> None: assert [ path for path in test_paths if not (WORKFLOW_ROOT / path).is_file() ] == [] + fixture_step = next( + step for step in yaml.safe_load(WORKFLOW)["jobs"]["windows-powershell"]["steps"] + if step.get("name") == "Qualify native CLI output fixture and diagnostic boundaries" + ) + args = shlex.split(fixture_step["run"]) + assert "tests/control_plane/test_cli_output_budget.py" in args + assert "tests/control_plane/test_cli_output_differential.py" in args + assert set(args[args.index("-k") + 1].split(" or ")) == { + "stable_budget_fixture", "long_path_budget", + "probe_process_failure_preserves_utf8_diagnostics", + } def test_six_shards_execute_each_test_once_and_merge_portable_coverage( @@ -467,10 +479,9 @@ def test_backend_and_mixed_prs_require_the_browser_qualified_artifact() -> None: assert "needs.changes.outputs.core_tests == 'true'" in producer aggregate = WORKFLOW.split(" checks:\n", 1)[1].split(" steps:", 1)[0] assert "chat-bundle-browser" in aggregate - for name in ("chat-bundle-browser", "kernel-static-checks", "typescript-core", "dashboard-browser", "test-shard", "stage2c-suite", "windows-powershell", "presentation"): + for name in ("chat-bundle-browser", "kernel-contracts", "typescript-core", "dashboard-browser", "test-shard", "stage2c-suite", "windows-powershell", "presentation"): job = WORKFLOW.split(f" {name}:\n", 1)[1].split(" - uses: actions/setup-", 1)[0] - dependencies = "[changes, chat-bundle, checks]" if name == "test-shard" else "[changes, chat-bundle]" - assert f"needs: {dependencies}" in job + assert "needs: [changes, chat-bundle]" in job assert "name: chat-bundle-${{ github.sha }}" in job diff --git a/tests/test_usage_goal.py b/tests/test_usage_goal.py index 8d8567af04..e37883d414 100644 --- a/tests/test_usage_goal.py +++ b/tests/test_usage_goal.py @@ -4,6 +4,7 @@ from pathlib import Path import subprocess import sys +import tempfile import threading import time @@ -279,6 +280,34 @@ def test_caller_relative_registry_and_runtime_reach_the_detached_observer(tmp_pa usage_ping.control("disable") +@pytest.mark.parametrize("package_kind", ["namespace", "regular"]) +def test_detached_cycle_ignores_packages_in_its_temporary_working_directory( + tmp_path, monkeypatch, usage_transport, package_kind, +): + from loopx.cli_commands.opencode2_goal_worker import _materialized_worker_path + + temporary = tmp_path / "detached-cwd" + temporary.mkdir() + for name in ("TMPDIR", "TEMP", "TMP"): + monkeypatch.setenv(name, str(temporary)) + monkeypatch.setattr(tempfile, "tempdir", str(temporary)) + # Qualify the installed child entry point without a source-path override or + # an ambient safe-path setting masking the historical startup failure. + monkeypatch.delenv("PYTHONPATH", raising=False) + monkeypatch.delenv("PYTHONSAFEPATH", raising=False) + worker = _materialized_worker_path() + assert worker.parent.parent == temporary / "loopx" + if package_kind == "regular": + (temporary / "loopx" / "__init__.py").write_text( + "raise AssertionError('temporary package must not be imported')\n", + encoding="utf-8", + ) + + preview, _, _, _ = _bound_cycle_through_detached_ts(tmp_path, monkeypatch, usage_transport) + assert {row["measurement"] for row in preview["counters"]} == {"quota_cycle", "codex_turn"} + assert all(row["host"] == "codex_app" for row in preview["counters"]) + + def test_provider_numeric_timing_reaches_real_detached_installation_aggregator(tmp_path, monkeypatch, usage_transport): preview, _, _, _ = _bound_cycle_through_detached_ts(tmp_path, monkeypatch, usage_transport, numeric_seconds=True) assert {row["measurement"] for row in preview["counters"]} == {"quota_cycle", "codex_turn"}