diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index b9d3750275..2aad7cff1b 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -762,6 +762,30 @@ Approval needs a reviewed decision, not implementation progress or silence. it does not complete R0's full V10 census, M1 public-import migration, split packaging or R1 managed minimal. D1–D7 remain open. No new preset or default. +### 2026-11-03 — M1 public status compatibility characterization + +- **Boundary:** `ChatStatusRequestMixin` and `goal_read_runtime_root` still live + in `loopx.chat_status_api`. The Chat HTTP handler and Todo detail are active + consumers. This is a characterization prerequisite, not a relocated owner. +- **Compatibility evidence:** `tests/test_chat_status_public_compatibility.py` + protects historical imports in three fresh-process import orders, historical + pickle lookups, reload with already-imported references, and live registry, + collector and host-observation patch points through the real HTTP handler. + Discovery tolerates a missing registry, not unreadable or invalid metadata. + A copied-export facade mutation fails the source-bound Todo-consumer patch + oracle: re-exported functions alone do not preserve legacy module globals. +- **Next reviewed slice:** move the complete status owner under `loopx/chat/` + with an identity-compatible public bridge and documented I3 deprecation. + Retarget both the exact loader path and `_discovery_registry` exception in + `tests/architecture/test_source_session_registry_denial.py` together, retaining + metadata-only discovery and executable source-session refusal. The present + slice leaves that owner and oracle unchanged; do not split ownership or relax + the denial oracle just to make a partial move pass. +- **Remaining:** status relocation and its installed-wheel integration are not + delivered by characterization. I3 retains at least one minor release; D3's + exact expiry remains a release-owner decision. M1 is open. No module-count, + loading-performance, split-package or R1 managed-Turn benefit is claimed. + ## 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 be8f579997..a4793dc90f 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -551,6 +551,25 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, M1 公开 import 迁移、发行物拆分或 R1 managed minimal。D1–D7 仍未决, 不新增预设或改变默认值。 +### 2026-11-03 — M1 公开 status 兼容性刻画 + +- **边界:** `ChatStatusRequestMixin` 与 `goal_read_runtime_root` 仍由 + `loopx.chat_status_api` 所有,Chat HTTP handler 与 Todo detail 是活跃调用方。 + 本片交付刻画前置条件,不是 owner 迁移。 +- **兼容证据:** `tests/test_chat_status_public_compatibility.py` 保护三个新进程 + import 顺序下的历史 import、历史 pickle 查找、reload 后已导入引用,以及经真实 + HTTP handler 生效的 registry、collector 和宿主观察 patch 点。发现路径仅容忍 + registry 缺失,不吞掉不可读或非法元数据。复制导出的 facade mutation 被源绑定 + Todo 调用方的 patch oracle 拒绝:仅 re-export 函数不能保留旧模块的 globals。 +- **下一完整评审片:** 将完整 status owner 迁入 `loopx/chat/`,保留模块身份兼容的 + 公开 bridge,并文档化 I3 弃用。同步重定向 + `tests/architecture/test_source_session_registry_denial.py` 中精确 loader 路径与 + `_discovery_registry` 函数例外,保留仅元数据发现及可执行 source-session 拒绝。 + 本片不改变该 owner 或 oracle;不能为通过部分迁移而拆散所有权或放松拒绝 oracle。 +- **剩余:** 刻画不交付 status 迁移及其安装 wheel 集成。I3 至少保留一个 minor + 版本,D3 精确退役时间仍由发布负责人决定。M1 未关闭,不声明模块数量、加载性能、 + 拆包或 R1 managed Turn 收益。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | diff --git a/tests/test_chat_status_public_compatibility.py b/tests/test_chat_status_public_compatibility.py new file mode 100644 index 0000000000..bf3737cafe --- /dev/null +++ b/tests/test_chat_status_public_compatibility.py @@ -0,0 +1,148 @@ +"""I3 protects historical imports and their live patch points, not copied exports.""" + +from __future__ import annotations + +import http.client +import importlib +import json +import subprocess +import sys +import threading +from types import SimpleNamespace + +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + + +@pytest.mark.parametrize("first_import", [ + "loopx.chat_status_api", "loopx.chat_server", "loopx.chat.todo_detail", +]) +def test_historical_status_imports_survive_import_order_reload_and_pickle(first_import): + # A fresh process avoids an earlier test silently choosing the import order. + code = """ +import importlib +import pickle +import sys +from types import SimpleNamespace + +importlib.import_module(sys.argv[1]) +public = importlib.import_module('loopx.chat_status_api') +from loopx.chat_status_api import ChatStatusRequestMixin, goal_read_runtime_root +from loopx.chat_server import ChatRequestHandler +from loopx.chat.todo_detail import goal_read_runtime_root as todo_runtime_root + +assert issubclass(ChatRequestHandler, ChatStatusRequestMixin) +assert todo_runtime_root is goal_read_runtime_root +assert pickle.loads(b'cloopx.chat_status_api\\nChatStatusRequestMixin\\n.') is ChatStatusRequestMixin +assert pickle.loads(b'cloopx.chat_status_api\\ngoal_read_runtime_root\\n.') is goal_read_runtime_root + +# Reload replaces definitions, but already-imported functions keep live globals. +assert importlib.reload(public) is public +assert importlib.import_module('loopx.chat_status_api') is public +public.load_project_registry = lambda _: {'common_runtime_root': 'source-owned'} +server = SimpleNamespace(registry_path=None, runtime_root_override='chat-decoy') +assert goal_read_runtime_root(server) is None +assert todo_runtime_root(server) is None +""" + result = subprocess.run( + [sys.executable, "-c", code, first_import], + capture_output=True, text=True, timeout=60, check=False, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("source_bound,controller_root,expected", [ + (True, "coordinator-decoy", None), + (False, "controller-runtime", "controller-runtime"), + (False, None, "chat-fallback"), +]) +def test_historical_registry_patch_reaches_already_imported_todo_consumer( + monkeypatch, tmp_path, source_bound, controller_root, expected, +): + from loopx.chat.todo_detail import goal_read_runtime_root + + registry_path = tmp_path / "registry.json" + calls = [] + + def registry(path): + calls.append(path) + return {"common_runtime_root": "source-runtime"} if source_bound else {} + + monkeypatch.setattr("loopx.chat_status_api.load_project_registry", registry) + server = SimpleNamespace( + registry_path=registry_path, + runtime_root_override="chat-fallback", + runtime_controller=SimpleNamespace(coordination_runtime_root=controller_root), + ) + assert goal_read_runtime_root(server) == expected + assert calls == [registry_path] + + +@pytest.mark.parametrize("failure", [FileNotFoundError, PermissionError, ValueError]) +def test_historical_discovery_patch_only_tolerates_missing_registry(monkeypatch, failure): + public = importlib.import_module("loopx.chat_status_api") + + def unreadable(_path): + raise failure("synthetic unavailable registry") + + monkeypatch.setattr("loopx.chat_status_api.load_project_registry", unreadable) + server = SimpleNamespace(registry_path=None, runtime_root_override=None) + if failure is FileNotFoundError: + assert public.goal_read_runtime_root(server) is None + else: + with pytest.raises(failure): + public.goal_read_runtime_root(server) + + +def test_historical_status_patch_points_reach_the_real_http_handler(monkeypatch, tmp_path): + registry = {"goals": [{"id": "alpha"}]} + registry_reads, collected = [], [] + + def load_registry(path): + registry_reads.append(path) + return registry + + def collect_status(**kwargs): + collected.append(kwargs) + return {"ok": True, "compatibility_probe": "collector"} + + def attach_activity(projection, **_kwargs): + projection["compatibility_activity"] = True + + def attach_delivery(projection, **_kwargs): + projection["compatibility_delivery"] = True + + monkeypatch.setattr("loopx.chat_status_api.load_registry", load_registry) + monkeypatch.setattr("loopx.chat_status_api.load_project_registry", lambda _: {}) + monkeypatch.setattr("loopx.chat_status_api.collect_status", collect_status) + monkeypatch.setattr("loopx.chat_status_api.attach_host_thread_activity", attach_activity) + monkeypatch.setattr("loopx.chat_status_api.attach_host_delivery_windows", attach_delivery) + monkeypatch.setattr("loopx.chat_status_api.codex_thread_observers", lambda **_: []) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = tmp_path / "registry.json" + server.runtime_root = tmp_path / "runtime" + server.runtime_root_override = None + server.selected_goal_id = None + server.scan_roots = [] + server.limit = 20 + server.verbose = False + server.goal_subagent_configuration_enabled = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection(*server.server_address, timeout=5) + try: + connection.request("GET", "/status.json?goal_id=alpha") + response = connection.getresponse() + payload = json.loads(response.read()) + assert response.status == 200, payload + assert payload["compatibility_probe"] == "collector" + assert payload["compatibility_activity"] is True + assert payload["compatibility_delivery"] is True + assert len(collected) == 1 and collected[0]["goal_id"] == "alpha" + assert registry_reads == [server.registry_path, server.registry_path] + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + server.server_close()