diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 3520404c27..6eef01a39b 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -4505,3 +4505,35 @@ view shows its independently checked result in the same conversation. actual-model improvements, full D2/D5 closure, R1/M3 or C1 default cutover. Preserve prior failures and the original ten requirements; full original-goal regression still precedes completion and EdgeBench preparation. + +### 2026-11-15 — Load PR wait observation after permission recheck + +- **Gap:** composing an executing Turn imported PR reconciliation and its + metadata/content providers before resolving observation permission, even + when the hook was unavailable. A provider import failure escaped the optional + observation failure boundary. +- **Delta:** the existing producer imports the reconciler inside its provider + `try`, after the invocation-time capability check succeeds. Registration and + invocation retain their separate permission checks and revocation behavior. + No wrapper, cached decision, new activation flag or policy owner is added. + Python remains the stable GitHub provider/transport adapter; typed selection, + permissions, authority and observation cadence retain their existing owners. +- **Evidence:** fresh-process blocked-provider cases cover denied, revoked and + unavailable permission, plus import failure with unknown reads/effects. + Eight matched hook scenarios use a frozen clock to compare deterministic + returns, permission checks and provider calls; + source File/SQLite checks retain provider success, failure, response loss, + cadence and full-source diagnostics. Separate installed Core wheels execute + authorized File/SQLite observation and canonical Todo readback, alongside + execution, replay, process-loss and invalid-artifact checks. The original + consumer suite retains four matching setup failures on both arms; a separate + current-carrier protocol fixture does not replace those records. +- **Boundary:** unavailable observation avoids loading its provider closure; + granted observation keeps the existing behavior. This is a bounded default + closure increment. The first granted invocation now includes deferred imports + in the existing `duration_ms` observation window; real timing equivalence or + performance gains are not qualified. It does not establish full D2/D5, + actual-model/context adoption, complete-cost + improvement, original-ten acceptance or C1 cutover. Preserve prior sources, + failures and seals; full original-goal regression still precedes completion + and EdgeBench preparation. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index 9d0aa7b756..54af114ba9 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -3488,3 +3488,25 @@ workspace 批准和脚本模型工作。桌面视图在采用检查后显示原 激活、capture 发布、恢复或结算边界。它不证明总延迟、完整成本或真实模型改善, 也不完成 D2/D5 闭包、R1/M3 或 C1 默认切换。保留历史失败和原十项要求;完成前 仍需完整原目标回归,随后才准备 EdgeBench。 + +### 2026-11-15 — PR 等待观察在权限复查后加载 + +- **缺口:**执行性 Turn 的组合在解析观察权限前导入 PR reconciler 及其 metadata/ + content provider,即使 hook 不可用也会加载。provider 导入失败还会逃出可选 + 观察原有的故障边界。 +- **变化:**既有 producer 在 invocation-time capability 检查成功后,于原 provider + `try` 内导入 reconciler。注册和调用仍分别检查权限,保留撤销行为;没有新增 + wrapper、缓存决策、激活 flag 或 policy owner。Python 仍承担稳定的 GitHub + provider/transport adapter;typed selection、权限、authority 和观察 cadence + 继续由既有 owner 持有。 +- **证据:**独立进程的 blocked-provider 反例覆盖未授权、权限撤销、权限源不可用, + 以及导入失败时读取/效果为 unknown。八类配对 hook 场景冻结时钟,比较确定性 + 返回、权限检查与 provider 调用;源码 File/SQLite 检查保留 provider 成功、失败、响应丢失、cadence + 和完整源诊断。独立安装的两份 Core wheel 执行获权的 File/SQLite 观察和 canonical + Todo 回读,并覆盖执行、回放、进程丢失与非法 artifact。原 consumer 套件在两侧 + 保留四个一致的 setup 失败;独立的当前 carrier 协议 fixture 不替代这些记录。 +- **边界:**不可用观察不加载其 provider 闭包,获权观察保留既有行为。本次是有界 + 默认闭包增量;首次获权调用现在把 deferred import 纳入既有 `duration_ms` 观察 + 窗口,不证明真实计时等价或性能收益。它不完成 D2/D5、真实模型/上下文采用、完整成本改善、原十项验收 + 或 C1 切换。保留历史源码、失败和 seal;完成前仍需完整原目标回归,随后才准备 + EdgeBench。 diff --git a/loopx/capabilities/issue_fix/pr_wait_hook.py b/loopx/capabilities/issue_fix/pr_wait_hook.py index 74689d73dc..5eb7569006 100644 --- a/loopx/capabilities/issue_fix/pr_wait_hook.py +++ b/loopx/capabilities/issue_fix/pr_wait_hook.py @@ -13,7 +13,6 @@ TURN_START_HOOK_RESULT_SCHEMA_VERSION, TurnStartHookRegistration, ) -from .pr_wait_reconcile import reconcile_pr_wait_dependencies PR_WAIT_OBSERVATION_HOOK_ID = "issue_fix.pr_wait_observation" @@ -91,6 +90,9 @@ def produce() -> dict[str, Any]: "external_read_count": 0, "state_written": False, "summary_lines": ["Observation permission source is unavailable; provider not contacted."]}} try: + # Optional provider loading follows the invocation-time permission recheck. + from .pr_wait_reconcile import reconcile_pr_wait_dependencies + readback = reconcile_pr_wait_dependencies(registry_path=registry_path, runtime_root_arg=str(runtime_root), goal_id=goal_id, agent_id=agent_id, fetch_timeout_seconds=fetch_timeout_seconds) diff --git a/tests/capabilities/test_pr_wait_lifecycle.py b/tests/capabilities/test_pr_wait_lifecycle.py index 89951dabed..780783adf9 100644 --- a/tests/capabilities/test_pr_wait_lifecycle.py +++ b/tests/capabilities/test_pr_wait_lifecycle.py @@ -249,21 +249,19 @@ def read(inspector): @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_lost_observation_response_refreshes_cached_readiness(tmp_path, monkeypatch, provider): - from loopx.capabilities.issue_fix import pr_wait_hook - registry, runtime, _, todo_id, argv = _native_wait_turn(tmp_path, monkeypatch, provider) # Capture the old pending projection before permission is configured. _invoke_guard(argv, "capture-pending", "--write-projection-cache") _declare_observation(registry) monkeypatch.setattr(pr_lifecycle, "fetch_github_pr_lifecycle_payload", lambda *a, **kw: { "url": "https://github.com/owner/repo/pull/42", "state": "MERGED", "mergedAt": "2026-10-01T01:12:00Z"}) - original = pr_wait_hook.reconcile_pr_wait_dependencies + original = pr_wait_reconcile.reconcile_pr_wait_dependencies def response_lost(**kwargs): original(**kwargs) raise OSError("response lost after local commit") - monkeypatch.setattr(pr_wait_hook, "reconcile_pr_wait_dependencies", response_lost) + monkeypatch.setattr(pr_wait_reconcile, "reconcile_pr_wait_dependencies", response_lost) result = _invoke_guard(argv, "lost-response", "--use-projection-cache") readback = _observation(result) assert readback["external_read_count"] is None and readback["state_written"] is None @@ -291,8 +289,6 @@ def test_unavailable_permission_source_never_contacts_provider(tmp_path, monkeyp @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_full_source_selection_is_independent_of_bounded_hook_diagnostics(tmp_path, monkeypatch, provider): - from loopx.capabilities.issue_fix import pr_wait_hook - isolate_sqlite_runtime(tmp_path, monkeypatch) lines = tuple(line for number in range(1, 501) for line in ( f"- [ ] [P2] Continue dependency {number}.", @@ -301,7 +297,7 @@ def test_full_source_selection_is_independent_of_bounded_hook_diagnostics(tmp_pa _, runtime, registry = _write_live_fixture(tmp_path, provider=provider, extra_agent_todo_lines=lines) _declare_observation(registry) calls, full_results = [], [] - original = pr_wait_hook.reconcile_pr_wait_dependencies + original = pr_wait_reconcile.reconcile_pr_wait_dependencies def capture(**kwargs): result = original(**kwargs) @@ -312,7 +308,7 @@ def fetch(reference, **kwargs): calls.append(reference) return {"url": f"https://github.com/{reference['repo']}/pull/{reference['number']}", "state": "OPEN"} - monkeypatch.setattr(pr_wait_hook, "reconcile_pr_wait_dependencies", capture) + monkeypatch.setattr(pr_wait_reconcile, "reconcile_pr_wait_dependencies", capture) monkeypatch.setattr(pr_lifecycle, "fetch_github_pr_lifecycle_payload", fetch) dispatch = extend_turn_start_dispatch({}, registry_path=registry, runtime_root=runtime, goal_id="loopx-turn-fixture", agent_id="codex-fixture", available=None) diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 8f6cd1092e..e71e2a9a04 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -661,7 +661,8 @@ def test_heartbeat_chain_routes_do_not_load_the_chat_layer(tmp_path, monkeypatch initialize_canonical_source_fixture(runtime, "perf-goal", state_path=state, provider="sqlite") argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *command] result = run_cli_main("loopx.entrypoint", argv, - forbidden_modules=("loopx.cli", "loopx.chat_manager", "loopx.chat_agent", "loopx.chat_store")) + forbidden_modules=("loopx.cli", "loopx.chat_manager", "loopx.chat_agent", "loopx.chat_store", + "loopx.capabilities.issue_fix.pr_wait_reconcile")) assert result.returncode == 0, result.stderr assert json.loads(result.stdout)["ok"] is True @@ -700,6 +701,10 @@ def test_capability_hook_imports_defer_unselected_facade_exports() -> None: "issue_fix.periodic_report", "issue_fix.pr_description", "issue_fix.reward_memory", + "issue_fix.pr_wait_reconcile", + "issue_fix.pr_lifecycle", + "issue_fix.metadata_preview", + "content_ops.surface", "reward_memory.codex_app_outcome", "reward_memory.dogfood", "reward_memory.evaluation", @@ -719,6 +724,75 @@ def test_capability_hook_imports_defer_unselected_facade_exports() -> None: assert completed.returncode == 0, completed.stderr +@pytest.mark.parametrize("permission", ["denied", "revoked", "source-unavailable", "recheck-unavailable", "granted"]) +def test_pr_wait_provider_import_obeys_permission_and_failure_owner(permission): + script = f""" +import importlib.abc +import sys +from pathlib import Path + +attempts = [] +class UnavailableProvider(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "loopx.capabilities.issue_fix.pr_wait_reconcile": + attempts.append(fullname) + raise ImportError("optional observation provider unavailable") + +sys.meta_path.insert(0, UnavailableProvider()) +from loopx.capabilities.issue_fix import pr_wait_hook + +permission = {permission!r} +grant = ["external_evidence_poll"] +states = iter({{ + "denied": [[]], "revoked": [grant, []], + "source-unavailable": [OSError("permission source unavailable")] * 2, + "recheck-unavailable": [grant, OSError("permission source unavailable")], + "granted": [grant, grant], +}}[permission]) +checks = [] +def availability(*args): + value = next(states) + checks.append(value) + if isinstance(value, Exception): + raise value + return {{"effective": value}} + +pr_wait_hook._availability = availability +hook = pr_wait_hook.turn_start_hook(registry_path=Path("unused"), runtime_root=Path("unused"), + goal_id="fixture", agent_id="fixture", available=None) +assert attempts == [], attempts +if permission == "denied": + assert hook is None and len(checks) == 1 +else: + assert hook is not None + result = hook.producer() + assert len(checks) == 2 + assert result["external_writes_performed"] is False + if permission == "granted": + assert len(attempts) == 1 + assert result["status"] == "failed" + assert result["error_code"] == "dependency_observation_failed" + assert result["external_reads_performed"] is None + assert result["local_private_state_mutated"] is None + assert result["readback"]["external_read_count"] is None + assert result["readback"]["state_written"] is None + assert result["readback"]["error_category"] == "ImportError" + else: + assert attempts == [] + assert result["external_reads_performed"] is False + assert result["local_private_state_mutated"] is False + assert result["status"] == ("not_applicable" if permission == "revoked" else "unavailable") + if permission != "revoked": + assert result["error_code"] == "capability_source_unavailable" + assert result["readback"]["external_read_count"] == 0 +for name in ("pr_wait_reconcile", "pr_lifecycle", "metadata_preview"): + assert "loopx.capabilities.issue_fix." + name not in sys.modules +assert "loopx.capabilities.content_ops.surface" not in sys.modules +""" + completed = run_isolated_script(script) + assert completed.returncode == 0, completed.stderr + + def test_quota_facade_lazy_exports_preserve_owner_identity() -> None: script = """ from importlib import import_module