From 4a061e016810cc5f2ab1eca5c2de1ec7193ecf1f Mon Sep 17 00:00:00 2001 From: aihop <4408344+hhyykk@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:37:11 +0800 Subject: [PATCH 1/4] refactor(collaboration): project delegation inventory pages in TypeScript Signed-off-by: aihop <4408344+hhyykk@users.noreply.github.com> --- .../control_plane/collaboration/delegation.ts | 39 ++++++++++- .../collaboration/delegation_inventory.py | 23 ++----- loopx/control_plane/effect_runtime.py | 1 + .../control_plane/effect_runtime_handlers.ts | 2 +- loopx/control_plane/effect_runtime_server.ts | 1 + .../delegation_inventory.test.ts | 66 +++++++++++++++++++ tests/test_delegation_inventory.py | 55 ++++++++++++++-- tsconfig.control-plane.json | 1 + 8 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 tests/control_plane_ts/delegation_inventory.test.ts diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 0c0c5d5a34..f2869702ba 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -1,7 +1,7 @@ /** Explicit local execution bindings. Registration/messages alone grant no launch. * These are host observations; canonical task/Turn/acceptance remain authoritative. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject} from "../runtime_decode.ts"; +import {requireJsonObject, requireStringArray} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {acceptanceValidationEffects, goalAcceptanceTodoDigest, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; @@ -414,6 +414,43 @@ export function delegationInventoryItem(params: JsonObject): JsonObject { return result; } +function inventoryPageItem(raw: unknown, recordId: string): JsonObject { + const entry = requireJsonObject(raw, "delegation page observation"); + const record = requireJsonObject(entry.record, "delegation inventory record"); + requireThat(record.record_id === recordId, "delegation inventory page identity mismatch"); + try { + return delegationInventoryItem({record, observation: entry.observation}); + } catch (error) { + // A malformed current observation cannot hide healthy siblings or imply acceptance. + if (error instanceof EffectRuntimeRequestError || error instanceof TypeError) + return delegationInventoryItem({record, observation: null}); + throw error; + } +} + +/** The lookahead address proves another page exists; its operation is not read. */ +export function delegationInventoryPage(params: JsonObject): JsonObject { + const query = delegationInventoryQuery(requireJsonObject(params.query, "delegation inventory query")); + const limit = Number(query.limit); + const recordIds = requireStringArray(params.record_ids, "delegation inventory addresses"); + requireThat(recordIds.length <= limit + 1 && recordIds.every((id, index) => + BARE_SHA256_PATTERN.test(id) && (query.cursor === null || id > String(query.cursor)) + && (index === 0 || id > recordIds[index - 1]!)), "invalid delegation inventory page addresses"); + const observations = params.observations; + requireThat(Array.isArray(observations) && observations.length === Math.min(limit, recordIds.length), + "delegation inventory page observations must match its addresses"); + const items = observations.map((entry, index) => inventoryPageItem(entry, recordIds[index]!)); + const more = recordIds.length > limit; + return { + schema_version: "loopx_delegation_inventory_v0", items, has_more: more, + next_cursor: more ? recordIds[limit - 1]! : null, + page_readback_complete: items.every(item => item.status !== "unavailable"), + note: "Live requester-scoped page, not a snapshot or proof that the whole Goal is complete. " + + "Read original operations for full results; resume only when recovery is required. " + + "Restart paging to discover work added before the cursor. Listing starts no work.", + }; +} + export function transitionDelegationObservation(params: JsonObject): JsonObject { const from = params.from as Observation, to = params.to as Observation; requireThat(Object.hasOwn(transitions, from) && Object.hasOwn(transitions, to), "invalid delegation observation"); diff --git a/loopx/control_plane/collaboration/delegation_inventory.py b/loopx/control_plane/collaboration/delegation_inventory.py index 4fb2fc3a84..562bf3d42e 100644 --- a/loopx/control_plane/collaboration/delegation_inventory.py +++ b/loopx/control_plane/collaboration/delegation_inventory.py @@ -42,7 +42,7 @@ def addresses(): raise keys = heapq.nsmallest(query["limit"] + 1, addresses()) - items = [] + observations = [] for key in keys[:query["limit"]]: record = {"record_id": key, "operation_id": None} try: @@ -54,20 +54,9 @@ def addresses(): raise ValueError("delegation record identity mismatch") record["operation_id"] = operation_id observed = service.read(operation_id) - item = effect_runtime_result("collaboration.delegation.inventory_item", - {"record": record, "observation": observed}) except (OSError, ValueError, KeyError, TypeError, EffectRuntimeRemoteError): - # A corrupt/stale branch must not hide healthy siblings or be called accepted. - item = effect_runtime_result("collaboration.delegation.inventory_item", - {"record": record, "observation": None}) - items.append(item) - more = len(keys) > query["limit"] - return { - "schema_version": "loopx_delegation_inventory_v0", - "items": items, "has_more": more, - "next_cursor": keys[query["limit"] - 1] if more else None, - "page_readback_complete": all(item["status"] != "unavailable" for item in items), - "note": "Live requester-scoped page, not a snapshot or proof that the whole Goal is complete. " - "Read original operations for full results; resume only when recovery is required. " - "Restart paging to discover work added before the cursor. Listing starts no work.", - } + observed = None + observations.append({"record": record, "observation": observed}) + return effect_runtime_result("collaboration.delegation.inventory_page", { + "query": query, "record_ids": keys, "observations": observations, + }, large_local_snapshot=True) diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 16f8651c68..74a8803146 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -42,6 +42,7 @@ MAX_REQUEST_BYTES = 2 * 1024 * 1024 MAX_LOCAL_SNAPSHOT_BYTES = 64 * 1024 * 1024 LOCAL_SNAPSHOT_METHODS = frozenset({ + "collaboration.delegation.inventory_page", "coordination.local_authority.todo_source", "todo.context.page", "work_item.context.project", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 7e0b25b5d9..d31ab149ba 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -594,7 +594,7 @@ export function createEffectRuntimeHandlers( ["collaboration.delegation.checked_artifacts", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationCheckedArtifacts}) => delegationCheckedArtifacts)], ["collaboration.delegation.turn_plan", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationTurnPlanDecision}) => delegationTurnPlanDecision)], ["collaboration.delegation.inventory_query", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryQuery}) => delegationInventoryQuery)], - ["collaboration.delegation.inventory_item", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryItem}) => delegationInventoryItem)], + ["collaboration.delegation.inventory_page", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationInventoryPage}) => delegationInventoryPage)], ["collaboration.chat_mode", lazyHandler(() => import("./collaboration/chat_mode.ts"), ({planChatMode}) => planChatMode)], ["collaboration.goal_draft", lazyHandler(() => import("./collaboration/goal_draft.ts"), ({admitGoalDraft}) => (params) => ({draft: admitGoalDraft(params)}))], ["collaboration.conversation.trigger", lazyHandler(() => import("./collaboration/conversation_trigger.ts"), ({resolveConversationTrigger}) => resolveConversationTrigger)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 60aeb88f96..64ff064386 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -28,6 +28,7 @@ const MAX_REQUEST_BYTES = 2 * 1024 * 1024; const MAX_INLINE_RESPONSE_BYTES = 2 * 1024 * 1024; // Explicit opt-in: ordinary effects retain the 2 MiB request/response wire. const LOCAL_SNAPSHOT_METHODS = new Set([ + "collaboration.delegation.inventory_page", "coordination.local_authority.todo_source", "todo.context.page", "work_item.context.project", diff --git a/tests/control_plane_ts/delegation_inventory.test.ts b/tests/control_plane_ts/delegation_inventory.test.ts new file mode 100644 index 0000000000..7bd7c68452 --- /dev/null +++ b/tests/control_plane_ts/delegation_inventory.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {delegationInventoryPage} from "../../loopx/control_plane/collaboration/delegation.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; + +function inventoryPageFixture() { + const record_ids = ["a", "b", "c"].map(key => key.repeat(64)); + const observations = record_ids.slice(0, 2).map((record_id, index) => ({ + record: {record_id, operation_id: `review-${index}`}, + observation: {operation_id: `review-${index}`, request_id: `request-${index}`, + agent_id: "reviewer", todo_id: "todo_review", status: "prepared", + worker_active: false, recovery_required: true}, + })); + return {query: {limit: 2, cursor: null}, record_ids, observations}; +} + +test("inventory page uses an unread lookahead without skipping or inventing results", () => { + const request = inventoryPageFixture(); + for (const [count, more] of [[0, false], [1, false], [2, false], [3, true]] as const) { + const page = delegationInventoryPage({...request, record_ids: request.record_ids.slice(0, count), + observations: request.observations.slice(0, Math.min(count, 2))}); + assert.deepEqual((page.items as JsonObject[]).map(row => row.record_id), request.record_ids.slice(0, Math.min(count, 2))); + assert.equal(page.has_more, more); + assert.equal(page.next_cursor, more ? request.record_ids[1] : null); + assert.equal(page.page_readback_complete, true); + } +}); + +test("malformed observations stay unavailable without hiding a healthy sibling or the next page", () => { + const request = inventoryPageFixture(); + const invalid: (JsonObject | null)[] = [null, + {...request.observations[0]!.observation, operation_id: "other"}, + {...request.observations[0]!.observation, status: "not-an-observation"}, + {...request.observations[0]!.observation, worker_active: null}, + {...request.observations[0]!.observation, status: "accepted", artifacts: []}, + ]; + for (const observation of invalid) { + const page = delegationInventoryPage({...request, observations: [ + {...request.observations[0]!, observation}, request.observations[1]!, + ]}); + const rows = page.items as JsonObject[]; + assert.equal(rows[0]!.status, "unavailable"); + assert.equal(rows[0]!.operation_id, "review-0"); + assert.equal(rows[0]!.recovery_required, null); + assert.equal(rows[0]!.artifacts, undefined); + assert.equal(rows[1]!.status, "prepared"); + assert.equal(page.page_readback_complete, false); + assert.equal(page.has_more, true); + assert.equal(page.next_cursor, request.record_ids[1]); + } +}); + +test("inventory page refuses incomplete, reordered or out-of-cursor framing", () => { + const request = inventoryPageFixture(); + for (const patch of [ + {record_ids: ["invalid"]}, + {record_ids: [request.record_ids[0]!, request.record_ids[0]!] }, + {record_ids: [...request.record_ids].reverse()}, + {record_ids: [...request.record_ids, "d".repeat(64)]}, + {query: {limit: 2, cursor: request.record_ids[0]!}}, + {observations: request.observations.slice(0, 1)}, + {observations: [...request.observations].reverse()}, + {observations: [{...request.observations[0]!, + record: {...request.observations[0]!.record, operation_id: "../other"}}, request.observations[1]!] }, + ]) assert.throws(() => delegationInventoryPage({...request, ...patch})); +}); diff --git a/tests/test_delegation_inventory.py b/tests/test_delegation_inventory.py index 9ac402e4c4..ddad9e6ee1 100644 --- a/tests/test_delegation_inventory.py +++ b/tests/test_delegation_inventory.py @@ -1,9 +1,12 @@ """Journal recovery must preserve scope and expose incomplete readback honestly.""" import json +from hashlib import sha256 import pytest +from loopx.control_plane.collaboration import delegation_inventory from loopx.control_plane.collaboration.inbox import _read, _write +from loopx.control_plane.effect_runtime import effect_runtime_result from test_local_delegation import brief, service as delegation_service service = delegation_service @@ -16,10 +19,20 @@ def test_pages_find_all_original_operations_without_dispatch(service, monkeypatc for i in range(7): runner.start("analysis", f"work-{i}", brief()) starts.clear() + projection_requests = [] + real_projection = delegation_inventory.effect_runtime_result + + def traced_projection(method, *args, **kwargs): + projection_requests.append(method) + return real_projection(method, *args, **kwargs) + + monkeypatch.setattr(delegation_inventory, "effect_runtime_result", traced_projection) seen = [] cursor = None while True: + projection_requests.clear() page = runner.operations(limit=2, cursor=cursor) + assert len(projection_requests) <= 2 # One preflight and one page reduction across the RPC boundary. assert page["page_readback_complete"] and len(page["items"]) <= 2 seen.extend(row["operation_id"] for row in page["items"]) if not page["has_more"]: @@ -33,7 +46,9 @@ def test_pages_find_all_original_operations_without_dispatch(service, monkeypatc config = json.loads(runner.config.read_text()) config["bindings"] = [] runner.config.write_text(json.dumps(config)) + projection_requests.clear() page = runner.operations() + assert len(projection_requests) <= 2 assert not page["page_readback_complete"] and len(page["items"]) == 7 assert all(row["status"] == "unavailable" for row in page["items"]) @@ -41,7 +56,7 @@ def test_pages_find_all_original_operations_without_dispatch(service, monkeypatc def test_corruption_and_stopped_worker_do_not_hide_healthy_sibling(service, monkeypatch): _, runner = service monkeypatch.setattr(runner, "_spawn", lambda _: None) - for name in ["healthy", "stopped", "corrupt", "mismatched"]: + for name in ["healthy", "stopped", "corrupt", "mismatched", "malformed"]: runner.start("analysis", name, brief()) row = _read(runner.path("stopped")) row["created_at"] = 0 @@ -50,12 +65,16 @@ def test_corruption_and_stopped_worker_do_not_hide_healthy_sibling(service, monk row = _read(runner.path("mismatched")) row["identity"]["operation_id"] = "healthy" _write(runner.path("mismatched"), row) + row = _read(runner.path("malformed")) + row["status"] = "not-an-observation" + _write(runner.path("malformed"), row) page = runner.operations() by_id = {row["operation_id"]: row for row in page["items"] if row["operation_id"]} assert by_id["healthy"]["status"] == "prepared" assert by_id["stopped"]["recovery_required"] - assert len(page["items"]) == 4 and not page["page_readback_complete"] - assert sum(row["status"] == "unavailable" for row in page["items"]) == 2 + assert len(page["items"]) == 5 and not page["page_readback_complete"] + assert by_id["malformed"]["status"] == "unavailable" + assert sum(row["status"] == "unavailable" for row in page["items"]) == 3 # A stop receipt beside its record is not another record and reads back as stopped. assert runner.stop("healthy", execute=True)["phase"] == "settled" assert runner._stop_path(runner.path("healthy")).exists() @@ -63,7 +82,7 @@ def test_corruption_and_stopped_worker_do_not_hide_healthy_sibling(service, monk runner._host_process_record(runner.path("healthy")).write_text("{}") page = runner.operations() by_id = {row["operation_id"]: row for row in page["items"] if row["operation_id"]} - assert len(page["items"]) == 4 and by_id["healthy"]["status"] == "stopped" + assert len(page["items"]) == 5 and by_id["healthy"]["status"] == "stopped" assert not by_id["healthy"]["recovery_required"] @@ -84,3 +103,31 @@ def denied(path): monkeypatch.setattr(type(directory), "iterdir", denied) with pytest.raises(PermissionError): runner.operations() + + +def test_full_page_preserves_accepted_references_above_inline_transport_budget(service): + record_ids = sorted(sha256(str(index).encode()).hexdigest() for index in range(50)) + observations = [{ + "record": {"record_id": record_id, "operation_id": f"result-{index}"}, + "observation": { + "operation_id": f"result-{index}", "request_id": f"request-{index}", + "agent_id": "worker", "todo_id": f"todo_{index}", "status": "accepted", + "worker_active": False, "recovery_required": False, + "artifacts": [{"ref": f"output-{index}.json", "sha256": "a" * 64, + "text": "private body " * 4000}], + }, + } for index, record_id in enumerate(record_ids)] + request = {"query": {"limit": 50, "cursor": None}, "record_ids": record_ids, + "observations": observations} + assert len(json.dumps(request).encode()) > 2 * 1024 * 1024 + + page = effect_runtime_result("collaboration.delegation.inventory_page", request, + large_local_snapshot=True) + + assert page["page_readback_complete"] and not page["has_more"] + assert page["next_cursor"] is None + assert [row["record_id"] for row in page["items"]] == record_ids + assert all(row["status"] == "accepted" for row in page["items"]) + assert [row["artifacts"] for row in page["items"]] == [ + [{"ref": f"output-{index}.json", "sha256": "a" * 64}] for index in range(50) + ] diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index a009df79e7..992f06061e 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -39,6 +39,7 @@ "tests/control_plane_ts/goal_workspace_digest.test.ts", "tests/control_plane_ts/interaction_contract.test.ts", "tests/control_plane_ts/runtime_decode.test.ts", + "tests/control_plane_ts/delegation_inventory.test.ts", "tests/control_plane_ts/authority_store.test.ts", "tests/control_plane_ts/authority_state_log.test.ts", "tests/control_plane_ts/sqlite_authority_store.test.ts", From bd465fc1c9abf031e370439c0c128e3942994039 Mon Sep 17 00:00:00 2001 From: aihop <4408344+hhyykk@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:37:11 +0800 Subject: [PATCH 2/4] docs(architecture): record delegation inventory page cutover Signed-off-by: aihop <4408344+hhyykk@users.noreply.github.com> --- .../rfcs/typescript-control-plane-migration-v0.md | 11 +++++++++++ .../typescript-control-plane-migration-v0.zh-CN.md | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index c056cf2fc6..6ae2f6ff9c 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -28,6 +28,17 @@ identity. No second inbox store, per-field bridge expansion or full rewrite is required for the first App outcome. These are planned product consumers of T0–T4, not additional provider promotion or completed migration claims. +Requester-scoped delegation inventory now composes page readback in +`collaboration/delegation.ts`. Python retains bounded address scanning, requester +registration and fresh `Delegations.read` IO, including canonical acceptance and +artifact checks. TypeScript validates page order and item observations, selects +the next cursor and reports completeness. The per-item projection RPC and Python +page decisions are retired: query admission and final page projection use two +requests per page; provider reads and validation calls remain. The existing +private snapshot transport carries complete pages above the inline wire budget. +CLI, MCP and Goal Chat keep their existing live-page contract. Async lifecycle +cutover and receiver-adoption qualification remain separate. + 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 9b76cb9208..09577b2417 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 @@ -26,6 +26,14 @@ R1–R3 的 TS 消费者包括 App 产品路径,不只 CLI 结算。 首个 App 结果不需要第二份 inbox 存储、逐字段 RPC 或全量重写。 这里是 T0–T4 的产品消费计划,不新增 provider promotion,也不声称迁移完成。 +请求者范围内的委派清单现在由 `collaboration/delegation.ts` 一次组装整页读回。 +Python 保留有界地址扫描、请求者注册检查和实时 `Delegations.read` IO, +包括 canonical 验收与产物检查。TS 校验页内顺序和逐项观察,选择下一游标并报告 +完整性。逐项投影 RPC 与 Python 页级决定已退役:查询准入和整页投影每页共两次 +请求;provider 读取与验证调用仍保留。超过内联传输预算的完整页复用既有私有 +快照传输。CLI、MCP 与 Goal Chat 保持原有实时分页契约。异步生命周期切换和 +接收方采用资格仍需单独验收。 + From 7bc2849f5d93ae1ba488fdc33461e92f37d5a4cd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:32:48 +0800 Subject: [PATCH 3/4] fix(collaboration): seal delegation inventory page readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 4 ++ ...script-control-plane-migration-v0.zh-CN.md | 3 + .../contracts/projection-envelope-contract.md | 10 ++++ loopx/collaboration_mcp.py | 2 + .../control_plane/collaboration/delegation.ts | 25 ++++++++- .../collaboration/delegation_inventory.py | 10 ++++ .../delegation_inventory.test.ts | 35 +++++++++++- tests/test_chat_loopx_mode.py | 2 + tests/test_delegation_inventory.py | 55 ++++++++++++++++++- tests/test_local_delegation.py | 2 + 10 files changed, 143 insertions(+), 5 deletions(-) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 6b6a4eb4fd..11a585b6fa 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -38,6 +38,10 @@ requests per page; provider reads and validation calls remain. The existing private snapshot transport carries complete pages above the inline wire budget. CLI, MCP and Goal Chat keep their existing live-page contract. Async lifecycle cutover and receiver-adoption qualification remain separate. +The same page reduction now seals actual directory/operation read times through +the existing `projection_envelope.ts` owner. Completeness is scoped to +`requester_page`; unreadable operations alert, while shown/available counts +disclose remaining pages. Replay preserves the original observation time. 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 44e86c629a..a703632707 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 @@ -33,6 +33,9 @@ Python 保留有界地址扫描、请求者注册检查和实时 `Delegations.re 请求;provider 读取与验证调用仍保留。超过内联传输预算的完整页复用既有私有 快照传输。CLI、MCP 与 Goal Chat 保持原有实时分页契约。异步生命周期切换和 接收方采用资格仍需单独验收。 +同一次整页投影现经既有 `projection_envelope.ts` owner 封装真实目录与操作读取 +时间。完整性仅限 `requester_page`;不可读操作触发告警,展示/可用数量说明 +剩余页,重放保留原始观察时间。 diff --git a/docs/reference/contracts/projection-envelope-contract.md b/docs/reference/contracts/projection-envelope-contract.md index 80799f6848..6f3fe52aea 100644 --- a/docs/reference/contracts/projection-envelope-contract.md +++ b/docs/reference/contracts/projection-envelope-contract.md @@ -23,6 +23,16 @@ Python-owned projections do so through `loopx/control_plane/projection_envelope_ | `loopx global-gates` | `global_gates` | `global` | | `loopx global-todos` | `global_todos` | `global` | | `loopx global-risks` | `global_risks` | `global` | +| Delegation operations (CLI, MCP, Goal Chat) | `delegation_inventory` | `requester_page` | + +The delegation inventory seals directory and operation read facts in its existing +native page reduction. A complete `requester_page` certifies only the requested +page: `has_more`, `next_cursor` and the shown/available counts disclose the +remaining requester inventory, not whole-Goal completion. Unavailable operations +remain visible and count as omissions with unreadable-source alerts. A missing +journal is a freshly observed empty inventory; a denied directory read still +fails rather than returning an apparently complete empty page. No cache or +cross-page snapshot is introduced; restart paging to discover earlier additions. Other read models adopt the envelope in the order listed in the RFC; until then they carry no freshness guarantee and consumer rule 1 below applies. diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 6994599d72..141999db06 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -1861,6 +1861,8 @@ def list_delegations(limit: int = 20, cursor: str | None = None) -> dict: Accepted items are rechecked; unavailable requires reconciliation, not duplicate dispatch. Read the original operation for full artifacts. Listing starts no work. + Read projection_envelope before claiming current or complete state: its scope + is this requester page, and alerts and omitted operations must be disclosed. """ return delegations.operations(limit=limit, cursor=cursor) diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index f2869702ba..718fb929c3 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -1,13 +1,14 @@ /** Explicit local execution bindings. Registration/messages alone grant no launch. * These are host observations; canonical task/Turn/acceptance remain authoritative. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireStringArray} from "../runtime_decode.ts"; +import {requireInteger, requireJsonObject, requireStringArray} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {acceptanceValidationEffects, goalAcceptanceTodoDigest, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; import {readTurnSelectionRejection, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts"; import { BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; +import {PROJECTION_ENVELOPE_SEAL_REQUEST, sealProjectionEnvelope} from "../projection_envelope.ts"; function requireThat(ok: unknown, message: string): asserts ok { if (!ok) throw new EffectRuntimeRequestError(message); @@ -440,11 +441,31 @@ export function delegationInventoryPage(params: JsonObject): JsonObject { requireThat(Array.isArray(observations) && observations.length === Math.min(limit, recordIds.length), "delegation inventory page observations must match its addresses"); const items = observations.map((entry, index) => inventoryPageItem(entry, recordIds[index]!)); + const facts = requireJsonObject(params.read_facts, "delegation inventory read facts"); + const available = requireInteger(facts.available_count, "delegation inventory available_count"); + requireThat(recordIds.length === Math.min(available, limit + 1), + "delegation inventory count does not match its page addresses"); + const unavailable = items.filter(item => item.status === "unavailable").length; + const projectionEnvelope = sealProjectionEnvelope({ + schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, projection: "delegation_inventory", + observed_at: facts.observed_at, + sources: [ + {source_id: "delegation_directory", read_status: "read", last_read_at: facts.directory_read_at, + item_count: available}, + {source_id: "delegation_operations", read_status: "read", last_read_at: facts.operations_read_at, + item_count: items.length, unreadable_count: unavailable}, + ], + coverage: {scope: "requester_page", expected_count: items.length, + included_count: items.length - unavailable, + omitted: unavailable ? [{reason: "unavailable_operations", count: unavailable}] : [], + shown_count: items.length, available_count: available}, + }); const more = recordIds.length > limit; return { schema_version: "loopx_delegation_inventory_v0", items, has_more: more, next_cursor: more ? recordIds[limit - 1]! : null, - page_readback_complete: items.every(item => item.status !== "unavailable"), + page_readback_complete: unavailable === 0, + projection_envelope: projectionEnvelope, note: "Live requester-scoped page, not a snapshot or proof that the whole Goal is complete. " + "Read original operations for full results; resume only when recovery is required. " + "Restart paging to discover work added before the cursor. Listing starts no work.", diff --git a/loopx/control_plane/collaboration/delegation_inventory.py b/loopx/control_plane/collaboration/delegation_inventory.py index 562bf3d42e..d76a40a792 100644 --- a/loopx/control_plane/collaboration/delegation_inventory.py +++ b/loopx/control_plane/collaboration/delegation_inventory.py @@ -8,6 +8,7 @@ from .inbox import _read from .peers import _goal, require_operation_id from ..content_digest import BARE_SHA256_PATTERN +from ..runtime.time import now_utc_iso if TYPE_CHECKING: from ...collaboration_mcp import Delegations @@ -25,8 +26,10 @@ def read_delegation_inventory(service: Delegations, *, limit: int = 20, {"limit": limit, "cursor": cursor}) _goal(service.registry, service.goal_id, service.agent_id) directory = service.path("inventory").parent + available_count = 0 def addresses(): + nonlocal available_count try: entries = directory.iterdir() for path in entries: @@ -35,6 +38,7 @@ def addresses(): if not BARE_SHA256_PATTERN.fullmatch(path.stem): raise ValueError("unexpected delegation record address; reconcile inventory storage") if query["cursor"] is None or path.stem > query["cursor"]: + available_count += 1 yield path.stem except FileNotFoundError: # No journal yet is a valid empty inventory; other IO errors propagate. @@ -42,6 +46,9 @@ def addresses(): raise keys = heapq.nsmallest(query["limit"] + 1, addresses()) + directory_read_at = now_utc_iso() + # Bound the aggregate by its earliest read, never by final page assembly. + operations_read_at = now_utc_iso() if keys else directory_read_at observations = [] for key in keys[:query["limit"]]: record = {"record_id": key, "operation_id": None} @@ -59,4 +66,7 @@ def addresses(): observations.append({"record": record, "observation": observed}) return effect_runtime_result("collaboration.delegation.inventory_page", { "query": query, "record_ids": keys, "observations": observations, + "read_facts": {"observed_at": now_utc_iso(), "directory_read_at": directory_read_at, + "operations_read_at": operations_read_at, + "available_count": available_count}, }, large_local_snapshot=True) diff --git a/tests/control_plane_ts/delegation_inventory.test.ts b/tests/control_plane_ts/delegation_inventory.test.ts index 7bd7c68452..2ed8183138 100644 --- a/tests/control_plane_ts/delegation_inventory.test.ts +++ b/tests/control_plane_ts/delegation_inventory.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import {delegationInventoryPage} from "../../loopx/control_plane/collaboration/delegation.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {PROJECTION_ENVELOPE_SERVE_REQUEST, sealProjectionEnvelope} from "../../loopx/control_plane/projection_envelope.ts"; function inventoryPageFixture() { const record_ids = ["a", "b", "c"].map(key => key.repeat(64)); @@ -11,14 +12,17 @@ function inventoryPageFixture() { agent_id: "reviewer", todo_id: "todo_review", status: "prepared", worker_active: false, recovery_required: true}, })); - return {query: {limit: 2, cursor: null}, record_ids, observations}; + const read_facts = {observed_at: "2026-10-01T10:00:02Z", directory_read_at: "2026-10-01T10:00:00Z", + operations_read_at: "2026-10-01T10:00:01Z", available_count: 3}; + return {query: {limit: 2, cursor: null}, record_ids, observations, read_facts}; } test("inventory page uses an unread lookahead without skipping or inventing results", () => { const request = inventoryPageFixture(); for (const [count, more] of [[0, false], [1, false], [2, false], [3, true]] as const) { const page = delegationInventoryPage({...request, record_ids: request.record_ids.slice(0, count), - observations: request.observations.slice(0, Math.min(count, 2))}); + observations: request.observations.slice(0, Math.min(count, 2)), + read_facts: {...request.read_facts, available_count: count}}); assert.deepEqual((page.items as JsonObject[]).map(row => row.record_id), request.record_ids.slice(0, Math.min(count, 2))); assert.equal(page.has_more, more); assert.equal(page.next_cursor, more ? request.record_ids[1] : null); @@ -26,6 +30,31 @@ test("inventory page uses an unread lookahead without skipping or inventing resu } }); +test("inventory envelopes distinguish complete page, oldest read, unreadable and later replay", () => { + const request = inventoryPageFixture(); + const page = delegationInventoryPage(request); + const envelope = page.projection_envelope as JsonObject; + assert.equal(envelope.complete, true); + assert.equal(envelope.fresh, true); + assert.deepEqual(envelope.coverage, {scope: "requester_page", expected_count: 2, + included_count: 2, omitted: [], shown_count: 2, available_count: 3, truncated: true, complete: true}); + const replay = sealProjectionEnvelope({schema_version: PROJECTION_ENVELOPE_SERVE_REQUEST, + envelope, served_at: "2026-10-01T10:06:00Z"}); + assert.equal(replay.observed_at, envelope.observed_at); + assert.equal(replay.served_at, "2026-10-01T10:06:00Z"); + assert.equal(replay.fresh, false); + assert.equal(replay.served_from_cache, true); + assert.deepEqual(replay.alert_reasons, ["stale_sources"]); + const slow = delegationInventoryPage({...request, + read_facts: {...request.read_facts, observed_at: "2026-10-01T10:06:00Z"}}).projection_envelope as JsonObject; + assert.equal(slow.fresh, false); // Assembly time cannot refresh earlier source reads. + const damaged = delegationInventoryPage({...request, observations: [ + {...request.observations[0]!, observation: null}, request.observations[1]!], + }).projection_envelope as JsonObject; + assert.equal(damaged.complete, false); + assert.deepEqual(damaged.alert_reasons, ["incomplete_coverage", "unreadable_sources"]); +}); + test("malformed observations stay unavailable without hiding a healthy sibling or the next page", () => { const request = inventoryPageFixture(); const invalid: (JsonObject | null)[] = [null, @@ -60,6 +89,8 @@ test("inventory page refuses incomplete, reordered or out-of-cursor framing", () {query: {limit: 2, cursor: request.record_ids[0]!}}, {observations: request.observations.slice(0, 1)}, {observations: [...request.observations].reverse()}, + {read_facts: null}, + {read_facts: {...request.read_facts, available_count: 1}}, {observations: [{...request.observations[0]!, record: {...request.observations[0]!.record, operation_id: "../other"}}, request.observations[1]!] }, ]) assert.throws(() => delegationInventoryPage({...request, ...patch})); diff --git a/tests/test_chat_loopx_mode.py b/tests/test_chat_loopx_mode.py index 0b0083e18e..270c387811 100644 --- a/tests/test_chat_loopx_mode.py +++ b/tests/test_chat_loopx_mode.py @@ -353,6 +353,7 @@ def test_pause_fences_dispatch_and_does_not_cancel_members(mode, monkeypatch): inventory = adapter.session.read_tool_handler(TOOL["name"], {"action": "operations"}) assert inventory["ok"] and inventory["items"] == [] assert inventory["page_readback_complete"] and not inventory["has_more"] + assert inventory["projection_envelope"]["fresh"] and inventory["projection_envelope"]["complete"] invalid = adapter.session.read_tool_handler(TOOL["name"], {"action": "operations", "operation_id": "one"}) assert invalid["error"] == "collaboration_request_rejected" @@ -555,6 +556,7 @@ def test_owner_team_readback_is_configured_scoped_and_does_not_start_a_turn(mode before = service.store.load_session(sid) result = service.read_team(sid, {"operation": "operations"}) assert result["items"] == [] and result["page_readback_complete"] + assert result["projection_envelope"]["coverage"]["scope"] == "requester_page" assert service.store.load_session(sid) == before assert calls == [] with pytest.raises(ValueError, match="invalid team readback"): diff --git a/tests/test_delegation_inventory.py b/tests/test_delegation_inventory.py index ddad9e6ee1..2f30a5090d 100644 --- a/tests/test_delegation_inventory.py +++ b/tests/test_delegation_inventory.py @@ -12,6 +12,56 @@ service = delegation_service +def test_cli_inventory_discloses_page_scope_and_unreadable_operations(service, monkeypatch): + from test_delegation_cli import cli + + root, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _: None) + for name in ["first", "second", "third", "fourth", "fifth"]: + runner.start("analysis", name, brief()) + before = {path.name: path.read_bytes() for path in runner.path("inventory").parent.glob("*.json")} + + status, page = cli(runner, "operations", "--limit", "2") + assert status == 0 and page["has_more"] + envelope = page["projection_envelope"] + assert envelope["fresh"] and envelope["complete"] and not envelope["alert"] + assert envelope["coverage"]["scope"] == "requester_page" + assert envelope["coverage"]["shown_count"] == 2 + assert envelope["coverage"]["available_count"] == 5 + assert envelope["coverage"]["truncated"] # Complete page is not the whole inventory. + # Lock-holder diagnostics change on reads; the operation records must not. + assert {path.name: path.read_bytes() for path in runner.path("inventory").parent.glob("*.json")} == before + + runner.path(page["items"][0]["operation_id"]).write_text("{") + status, damaged = cli(runner, "operations", "--limit", "2") + assert status == 0 and len(damaged["items"]) == 2 + assert not damaged["page_readback_complete"] + envelope = damaged["projection_envelope"] + assert not envelope["fresh"] and not envelope["complete"] and envelope["alert"] + assert envelope["alert_reasons"] == ["incomplete_coverage", "unreadable_sources"] + assert envelope["coverage"]["omitted"] == [{"reason": "unavailable_operations", "count": 1, "refs": []}] + assert not (root / "host-started").exists() + + +def test_inventory_source_read_times_survive_assembly_and_replay(service, monkeypatch): + _, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _: None) + runner.start("analysis", "slow-read", brief()) + times = iter(["2026-10-01T10:00:00Z", "2026-10-01T10:00:01Z", "2026-10-01T10:06:00Z"]) + monkeypatch.setattr(delegation_inventory, "now_utc_iso", lambda: next(times)) + envelope = runner.operations()["projection_envelope"] + assert not envelope["fresh"] and envelope["complete"] + assert envelope["alert_reasons"] == ["stale_sources"] + assert [row["last_read_at"] for row in envelope["sources"]] == [ + "2026-10-01T10:00:00Z", "2026-10-01T10:00:01Z"] + replay = effect_runtime_result("projection.envelope.seal", { + "schema_version": "loopx_projection_envelope_serve_request_v0", "envelope": envelope, + "served_at": "2026-10-01T10:07:00Z"}) + assert replay["observed_at"] == envelope["observed_at"] + assert replay["served_at"] == "2026-10-01T10:07:00Z" + assert not replay["fresh"] and replay["age_seconds"] == 60 + + def test_pages_find_all_original_operations_without_dispatch(service, monkeypatch): root, runner = service starts = [] @@ -118,7 +168,10 @@ def test_full_page_preserves_accepted_references_above_inline_transport_budget(s }, } for index, record_id in enumerate(record_ids)] request = {"query": {"limit": 50, "cursor": None}, "record_ids": record_ids, - "observations": observations} + "observations": observations, + "read_facts": {"observed_at": "2026-10-01T10:00:02Z", + "directory_read_at": "2026-10-01T10:00:00Z", + "operations_read_at": "2026-10-01T10:00:01Z", "available_count": 50}} assert len(json.dumps(request).encode()) > 2 * 1024 * 1024 page = effect_runtime_result("collaboration.delegation.inventory_page", request, diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 05887582f2..fa602e7ba5 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -393,6 +393,7 @@ async def disconnect_requester(): assert not (root / "host-started").exists() inventory = await session.call_tool("list_delegations", {}) assert not inventory.isError and json.loads(inventory.content[0].text)["items"] == [] + assert json.loads(inventory.content[0].text)["projection_envelope"]["complete"] assert "stop_delegation" in {tool.name for tool in (await session.list_tools()).tools} result = await session.call_tool("start_delegation", { "binding_id": "analysis", "operation_id": "analysis-1", "brief": brief()}) @@ -400,6 +401,7 @@ async def disconnect_requester(): inventory = await session.call_tool("list_delegations", {}) assert not inventory.isError assert json.loads(inventory.content[0].text)["items"][0]["operation_id"] == "analysis-1" + assert json.loads(inventory.content[0].text)["projection_envelope"]["fresh"] return json.loads(result.content[0].text) # Exiting the real stdio session closes the requesting MCP process. first = asyncio.run(disconnect_requester()) From fa8d37ea978b2a744a3ef325f8b289aaa9b83304 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:48:36 +0800 Subject: [PATCH 4/4] perf: pin one executable revision per delegation inventory page Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 2 ++ ...script-control-plane-migration-v0.zh-CN.md | 1 + loopx/collaboration_mcp.py | 5 ++- ...test_delegation_preflight_request_scope.py | 33 ++++++++++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 11a585b6fa..48e1f66497 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -42,6 +42,8 @@ The same page reduction now seals actual directory/operation read times through the existing `projection_envelope.ts` owner. Completeness is scoped to `requester_page`; unreadable operations alert, while shown/available counts disclose remaining pages. Replay preserves the original observation time. +Each page reuses the existing executable-revision request scope; operation +records remain live reads, and the next page resolves the current code again. 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 a703632707..7eba6ba671 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 @@ -36,6 +36,7 @@ Python 保留有界地址扫描、请求者注册检查和实时 `Delegations.re 同一次整页投影现经既有 `projection_envelope.ts` owner 封装真实目录与操作读取 时间。完整性仅限 `requester_page`;不可读操作触发告警,展示/可用数量说明 剩余页,重放保留原始观察时间。 +每页复用既有代码版本请求作用域;操作记录仍实时读取,下一页重新解析当前代码。 diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 141999db06..a850904a07 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -538,7 +538,10 @@ def _read_stop(path: Path) -> dict | None: def operations(self, *, limit: int = 20, cursor: str | None = None) -> dict: from .control_plane.collaboration.delegation_inventory import read_delegation_inventory - return read_delegation_inventory(self, limit=limit, cursor=cursor) + # One page uses one executable revision; records are still read live, + # and the next page resolves its own current revision. + with effect_runtime_request_scope(): + return read_delegation_inventory(self, limit=limit, cursor=cursor) def inspect(self, binding_id: str) -> dict: """Observe the real Turn preflight; never create a request or run a host.""" diff --git a/tests/control_plane/test_delegation_preflight_request_scope.py b/tests/control_plane/test_delegation_preflight_request_scope.py index 23631aecf7..a4c16a57f2 100644 --- a/tests/control_plane/test_delegation_preflight_request_scope.py +++ b/tests/control_plane/test_delegation_preflight_request_scope.py @@ -6,11 +6,42 @@ import pytest from loopx.control_plane import effect_runtime -from test_local_delegation import service as delegation_service +from test_local_delegation import brief, service as delegation_service service = delegation_service +def test_each_inventory_page_pins_once_and_reads_current_records(service, monkeypatch): + root, runner = service + monkeypatch.setattr(runner, "_spawn", lambda _: None) + for name in ["first", "second", "third"]: + runner.start("analysis", name, brief()) + original = effect_runtime._runtime_fingerprint + revisions = [] + + def measured_fingerprint(): + revision = original() + revisions.append(revision) + return revision + + monkeypatch.setattr(effect_runtime, "_runtime_fingerprint", measured_fingerprint) + first = runner.operations(limit=2) + assert len(revisions) == 1 + assert first["page_readback_complete"] and first["has_more"] + second = runner.operations(limit=2, cursor=first["next_cursor"]) + assert len(revisions) == 2 + assert second["page_readback_complete"] and len(second["items"]) == 1 + + # A page pins executable source, never a previous operation observation. + runner.path(first["items"][0]["operation_id"]).write_text("{") + damaged = runner.operations(limit=2) + assert len(revisions) == 3 + assert not damaged["page_readback_complete"] + assert not damaged["projection_envelope"]["complete"] + assert len(damaged["items"]) == 2 + assert not (root / "host-started").exists() + + def test_each_inspection_pins_once_and_reads_current_binding(service, monkeypatch): root, runner = service original = effect_runtime._runtime_fingerprint