From eb6f3eb177ced2200344554e41fa32d86f5cbf67 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Sat, 7 Nov 2026 04:13:25 +0800 Subject: [PATCH] refactor(cli): load history reads on demand Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 51 +++++ .../monorepo-distribution-split-v0.zh-CN.md | 40 ++++ ...trol-plane-command-modularization-smoke.py | 24 +++ loopx/cli_runtime.py | 19 ++ tests/test_cli_entrypoint.py | 181 ++++++++++++++++++ 5 files changed, 315 insertions(+) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index c8e7ce79f9..d68c669cd0 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -824,6 +824,57 @@ 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-06 — History reader selected loading + +- **Baseline and boundary:** `22b0f6c25`. Ordinary `history` reads and help use + the existing registrar/handler through the shared CLI grammar and guard. + Maintenance actions, rejected parser requests and a preloaded full CLI retain + the original route, including handler/renderer patch points. History algorithms, + evidence windows, public imports and the TS owner remain unchanged. This is a + reversible R0 reader slice in the monolithic distribution; I3 still requires + at least one minor release for public moves. +- **Real-path evidence:** 27 actual-console cases per baseline/candidate source + and installed-wheel environment preserve stdout/stderr/exit codes, UTC order, + Agent filtering, limits, empty/unknown inputs and current/stale evidence reads. + Four additional installed/source facade probes preserve patched handlers and + renderers. The preloaded-handler oracle failed before its correction; final + history/parser selection passes 26 tests. Existing durable history, artifact + and evidence oracles are retained. Required native services use disposable + process and data namespaces, with authenticated exit readback. +- **Matched measurement:** Apple M4 Pro, 48 GiB, macOS 27 arm64, Python 3.13.5, + Node 26.0.0; matched Python dependencies and source-pair Node dependencies. + Each of four workloads has 30 interleaved baseline/candidate pairs, precompiled + bytecode and fresh Python processes with warm native-service reuse. The final + sampling takes 229 seconds; all three rounds total 698 seconds, below the + frozen 20-minute sampling bound. Setup, validation and tracing are separate. + Earlier environment/refinement observations retain their original scope. + +| Workload | Median / p95 elapsed ms, base → candidate | Median CPU ms | Median CLI peak RSS MiB | +| --- | --- | --- | --- | +| Source help | 1552 / 1862 → 204 / 349 | 670 → 100 | 91.4 → 31.3 | +| Source Agent read | 1603 / 1833 → 417 / 482 | 665 → 150 | 91.9 → 34.4 | +| Wheel help | 1621 / 2053 → 220 / 375 | 695 → 100 | 91.8 → 31.0 | +| Wheel Agent read | 1648 / 1825 → 414 / 462 | 725 → 150 | 92.2 → 34.6 | + +- **Loading and effects:** source/wheel help loads 155 rather than 1157 LoopX + modules; registered reads load 208 rather than 1158, evidence reads 210 rather + than 1159. Python audit read-open observations decrease from 1290 to 246 for + source help and 1311 to 723 for its cold native read; wheel observations are + 1291 → 247 and 1710 → 723. Help has zero observed writes/processes/connections. + Cold registered reads retain six write-opens, two process starts and one + connection, with 252/355 client IPC bytes sent/received. Evidence reads retain + one write-open, two connections and 1377/804 IPC bytes. These required effects + are not optional activation. A serving runtime can work without Node on PATH; + cold absence retains the existing failure. Output bytes match. Timing includes + interpreter and process invocation; descendant CPU/RSS/I/O, full model and + attention costs remain unknown. +- **Holds and remaining work:** measured help p95 remains above the proposed + 200 ms target; D6 is not adopted. Source-session loader inventory and Turn + preview import checks fail identically on the untouched baseline. Their failures + are retained as qualification holds. This does not close full R0/V10, M1, + split installation, R1/context/host qualification or open design decisions. + Revert the loader slice for rollback; no state migration is involved. + ## 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 5136da5181..097601ab7d 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,46 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, 不关闭完整 R0/V10、M1、拆包安装、R1 managed minimal 或 D1–D7。不改变 preset、 前端操作、权限或持久化状态;受保护执行仍由既有 owner 所有。 +### 2026-11-06 — History 读取路径的所选加载 + +- **基线与边界:** `22b0f6c25`。普通 `history` 读取与帮助通过共享 CLI grammar + 和 guard 复用既有 registrar/handler。维护动作、parser 拒绝及已预加载完整 CLI + 的进程保留原路径,包括 handler/renderer patch 点。历史算法、证据窗口、公开 + import 与 TS owner 均不变。这是单体发行物中可回滚的 R0 读取片;公开迁移的 + I3 仍至少保留一个 minor 版本。 +- **真实路径证据:** 基线/候选源码与实际安装 wheel 的四个环境,各有 27 个 + console 用例保留 stdout/stderr/退出码、UTC 排序、Agent 过滤、limit、空/未知 + 输入与当前/过期证据读取。另有四个安装/源码 facade 探针保留被 patch 的 + handler 与 renderer。预加载 handler 的 oracle 在修复前失败;最终 history/ + parser 选择通过 26 个测试。既有历史、artifact 与证据 oracle 保留。必需原生 + 服务使用可丢弃的数据/进程命名空间,并通过认证的退出回读确认清理。 +- **匹配测量:** Apple M4 Pro、48 GiB、macOS 27 arm64、Python 3.13.5、 + Node 26.0.0;Python 依赖及源码配对中的 Node 依赖版本一致。四个负载各有 30 + 组交错基线/候选配对,bytecode 预编译,Python 每次启动新进程,原生服务暖复用。 + 最终采样 229 秒,三轮合计 698 秒,低于冻结的 20 分钟采样上限;安装、验证及 + tracing 单独记录。此前环境/兼容性修正前的观察保留原有效性范围。 + +| 负载 | elapsed 中位数 / p95 ms,基线 → 候选 | CPU 中位数 ms | CLI 峰值 RSS 中位数 MiB | +| --- | --- | --- | --- | +| 源码帮助 | 1552 / 1862 → 204 / 349 | 670 → 100 | 91.4 → 31.3 | +| 源码 Agent 读取 | 1603 / 1833 → 417 / 482 | 665 → 150 | 91.9 → 34.4 | +| Wheel 帮助 | 1621 / 2053 → 220 / 375 | 695 → 100 | 91.8 → 31.0 | +| Wheel Agent 读取 | 1648 / 1825 → 414 / 462 | 725 → 150 | 92.2 → 34.6 | + +- **加载与效果:** 源码/wheel 帮助由 1157 降至 155 个 LoopX 模块,注册 Goal + 读取由 1158 降至 208,证据读取由 1159 降至 210。Python audit 的 read-open + 观察中,源码帮助为 1290 → 246,冷原生读取为 1311 → 723;wheel 分别为 + 1291 → 247、1710 → 723。帮助观察到的写入/进程/连接均为零。冷注册读取 + 保留六次 write-open、两个进程启动、一个连接,客户端 IPC 发送/接收为 + 252/355 bytes;证据读取保留一次 write-open、两个连接及 1377/804 IPC bytes。 + 这些必需效果不属于可选激活。已有服务可在 PATH 无 Node 时继续工作,冷缺失 + 保留原拒绝。输出字节一致;耗时包含解释器与进程调用,子孙进程 CPU/RSS/I/O、 + 完整模型与人工注意力成本仍未知。 +- **保留项与后续:** 实测帮助 p95 仍高于提出的 200 ms 目标,不采纳 D6。 + source-session loader 清单及 Turn preview import 检查在未改动基线上同样失败, + 保留为资格验证限制。本片不关闭完整 R0/V10、M1、拆包安装、R1/context/宿主 + 资格或开放设计决策。回滚只需撤回加载片,不涉及状态迁移。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | diff --git a/examples/cli-control-plane-command-modularization-smoke.py b/examples/cli-control-plane-command-modularization-smoke.py index 31cd0cb91a..2cdb180627 100644 --- a/examples/cli-control-plane-command-modularization-smoke.py +++ b/examples/cli-control-plane-command-modularization-smoke.py @@ -175,11 +175,35 @@ def assert_todo_markdown_error_payload() -> None: assert_contains(result.stdout, "- error: todo add requires --role", "todo markdown error") +def assert_history_reader_route() -> None: + with tempfile.TemporaryDirectory(prefix="loopx-cli-history-smoke-") as tmp: + script = f""" +import contextlib +import io +import json +import sys +from loopx.entrypoint import main +output = io.StringIO() +with contextlib.redirect_stdout(output): + result = main(["--registry", {str(Path(tmp) / 'absent.json')!r}, + "--format", "json", "history", "--agent-id", "fixture-agent"]) +assert result == 1 +assert json.loads(output.getvalue()) == {{"ok": False, + "error": "--agent-id requires --goal-id without a history action"}} +for module in ("loopx.cli", "loopx.chat_server", "loopx.cli_commands.benchmark_dispatch"): + assert module not in sys.modules, module +""" + result = subprocess.run([sys.executable, "-c", script], cwd=REPO_ROOT, + text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stdout + result.stderr + + def main() -> int: assert_source_shape() assert_help_surfaces() assert_todo_error_payload() assert_todo_markdown_error_payload() + assert_history_reader_route() print("cli-control-plane-command-modularization-smoke: ok") return 0 diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index e5a5514495..6cd2d1e4fe 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -81,6 +81,7 @@ "goal-acceptance", "host-mode-plan", "capability", + "history", } # Existing CLI vocabulary; this set chooses loading, not operation authority. _CAPABILITY_DISCOVERY_COMMANDS = frozenset({"list", "show"}) @@ -416,6 +417,10 @@ def _build_selected_parser(command: str) -> LoopXArgumentParser: from .cli_commands.capability import register_capability_commands register_capability_commands(subparsers, add_subcommand_format) + elif command == "history": + from .cli_commands.history import register_history_command + + register_history_command(subparsers) else: # pragma: no cover - caller guards the private interface raise ValueError(f"unsupported selected command: {command}") return parser @@ -718,6 +723,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 == "history": + from .cli_commands.history import handle_history_command + + # Direct full-CLI callers retain their original facade-bound handler. + result = handle_history_command( + args, registry_path=registry_path, runtime_root_arg=args.runtime_root, + print_payload=print_payload, + ) else: result = dispatch_common_command( args, @@ -766,11 +779,17 @@ def main(argv: list[str] | None = None) -> int: def _run_command(command: str | None, raw_argv: list[str]) -> int: + if command == "history" and "loopx.cli" in sys.modules: + # An explicitly loaded facade keeps its existing handler/renderer bindings. + return _run_full_cli(raw_argv) if command in _SELECTED_COMMANDS: try: args = _build_selected_parser(command).parse_args(raw_argv) except _SelectedParseRejected: return _run_full_cli(raw_argv) + if command == "history" and args.history_action is not None: + # Select ordinary reads; maintenance keeps its complete original route. + return _run_full_cli(raw_argv) if command == "capability": if args.capability_command not in _CAPABILITY_DISCOVERY_COMMANDS: return _run_full_cli(raw_argv) diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 44f6c15192..cb253ed91a 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -1502,3 +1502,184 @@ def test_global_read_preserves_empty_coverage_without_loading_full_cli(tmp_path, assert payload["coverage"]["complete"] is False assert payload["goals"] == [] assert registry.read_bytes() == before + + +@pytest.mark.parametrize("arguments", [ + ["--help"], ["--evidence-ref", "absent", "--help"], + ["repair-index-duplicates", "--help"], +]) +def test_history_help_loads_only_its_reader(arguments): + result = run_cli_main("loopx.entrypoint", ["history", *arguments], + forbidden_modules=("loopx.cli", "loopx.chat_server", + "loopx.cli_commands.benchmark_dispatch"), + environment={"PATH": ""}) + assert result.returncode == 0, result.stderr + assert "--evidence-ref" in result.stdout + assert "--agent-id" in result.stdout + assert result.stderr == "" + + +@pytest.mark.parametrize("format_name", ["json", "markdown"]) +@pytest.mark.parametrize("limit", [-1, 0, 1, 10]) +def test_history_reader_preserves_scope_chronology_and_limits(tmp_path, format_name, limit): + registry, runtime = write_command_fixture(tmp_path) + index = runtime / "goals/perf-goal/runs/index.jsonl" + index.parent.mkdir(parents=True) + rows = [ + {"goal_id": "perf-goal", "agent_id": "perf-agent", + "generated_at": "2026-08-19T00:30:00+08:00", "classification": "offset-earlier"}, + {"goal_id": "perf-goal", "agent_id": "perf-agent", + "generated_at": "2026-08-18T17:00:00Z", "classification": "utc-later"}, + {"goal_id": "perf-goal", "agent_id": "other-agent", + "generated_at": "2026-08-18T18:00:00Z", "classification": "other-lane"}, + ] + index.write_text("".join(json.dumps(row) + "\n" for row in rows)) + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + argv = ["--registry=" + str(registry), "--runtime-root=" + str(runtime), + "--cli-bin", "loopx", "--format", format_name, "history", + "--goal-id", "perf-goal", "--agent-id", "perf-agent", "--limit", str(limit)] + selected = run_cli_main("loopx.entrypoint", argv, forbidden_modules=("loopx.cli",)) + full = run_cli_main("loopx.cli", argv) + assert selected.returncode == full.returncode == 0, (selected.stderr, full.stderr) + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + assert "other-lane" not in selected.stdout + if format_name == "json": + payload = json.loads(selected.stdout) + expected = ["utc-later", "offset-earlier"][:max(0, limit)] + assert [row["classification"] for row in payload["runs"]] == expected + assert [row["classification"] for row in payload["goals"][0]["latest_runs"]] == expected + assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before + + +def test_history_reader_retains_required_native_runtime_failure(tmp_path): + registry, runtime = write_command_fixture(tmp_path) + index = runtime / "goals/perf-goal/runs/index.jsonl" + index.parent.mkdir(parents=True) + index.write_text(json.dumps({"goal_id": "perf-goal", "agent_id": "perf-agent", + "generated_at": "2026-08-18T17:00:00Z", "classification": "bounded_probe"}) + "\n") + argv = ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "history", "--goal-id", "perf-goal"] + temporary = tmp_path / "no-node-runtime" + temporary.mkdir() + environment = {"PATH": "", **{key: str(temporary) for key in ("TMPDIR", "TEMP", "TMP")}} + selected = run_cli_main("loopx.entrypoint", argv, forbidden_modules=("loopx.cli",), + environment=environment) + full = run_cli_main("loopx.cli", argv, environment=environment) + assert selected.returncode == full.returncode == 1 + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + assert json.loads(selected.stdout)["error"] == "LoopX Effect runtime requires Node.js 22.22.3 or newer on PATH" + + +@pytest.mark.parametrize("case", ["current", "other-agent", "stale", "no-node"]) +def test_history_reader_preserves_native_evidence_reference_window(tmp_path, case): + from loopx.control_plane.work_items.replan_context_codec import project_replan_context + + registry, runtime = write_command_fixture(tmp_path) + index = runtime / "goals/perf-goal/runs/index.jsonl" + index.parent.mkdir(parents=True) + observation = "Keep independent failure evidence.\nPreserve the next decision's constraint." + index.write_text(json.dumps({"goal_id": "perf-goal", "agent_id": "perf-agent", + "generated_at": "2026-08-18T17:00:00Z", "classification": "bounded_probe", + "health_check": observation}) + "\n") + context = project_replan_context(goal_id="perf-goal", agent_id="perf-agent", runs=(), + source_status={"registry": str(registry), "runtime_root": str(runtime), + "run_history": {"goals": []}}) + argv = ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "history", "--goal-id", "perf-goal", "--agent-id", + "other-agent" if case == "other-agent" else "perf-agent", + "--evidence-ref", context["evidence"][0]["evidence_ref"], "--limit", "0"] + if case == "stale": + index.write_text("") + before = index.read_bytes() + environment = None + if case == "no-node": + temporary = tmp_path / "no-node-runtime" + temporary.mkdir() + environment = {"PATH": "", **{key: str(temporary) for key in ("TMPDIR", "TEMP", "TMP")}} + selected = run_cli_main("loopx.entrypoint", argv, forbidden_modules=("loopx.cli",), + environment=environment) + full = run_cli_main("loopx.cli", argv, environment=environment) + assert selected.returncode == full.returncode == (0 if case == "current" else 1) + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + payload = json.loads(selected.stdout) + if case == "current": + assert payload["evidence"]["health_check"] == observation + elif case == "no-node": + assert "requires Node.js" in payload["error"] + else: + assert "unavailable" in payload["error"] + assert index.read_bytes() == before + + +def test_history_reader_keeps_complete_parser_diagnostics(): + cases = [ + ["history", "unknown-action"], ["history", "--unknown"], + ["history", "--agent-i", "agent"], ["history", "--evidence-ref"], + ["history", "--limit", "invalid"], ["history", "--format", "json"], + ["--format", "unknown", "history"], ["--cli-bin", "", "history"], + ["--registry", "history"], ["--runtime-root", "history"], + ] + selected, full = run_cli_batch("loopx.entrypoint", cases), run_cli_batch("loopx.cli", cases) + assert selected == full + assert all(result["returncode"] == 2 and not result["stdout"] for result in selected) + + +@pytest.mark.parametrize("arguments,error", [ + (["--agent-id", "perf-agent"], "--agent-id requires --goal-id without a history action"), + (["--evidence-ref", "absent", "--goal-id", "perf-goal"], + "--evidence-ref requires --goal-id and --agent-id without a history action"), +]) +def test_history_reader_validation_refusals_match_full_cli(tmp_path, arguments, error): + argv = ["--registry", str(tmp_path / "missing.json"), "--format", "json", "history", *arguments] + selected = run_cli_main("loopx.entrypoint", argv, forbidden_modules=("loopx.cli",)) + full = run_cli_main("loopx.cli", argv) + assert selected.returncode == full.returncode == 1 + assert selected.stdout == full.stdout + assert selected.stderr == full.stderr == "" + assert json.loads(selected.stdout) == {"ok": False, "error": error} + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("action", [ + None, "inspect-index-duplicates", "repair-index-duplicates", + "rebuild-index-collisions", "trajectory-hygiene", +]) +def test_history_maintenance_and_explicit_full_cli_keep_patch_points(tmp_path, action): + arguments = ["history", *([action] if action else []), "--goal-id", "perf-goal"] + # Direct full-CLI bindings retain their handler even for ordinary reads. + module = "loopx.entrypoint" if action else "loopx.cli" + script = f""" +import loopx.cli as full +from {module} import main +seen = [] +def original_path(args, **kwargs): + seen.append((args.history_action, args.goal_id)) + return 73 +full.handle_history_command = original_path +assert main(["--registry", {str(tmp_path / 'absent.json')!r}, *{arguments!r}]) == 73 +assert seen == [({action!r}, "perf-goal")] +""" + result = run_isolated_script(script) + assert result.returncode == 0, result.stderr + assert not list(tmp_path.iterdir()) + + +def test_history_entrypoint_retains_preloaded_full_cli_handler(tmp_path): + script = f""" +import loopx.cli as full +from loopx.entrypoint import main +seen = [] +def original_path(args, **kwargs): + seen.append((args.history_action, args.goal_id)) + return 73 +full.handle_history_command = original_path +assert main(["--registry", {str(tmp_path / 'absent.json')!r}, + "history", "--goal-id", "perf-goal"]) == 73 +assert seen == [(None, "perf-goal")] +""" + result = run_isolated_script(script) + assert result.returncode == 0, result.stderr + assert not list(tmp_path.iterdir())