From 65296441836a7ce4b6bad55ea8627c20707b684e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:17:57 +0800 Subject: [PATCH] fix(canary): forward premerge base to CLI budget checks Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/canary/premerge.py | 5 +- loopx/canary/runner.py | 44 ++++---- .../references/repair-patterns.md | 1 + tests/canary/test_repository_root.py | 102 ++++++++++++++++++ 4 files changed, 129 insertions(+), 23 deletions(-) diff --git a/loopx/canary/premerge.py b/loopx/canary/premerge.py index da31b0ebdd..0df7aeda38 100644 --- a/loopx/canary/premerge.py +++ b/loopx/canary/premerge.py @@ -900,6 +900,7 @@ def build_premerge_validation_gate( catalog_path: Path | None = None, ) -> dict[str, Any]: target_repo_root = (repo_root or REPO_ROOT).resolve() + base_ref = (base_ref or "origin/main").strip() or "origin/main" files = _dedupe(list(changed_files or [])) if files: catalog_path = resolve_canary_catalog_path(catalog_path, repo_root=target_repo_root) @@ -968,6 +969,7 @@ def build_premerge_validation_gate( timeout_seconds=timeout_seconds, fail_fast=fail_fast, progress_callback=catalog_progress if execute else None, + cli_output_base_ref=base_ref, ) _emit_section_progress( catalog_progress, @@ -1024,6 +1026,7 @@ def build_premerge_validation_gate( timeout_seconds=timeout_seconds, fail_fast=fail_fast, progress_callback=risk_progress if execute else None, + cli_output_base_ref=base_ref, ) _emit_section_progress( risk_progress, @@ -1088,7 +1091,7 @@ def build_premerge_validation_gate( "ok": ok, "schema_version": PREMERGE_GATE_SCHEMA_VERSION, "repo_root": str(target_repo_root), - "base_ref": (base_ref or "origin/main").strip() or "origin/main", + "base_ref": base_ref, "tier": tier if tier in PREMERGE_TIERS else "standard", "dry_run": not execute, "executes_checks": execute, diff --git a/loopx/canary/runner.py b/loopx/canary/runner.py index 53ab3c4a62..5da0fc5f84 100644 --- a/loopx/canary/runner.py +++ b/loopx/canary/runner.py @@ -8,6 +8,7 @@ import tempfile import time from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import partial from pathlib import Path from typing import Any, Callable @@ -305,6 +306,7 @@ def _run_check( check_index: int | None = None, check_count: int | None = None, repo_root: Path | None = None, + cli_output_base_ref: str | None = None, ) -> dict[str, Any]: repo_root = (repo_root or REPO_ROOT).resolve() normalized = normalize_canary_command(str(check.get("command") or ""), repo_root=repo_root) @@ -328,6 +330,14 @@ def _run_check( home.mkdir() env = {**os.environ, "LOOPX_USAGE_PING": "0", "HOME": str(home), "CODEX_HOME": str(home / ".codex")} + if ( + cli_output_base_ref is not None + and normalized["script"] == "examples/control_plane/cli-output-budget-regression-smoke.py" + ): + # Match CI's two-ref handoff so the child uses the selected + # base's merge-base with HEAD, even for non-main targets. + env["LOOPX_CLI_OUTPUT_BASE_REF"] = cli_output_base_ref + env["LOOPX_CLI_OUTPUT_MAIN_REF"] = cli_output_base_ref for name in ("LOOPX_RUNTIME_ROOT", "LOOPX_REGISTRY"): env.pop(name, None) for name in ("TMPDIR", "TEMP", "TMP"): @@ -612,6 +622,7 @@ def build_canary_smoke_suite_run( parallel_jobs: int = 1, progress_callback: ProgressCallback | None = None, repo_root: Path | None = None, + cli_output_base_ref: str | None = None, ) -> dict[str, Any]: """Build and optionally execute a continue-on-failure smoke suite. @@ -712,6 +723,14 @@ def build_canary_smoke_suite_run( "auto_restored": False, } if execute: + run_check = partial( + _run_check, + timeout_seconds=max(1.0, timeout_seconds), + check_count=len(selected), + repo_root=repo_root, + ) + if cli_output_base_ref is not None: + run_check = partial(run_check, cli_output_base_ref=cli_output_base_ref) git_ok, tracked_before, git_stderr = _tracked_change_paths(repo_root=repo_root) clean_start = git_ok and not tracked_before if allow_tracked_side_effects: @@ -749,13 +768,7 @@ def build_canary_smoke_suite_run( check=check, repo_root=repo_root, ) - result = _run_check( - check, - timeout_seconds=max(1.0, timeout_seconds), - check_index=index, - check_count=len(selected), - repo_root=repo_root, - ) + result = run_check(check, check_index=index) if side_effect_guard["enforced"]: after_ok, tracked_after, after_stderr = _tracked_change_paths(repo_root=repo_root) side_effects = sorted(set(tracked_after) - set(tracked_before)) @@ -799,14 +812,7 @@ def build_canary_smoke_suite_run( check=check, repo_root=repo_root, ) - future = executor.submit( - _run_check, - check, - repo_root=repo_root, - timeout_seconds=max(1.0, timeout_seconds), - check_index=index, - check_count=len(selected), - ) + future = executor.submit(run_check, check, check_index=index) futures[future] = (index, check) for future in as_completed(futures): index, check = futures[future] @@ -840,13 +846,7 @@ def build_canary_smoke_suite_run( check=check, repo_root=repo_root, ) - result = _run_check( - check, - timeout_seconds=max(1.0, timeout_seconds), - check_index=index, - check_count=len(selected), - repo_root=repo_root, - ) + result = run_check(check, check_index=index) result["serial_execution_required"] = True result["serial_execution_reason"] = _serial_smoke_reason(check, repo_root=repo_root) indexed_results[index] = result diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index bc95573fe9..d3c4efb494 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -270,6 +270,7 @@ teaches a reusable control-plane lesson. | `commit_hygiene_drift` | Broad commit includes temporary smokes, raw logs, local state, or unrelated docs. | `git status`, `git diff --stat`, `git ls-files --others --exclude-standard`, AGENTS.md. | Worktree was staged by chronology rather than reviewer logic. | Use explicit pathspecs, split commits, keep only durable smokes, and update AGENTS/skill if the failure mode recurs. | | `migration_terminal_receipt_replay_gap` | A migrated transaction succeeds once, then an idempotent legacy retry is rejected as receipt corruption or times out on a lock that the first response lost. | Versioned operation receipt, persisted state, lock owner/token, exact caller retry identity, pre-migration replay behavior, and focused native plus compatibility tests. | The new owner modeled only held/committed receipts and treated a closed no-op receipt as incomplete, or generated retry identity inside the callee after the caller's retry boundary. | Model terminal no-op receipts separately from held authority proofs, replay them without retired private tokens, and generate one stable operation id outside any transport retry while minting a new id for independent calls. Cover direct native replay and the compatibility adapter's response-loss boundary. | | `typed_kernel_import_surface_drift` | Focused tests and premerge canaries pass, but CI mypy starts reporting unrelated errors in a previously unchecked module after a small typed-kernel change. | Exact mypy entry files, the new import edge, base/head module diffs, and whether the imported module owns the new behavior or is only a type source. | A checked kernel dependency imported a broad runtime or Goal-routing owner for one enum, provider decision or filesystem helper, expanding strict mypy traversal beyond the owning contract. | Keep caller policy with its caller and shared filesystem helpers in a dependency-light runtime module. Validate behavior parity and the exact CI mypy command after changing kernel dependencies, using the same source installation and import environment; an extra PYTHONPATH can hide the imported-module failure. | +| `premerge_comparison_base_not_forwarded` | Premerge on an integration branch reports unexpected CLI output growth even though its receipt names the intended target. | Outer base, the comparison child's resolved Git ref, and the same fixture replay with the intended base. Keep the candidate's own ceiling failures separate. | The selector and direct checks know the target, but the budget child inherits a different base or falls back to main. | Pass the selected base through the shared runner to that child only, using its existing two-ref interface. Verify a non-main target, standalone inheritance, concurrent calls and a child failure through the real subprocess boundary. Preserve the original failed observation and budget checks. | | `browser_smoke_presentation_copy_drift` | Browser checks time out after retention wording changes, or mistake a longer source label in a content-sized layout for an empty primary column. | Same immutable base and scenario, production bundle, accessible names, native point identities, exact values, citation and focus return, plus affected pipeline entrypoints. | Smoke locators freeze superseded copy or box-size relationships rather than result identity and information hierarchy. | Keep provenance-accurate UI. Bind existing result keys with role locators, preserve accessible meaning and independent numeric, source, failure and recovery oracles, then run the same workload before and after against its real owner. A replacement layout oracle needs a failing equal-column control, not a weaker threshold. Do not restore overclaims, increase timeouts or delete assertions. | | `packaged_surface_completion_illusion` | A development route or API-mocked browser smoke passes while the packaged product still serves an older UI, loses local history after refresh, hides every healthy Agent, or records important contradictions only as non-failing observations. | packaged entry title and first viewport, emitted bundle identity, real loopback capabilities, service-restart history, Last-Event-ID replay, mobile navigation close behavior, and the acceptance report's untested/observation fields. | UI completion was inferred from component tests or a Vite route; the release asset, launcher environment, durable local session path, and hard browser assertions evolved separately. | Make packaged-route parity a blocking acceptance contract. Build and launch the real bundle with a controlled executable path, convert semantic observations into assertions, persist status-only and Agent-backed messages independently, test refresh plus restart recovery, and require every design criterion to be PASS or explicitly gated before claiming the surface complete. | | `dashboard_status_source_generation_gap` | Switching between local and SSH sources leaves the old Goal list visible, or the selector and route oscillate between two sources after a slow tunnel ensure or status response. | User selection order, tunnel-ensure start/finish order, status fetch start/finish order, requested and loaded URLs, projection revision, route commits, and the rendered Goal identity. | Request freshness starts only after SSH preparation, a foreground request captures its revision before advancing it, or an older route commit clears a newer pending selection. | Fence the complete selection transaction—from user intent through tunnel ensure, fetch, payload commit, and route commit—with one monotonic selection generation plus a projection revision. Let only the current generation clear pending state; reject background responses whose target is no longer the committed source. Cover delayed A/B fetches and delayed SSH-then-local selection with a focused browser smoke that asserts payload, selector, and route remain aligned. | diff --git a/tests/canary/test_repository_root.py b/tests/canary/test_repository_root.py index 22b0dced34..4e312fce3b 100644 --- a/tests/canary/test_repository_root.py +++ b/tests/canary/test_repository_root.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import os import subprocess from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -12,6 +14,7 @@ from loopx.canary.premerge import build_premerge_validation_gate from loopx.canary.runner import build_canary_smoke_suite_run, normalize_canary_command from loopx.cli import main +from tests.source_cli import run_source_cli def _git(repo: Path, *args: str) -> None: @@ -131,6 +134,105 @@ def test_concurrent_checkout_roots_do_not_share_process_state(tmp_path): assert str(repo) in result["selected_checks"][0]["stdout_tail"] +_CLI_BUDGET_SCRIPT = "examples/control_plane/cli-output-budget-regression-smoke.py" +_CLI_BUDGET_ENV = ("LOOPX_CLI_OUTPUT_BASE_REF", "LOOPX_CLI_OUTPUT_MAIN_REF") +_NEIGHBOR_SCRIPT = "examples/semantic-vocabulary-drift-smoke.py" +_AMBIENT_REFS = ("ambient-base", "ambient-main") + + +def _cli_budget_checkout(path: Path) -> Path: + repo = _checkout(path) + probe = ( + "import json, os\n" + f"print(json.dumps([os.environ.get(name) for name in {_CLI_BUDGET_ENV!r}]))\n" + ) + for script in ( + _NEIGHBOR_SCRIPT, + "examples/control_plane/control-plane-maintainability-ratchet-smoke.py", + _CLI_BUDGET_SCRIPT, + ): + child = repo / script + child.parent.mkdir(parents=True, exist_ok=True) + fail = ( + 'if os.environ.get("CANARY_FIXTURE_FAIL") == "1":\n' + " raise SystemExit(7)\n" + if script == _CLI_BUDGET_SCRIPT else "" + ) + child.write_text(probe + fail, encoding="utf-8") + metrics = Path("loopx/canary/module_metric_baseline.json") + (repo / metrics).parent.mkdir(parents=True) + (repo / metrics).write_bytes((DEFAULT_CATALOG_PATH.parents[2] / metrics).read_bytes()) + _git(repo, "add", "examples", "loopx") + _git(repo, "commit", "-m", "install synthetic validation probes") + _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD") + _git(repo, "commit", "--allow-empty", "-m", "integration target") + _git(repo, "branch", "integration/base") + changed = repo / "loopx/control_plane/testing/cli_output_budget.py" + changed.parent.mkdir(parents=True) + changed.write_text("# Exercise CLI output budget selection.\n", encoding="utf-8") + _git(repo, "add", "loopx") + _git(repo, "commit", "-m", "select CLI output budget check") + return repo + + +def _check_for_script(run: dict, script: str) -> dict: + return next(item for item in run["selected_checks"] if item["normalized"]["script"] == script) + + +@pytest.mark.parametrize( + ("base_args", "expected_base", "child_exit"), + [ + (["--git-diff-base", "integration/base"], "integration/base", 0), + ([], "origin/main", 0), + (["--git-diff-base", "integration/base"], "integration/base", 7), + ], +) +def test_premerge_scopes_cli_budget_base_to_its_child(tmp_path, base_args, expected_base, child_exit): + repo = _cli_budget_checkout(tmp_path / "repo") + env = {**os.environ, **dict(zip(_CLI_BUDGET_ENV, _AMBIENT_REFS)), "CANARY_FIXTURE_FAIL": str(int(child_exit != 0))} + response = run_source_cli( + ["--format", "json", "canary", "premerge", "--from-git-diff", "--tier", "quick", + "--no-progress", *base_args], + cwd=repo, env=env, check=False, timeout=60, + ) + assert response.stdout, response.stderr + payload = json.loads(response.stdout) + assert payload["base_ref"] == expected_base + budget = _check_for_script(payload["catalog_run"], _CLI_BUDGET_SCRIPT) + neighbor = _check_for_script(payload["catalog_run"], _NEIGHBOR_SCRIPT) + assert json.loads(budget["stdout_tail"]) == [expected_base, expected_base] + assert json.loads(neighbor["stdout_tail"]) == list(_AMBIENT_REFS) + assert budget["returncode"] == child_exit + assert budget["ok"] is (child_exit == 0) + assert payload["validation_summary"]["failure_count"] == int(child_exit != 0) + if child_exit: + assert response.returncode != 0 and payload["gate"]["merge_gate_passed"] is False + + +def test_cli_budget_children_keep_independent_bases_and_standalone_defaults(tmp_path, monkeypatch): + repo = _cli_budget_checkout(tmp_path / "repo") + for name, value in zip(_CLI_BUDGET_ENV, _AMBIENT_REFS): + monkeypatch.setenv(name, value) + monkeypatch.delenv("CANARY_FIXTURE_FAIL", raising=False) + + def run(base: str | None) -> dict: + return build_canary_smoke_suite_run( + repo_root=repo, scripts=[_CLI_BUDGET_SCRIPT, _NEIGHBOR_SCRIPT], + parallel_jobs=2, cli_output_base_ref=base, + ) + + bases = ("integration/base", "origin/main", None) + with ThreadPoolExecutor(max_workers=3) as executor: + results = list(executor.map(run, bases)) + for base, result in zip(bases, results): + assert result["ok"] and result["effective_parallel_jobs"] == 2 + budget = _check_for_script(result, _CLI_BUDGET_SCRIPT) + assert json.loads(budget["stdout_tail"]) == ([base, base] if base else list(_AMBIENT_REFS)) + neighbor = _check_for_script(result, _NEIGHBOR_SCRIPT) + assert json.loads(neighbor["stdout_tail"]) == list(_AMBIENT_REFS) + assert tuple(os.environ[name] for name in _CLI_BUDGET_ENV) == _AMBIENT_REFS + + @pytest.mark.parametrize("command", [ "python examples/../../escape.py", "python examples/root-smoke.py ; echo unsafe", "python ../elsewhere/examples/root-smoke.py",