Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion loopx/canary/premerge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 22 additions & 22 deletions loopx/canary/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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"):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
102 changes: 102 additions & 0 deletions tests/canary/test_repository_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import json
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
Loading