diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 13a7b40a05..41aebcbbc4 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -824,6 +824,61 @@ Approval needs a reviewed decision, not implementation progress or silence. installation, R1 managed minimal or D1–D7. No preset, frontend operation, permission or persisted state changes; protected execution keeps its owner. +### 2026-11-07 — Heartbeat pre-quota selected loading + +- **Baseline and owner:** `79665b97a`. The heartbeat builder already emits + `heartbeat-prequota` when external evidence polling is available. Its selected + parser reuses the existing heartbeat registrar; dispatch uses the existing + heartbeat transport module. The complete facade passes its original runner, + renderer and global-registry resolver patch points. Explicit/implicit routing, + aliases, format, timeout, exception, degraded-return and controller semantics + remain characterized; reconciliation/hook ordering and typed owners are unchanged. +- **Source and package evidence:** 50 new transport/loading/real-backend cases; + 22 cases on each of four independently verified source/wheel consoles; 16 + configured/degraded File/SQLite console scenarios with replay, retained opaque + work and zero quota debit. Dropping `continue_to_quota` is rejected by the same + public-console oracle on both real stores. The existing acknowledgement smoke + now exercises both CLI facades. The full source selection initially has 378 + passes and two route-test failures reproduced on the immutable baseline. The + fixture now reads the original digest-bound progressive capture without + re-admission; its complete affected file then passes all 17 cases. No failure + was waived. Focused lint, configured mypy and the code canary pass. +- **Matched local cost:** macOS 27.0 arm64, Python 3.13.5, Node 26.0.0; identical + dependencies and synthetic stores, precompiled bytecode, fresh Python processes, + warm filesystem caches and reused isolated Node services. Thirty alternating + base/head pairs for each workload/distribution yield 360 samples in 339 seconds; + study setup/validation takes 260 seconds separately. This shared development + host was not an idle-machine experiment. Values below are wall median/p95 in + seconds and median process RSS in MiB, including interpreter startup. + +| Workload / distribution | Base median / p95 | Candidate median / p95 | Base → candidate RSS | +| --- | --- | --- | --- | +| `source/prequota-help` | 1.433 / 1.889 | 0.172 / 0.226 | 91.5 → 24.7 | +| `source/prequota-file-off` | 1.494 / 1.927 | 0.545 / 0.667 | 92.0 → 47.0 | +| `source/prequota-sqlite-off` | 1.547 / 1.977 | 0.507 / 0.640 | 91.9 → 46.3 | +| `wheel/prequota-help` | 1.555 / 1.967 | 0.170 / 0.210 | 91.7 → 24.5 | +| `wheel/prequota-file-off` | 1.545 / 2.155 | 0.536 / 0.650 | 91.7 → 46.7 | +| `wheel/prequota-sqlite-off` | 1.397 / 1.866 | 0.531 / 0.667 | 92.0 → 46.9 | + +- **Closure and complete-cost limits:** help loads 29 LoopX modules instead of + 1,158; default-off execution loads 277 instead of 1,160. Python open observations + fall from 1,292–1,312 to 81–395. Opened-file size sums are footprints, not actual + read bytes. Median user/system process CPU is 0.04/0.02 seconds for candidate + help and 0.13–0.14/0.065–0.08 for execution, versus 0.40–0.42/0.16–0.185 at base. + Output remains 737 bytes for help and 827 for JSON execution. A separate SQLite + loopback profile observes 1,362 sent and 668 received bytes on each revision, + two Node starts for version/server discovery with a cold service and none with + a reused service. Its single instrumented cold/warm observations do not isolate + Node readiness or service CPU; those costs remain unqualified. The initial IPC + probe assumed Unix sockets and failed; loopback instrumentation corrected the + probe without changing product behavior. Help p95 of 0.226/0.210 seconds exceeds + the proposed 0.200-second target; that D6 proposal is not promoted or relabeled. +- **Remaining:** this is one R0/V1/V3/V10 caller proposal, preserving I3 for at + least one minor release. Full R0/V10, M1/#8011, R1, context/model and split-install + qualification, installed-host recovery, adoption and complete task costs remain + open. The bounded Python change adapts transport; it adds no decision owner, + preset, provider or authority rule. Required final-head review/CI remains a + delivery gate distinct from these local observations. ### 2026-11-07 — Registry inspection selected loading - **Baseline and boundary:** `ac6296225`. `registry` and `registry-boundary` @@ -1128,7 +1183,6 @@ comparison before adopting a performance change. This checkpoint creates no new owner, budget, preset or default. R0's complete census, R1/minimal, D2/D5/D6, I3/D3, hard-lease/context/model and split-install acceptance remain open; independent exact-head review and required validation govern adoption. - ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | 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 96fee286c3..f52e38482d 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -599,6 +599,49 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, 不关闭完整 R0/V10、M1、拆包安装、R1 managed minimal 或 D1–D7。不改变 preset、 前端操作、权限或持久化状态;受保护执行仍由既有 owner 所有。 +### 2026-11-07 — Heartbeat pre-quota 按需加载 + +- **基线与 owner:**`79665b97a`。heartbeat builder 已在 external evidence polling + 可用时生成 `heartbeat-prequota`。选择性 parser 复用原 heartbeat registrar,dispatch + 放在现有 heartbeat transport 模块;完整 facade 显式传入原 runner、renderer 和全局 + registry resolver patch points。显式/隐式路由、别名、格式、timeout、异常、降级返回和 + controller 语义均已刻画;reconciliation/hook 顺序及 typed owners 保持原路径。 +- **源码与包证据:**新增 50 个 transport/加载/真实后端用例;四个独立核对的源码/ + wheel console 各运行 22 个场景;16 个配置/降级 File/SQLite console 场景覆盖重放、 + opaque 原工作保留和零 quota 扣费。删除 `continue_to_quota` 的 mutation 经同一公开 + console oracle 在两种真实 store 上被拒绝。原 acknowledgement smoke 同时覆盖两个 + CLI facade。完整源码选择初轮得到 378 pass、两个路由测试失败,精确基线同样失败。 + fixture 改为读取原 digest-bound progressive capture,不重新准入;受影响文件随后 + 17 个用例全部通过。失败未豁免。聚焦 lint、配置内 mypy 和代码 canary 通过。 +- **配对本地成本:**macOS 27.0 arm64、Python 3.13.5、Node 26.0.0;相同依赖和合成 + store,预编译 bytecode、全新 Python 进程、热文件缓存及复用的隔离 Node 服务。 + 每个 workload/distribution 30 个交错 base/head 配对,共 360 个样本,采样 339 秒; + study 准备/验证另计 260 秒。测量使用共享开发机,并非空闲机器实验。下表是含解释器 + 启动的墙钟 median/p95(秒)和进程 RSS 中位数(MiB)。 + +| Workload / distribution | 基线 median / p95 | 候选 median / p95 | 基线 → 候选 RSS | +| --- | --- | --- | --- | +| `source/prequota-help` | 1.433 / 1.889 | 0.172 / 0.226 | 91.5 → 24.7 | +| `source/prequota-file-off` | 1.494 / 1.927 | 0.545 / 0.667 | 92.0 → 47.0 | +| `source/prequota-sqlite-off` | 1.547 / 1.977 | 0.507 / 0.640 | 91.9 → 46.3 | +| `wheel/prequota-help` | 1.555 / 1.967 | 0.170 / 0.210 | 91.7 → 24.5 | +| `wheel/prequota-file-off` | 1.545 / 2.155 | 0.536 / 0.650 | 91.7 → 46.7 | +| `wheel/prequota-sqlite-off` | 1.397 / 1.866 | 0.531 / 0.667 | 92.0 → 46.9 | + +- **加载边界与完整成本限制:**help 的 LoopX 模块为 29,基线 1,158;默认关闭的执行 + 为 277,基线 1,160。Python open 观察由 1,292–1,312 降至 81–395;打开文件尺寸之和 + 是 footprint,不是实际读取字节。候选 help 的 user/system 进程 CPU 中位数为 + 0.04/0.02 秒,执行为 0.13–0.14/0.065–0.08 秒;基线为 + 0.40–0.42/0.16–0.185 秒。help 输出仍为 737 字节,JSON 执行仍为 827 字节。 + 独立 SQLite loopback profile 在两版均观察到发送 1,362、接收 668 字节;冷服务启动 + Node version/server 两个进程,复用服务启动数为零。单次带 instrumentation 的冷/ + 热观察不能拆出 Node readiness 或 service CPU,相关成本未通过资格。初轮 IPC probe + 误以为使用 Unix socket 而失败,改为 loopback instrumentation 后通过,未修改产品。 + help p95 为 0.226/0.210 秒,超过提议的 0.200 秒目标;未晋升或重标该 D6 提议。 +- **剩余:**这是一条 R0/V1/V3/V10 caller 的作者提案,I3 至少保留一个 minor release。 + 完整 R0/V10、M1/#8011、R1、context/model、split-install、installed-host 恢复、 + 采纳和完整任务成本仍开放。Python 仅适配 transport,没有新增 decision owner、preset、 + provider 或权限规则。最终 exact-head review/CI 仍是独立交付门,与这些本地观察分开。 ### 2026-11-07 — Registry 检查的选择性加载 - **基线与边界:** `ac6296225`。`registry` 和 `registry-boundary` 使用现有 @@ -839,7 +882,6 @@ owner;采纳性能变更前必须另做匹配的真实路径对照。本 check 预算、preset 或默认行为。R0 完整清点、R1/minimal、D2/D5/D6、I3/D3、 hard-lease/context/model 和拆包安装验收仍开放;采纳仍受独立 exact-head 评审及必需验证约束。 - ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | diff --git a/examples/issue-fix-pr-review-reconcile-smoke.py b/examples/issue-fix-pr-review-reconcile-smoke.py index 36075c0fcb..d797710380 100644 --- a/examples/issue-fix-pr-review-reconcile-smoke.py +++ b/examples/issue-fix-pr-review-reconcile-smoke.py @@ -307,30 +307,31 @@ def check_reconciliation(provider: str) -> None: cli_env = dict(os.environ) cli_env["HOME"] = str(home) cli_env["PYTHONPATH"] = str(ROOT) - cli = subprocess.run( - [ - sys.executable, - "-m", - "loopx.cli", - "--format", - "json", - "heartbeat-prequota", - "-g", - "example-goal", - "-a", - "codex-test", - ], - cwd=project, - env=cli_env, - check=False, - capture_output=True, - text=True, - ) - assert cli.returncode == 0, cli.stderr - cli_payload = json.loads(cli.stdout) - assert cli_payload["schema_version"] == "heartbeat_pre_quota_v0", cli_payload - assert cli_payload["continue_to_quota"] is True, cli_payload - assert cli_payload["quota_spend_required"] is False, cli_payload + for module in ("loopx.entrypoint", "loopx.cli"): + cli = subprocess.run( + [ + sys.executable, + "-m", + module, + "--format", + "json", + "heartbeat-prequota", + "-g", + "example-goal", + "-a", + "codex-test", + ], + cwd=project, + env=cli_env, + check=False, + capture_output=True, + text=True, + ) + assert cli.returncode == 0, cli.stderr + cli_payload = json.loads(cli.stdout) + assert cli_payload["schema_version"] == "heartbeat_pre_quota_v0", cli_payload + assert cli_payload["continue_to_quota"] is True, cli_payload + assert cli_payload["quota_spend_required"] is False, cli_payload assert todo(state_file, "todo_other_provider")["status"] == "open" native = list_goal_todos(registry_path=registry_path, goal_id="example-goal") diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index ff42b7bf7e..3037c7e64b 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -234,24 +234,16 @@ def handle_support_control_command( ) if args.command == "heartbeat-prequota": - prequota_registry = ( - registry_path - if registry_was_supplied - else explicit_global_registry(args.runtime_root) - ) - payload = run_heartbeat_pre_quota( - registry_path=prequota_registry, - runtime_root_arg=args.runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, - fetch_timeout_seconds=args.fetch_timeout_seconds, - ) - print_payload( - payload, - output_format(args), - render_heartbeat_pre_quota_markdown, + from .support_control_heartbeat import handle_heartbeat_prequota_command + + return handle_heartbeat_prequota_command( + args, registry_path=registry_path, + registry_was_supplied=registry_was_supplied, + print_payload=print_payload, output_format=output_format, + runner=run_heartbeat_pre_quota, + markdown_renderer=render_heartbeat_pre_quota_markdown, + global_registry_resolver=explicit_global_registry, ) - return 0 supervisor_result = handle_supervisor_control_command( args, diff --git a/loopx/cli_commands/support_control_heartbeat.py b/loopx/cli_commands/support_control_heartbeat.py index 4944186505..5fe19b4436 100644 --- a/loopx/cli_commands/support_control_heartbeat.py +++ b/loopx/cli_commands/support_control_heartbeat.py @@ -27,6 +27,49 @@ FormatSelector = Callable[..., str] +def handle_heartbeat_prequota_command( + args: argparse.Namespace, + *, + registry_path: Path, + registry_was_supplied: bool, + print_payload: PrintPayload, + output_format: FormatSelector, + runner: Callable[..., dict[str, object]] | None = None, + markdown_renderer: Callable[[dict[str, object]], str] | None = None, + global_registry_resolver: Callable[[str | None], Path] | None = None, +) -> int: + # The complete facade passes its released patch points. The selected + # entry loads these same owners only when this command executes. + if runner is None: + from ..heartbeat_prequota import run_heartbeat_pre_quota + + runner = run_heartbeat_pre_quota + if markdown_renderer is None: + from ..heartbeat_prequota import render_heartbeat_pre_quota_markdown + + markdown_renderer = render_heartbeat_pre_quota_markdown + if global_registry_resolver is None: + from .support_control_registry import explicit_global_registry + + global_registry_resolver = explicit_global_registry + prequota_registry = ( + registry_path + if registry_was_supplied + else global_registry_resolver(args.runtime_root) + ) + payload = runner( + registry_path=prequota_registry, + runtime_root_arg=args.runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + fetch_timeout_seconds=args.fetch_timeout_seconds, + ) + print_payload(payload, output_format(args), markdown_renderer) + # Reconciliation is best effort: a returned degraded payload still + # continues to quota. Exceptions retain the complete facade's path. + return 0 + + def handle_heartbeat_prompt_command( args: argparse.Namespace, *, diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 75165b5a8a..fee6385e67 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -81,6 +81,7 @@ "handoff", "handoff-mode", "performance-diagnosis", "heartbeat-prompt", + "heartbeat-prequota", "goal-acceptance", "host-mode-plan", "capability", @@ -478,7 +479,7 @@ def _build_selected_parser(command: str) -> LoopXArgumentParser: from .capabilities.performance_diagnosis.cli import register_performance_diagnosis_commands register_performance_diagnosis_commands(subparsers, add_subcommand_format) - elif command == "heartbeat-prompt": + elif command in {"heartbeat-prompt", "heartbeat-prequota"}: from .cli_commands.support_control_heartbeat_registration import register_heartbeat_control_commands register_heartbeat_control_commands(subparsers, add_subcommand_format) @@ -808,6 +809,14 @@ def _dispatch_selected(args: argparse.Namespace, raw_argv: list[str]) -> int: args, registry_path=registry_path, runtime_root_arg=args.runtime_root, output_format=output_format, print_payload=print_payload, ) + elif args.command == "heartbeat-prequota": + from .cli_commands.support_control_heartbeat import handle_heartbeat_prequota_command + + result = handle_heartbeat_prequota_command( + args, registry_path=registry_path, + registry_was_supplied=user_supplied_registry(raw_argv), + output_format=output_format, print_payload=print_payload, + ) else: result = dispatch_common_command( args, diff --git a/tests/control_plane/test_heartbeat_registry_route.py b/tests/control_plane/test_heartbeat_registry_route.py index 3ac6efc2b2..ecec55ac74 100644 --- a/tests/control_plane/test_heartbeat_registry_route.py +++ b/tests/control_plane/test_heartbeat_registry_route.py @@ -124,7 +124,21 @@ def _execute(argv: list[str], cwd: Path): result = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--format", "json", *argv], cwd=cwd, env=env, text=True, capture_output=True, check=False) assert result.stdout, result.stderr - return result.returncode, json.loads(result.stdout) + payload = json.loads(result.stdout) + # Progressive presentation retains the original decision behind a private, + # digest-bound capture. Read it without repeating admission or changing the + # generated command whose authority route this journey verifies. + from loopx.control_plane.turn_driver.decision_capture import ( + read_context_capture_file, read_context_read_reference, + ) + if payload.get("schema_version") == "context_capture_navigation_v1": + _, reference = read_context_read_reference(payload["recovery"]["reference_file"]) + assert "selection" not in reference + payload = read_context_capture_file(reference["path"], reference["sha256"], private=True)[2] + elif isinstance(payload.get("captured_decision"), dict): + reference = payload["captured_decision"] + payload = read_context_capture_file(reference["path"], reference["sha256"], private=True)[2] + return result.returncode, payload @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index a5ed2ebe47..7e33ef3070 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -753,6 +753,15 @@ def test_quota_accounting_routes_skip_should_run_composition(tmp_path, monkeypat def test_selected_parser_matches_full_help_and_diagnostics() -> None: argv_cases = [ + ["heartbeat-prequota", "--help"], + ["heartbeat-prequota"], + ["heartbeat-prequota", "-g", "goal"], + ["heartbeat-prequota", "-a", "agent"], + ["heartbeat-prequota", "-g", "goal", "-a", "agent", "--unknown-option"], + ["heartbeat-prequota", "-g", "goal", "-a", "agent", "--fetch-timeout-seconds", "bad"], + ["heartbeat-prequota", "-g", "goal", "-a", "agent", "--fetch-timeout-seconds"], + ["heartbeat-prequota", "-g", "goal", "-a", "agent", "--format", "bad"], + ["heartbeat-prequota", "--goal-i", "goal", "-a", "agent"], *[[command, flag] for command in ("refresh-state", "checkpoint-context", "read-only-map", "reward", "operator-gate") for flag in ("--help", "--unknown-option")], @@ -824,6 +833,168 @@ def test_selected_parser_matches_full_help_and_diagnostics() -> None: assert selected == full +@pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) +@pytest.mark.parametrize("registry_mode", ["supplied", "equals", "empty", "omitted", "environment"]) +@pytest.mark.parametrize("format_args, expected_format", [ + ([], "markdown"), (["--format", "json"], "json"), + (["--format", "markdown"], "markdown"), +]) +def test_prequota_preserves_transport_patch_points_and_opaque_payload( + tmp_path, module, registry_mode, format_args, expected_format, +): + registry, runtime = write_command_fixture(tmp_path) + global_registry = runtime / "registry.global.json" + prefix = ["--runtime-root", str(runtime), "--format", "json"] + if registry_mode == "supplied": + prefix += ["--registry", str(registry)] + elif registry_mode == "equals": + prefix += ["--registry=" + str(registry)] + elif registry_mode == "empty": + prefix += ["--registry", ""] + argv = prefix + ["heartbeat-prequota", "-g", "", "-a", "perf-agent", *format_args] + # Global format applies when the subcommand does not override it. + format_name = expected_format if format_args else "json" + expected_registry = Path(".") if registry_mode == "empty" else ( + registry if registry_mode in {"supplied", "equals"} else global_registry + ) + script = f""" +import json, sys +from pathlib import Path +from {module} import main +from loopx import heartbeat_prequota +from loopx.cli_commands import support_control +calls = [] +payload = {{"ok": False, "degraded": True, "continue_to_quota": True, + "quota_spend_required": False, "future": {{"opaque": [None, True, "保留\\n空 格"]}}}} +def runner(**kwargs): + calls.append(kwargs) + return payload +def renderer(value): + assert value is payload + return "CUSTOM-MARKDOWN" +def resolver(runtime_arg): + assert runtime_arg == {str(runtime)!r} + return Path({str(global_registry)!r}) +support_control.run_heartbeat_pre_quota = heartbeat_prequota.run_heartbeat_pre_quota = runner +support_control.render_heartbeat_pre_quota_markdown = heartbeat_prequota.render_heartbeat_pre_quota_markdown = renderer +from loopx.cli_commands import support_control_registry +support_control.explicit_global_registry = support_control_registry.explicit_global_registry = resolver +sys.argv[0] = "loopx" +assert main({argv!r}) == 0 +assert calls == [{{"registry_path": Path({str(expected_registry)!r}), "runtime_root_arg": {str(runtime)!r}, + "goal_id": "", "agent_id": "perf-agent", "fetch_timeout_seconds": 10}}] +""" + result = run_isolated_script(script, environment={ + "LOOPX_REGISTRY": str(registry) if registry_mode == "environment" else "", + "LOOPX_USAGE_PING": "0", + }) + assert result.returncode == 0, result.stderr + if format_name == "json": + assert json.loads(result.stdout) == { + "ok": False, "degraded": True, "continue_to_quota": True, + "quota_spend_required": False, "future": {"opaque": [None, True, "保留\n空 格"]}, + } + else: + assert result.stdout == "CUSTOM-MARKDOWN\n" + + +@pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) +@pytest.mark.parametrize("timeout", [0, -1, 17]) +def test_prequota_forwards_timeout_and_preserves_uncaught_exception(tmp_path, module, timeout): + registry, runtime = write_command_fixture(tmp_path) + argv = ["--registry", str(registry), "--runtime-root", str(runtime), + "heartbeat-prequota", "-g", "perf-goal", "-a", "perf-agent", + "--fetch-timeout-seconds", str(timeout)] + script = f""" +from {module} import main +from loopx import heartbeat_prequota +from loopx.cli_commands import support_control +def runner(**kwargs): + assert kwargs["fetch_timeout_seconds"] == {timeout} + raise ValueError("synthetic-prequota-failure") +support_control.run_heartbeat_pre_quota = heartbeat_prequota.run_heartbeat_pre_quota = runner +try: + main({argv!r}) +except ValueError as exc: + assert str(exc) == "synthetic-prequota-failure" +else: + raise AssertionError("prequota must preserve its exception path") +""" + result = run_isolated_script(script, environment={"LOOPX_USAGE_PING": "0"}) + assert result.returncode == 0, result.stderr + assert result.stdout == "" + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_prequota_help_keeps_reconciliation_and_product_layers_unloaded(flag): + selected = run_cli_main("loopx.entrypoint", ["heartbeat-prequota", flag], + forbidden_modules=("loopx.cli", "loopx.cli_commands.support_control", + "loopx.heartbeat_prequota", "loopx.chat_server", "loopx.dashboard_launcher")) + full = run_cli_main("loopx.cli", ["heartbeat-prequota", flag]) + assert selected.returncode == full.returncode == 0 + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) +@pytest.mark.parametrize("observation", ["off", "merged", "degraded"]) +def test_prequota_real_authority_observation_and_zero_quota( + tmp_path, monkeypatch, capsys, provider, module, observation, +): + import importlib + from loopx.capabilities.issue_fix import pr_lifecycle + from loopx.rollout_event_log import load_rollout_events, rollout_event_log_path + from loopx.todos import add_goal_todo, list_goal_todos + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture + + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _ = promoted_create_fixture(tmp_path, provider=provider) + config = json.loads(registry.read_text()) + config["goals"][0]["quota"] = {"compute": 0} + if observation != "off": + config["goals"][0]["available_capabilities"] = ["external_evidence_poll"] + registry.write_text(json.dumps(config)) + text = "保留原始工作\n与 opaque data" + todo_id = add_goal_todo(registry_path=registry, goal_id="goal-a", role="agent", + claimed_by="agent-a", text=text, resume_when="pr_merged:owner/repo#42")["todo_id"] + before = list_goal_todos(registry_path=registry, goal_id="goal-a", todo_id=todo_id)["todo"] + calls = [] + + def fetch(reference, **kwargs): + calls.append(reference) + if observation == "off": + pytest.fail("an unconfigured observation must not fetch metadata") + if observation == "degraded": + raise subprocess.TimeoutExpired("synthetic-provider", 1) + return {"state": "MERGED", "mergedAt": "2026-10-01T00:00:00Z", + "url": "https://github.com/owner/repo/pull/42"} + + monkeypatch.setattr(pr_lifecycle, "fetch_github_pr_lifecycle_payload", fetch) + monkeypatch.setenv("LOOPX_KUNLUNCODE_OUTER_CONTROLLER", "1") + monkeypatch.setenv("LOOPX_KUNLUNCODE_OUTER_GOAL_ID", "goal-a") + main = importlib.import_module(module).main + argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "heartbeat-prequota", "-g", "goal-a", "-a", "agent-a"] + assert main(argv) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["continue_to_quota"] is True + assert payload["quota_spend_required"] is False + assert payload["degraded"] is (observation == "degraded") + assert len(calls) == (0 if observation == "off" else 1) + after = list_goal_todos(registry_path=registry, goal_id="goal-a", todo_id=todo_id)["todo"] + for field in ("text", "status", "claimed_by", "resume_when", "required_write_scopes"): + assert after.get(field) == before.get(field) + assert after.get("resume_ready", False) is (observation == "merged") + events = load_rollout_events(rollout_event_log_path(runtime, "goal-a")) + assert not any(e["event_kind"] in {"quota_spend", "todo_complete"} for e in events) + assert main(argv) == 0 + replay = json.loads(capsys.readouterr().out) + assert replay["continue_to_quota"] is True and replay["quota_spend_required"] is False + assert len(calls) == (0 if observation == "off" else 1) + + @pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) @pytest.mark.parametrize("command", ["agent-context", "agent-directory", "agent-capabilities"]) def test_agent_commands_read_registered_peer_without_changing_goal(