diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 4ce9d885b3..3c98b80fc0 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -47,6 +47,15 @@ request-scope adoption separately rather than counting its gain again. ### Product conversations and asynchronous inbox +**Chat HTTP POST scope.** `ChatRequestHandler.do_POST` now joins the existing +executable-revision request scope for each complete HTTP dispatch. Session +creation/resume and other POST routes reuse one code revision across their typed +calls; the next request resolves current code again. Session, workspace grant, +provider and authority observations remain live. Background Turn workers retain +their own scope. This adds no bridge operation, semantic owner or state cache. +Real HTTP and runtime regressions qualify source admission and revoked-grant +refusal separately from installed behavior and real-model response latency. + **Public quota read scope.** `quota/should_run.py` now reuses the existing executable-revision request scope for each complete public quota read. The compatibility facade and global views share that lifetime instead of resolving diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 9a6fde824b..203a5aac5c 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -43,6 +43,13 @@ guard、格式和 typed owner。这不新增领域规则、桥接操作或数据 ### 产品对话与通用异步 inbox +**Chat HTTP POST 作用域。** `ChatRequestHandler.do_POST` 现在为每次完整 HTTP +分发复用既有 executable-revision 请求作用域。Session 创建/恢复及其他 POST +路由的 typed 调用共用一个代码版本;下一次请求重新解析当前代码。Session、工作区 +授权、provider 与 authority 仍实时读取。后台 Turn worker 保留自己的作用域。 +这不新增桥接操作、语义 owner 或状态缓存。真实 HTTP 与 runtime 回归分别验证 +源码准入和撤销授权后的拒绝;安装后行为与真实模型响应延迟仍需独立验证。 + **公共 quota 读取作用域。** `quota/should_run.py` 现在为每次完整公共 quota 读取 复用既有 executable-revision 请求作用域。兼容 facade 和全局视图共用该生命周期, 不再在每次 typed 判定前重新解析代码树。已有外层作用域仍可组合;下一次独立读取 diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 0c8bcf2983..78abf218e2 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -64,6 +64,7 @@ project_chat_session_snapshot, ) from .control_plane.goals.active_state_metadata import active_state_section_text +from .control_plane.effect_runtime import effect_runtime_request_scope from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from .control_plane.status.ssh_host_catalog import ( SSH_HOST_CATALOG_PATH, @@ -1649,6 +1650,7 @@ def do_GET(self) -> None: return self._serve_asset(path) self._send_error("unknown path", status=404) + @effect_runtime_request_scope() def do_POST(self) -> None: if not self._require_loopback_origin(): return diff --git a/tests/test_chat_http_runtime_scope.py b/tests/test_chat_http_runtime_scope.py new file mode 100644 index 0000000000..8e342df45a --- /dev/null +++ b/tests/test_chat_http_runtime_scope.py @@ -0,0 +1,61 @@ +"""HTTP admission reuses code identity while grants and source reads stay fresh.""" + +import os +from pathlib import Path + +import pytest + +from test_chat_ordinary_project import ordinary # noqa: F401 +from test_lark_private_conversations import connect +from loopx.control_plane import effect_runtime + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_http_reuses_code_scan_but_rereads_revoked_grants(ordinary, monkeypatch, method): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + _, _, contexts, request, capture, _, workspace = ordinary + binding = transport._binding("notes-app") + session, _ = runtime.open_session( + goal_id=None, agent_id="codex", work_dir=workspace, objective="", + mode="resume_latest", conversation_binding_id=binding["binding_id"], + source_context={"source_ref": "a" * 24, "sender_ref": binding["operator_ref"], + "private_human_message": True}, + ) + path = "/api/chat/sessions" + body = None + if method == "GET": + path += "?conversation_binding_id=" + binding["binding_id"] + else: + body = {"context_kind": "project", "project_ref": contexts.available()[0]["project_ref"]} + # Warm the real source hash before counting filesystem traversals. + assert request(path, body)[0] in {200, 201} + source_root = Path(effect_runtime.__file__).parent + walk = os.walk + scans = [] + + def counted_walk(root, *args, **kwargs): + if Path(root) == source_root: + scans.append(root) + return walk(root, *args, **kwargs) + + monkeypatch.setattr(os, "walk", counted_walk) + try: + for _ in range(2): + scans.clear() + status, payload = request(path, body) + assert status == 200, payload + if method == "GET": + assert [row["session_id"] for row in payload["sessions"]] == [session["session_id"]] + else: + assert payload["resumed"] is True + assert payload["session"]["project_ref"] == body["project_ref"] + # A new HTTP request scans again; its typed calls share that scan. + assert len(scans) == 1 + before = store.list_sessions() + contexts.roots = [] + status, _ = request(path, body) + assert status == (409 if method == "GET" else 400) + assert store.list_sessions() == before + assert '"method": "turn/start"' not in capture.read_text() + finally: + runtime.close()