diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index adbe5bf3c5..1486c43615 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -729,6 +729,25 @@ and a global `loopx` may resolve to a different installed source tree. 与测试依赖;能否自动下载 Python 取决于下载配置与网络。系统 `python3` 可能过旧, 全局 `loopx` 也可能指向另一个已安装版本。 +An inherited `PYTHONPATH` can also shadow the editable checkout when running +`uv run loopx`: the console script starts in its scripts directory, while +`python -c` and `python -m` start with the working directory on their import +path. A successful checkout import in either mode does not qualify the console +process. For source validation on POSIX, use command-local +`env -u PYTHONPATH uv run --extra test loopx ...`; test subprocesses launched +outside the checkout should explicitly bind `PYTHONPATH` to that checkout. +Exercise the actual console and its semantic result. Keep installed-runtime +qualification separate; do not edit a shared environment, promote a release or +restart a shared service to repair a source-validation mismatch. + +继承的 `PYTHONPATH` 也可能让 `uv run loopx` 越过可编辑安装,加载其他源码。 +控制台脚本从 scripts 目录启动,而 `python -c`、`python -m` 会把当前目录放在 +导入路径前面;后两者导入成功不能证明控制台进程正确。POSIX 下源码验证可使用 +仅对本条命令生效的 `env -u PYTHONPATH uv run --extra test loopx ...`;在仓库外 +启动的测试子进程应显式将 `PYTHONPATH` 绑定到被测 checkout。验证真实控制台及其 +语义结果,并与安装版验收分开;不要为源码验收偏差修改共享环境、提升安装版或 +重启共享服务。 + Fixtures that model a child without LoopX must isolate that child from an inherited source-checkout `PYTHONPATH`. Check imports with the same interpreter mode and environment as the command under test: an isolated `python -I` probe diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 7d8de58837..1c8f10579d 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -191,7 +191,7 @@ teaches a reusable control-plane lesson. | `scheduler_reset_application_gap` | An App heartbeat cadence backs off, but repeated unchanged polls do not advance the progression; after user feedback, a new/reassigned todo, resolved gate, material transition, or `run_now`, the automation remains slow; LoopX reports `apply_needed=false` while the installed App RRULE differs; or a matching reset RRULE still demands a no-op host update before ACK. | actual automation RRULE, quota payload `scheduler_hint.app_automation.stateful_backoff` and `host_observation`, `app_automation.recommended_rrule`, `app_automation.ack_hint.cli_args`, `quota scheduler-ack-current` result, recent user interaction/todo/history transition. | The scheduler ACK ledger was treated as host truth even though ACK can precede/fail to follow `automation_update`, or the host can later drift; matching host evidence was also lost when `scheduler-ack-current` recomputed the decision. | Keep the CLI-owned ACK protocol, but reconcile a uniquely matched active heartbeat's observed RRULE before suppressing work. A mismatch must set `host_observation.status=drift_detected` and re-emit `recommended_rrule`; a matching unbound reset sets `ack_needed=true` and carries its exact reset token, identity, and CLI route into a no-write ACK. Missing readback remains `apply_needed=true`. Never read the prompt body into the hot payload or edit App manifests directly. Cover false/stale ACK, ambiguous host-update result, real cadence change, host match, and reset-to-`run_now` with smokes. | | `skill_cli_contract_drift` | A skill, installed release snapshot, generated prompt, smoke fixture, or agent handbook suggests a CLI form that the current parser rejects, or tells an already activated Goal to bootstrap another Goal. | `loopx --help`, current host task contract, failing command stderr, `rg` over `skills/`, installed skill copies, release snapshot, generated prompts, and relevant smoke fixtures. | Process documentation drifted from the CLI or host lifecycle contract, or a fixture preserved old command text without labeling it as legacy/non-executable. | Update the durable skill/source docs and installed skill copy, make activated continuation follow the exact CLI `interaction_contract` before bootstrap, add or update a focused smoke, and keep legacy fixture strings only when the test explicitly asserts they are untrusted historical payload. | | `effect_runtime_source_topology_fingerprint_gap` | A control-plane `.ts` or contract `.json` file is added or removed, but the managed Effect runtime fingerprint does not rotate and an older process remains eligible for reuse; or a concurrent install/update leaks `FileNotFoundError` between inventory, metadata collection, and content hashing. | Exact source-file inventory before and after the change, directory metadata on the affected filesystem, fingerprint values, bounded full-fingerprint attempts, and managed runtime process identity. | Source topology caching treated directory `mtime`/`ctime` as authoritative change evidence even though some filesystems preserve that metadata across rapid entry changes; deleting that cache also deleted its bounded race recovery, and restoring the retry around metadata alone left the later content-read boundary exposed. | Discover the bounded owned source inventory on each fingerprint check, retain content hashing behind the file-metadata snapshot cache, retry the complete inventory-plus-metadata-plus-content-hash operation once when a source disappears, then raise a stable diagnostic if churn continues. Cover unchanged directory metadata, added and removed files, deterministic scan/stat disappearance, and stat/read disappearance through request and readiness. Do not add a watcher or TTL. | -| `local_release_snapshot_stale_after_merge` | Source checkout or `PYTHONPATH= python -m loopx.cli` shows a fix, but the default `loopx` command still returns the pre-fix quota/status behavior immediately after a self-merge or fast-forward. | `which loopx`, `readlink ~/.local/bin/loopx`, `loopx doctor` install freshness and promotion mode, default `loopx ...` payload, source-run `PYTHONPATH= python3 -m loopx.cli ...` payload, latest `origin/main`. | The default command is a release snapshot wrapper that forces `PYTHONPATH` to its release root; the source checkout was updated but the installed local release was not refreshed. | Refresh with `loopx update --execute --ref main` or a clean `origin/main` checkout's `scripts/install-local.sh`, rerun the default command without `PYTHONPATH`, and write back the repair. Dirty/non-main worktrees are canary-only unless an explicit validated promotion uses `LOOPX_PROMOTE_DEFAULT=1`. | +| `local_release_snapshot_stale_after_merge` | Source checkout or `PYTHONPATH= python -m loopx.cli` shows a fix, but the default `loopx` command still returns the pre-fix quota/status behavior immediately after a self-merge or fast-forward. | `which loopx`, `readlink ~/.local/bin/loopx`, `loopx doctor` install freshness and promotion mode, default `loopx ...` payload, source-run `PYTHONPATH= python3 -m loopx.cli ...` payload, latest `origin/main`; imported package under the console's actual environment. | The default command is a release snapshot wrapper that forces `PYTHONPATH` to its release root; the source checkout was updated but the installed local release was not refreshed. That inherited path can also shadow the editable checkout in `uv run loopx`, although `python -c` or `-m` from the checkout imports correctly. | First distinguish installed-runtime drift from source-validation shadowing. For the latter, clear or bind `PYTHONPATH` only for the source command and test the real console from outside the checkout; do not reinstall or restart shared services. For actual installed drift, refresh only under existing installation authority with `loopx update --execute --ref main` or a clean `origin/main` checkout's `scripts/install-local.sh`, rerun the default command without `PYTHONPATH`, and write back the repair. Dirty/non-main worktrees are canary-only unless an explicit validated promotion uses `LOOPX_PROMOTE_DEFAULT=1`. | | `stale_local_service_reused_after_upgrade` | The default command and desktop assets contain a fix, but dashboard mutations still return a pre-fix error after an upgrade or reinstall; a UI-only development preview may instead expose an empty `502` response as a raw JSON parser failure. | `readlink ~/.local/bin/loopx`, release manifests for the selected command and listeners, listener process start times, `LOOPX_RELEASE_ROOT` for the status/Chat services, Vite proxy target reachability, and loopback capability payloads. | A long-lived local status or Chat process survived the upgrade, the UI was started without its matching Chat service, or the launcher accepted a schema-only service fingerprint and silently reused code from another release. | Expose a public-safe runtime identity (`package_version`, immutable `release_id`, and source revision) on local service probes. Start development UI and Chat as one version-matched surface; parse API failures at the shared client boundary so empty or non-JSON responses become actionable service errors. Reuse only an exact installed-release match; classify a valid LoopX service from another release as stale and require its owning dashboard/app to stop before the current release starts. Cover browser launcher, UI-only proxy failure, and desktop supervisor probes. | | `release_evidence_goal_binding_gap` | A release/promotion canary passes every check, then its evidence writeback fails because a fixed or caller-selected project Goal is absent from the registry. | canary command result, evidence write command, registry goals, runtime root, promotion gate and doctor readback. | Installation-scoped release evidence reused project `refresh-state`, so an unrelated Goal became a hidden prerequisite for validating the shared local release. | Store new evidence in an append-only runtime release ledger through an explicit CLI write contract; read it for every Goal and retain old Goal run-history events only as a compatibility fallback. Cover an empty-goal registry plus installer, gate, doctor, and goal-filtered status readback. | | `local_release_python_runtime_drift` | A locally promoted release imports under CI's declared Python but the default macOS `python3` exits during CLI startup on a newly added standard-library symbol. | `python3 --version`, default wrapper target, failing import traceback, `pyproject.toml` requirement, changed module imports, candidate doctor under the actual wrapper interpreter. | A new module used a newer convenience alias even though the source-based local wrapper still runs on an older system interpreter, and release validation did not exercise that real default path before promotion. | Prefer an equivalent broadly available standard-library API at the narrow import seam, add a focused timestamp/import regression, run candidate doctor plus the affected product smoke with the actual default interpreter, and only then promote the repaired release. Do not claim broad interpreter support beyond the verified CLI path. | diff --git a/skills/loopx-self-repair/references/targeted-diagnostics.md b/skills/loopx-self-repair/references/targeted-diagnostics.md index f580f922a7..64213c7b4a 100644 --- a/skills/loopx-self-repair/references/targeted-diagnostics.md +++ b/skills/loopx-self-repair/references/targeted-diagnostics.md @@ -7,7 +7,7 @@ does not authorize borrowing another session's identity or changing providers. | Missing fact | First useful surface | | --- | --- | | Why the current operation was rejected | Its existing JSON error, typed recovery and operation receipt; look up the error before opening other projections | -| Whether a receipt defect still needs a source fix | Resolve the failing executable and source revision, then run the same synthetic sequence on the intended current base. An installed release can predate an already merged repair; source tests do not prove installed behavior. | +| Whether a receipt defect still needs a source fix | Resolve the failing executable, imported package and source revision in the same process mode/environment, then run the same synthetic sequence on the intended current base. An inherited `PYTHONPATH` can shadow an editable install in `uv run loopx` while `python -c` or `-m` from the checkout imports correctly. Bind or clear it only for the source-validation command and exercise the real console. An installed release can predate an already merged repair; source tests do not prove installed behavior. | | Whether an ambiguous Todo create committed | `loopx --format json todo receipt --goal-id --operation-id `; inspect the receipt before any retry with the same identity | | Current lease ownership/version | `loopx --format json task-lease inspect --goal-id --todo-id ` | | One Todo's current state | `loopx --format json todo list --goal-id --todo-id `; use `--agent-id` when needed by the existing lane | diff --git a/tests/test_delegation_turn_context.py b/tests/test_delegation_turn_context.py index cd88e6be24..45956d8de5 100644 --- a/tests/test_delegation_turn_context.py +++ b/tests/test_delegation_turn_context.py @@ -1,7 +1,10 @@ """Real receipt/store journeys: a Turn agenda does not complete or replay work.""" import json import os +from pathlib import Path import shlex +import subprocess +import sysconfig import pytest @@ -99,11 +102,28 @@ def test_separate_agent_has_no_access_to_another_receivers_agenda(flow): def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monkeypatch, provider, delivery): from control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection - from loopx.control_plane.testing.canary_harness import run_json_cli_result, write_fixture_registry + from loopx.control_plane.testing.canary_harness import write_fixture_registry from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source from loopx.control_plane.todos.goal_todo_projection import retained_todo_summary_fields if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) + # A module launched from the checkout can mask a stale inherited package. + # Exercise the installed console from the fixture project instead. + stale_package = tmp_path / "stale-release" / "loopx" + stale_package.mkdir(parents=True) + (stale_package / "__init__.py").write_text('raise RuntimeError("wrong source checkout")\n') + monkeypatch.setenv("PYTHONPATH", str(stale_package.parent)) + console = Path(sysconfig.get_path("scripts")) / ("loopx.exe" if os.name == "nt" else "loopx") + assert console.is_file(), "Install this checkout's console with uv sync --extra test." + + def run_console(*arguments): + result = subprocess.run([str(console), "--registry", str(registry), + "--runtime-root", str(root), "--format", "json", *arguments], cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])}, + capture_output=True, text=True, encoding="utf-8", timeout=90) + assert result.stdout.strip(), result.stderr + return result.returncode, json.loads(result.stdout) + root, registry, state = tmp_path / "runtime", tmp_path / "registry.json", tmp_path / "state.md" text = ("---\nstatus: active\n---\n# Goal\n## Objective\nQualify the report.\n## Agent Todo\n" "- [ ] [P1] Inspect the current report and return findings.\n" @@ -138,9 +158,9 @@ def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monk presentation = ["--turn-envelope"] if delivery == "host_envelope" else [ "--codex-app", "--context-delivery", "on", "--decision-output-root", str(tmp_path / "captures"), "--codex-context-root", str(tmp_path / "context")] - code, packet = run_json_cli_result("quota", "should-run", "--goal-id", "research", + code, packet = run_console("quota", "should-run", "--goal-id", "research", "--agent-id", "worker", "--turn-instance-id", turn_id, - *presentation, registry_path=registry, runtime_root=root) + *presentation) assert code == 0, (packet.get("error_code"), packet.get("reason"), packet.get("recovery")) if delivery == "native_files": if turn_id == "agenda-turn-one": @@ -153,7 +173,7 @@ def test_actual_cli_guards_and_host_envelopes_preserve_the_agenda(tmp_path, monk command = packet["recovery"]["command"] + " --initial-read" words = shlex.split(command) assert words[0] == "loopx" - code, page = run_json_cli_result(*words[1:], registry_path=registry, runtime_root=root) + code, page = run_console(*words[1:]) assert code == 0 and page["ok"] and page["eof"], page values = {row["pointer"]: row["value"] for row in json.loads(page["content"])["selections"]} pointer = next(key for key in values if key.endswith("/content/unfinished_delegations"))