From 6dce03a07ad360c743592298e5af211b2409074d Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 00:31:29 -0700 Subject: [PATCH 01/27] feat(goal-channel): compose revision-guarded local room work Signed-off-by: Lihua <1017343802@qq.com> --- loopx/cli_commands/goal_channel.py | 28 +++ .../coordination/local_authority.py | 3 + .../coordination/local_authority_runtime.ts | 3 + .../control_plane/coordination/todo_claim.ts | 4 + .../control_plane/effect_runtime_handlers.ts | 2 + .../goals/room_work_projection.ts | 48 +++++ loopx/extensions/lark/goal_channel_work.py | 179 ++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 loopx/control_plane/goals/room_work_projection.ts create mode 100644 loopx/extensions/lark/goal_channel_work.py diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index 0644d58d6e..786d3be929 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -212,6 +212,19 @@ def register_goal_channel_commands( _add_common_args, ) + work = sub.add_parser("work", help="Publish canonical room orientation or claim through the local Agent CLI.") + work_sub = work.add_subparsers(dest="goal_channel_work_command", required=True) + for work_command in ("project", "claim"): + work_parser = work_sub.add_parser(work_command) + add_subcommand_format(work_parser) + _add_common_args(work_parser) + work_parser.add_argument("--agent-id", required=True) + work_parser.add_argument("--execute", action="store_true") + if work_command == "claim": + work_parser.add_argument("--todo-id", required=True) + work_parser.add_argument("--expected-revision", required=True) + work_parser.add_argument("--idempotency-key", required=True) + register_goal_channel_runtime_commands(sub, add_subcommand_format) @@ -446,6 +459,21 @@ def handle_goal_channel_command( runtime_root_arg, registry_path=registry_path, ) + if command == "work": + from ..extensions.lark.goal_channel_work import run_goal_channel_work + + assert goal_id is not None + _, source_registry_path, work_binding_path, source_runtime_root = _source_context( + registry=registry, registry_path=registry_path, goal_id=goal_id, + binding_path_arg=getattr(args, "binding_path", None)) + payload = run_goal_channel_work(registry_path=source_registry_path, + runtime_root=source_runtime_root, binding_path=work_binding_path, + target_path=_target_path(args, runtime_root), goal_id=goal_id, actor_id=args.agent_id, + command=args.goal_channel_work_command, execute=execute, + todo_id=getattr(args, "todo_id", None), expected_revision=getattr(args, "expected_revision", None), + idempotency_key=getattr(args, "idempotency_key", None)) + print_payload(payload, output_format(args), render_goal_channel_markdown) + return 0 if payload.get("ok") else 1 if command == "runtime": assert goal_id is not None source_registry, source_registry_path, _, _ = _source_context( diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index 3d6c6587d6..5181a3b192 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -96,6 +96,7 @@ def claim_canonical_todo_if_promoted( actor_agent_id: str | None, dry_run: bool, operation_id: str | None = None, + expected_provider_revision: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, project: Path | None = None, @@ -124,6 +125,8 @@ def claim_canonical_todo_if_promoted( if operation_id is not None else f"todo-claim:{goal_id}:{todo_id}:{uuid4().hex}" ), + **({"expected_provider_revision": expected_provider_revision} + if expected_provider_revision is not None else {}), "lease_request": ( { "idempotency_key": task_lease_idempotency_key, diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 60a53f4b15..1d5cfeb260 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1062,6 +1062,9 @@ export async function claimLocalCoordinationTodo( : requireAuthorityStoreId(input.role, "role"), registered_agents: registeredAgents, operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + ...(input.expected_provider_revision === undefined ? {} : { + expected_provider_revision: requireAuthorityStoreId(input.expected_provider_revision, "expected provider revision"), + }), lease_request: leaseRequest, dry_run: input.dry_run === true, now: claimObservedAt(input.observed_at), diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index a906a1c2b8..b1ea930a95 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -189,6 +189,10 @@ function rejectIneligibleTodo( { requested_role: input.expected_role, todo_role: todo.role }, ); } + if (todo.bound_agent != null && todo.bound_agent !== "" && + normalizeTodoAgent(todo.bound_agent, "todo.bound_agent") !== input.claimed_by) { + return decisionFailure("bound_agent_mismatch", "Todo is bound to another agent"); + } if (todo.status !== "open") { return decisionFailure( "todo_not_open", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index ef844d79c6..e750ccf278 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,3 +1,4 @@ +import {projectRoomWork} from "./goals/room_work_projection.ts"; import {projectDecisionNotice} from "./presentation/decision_notice.ts"; import {projectTodoSummary} from "./todos/summary_projection.ts"; import {admitAutomationStart, confirmAutomationStart, manageAutomationCadence, projectCadenceSchedule} from "./quota/automation_cadence.ts"; @@ -581,6 +582,7 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.promotion_review", withCoordinationSourceTransfer("coordination.local_authority.promotion_review", reviewLocalCoordinationAuthorityPromotion)], ["coordination.local_authority.promotion_reviewed", executeReviewedCoordinationPromotion], ["coordination.local_authority.todo_continuation", continueLocalTodo], + ["goal_channel.work.project", projectRoomWork], ["coordination.local_authority.todo_claim", claimLocalCoordinationTodo], ["coordination.local_authority.todo_create", createLocalCoordinationTodo], ["work_items.team_plan.preview", previewTeamPlan], diff --git a/loopx/control_plane/goals/room_work_projection.ts b/loopx/control_plane/goals/room_work_projection.ts new file mode 100644 index 0000000000..6ffe69434d --- /dev/null +++ b/loopx/control_plane/goals/room_work_projection.ts @@ -0,0 +1,48 @@ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; +import {evaluateCoordinationTodoClaimDecision} from "../coordination/todo_claim.ts"; + +/** Content-minimal room orientation. Claim admission remains in the Todo owner. */ +export function projectRoomWork(value: unknown): JsonObject { + const input = requireJsonObject(value, "room work projection"); + const goal = requireNonEmptyString(input.goal_id, "goal_id"); + const actor = requireNonEmptyString(input.actor_id, "actor_id"); + const registered = requireStringArray(input.registered_agents, "registered_agents"); + if (!registered.includes(actor)) throw new Error("room actor is not registered"); + const snapshot = requireJsonObject(input.snapshot, "canonical snapshot"); + if (snapshot.status !== "loaded" || snapshot.decision_read_from_provider !== true || + snapshot.legacy_fallback_used !== false || !Array.isArray(snapshot.todos)) { + throw new Error("canonical room snapshot unavailable"); + } + const revision = requireNonEmptyString(snapshot.provider_revision, "provider_revision"); + const observed = requireNonEmptyString(input.observed_at, "observed_at"); + const now = new Date(observed); + if (Number.isNaN(now.valueOf())) throw new Error("invalid observation time"); + const guards = snapshot.goal_acceptance_work_guards == null ? {} : + requireJsonObject(snapshot.goal_acceptance_work_guards, "acceptance guards"); + const available: JsonObject[] = []; + let gates = 0; + for (const item of snapshot.todos) { + const todo = requireJsonObject(item, "canonical Todo"); + if (todo.role === "user" && todo.status === "open" && todo.task_class === "user_gate" && + (todo.global_gate === true || todo.blocks_agent === actor || todo.bound_agent === actor)) gates++; + if (todo.role === "agent" && todo.task_class != null && todo.task_class !== "advancement_task") continue; + if (todo.claimed_by != null && todo.claimed_by !== "") continue; + const id = requireNonEmptyString(todo.todo_id, "todo_id"); + const guard = guards[id]; + if (guard != null && requireJsonObject(guard, "acceptance guard").allowed === false) continue; + const decision = evaluateCoordinationTodoClaimDecision(todo, { + goal_id: goal, todo_id: id, claimed_by: actor, actor_agent_id: actor, + expected_role: "agent", registered_agents: registered, operation_id: "room-orientation", + expected_provider_revision: revision, dry_run: true, now, + }); + if (decision.status === "accepted") available.push({todo_id: id, claimed_by: null, + actionability: "unclaimed"}); + } + return {schema_version: "loopx_room_work_projection_v0", goal_id: goal, actor_id: actor, + authority_state: "available", mode: "read_only", source_revision: revision, + generated_at: observed, selected_todo: available[0] ?? null, + counts: {unclaimed: available.length, user_gates: gates}, + truth_contract: {projection_grants_authority: false, memory_grants_authority: false, + private_task_content_included: false}}; +} diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py new file mode 100644 index 0000000000..725766f5e7 --- /dev/null +++ b/loopx/extensions/lark/goal_channel_work.py @@ -0,0 +1,179 @@ +"""Local Agent CLI composition of canonical work and existing Goal Channel delivery. + +No IM principal, recalled text, or displayed card can supply an Agent identity. +Python owns provider IO; the typed Todo owner owns every state transition. +""" +from __future__ import annotations + +import hashlib +import json +from contextlib import ExitStack +from pathlib import Path +from typing import Any + +from ...agent_registry import registered_agent_ids_from_registry +from ...control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, claim_canonical_todo_if_promoted, + read_canonical_todos_if_promoted, +) +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.public_safety import validate_public_safe_value +from ...control_plane.runtime.time import now_local_iso +from ...file_lock import exclusive_file_lock +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .goal_channel_contracts import binding_for_goal, read_goal_channel_binding +from .goal_channel_delivery_contract import goal_channel_delivery_route +from .goal_channel_message_delivery import ( + GoalChannelDeliveryStageError, GoalChannelMessageDeliverySession, resolve_bound_goal_channel, +) +from .presentation.kanban import CommandRunner, default_subprocess_runner + +SCHEMA = "loopx_goal_channel_work_result_v0" + + +def _resolve_binding(binding_path: Path, target_path: Path, goal_id: str, actor: str) -> dict[str, Any]: + # The delivery helper supports default routes; this work facade requires an + # exact Agent route and must never fall back to a sibling or default lane. + raw = binding_for_goal(read_goal_channel_binding(binding_path), goal_id, agent_id=actor) + if raw is None or raw.get("agent_id") != actor or raw.get("enabled") is not True: + raise ValueError("exact enabled Agent channel required") + resolved = resolve_bound_goal_channel(binding_path=binding_path, target_path=target_path, + goal_id=goal_id, agent_id=actor) + goal_channel_delivery_route(goal_id, lambda _: resolved) + return resolved + + +def build_work_card(packet: dict[str, Any]) -> dict[str, Any]: + """Only content-minimal, validated projection/receipt fields reach the room.""" + validate_public_safe_value(packet) + projection = packet.get("projection") or {} + receipt = packet.get("receipt") + selected = projection.get("selected_todo") or {} + counts = projection.get("counts") or {} + body = (f"Goal: {packet['goal_id']}\nAgent: {packet['actor_id']}\n" + f"Available tasks: {counts.get('unclaimed', 0)} · User gates: {counts.get('user_gates', 0)}\n" + f"Selected task: {selected.get('todo_id') or 'none'}\n" + f"Revision: {projection.get('source_revision') or 'unavailable'}") + if receipt: + # Replays publish the same semantic acceptance card and provider key. + body += f"\nHistorical claim receipt: {receipt['acceptance']} · Task: {receipt['todo_id']}\nCurrent owner: {receipt.get('current_owner') or 'none'}\nReceipt: {receipt['receipt_id']}" + body += "\nRead-only orientation. Claim through the scoped Agent CLI; recheck current authority after reconnect." + return {"config": {"wide_screen_mode": True}, + "header": {"title": {"tag": "plain_text", "content": "LoopX work"}}, + "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": body}}]} + + +def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_path: Path, + target_path: Path, goal_id: str, actor_id: str, command: str, execute: bool, + todo_id: str | None = None, expected_revision: str | None = None, + idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + packet: dict[str, Any] = {"schema_version": SCHEMA, "ok": False, "goal_id": goal_id, + "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, + "status": "failed", "external_write_performed": False, "readback_verified": False, + "canonical_claim_accepted": False, "execution_authority_granted": False, "public_summary": "Room work is unavailable."} + stage = "scope" + try: + validate_public_safe_value({"goal_id": goal_id, "actor_id": actor_id}) + if command not in {"project", "claim"}: + raise ValueError("unsupported work command") + if command == "claim" and (not todo_id or not expected_revision or not idempotency_key): + raise ValueError("claim identity is incomplete") + registered = registered_agent_ids_from_registry(registry_path, goal_id) + if actor_id not in registered: + raise ValueError("actor is not registered") + def current_binding() -> dict[str, Any]: + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise ValueError("actor scope was revoked") + if execute: + resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(runtime_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + return _resolve_binding(binding_path, target_path, goal_id, actor_id) + + binding = current_binding() + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, + history_start_at=str(binding.get("created_at") or "1970-01-01T00:00:00Z"), + resolve_current_binding=current_binding, + runner=runner) + route = goal_channel_delivery_route(goal_id, session.resolve) + if execute: + stage = "delivery_preflight" + activation = resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(runtime_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + if activation.get("enabled") is not True or not session.verify(route): + raise ValueError("delivery is unavailable") + if command == "claim": + stage = "claim" + # Binding revocation and re-targeting serialize with the canonical + # write. Registry identity is separately witnessed by the Todo owner. + with ExitStack() as locks: + locks.enter_context(exclusive_file_lock(binding_path, operation="room_work_claim")) + if target_path != binding_path: + locks.enter_context(exclusive_file_lock(target_path, operation="room_work_claim")) + if current_binding() != binding: + raise ValueError("binding changed") + result = claim_canonical_todo_if_promoted(registry_path=registry_path, + runtime_root=runtime_root, goal_id=goal_id, todo_id=str(todo_id), role="agent", + claimed_by=actor_id, actor_agent_id=actor_id, dry_run=not execute, + operation_id=str(idempotency_key), expected_provider_revision=str(expected_revision)) + if result is None: + raise ValueError("canonical promotion required") + accepted = result.get("status") in {"applied", "replayed", "recovered", "no_change"} + packet["canonical_claim_accepted"] = accepted + packet["status"] = ("already_applied" if result.get("status") in {"replayed", "recovered"} + else "applied" if accepted else "planned") + # Receipts never copy arbitrary provider payloads, original requests, + # lease secrets, validation paths or exception text to the room. + packet["receipt"] = {"acceptance": "accepted" if accepted else "planned", + "todo_id": str(todo_id), "actor_id": actor_id, "scope": "historical_acceptance_only", + "receipt_id": "sha256:" + hashlib.sha256(str(idempotency_key).encode()).hexdigest()} + stage = "projection" + snapshot = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + if snapshot is None: + raise ValueError("canonical promotion required") + packet["projection"] = effect_runtime_result("goal_channel.work.project", { + "goal_id": goal_id, "actor_id": actor_id, "registered_agents": registered, + "snapshot": snapshot, "observed_at": now_local_iso()}) + if command == "claim": + current: dict[str, Any] = next((todo for todo in snapshot["todos"] if todo["todo_id"] == todo_id), {}) + packet["receipt"]["current_owner"] = current.get("claimed_by") + packet["receipt"]["current_claim_matches_actor"] = current.get("claimed_by") == actor_id + if command == "project": + packet["status"] = "projected" + packet["public_summary"] = "Canonical room work readback is ready." + validate_public_safe_value(packet) + if execute: + stage = "delivery" + # Stable card identity permits lost-response recovery independently + # of the accepted state transition. No new claim is minted here. + card = build_work_card(packet) + key = "room-work:" + hashlib.sha256(json.dumps(card, sort_keys=True).encode()).hexdigest() + sent = session.send(card, key, route) + packet["external_write_performed"] = sent.get("external_write_performed") is True + readback = session.readback(str(sent["message_id"])) + packet["readback_verified"] = readback.get("verified") is True + if not packet["readback_verified"]: + raise ValueError("delivery readback failed") + packet["ok"] = True + return packet + except LocalCoordinationAuthorityUnavailable as exc: + packet["status"] = "conflict" if exc.code == "provider_revision_mismatch" else "rejected" if exc.payload.get("failure_kind") == "decision_rejection" else "failed" + packet["blocker"] = packet["status"] + except GoalChannelDeliveryStageError as exc: + packet["external_write_performed"] = exc.external_write_performed is not False + packet["blocker"] = exc.blocker + except Exception: + packet["blocker"] = f"{stage}_unavailable" + packet["failure_stage"] = stage + packet["public_summary"] = ("Claim accepted; room delivery/readback needs recovery using the same key." + if packet["canonical_claim_accepted"] else "No claim acceptance is reported; refresh scope and canonical state.") + try: + validate_public_safe_value(packet) + except ValueError: + return {"schema_version": SCHEMA, "ok": False, "status": "failed", + "blocker": "public_projection_invalid", "canonical_claim_accepted": packet["canonical_claim_accepted"], + "external_write_performed": packet["external_write_performed"], "readback_verified": False} + return packet From cce2d814e1ca56fec02df94fa9fa7d55a02b44e8 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 00:31:34 -0700 Subject: [PATCH 02/27] test(goal-channel): qualify claim and recovery on local stores Signed-off-by: Lihua <1017343802@qq.com> --- tests/control_plane/test_room_work.py | 224 ++++++++++++++++++ .../room_work_projection.test.ts | 32 +++ 2 files changed, 256 insertions(+) create mode 100644 tests/control_plane/test_room_work.py create mode 100644 tests/control_plane_ts/room_work_projection.test.ts diff --git a/tests/control_plane/test_room_work.py b/tests/control_plane/test_room_work.py new file mode 100644 index 0000000000..e9f7577664 --- /dev/null +++ b/tests/control_plane/test_room_work.py @@ -0,0 +1,224 @@ +"""Synthetic room transport, real canonical File/SQLite authority and source CLI.""" +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.extensions.lark import goal_channel_work as work +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + +ROOT = Path(__file__).resolve().parents[2] +GOAL = "room-fixture" +TODO = "todo_room_fixture" + + +class Room: + """A synthetic Lark protocol responder, never an external room.""" + def __init__(self): + self.messages = [] + self.fail_send = False + self.authenticated = True + + def __call__(self, args, cwd, timeout): + payload = {} + if "auth" in args: + payload = {"appId": "cli_room_fixture", "identities": {"bot": { + "available": self.authenticated, "verified": self.authenticated, "appName": "Fixture Bot"}}} + elif "+chat-members-list" in args: + payload = {"data": {"bots": [{"app_id": "cli_room_fixture"}]}} + elif "+messages-send" in args: + card = json.loads(args[args.index("--content") + 1]) + message = {"message_id": f"om_room_{len(self.messages)}", "chat_id": "oc_room_fixture", + "sender": {"sender_type": "app", "id": "cli_room_fixture"}, + "body": {"content": json.dumps(card)}} + self.messages.append(message) + if self.fail_send: + self.fail_send = False + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + payload = {"data": {"message_id": message["message_id"]}} + elif "+messages-mget" in args: + payload = {"data": {"items": self.messages}} + elif "+chat-messages-list" in args: + payload = {"data": {"items": self.messages, "has_more": False}} + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + +@pytest.fixture(params=["file", "sqlite"]) +def setup(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, registry, state = tmp_path / "runtime", tmp_path / ".loopx/registry.json", tmp_path / "state.md" + registry.parent.mkdir() + state.write_text("## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) + todos = [{"schema_version": "todo_item_v0", "todo_id": TODO, "role": "agent", "status": "open", + "done": False, "text": "PRIVATE_ORGANIZATIONAL_CONTEXT", "archive_state": "active", + "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task"}] + provider = request.param.split(":")[0] + if request.param.endswith(":bound"): + todos[0]["bound_agent"] = "agent-b" + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=todos, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, GOAL, projection, state_path=state, provider=provider) + binding_path = registry.parent / "goal-channel.json" + binding = {"schema_version": "loopx_goal_channel_lark_binding_v0", "bindings": {GOAL: { + "schema_version": "loopx_goal_channel_connection_set_v0", "connections": { + actor: {"goal_id": GOAL, "agent_id": actor, "provider": "lark", "enabled": True, + "channel": {"chat_id": "oc_room_fixture"}, "identity": {"mode": "project_bot", + "sender_identity": "bot", "sender_profile": "room-fixture", + "bot_app_id": "cli_room_fixture", "bot_display_name": "Fixture Bot", "cli_bin": "fixture-cli"}} + for actor in ["agent-a", "agent-b"]}}}} + write_goal_channel_binding(binding_path, binding) + room = Room() + monkeypatch.setattr(work, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + args = dict(registry_path=registry, runtime_root=runtime, binding_path=binding_path, + target_path=runtime / "targets.json", goal_id=GOAL, runner=room) + yield args, room, binding + # Each fixture owns its disposable runtime; shut it down before its root vanishes. + from loopx.control_plane.effect_runtime import effect_runtime_result + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def snapshot(args): + return read_canonical_todos_if_promoted(runtime_root=args["runtime_root"], goal_id=GOAL) + + +def claim(args, actor, revision, key, **extra): + return work.run_goal_channel_work(**args, actor_id=actor, command="claim", execute=True, + todo_id=TODO, expected_revision=revision, idempotency_key=key, **extra) + + +def test_competing_hosts_retry_and_direct_cli_readback(setup): + args, room, _ = setup + before = snapshot(args) + project = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=False) + assert project["ok"], project + assert project["projection"]["counts"] == {"unclaimed": 1, "user_gates": 0} + assert not room.messages and "PRIVATE_" not in json.dumps(project) + revision = before["provider_revision"] + with ThreadPoolExecutor(2) as pool: + results = list(pool.map(lambda actor: claim(args, actor, revision, "room-claim-" + actor), ["agent-a", "agent-b"])) + assert sum(r["canonical_claim_accepted"] for r in results) == 1, results + assert sorted(r["status"] for r in results) == ["applied", "conflict"], results + winner = next(r["actor_id"] for r in results if r["canonical_claim_accepted"]) + committed = snapshot(args) + replay = claim(args, winner, revision, "room-claim-" + winner) + assert replay["status"] == "already_applied" and replay["readback_verified"], replay + assert snapshot(args)["provider_revision"] == committed["provider_revision"] + assert snapshot(args)["todos"][0]["claimed_by"] == winner + assert "PRIVATE_" not in json.dumps(room.messages) + readback = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "todo", "list", "--goal-id", GOAL], cwd=ROOT, text=True, capture_output=True) + assert readback.returncode == 0, readback.stdout + readback.stderr + assert json.loads(readback.stdout)["todos"][0]["claimed_by"] == winner + + +def test_reconnect_after_unknown_delivery_reuses_canonical_receipt(setup): + args, room, _ = setup + revision = snapshot(args)["provider_revision"] + room.fail_send = True + first = claim(args, "agent-a", revision, "room-lost-response") + assert first["canonical_claim_accepted"] and not first["ok"], first + assert first["external_write_performed"] and first["blocker"] == "delivery_outcome_unknown" + committed = snapshot(args)["provider_revision"] + recovered = claim(args, "agent-a", revision, "room-lost-response") + assert recovered["ok"] and recovered["status"] == "already_applied", recovered + assert len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == committed + + +def test_revocation_and_auth_failure_cannot_create_or_replay_claim(setup): + args, room, binding = setup + before = snapshot(args)["provider_revision"] + room.authenticated = False + denied = claim(args, "agent-a", before, "room-auth-denied") + assert not denied["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + room.authenticated = True + assert claim(args, "agent-a", before, "room-revocation")["canonical_claim_accepted"] + committed = snapshot(args)["provider_revision"] + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + replay = claim(args, "agent-a", before, "room-revocation") + assert not replay["canonical_claim_accepted"] and replay["failure_stage"] == "scope", replay + assert snapshot(args)["provider_revision"] == committed + + +def test_real_room_cli_preview_and_stale_revision(setup): + args, room, _ = setup + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "project", "--goal-id", GOAL, + "--agent-id", "agent-a"], cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + packet = json.loads(proc.stdout) + assert packet["projection"]["selected_todo"]["todo_id"] == TODO + assert packet["external_write_performed"] is False and "PRIVATE_" not in proc.stdout + revision = snapshot(args)["provider_revision"] + rejected = claim(args, "agent-a", "stale-revision", "room-stale") + assert rejected["status"] == "conflict", rejected + assert snapshot(args)["provider_revision"] == revision and not room.messages + + +def test_provider_loss_does_not_fall_back_or_disclose(setup, monkeypatch): + args, room, _ = setup + def unavailable(**kwargs): + raise RuntimeError("PRIVATE_CREDENTIAL_AND_BACKEND_PATH") + monkeypatch.setattr(work, "read_canonical_todos_if_promoted", unavailable) + packet = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=True) + assert not packet["ok"] and packet["failure_stage"] == "projection" + assert "PRIVATE_" not in json.dumps(packet) and not room.messages + + +@pytest.mark.parametrize("setup", ["file:bound", "sqlite:bound"], indirect=True) +def test_bound_agent_guard_applies_to_room_and_existing_direct_claim(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + project = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=False) + assert project["projection"]["counts"]["unclaimed"] == 0 + denied = claim(args, "agent-a", before, "room-bound-denied") + assert denied["status"] == "rejected" and not denied["canonical_claim_accepted"], denied + direct = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "todo", "claim", "--goal-id", GOAL, "--todo-id", TODO, + "--agent-id", "agent-a", "--claimed-by", "agent-a"], cwd=ROOT, text=True, capture_output=True) + assert direct.returncode == 1 + assert json.loads(direct.stdout)["error_code"] == "bound_agent_mismatch" + assert snapshot(args)["provider_revision"] == before and not room.messages + + +def test_changed_key_intent_and_revoked_identity_cannot_reapply(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + assert claim(args, "agent-a", before, "room-exact-intent")["canonical_claim_accepted"] + committed = snapshot(args)["provider_revision"] + changed = claim(args, "agent-a", committed, "room-exact-intent") + assert not changed["canonical_claim_accepted"] + registry = json.loads(args["registry_path"].read_text()) + registry["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(registry)) + revoked = claim(args, "agent-a", before, "room-exact-intent") + assert not revoked["canonical_claim_accepted"] and revoked["failure_stage"] == "scope" + assert snapshot(args)["provider_revision"] == committed and len(room.messages) == 1 + + +def test_scope_revoked_during_provider_preflight_is_rechecked_before_claim(setup): + args, room, _ = setup + before = snapshot(args)["provider_revision"] + revoked = False + def revoke_then_respond(argv, cwd, timeout): + nonlocal revoked + if "auth" in argv and not revoked: + registry = json.loads(args["registry_path"].read_text()) + registry["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(registry)) + revoked = True + return room(argv, cwd, timeout) + changed_args = {**args, "runner": revoke_then_respond} + denied = claim(changed_args, "agent-a", before, "room-preflight-revoked") + assert revoked and not denied["canonical_claim_accepted"], denied + assert snapshot(args)["provider_revision"] == before and not room.messages diff --git a/tests/control_plane_ts/room_work_projection.test.ts b/tests/control_plane_ts/room_work_projection.test.ts new file mode 100644 index 0000000000..a4f01b79e0 --- /dev/null +++ b/tests/control_plane_ts/room_work_projection.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {projectRoomWork} from "../../loopx/control_plane/goals/room_work_projection.ts"; + +const todo = (todo_id: string, patch = {}) => ({todo_id, role: "agent", status: "open", + archive_state: "active", task_class: "advancement_task", text: "PRIVATE_TASK_TEXT", ...patch}); +const packet = (todos: unknown[], patch = {}) => ({goal_id: "room-goal", actor_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], observed_at: "2026-01-01T00:00:00Z", + snapshot: {status: "loaded", provider_revision: "fixture:1", decision_read_from_provider: true, + legacy_fallback_used: false, todos, ...patch}}); + +test("room orientation reuses claim admission and exposes no task content", () => { + const result = projectRoomWork(packet([ + todo("todo_done", {status: "done"}), todo("todo_bound", {bound_agent: "agent-b"}), + todo("todo_excluded", {excluded_agents: ["agent-a"]}), todo("todo_claimed", {claimed_by: "agent-b"}), + todo("todo_monitor", {task_class: "continuous_monitor"}), todo("todo_guarded"), todo("todo_available"), + todo("todo_gate", {role: "user", task_class: "user_gate", blocks_agent: "agent-a"}), + todo("todo_foreign_gate", {role: "user", task_class: "user_gate", blocks_agent: "agent-b"}), + ], {goal_acceptance_work_guards: {todo_guarded: {allowed: false}}})); + assert.deepEqual(result.counts, {unclaimed: 1, user_gates: 1}); + assert.deepEqual(result.selected_todo, {todo_id: "todo_available", claimed_by: null, actionability: "unclaimed"}); + assert.equal(JSON.stringify(result).includes("PRIVATE_"), false); + assert.deepEqual(result.truth_contract, {projection_grants_authority: false, + memory_grants_authority: false, private_task_content_included: false}); +}); + +test("room orientation refuses revoked identity, partial snapshot and invalid freshness", () => { + for (const bad of [{...packet([]), registered_agents: []}, packet([], {status: "failed"}), + packet([], {legacy_fallback_used: true}), {...packet([]), observed_at: "not-a-date"}]) { + assert.throws(() => projectRoomWork(bad)); + } +}); From 09fc4d79f748c357f127b19126f878c701b3a21f Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 00:31:38 -0700 Subject: [PATCH 03/27] docs(goal-channel): record room work usage and qualification boundary Signed-off-by: Lihua <1017343802@qq.com> --- .../agent-im-openviking-collaboration-v0.md | 16 +++ docs/reference/goal-channel-room-work.md | 128 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 docs/reference/goal-channel-room-work.md diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 877d680aca..ce33d0cf0e 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -284,6 +284,22 @@ Measure outcome quality rather than message volume: - false acceptance or rejection of controlled transitions; - verified goal outcomes produced after handoff. +## Current Delivery Checkpoint + +Task [#5198](https://github.com/loopx-project/loopx/issues/5198) tracks composition. +The first proposed implementation stage is the +[canonical room-work Agent CLI](../../reference/goal-channel-room-work.md): compact +actor-scoped orientation, revision-guarded claim, historical acceptance plus +current ownership, and existing verified Goal Channel delivery/readback. It +reuses promoted File/SQLite authority and the typed Todo owner; it does not +replace the shared-service or provisioning owners. + +Validation uses synthetic Lark transport with disposable real local stores and +source CLI readback. Real non-production room qualification, independent hosts, +daemon reconnect, IM callbacks, authorized artifact references and scoped live +OpenViking retrieval remain unqualified. Design acceptance, a proposed PR and +synthetic checks do not close those requirements or authorize promotion. + ## Open Questions 1. Which LoopX projection fields are stable enough for the first public diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md new file mode 100644 index 0000000000..50bb63d337 --- /dev/null +++ b/docs/reference/goal-channel-room-work.md @@ -0,0 +1,128 @@ +# Canonical room work through the Agent CLI + +This first composition stage of [the Agent IM / LoopX / OpenViking RFC](../architecture/rfcs/agent-im-openviking-collaboration-v0.md) +lets a local registered Agent publish compact work orientation and a canonical +claim receipt into its existing Lark Goal Channel. It uses one already promoted +local File or SQLite authority. It does not qualify independent multi-host +service authority, an IM button callback, or live OpenViking integration. + +The existing Goal Channel connection remains the configuration owner. An exact, +enabled Agent connection and a verified project Bot are required; a default or +sibling connection cannot stand in for the named Agent. No new capability, +provider, scheduler, memory store, or frontend setting is introduced. Python +adapts CLI/provider IO; the TypeScript Todo transaction retains state, CAS, +identity and receipt authority. Identity follows the existing trusted local CLI +model; `--agent-id` is not remote authentication. The typed room read model reuses that claim +admission rule. No existing command automatically publishes work cards. + +## Preview and publish + +From a source checkout use `uv run --extra test loopx` in place of `loopx`. +Use the source registry and a previously authorized non-production connection. +Do not promote an active Goal merely to try these commands. Follow the existing +[reviewed promotion](reviewed-coordination-promotion.md) procedure on a disposable +fixture if qualification needs canonical authority. + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work project \ + --goal-id room-goal --agent-id agent-a +``` + +This preview reads canonical state and resolves the exact delivery binding. It +does not contact Lark, accept a claim, or send a message. The projection carries +`source_revision`, `generated_at`, the first eligible unclaimed Todo id and +actor-scoped counts. Task prose, notes, validation declarations, evidence, +artifacts and recalled context are omitted. `authority_state=available` describes +the provider read, not Goal completion or permission to execute. + +Add `--execute` to publish that orientation through the existing verified Bot: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work project \ + --goal-id room-goal --agent-id agent-a --execute +``` + +## Claim and receipt recovery + +Take `--expected-revision` from the fresh projection and choose a stable +idempotency key. Preview first by omitting `--execute`: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work claim \ + --goal-id room-goal --agent-id agent-a --todo-id todo_example \ + --expected-revision '' --idempotency-key room-claim-example +``` + +With `--execute`, the local Agent CLI asks the same canonical Todo owner used +by direct `todo claim`, then publishes the receipt and current orientation. +This explicit command authorizes those two effects. Room membership, delivery, +a card or recalled text supplies neither the Agent identity nor write scope. +Identity and binding are reread; binding/target revocation serialize with the +claim transaction; the registry witness is rechecked by the typed owner. + +The provider compares the expected revision inside the claim transaction. +Competing commands at one revision cannot both commit. A stale revision returns +`conflict`; it never silently refreshes its basis. Canonical claims now also +reject a Todo bound to another Agent, matching the lifecycle authority boundary. +This affects direct promoted claims as well as the optional room facade. + +On an uncertain response, retry the **same** Agent, Todo, revision and key. +Changing intent with the same key is rejected. `already_applied` returns +historical acceptance; it does not renew or acquire a lease. The receipt reports +`current_owner` and `current_claim_matches_actor`, and every result declares +`execution_authority_granted=false`. Execution still needs current quota, +applicable gates and, in hard-lease mode, a separate current lease acquisition. + +Check current canonical ownership through the existing direct CLI: + +```sh +loopx --registry .loopx/registry.json --format json todo list --goal-id room-goal +``` + +`canonical_claim_accepted` and `readback_verified` are separate facts. If a +claim committed but room send/readback failed, the result retains acceptance +and reports a recovery requirement. Unknown sends are conservatively reported +as possible external writes. Repeating the same semantic card uses exact Bot +history and provider idempotency rather than minting a new claim. Outages do +not queue unbounded writes or fall back to Markdown authority. After reconnect, +resolve the current binding and canonical revision again; a revoked connection +or identity cannot use an old receipt to resume this facade. + +## Read-only context and remaining qualification + +Keep any authorized OpenViking retrieval in the existing private +[Agent Turn Recall](../../loopx/capabilities/agent_turn_recall/README.md) path. +It consumes the exact selected quota packet, enforces its existing provider +scope, and produces private observations. This room stage never enables recall, +imports context, forwards pointers, writes memory, or treats a recalled approval +as a current gate. Authorized artifact-reference composition and live context +recovery remain later acceptance work on #5198. + +The new checks use synthetic Lark transport and real disposable File/SQLite +stores, including source CLI projection and direct CLI ownership readback. +They are not evidence of a real room, two independently authenticated hosts, +a live daemon reconnect, or OpenViking provider qualification. Those checks need +an explicitly authorized non-production room, actor bindings and read-only +resource scope. Existing shared authority and provisioning tasks retain their +ownership; this stage does not promote or deploy them. + +To disable publication, stop issuing the optional `work ... --execute` commands +or disconnect the exact connection through the existing Goal Channel connection +owner. This does not delete canonical claims or retract their receipts. Revert +this change to remove the optional CLI facade; existing direct Todo CLI and +Goal Channel configuration remain usable. + +## 中文边界 + +本阶段组合已有的 Agent CLI、Goal Channel 与 File/SQLite Todo 权威。默认仅预览; +显式 `--execute` 才请求领取并发布房间回执。没有新增配置 owner 或前端开关, +不自动发送卡片,也不把群成员、卡片、投递或记忆当作执行权限。 + +竞争领取在权威事务内校验 revision;相同 key 的重试恢复历史接受结果,不能续租。 +当前 owner、历史回执与消息读回分别呈现。领取成功但消息失败时保留接受事实; +重新连接后复核当前身份、连接、权威、quota 和 lease。绑定给其他 Agent 的任务会被拒绝, +这一修复也适用于直接的 promoted Todo claim。 + +公开卡片仅包含标识、计数与安全回执,不携带任务正文、证据、artifact 或记忆内容。 +真实房间、多主机、daemon reconnect、授权 artifact 引用和 OpenViking 只读检索仍待联调。 +合成 Lark transport 加真实隔离权威的测试不能宣称三方 RFC 已完成。 From 5d331c36ce9de422b0e54f392349e560d5e00157 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 01:28:36 -0700 Subject: [PATCH 04/27] fix(goal-channel): refresh census and scope the work schema name Signed-off-by: Lihua <1017343802@qq.com> --- loopx/extensions/lark/goal_channel_work.py | 6 +++--- loopx/semantics/project_registry_io_manifest_v1.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py index 725766f5e7..fc087ed7eb 100644 --- a/loopx/extensions/lark/goal_channel_work.py +++ b/loopx/extensions/lark/goal_channel_work.py @@ -29,7 +29,7 @@ ) from .presentation.kanban import CommandRunner, default_subprocess_runner -SCHEMA = "loopx_goal_channel_work_result_v0" +GOAL_CHANNEL_WORK_RESULT_SCHEMA = "loopx_goal_channel_work_result_v0" def _resolve_binding(binding_path: Path, target_path: Path, goal_id: str, actor: str) -> dict[str, Any]: @@ -68,7 +68,7 @@ def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_pa target_path: Path, goal_id: str, actor_id: str, command: str, execute: bool, todo_id: str | None = None, expected_revision: str | None = None, idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: - packet: dict[str, Any] = {"schema_version": SCHEMA, "ok": False, "goal_id": goal_id, + packet: dict[str, Any] = {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "goal_id": goal_id, "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, "status": "failed", "external_write_performed": False, "readback_verified": False, "canonical_claim_accepted": False, "execution_authority_granted": False, "public_summary": "Room work is unavailable."} @@ -173,7 +173,7 @@ def current_binding() -> dict[str, Any]: try: validate_public_safe_value(packet) except ValueError: - return {"schema_version": SCHEMA, "ok": False, "status": "failed", + return {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "status": "failed", "blocker": "public_projection_invalid", "canonical_claim_accepted": packet["canonical_claim_accepted"], "external_write_performed": packet["external_write_performed"], "readback_verified": False} return packet diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 09c271ee70..ded5300060 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -591,7 +591,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::._source_context::codec_read:load_registry#1", - "line": 303, + "line": 316, "column": 14, "kind": "codec_read", "api": "load_registry", @@ -599,7 +599,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::.handle_goal_channel_command::codec_read:load_registry#1", - "line": 443, + "line": 456, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -887,7 +887,7 @@ }, { "site": "loopx/contract.py::.check_contract::codec_read:load_registry#1", - "line": 1014, + "line": 1027, "column": 20, "kind": "codec_read", "api": "load_registry", From 67381192f771e1bb48f61b9ae3847d3c0286a900 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 08:00:56 -0700 Subject: [PATCH 05/27] feat(goal-channel): add scoped room claim offers and callback recovery Signed-off-by: Lihua <1017343802@qq.com> --- loopx/cli_commands/goal_channel.py | 34 +- .../control_plane/effect_runtime_handlers.ts | 3 + .../control_plane/goals/room_claim_request.ts | 44 +++ .../lark/event_collector_runtime.py | 34 +- loopx/extensions/lark/goal_channel_work.py | 5 +- loopx/extensions/lark/room_claim.py | 317 ++++++++++++++++++ .../project_registry_io_manifest_v1.json | 4 +- 7 files changed, 418 insertions(+), 23 deletions(-) create mode 100644 loopx/control_plane/goals/room_claim_request.ts create mode 100644 loopx/extensions/lark/room_claim.py diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index 786d3be929..86a62617e9 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -214,16 +214,21 @@ def register_goal_channel_commands( work = sub.add_parser("work", help="Publish canonical room orientation or claim through the local Agent CLI.") work_sub = work.add_subparsers(dest="goal_channel_work_command", required=True) - for work_command in ("project", "claim"): + for work_command in ("project", "claim", "offer", "revoke"): work_parser = work_sub.add_parser(work_command) add_subcommand_format(work_parser) _add_common_args(work_parser) work_parser.add_argument("--agent-id", required=True) work_parser.add_argument("--execute", action="store_true") - if work_command == "claim": + if work_command in {"claim", "offer"}: work_parser.add_argument("--todo-id", required=True) work_parser.add_argument("--expected-revision", required=True) work_parser.add_argument("--idempotency-key", required=True) + if work_command == "offer": + work_parser.add_argument("--principal", action="append", required=True) + work_parser.add_argument("--expires-at", required=True) + if work_command == "revoke": + work_parser.add_argument("--request-id", required=True) register_goal_channel_runtime_commands(sub, add_subcommand_format) @@ -466,12 +471,25 @@ def handle_goal_channel_command( _, source_registry_path, work_binding_path, source_runtime_root = _source_context( registry=registry, registry_path=registry_path, goal_id=goal_id, binding_path_arg=getattr(args, "binding_path", None)) - payload = run_goal_channel_work(registry_path=source_registry_path, - runtime_root=source_runtime_root, binding_path=work_binding_path, - target_path=_target_path(args, runtime_root), goal_id=goal_id, actor_id=args.agent_id, - command=args.goal_channel_work_command, execute=execute, - todo_id=getattr(args, "todo_id", None), expected_revision=getattr(args, "expected_revision", None), - idempotency_key=getattr(args, "idempotency_key", None)) + if args.goal_channel_work_command in {"offer", "revoke"}: + from ..extensions.lark.room_claim import run_room_claim_offer + + payload = run_room_claim_offer(registry_path=source_registry_path, + authority_root=source_runtime_root, broker_root=runtime_root, + binding_path=work_binding_path, target_path=_target_path(args, runtime_root), + goal_id=goal_id, actor_id=args.agent_id, command=args.goal_channel_work_command, + execute=execute, todo_id=getattr(args, "todo_id", None), + expected_revision=getattr(args, "expected_revision", None), + idempotency_key=getattr(args, "idempotency_key", None), + principals=getattr(args, "principal", None), expires_at=getattr(args, "expires_at", None), + request_id=getattr(args, "request_id", None)) + else: + payload = run_goal_channel_work(registry_path=source_registry_path, + runtime_root=source_runtime_root, binding_path=work_binding_path, + target_path=_target_path(args, runtime_root), goal_id=goal_id, actor_id=args.agent_id, + command=args.goal_channel_work_command, execute=execute, + todo_id=getattr(args, "todo_id", None), expected_revision=getattr(args, "expected_revision", None), + idempotency_key=getattr(args, "idempotency_key", None)) print_payload(payload, output_format(args), render_goal_channel_markdown) return 0 if payload.get("ok") else 1 if command == "runtime": diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index e750ccf278..cbeac3644b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,3 +1,4 @@ +import {decodeRoomClaimRequest, admitRoomClaimCallback} from "./goals/room_claim_request.ts"; import {projectRoomWork} from "./goals/room_work_projection.ts"; import {projectDecisionNotice} from "./presentation/decision_notice.ts"; import {projectTodoSummary} from "./todos/summary_projection.ts"; @@ -583,6 +584,8 @@ export function createEffectRuntimeHandlers( ["coordination.local_authority.promotion_reviewed", executeReviewedCoordinationPromotion], ["coordination.local_authority.todo_continuation", continueLocalTodo], ["goal_channel.work.project", projectRoomWork], + ["goal_channel.work.claim_request", decodeRoomClaimRequest], + ["goal_channel.work.claim_admission", admitRoomClaimCallback], ["coordination.local_authority.todo_claim", claimLocalCoordinationTodo], ["coordination.local_authority.todo_create", createLocalCoordinationTodo], ["work_items.team_plan.preview", previewTeamPlan], diff --git a/loopx/control_plane/goals/room_claim_request.ts b/loopx/control_plane/goals/room_claim_request.ts new file mode 100644 index 0000000000..3d901be24e --- /dev/null +++ b/loopx/control_plane/goals/room_claim_request.ts @@ -0,0 +1,44 @@ +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; +import type {JsonObject} from "../effect_program.ts"; +import {requireAuthorityStoreId} from "../coordination/authority_store_codec.ts"; +import {normalizeTodoAgent} from "../coordination/todo_agents.ts"; + +/** One scoped claim intent, not an actor lifecycle or execution grant. */ +export function decodeRoomClaimRequest(value: unknown): JsonObject { + const p = requireJsonObject(value, "room claim request"); + const fields = ["schema_version", "command", "goal_id", "actor_id", "todo_id", "expected_revision", + "idempotency_key", "authorized_principals", "expires_at"]; + if (Object.keys(p).length !== fields.length || fields.some(k => !Object.hasOwn(p, k)) || + p.schema_version !== "loopx_room_claim_request_v0" || p.command !== "claim_todo") { + throw new Error("unsupported room claim request"); + } + const principals = requireStringArray(p.authorized_principals, "authorized principals"); + if (!principals.length || principals.length > 8 || new Set(principals).size !== principals.length || + principals.some(v => !/^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9_.:-]{0,200}$/.test(v))) { + throw new Error("invalid scoped principals"); + } + const expires = requireNonEmptyString(p.expires_at, "expires_at"); + if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(expires) || Number.isNaN(new Date(expires).valueOf())) { + throw new Error("invalid room claim expiry"); + } + return {...p, goal_id: requireAuthorityStoreId(p.goal_id, "goal id"), + actor_id: normalizeTodoAgent(p.actor_id, "actor id"), todo_id: requireAuthorityStoreId(p.todo_id, "todo id"), + expected_revision: requireAuthorityStoreId(p.expected_revision, "expected revision"), + idempotency_key: requireAuthorityStoreId(p.idempotency_key, "idempotency key"), + authorized_principals: [...principals].sort(), expires_at: new Date(expires).toISOString()}; +} + +/** Provider authentication is done by the Lark boundary before this admission. */ +export function admitRoomClaimCallback(value: unknown): JsonObject { + const p = requireJsonObject(value, "room claim callback admission"); + const request = decodeRoomClaimRequest(p.request); + const now = new Date(requireNonEmptyString(p.observed_at, "observation time")); + if (Number.isNaN(now.valueOf()) || !["active", "revoked"].includes(String(p.scope_state))) { + throw new Error("invalid room claim scope observation"); + } + const reason = p.scope_state === "revoked" ? "offer_revoked" : + now >= new Date(String(request.expires_at)) ? "offer_expired" : + !(request.authorized_principals as string[]).includes(String(p.principal)) ? "principal_not_authorized" : null; + return {schema_version: "loopx_room_claim_admission_v0", allowed: reason === null, + reason_code: reason, execution_authority_granted: false}; +} diff --git a/loopx/extensions/lark/event_collector_runtime.py b/loopx/extensions/lark/event_collector_runtime.py index 41fc01b658..45ac421967 100644 --- a/loopx/extensions/lark/event_collector_runtime.py +++ b/loopx/extensions/lark/event_collector_runtime.py @@ -27,6 +27,7 @@ recover_goal_channel_operation_results, recover_goal_channel_simulation_claims, ) +from .room_claim import is_room_claim_callback, handle_room_claim_callback, recover_room_claim_results from .private_json import write_private_json_atomic APP_ID_PATTERN = re.compile(r"cli_[A-Za-z0-9_-]+") @@ -843,17 +844,22 @@ def consume_operation_callbacks() -> None: raise ValueError( "collector Bot application identity is unverified" ) - receipt = handle_goal_channel_operation_callback( - payload, - runtime_root=resolved_runtime_root, - action_store_root=resolved_runtime_root - / "chat" - / "actions", - profile_app_id=profile_app_id, - cli_bin=lark_cli_executable, - profile=str(config["profile"]), - runner=transport_runner, - ) + if is_room_claim_callback(payload): + receipt = handle_room_claim_callback(payload, + runtime_root=resolved_runtime_root, profile_app_id=profile_app_id, + cli_bin=lark_cli_executable, profile=str(config["profile"]), runner=transport_runner) + else: + receipt = handle_goal_channel_operation_callback( + payload, + runtime_root=resolved_runtime_root, + action_store_root=resolved_runtime_root + / "chat" + / "actions", + profile_app_id=profile_app_id, + cli_bin=lark_cli_executable, + profile=str(config["profile"]), + runner=transport_runner, + ) if receipt.get("ok") is not True: raise RuntimeError( "operation callback result delivery was not verified" @@ -917,6 +923,12 @@ def recover_operation_results() -> None: ) except Exception: # noqa: BLE001 result = {"attempted": 1, "delivered": 0, "failed": 1} + room_result = recover_room_claim_results(runtime_root=resolved_runtime_root, + profile_app_id=str(profile_app_id or ""), allowed_chat_ids=set(routes_by_chat), + cli_bin=lark_cli_executable, profile=str(config["profile"]), + runner=_operation_transport_runner(runner, command_prefix=command_prefix)) + for key in result_recovery_stats: + result[key] = int(result.get(key) or 0) + int(room_result.get(key) or 0) for key in simulation_recovery_stats: simulation_recovery_stats[key] += int( simulation_result.get(key) or 0 diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py index fc087ed7eb..7632e1a803 100644 --- a/loopx/extensions/lark/goal_channel_work.py +++ b/loopx/extensions/lark/goal_channel_work.py @@ -67,7 +67,8 @@ def build_work_card(packet: dict[str, Any]) -> dict[str, Any]: def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_path: Path, target_path: Path, goal_id: str, actor_id: str, command: str, execute: bool, todo_id: str | None = None, expected_revision: str | None = None, - idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner, + publish_receipt: bool = True) -> dict[str, Any]: packet: dict[str, Any] = {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "goal_id": goal_id, "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, "status": "failed", "external_write_performed": False, "readback_verified": False, @@ -145,7 +146,7 @@ def current_binding() -> dict[str, Any]: packet["status"] = "projected" packet["public_summary"] = "Canonical room work readback is ready." validate_public_safe_value(packet) - if execute: + if execute and publish_receipt: stage = "delivery" # Stable card identity permits lost-response recovery independently # of the accepted state transition. No new claim is minted here. diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py new file mode 100644 index 0000000000..ba3d1bec24 --- /dev/null +++ b/loopx/extensions/lark/room_claim.py @@ -0,0 +1,317 @@ +"""One-operation room claim grants and Lark transport; Todo owns transitions. + +Offers are authored by a trusted local Agent CLI. Room membership never grants +claim scope. Private offer files contain only intent/scope and delivery facts; +canonical receipts remain the sole source of accepted work transitions. +""" +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from ...agent_registry import registered_agent_ids_from_registry +from ...control_plane.coordination.local_authority import claim_canonical_todo_if_promoted +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.public_safety import validate_public_safe_value +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ...file_lock import exclusive_file_lock +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .card_callback import operator_membership_verified, read_callback_card_content, update_callback_card, patch_result_card +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route +from .goal_channel_message_delivery import GoalChannelMessageDeliverySession, GoalChannelDeliveryStageError +from .goal_channel_work import _resolve_binding, build_work_card, run_goal_channel_work +from .presentation.kanban import CommandRunner, default_subprocess_runner +from .goal_channel_transport import verified_app_id +from .goal_channel_operation import _card_text +from .private_json import write_private_json_atomic + +ROOM_CLAIM_OFFER_RECORD_SCHEMA = "loopx_lark_room_claim_offer_record_v0" +ROOM_CLAIM_OFFER_RESULT_SCHEMA = "loopx_lark_room_claim_offer_result_v0" +ROOM_CLAIM_ACTION_SCHEMA = "loopx_room_claim_action_v0" +ROOM_CLAIM_CALLBACK_SCHEMA = "loopx_lark_room_claim_callback_v0" +_OFFER_ID = re.compile(r"^rc_[a-f0-9]{32}$") + + +class RoomClaimCallbackError(ValueError): + def __init__(self, code: str, stage: str = "room_claim_scope") -> None: + super().__init__("Room claim callback could not be admitted or delivered") + self.code = code + self.failure_stage = stage + + +def _digest(value: object) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _path(root: Path, request_id: str) -> Path: + if not _OFFER_ID.fullmatch(request_id): + raise RoomClaimCallbackError("invalid_offer_id") + return root / "room-claim-offers" / (request_id + ".json") + + +def _read(path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise RoomClaimCallbackError("offer_unavailable") + try: + p = json.loads(path.read_text()) + if not isinstance(p, dict) or p.get("schema_version") != ROOM_CLAIM_OFFER_RECORD_SCHEMA: + raise ValueError("invalid offer") + normalized = effect_runtime_result("goal_channel.work.claim_request", p["request"]) + if normalized != p["request"] or p["intent_digest"] != _digest(normalized): + raise ValueError("intent drift") + if (p["request_id"] != "rc_" + p["intent_digest"][:32] or path.stem != p["request_id"] or + p["scope_state"] not in {"active", "revoked"}): + raise ValueError("scope drift") + return p + except RoomClaimCallbackError: + raise + except Exception as exc: + raise RoomClaimCallbackError("offer_unavailable") from exc + + +def _scope(p: Mapping[str, Any], broker_root: Path) -> dict[str, Any]: + request = p["request"] + # The current broker route must still name the original source and provider + # root. Reconnect cannot silently follow a replacement Goal authority. + route = resolve_goal_source_runtime_route(registry_path=broker_root / "registry.global.json", + goal_id=request["goal_id"]) + if (Path(route["source_registry"]).resolve() != Path(p["source_registry"]).resolve() or + Path(route["source_runtime_root"]).resolve() != Path(p["authority_root"]).resolve()): + raise RoomClaimCallbackError("source_route_changed") + if request["actor_id"] not in registered_agent_ids_from_registry(Path(p["source_registry"]), request["goal_id"]): + raise RoomClaimCallbackError("actor_scope_revoked") + resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(Path(p["authority_root"])), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + binding = _resolve_binding(Path(p["binding_path"]), Path(p["target_path"]), request["goal_id"], request["actor_id"]) + if goal_channel_binding_digest(binding) != p["binding_digest"]: + raise RoomClaimCallbackError("binding_changed") + return binding + + +def build_room_claim_card(request: Mapping[str, Any], request_id: str) -> dict[str, Any]: + text = f"Task: {_card_text(request['todo_id'])}\nGoal: {_card_text(request['goal_id'])}\nClaim as: {_card_text(request['actor_id'])}" + return {"schema": "2.0", "config": {"update_multi": True}, + "header": {"title": {"tag": "plain_text", "content": "Claim task"}}, + "body": {"elements": [{"tag": "markdown", "content": text}, + {"tag": "button", "type": "primary", "text": {"tag": "plain_text", "content": "Claim task"}, + "behaviors": [{"type": "callback", "value": {"schema_version": ROOM_CLAIM_ACTION_SCHEMA, + "request_id": request_id, "intent_digest": _digest(dict(request)), "command": "claim_todo"}}]}]}} + + +def run_room_claim_offer(*, registry_path: Path, authority_root: Path, broker_root: Path, + binding_path: Path, target_path: Path, goal_id: str, actor_id: str, command: str, + execute: bool, todo_id: str | None = None, expected_revision: str | None = None, + idempotency_key: str | None = None, principals: list[str] | None = None, + expires_at: str | None = None, request_id: str | None = None, + runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + result: dict[str, Any] = {"schema_version": ROOM_CLAIM_OFFER_RESULT_SCHEMA, "ok": False, + "goal_id": goal_id, "actor_id": actor_id, "operation": "work_" + command, "execute": execute, + "external_write_performed": False, "readback_verified": False, "canonical_claim_accepted": False, + "execution_authority_granted": False} + try: + validate_public_safe_value({"goal_id": goal_id, "actor_id": actor_id}) + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise RoomClaimCallbackError("actor_scope_revoked") + if command == "revoke": + path = _path(broker_root, str(request_id or "")) + p = _read(path) + if p["request"]["goal_id"] != goal_id or p["request"]["actor_id"] != actor_id: + raise RoomClaimCallbackError("offer_scope_mismatch") + if execute: + with exclusive_file_lock(path, operation="room_claim_offer"): + p = _read(path) + p["scope_state"] = "revoked" + write_private_json_atomic(path, p) + return {**result, "ok": True, "status": "revoked" if execute else "planned", "request_id": p["request_id"]} + if command != "offer": + raise RoomClaimCallbackError("unsupported_offer_command") + if not principals or any(not re.fullmatch(r"lark:ou_[A-Za-z0-9_-]{1,160}", principal) for principal in principals): + raise RoomClaimCallbackError("invalid_lark_principal") + request = effect_runtime_result("goal_channel.work.claim_request", {"schema_version": "loopx_room_claim_request_v0", + "command": "claim_todo", "goal_id": goal_id, "actor_id": actor_id, "todo_id": todo_id, + "expected_revision": expected_revision, "idempotency_key": idempotency_key, + "authorized_principals": principals or [], "expires_at": expires_at}) + validate_public_safe_value({k:request[k] for k in ["goal_id", "actor_id", "todo_id", "expected_revision"]}) + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": "active", "principal": request["authorized_principals"][0], "observed_at": _now()}) + if not admission["allowed"]: + raise RoomClaimCallbackError(admission["reason_code"]) + rid = "rc_" + _digest(request)[:32] + path = _path(broker_root, rid) + binding = _resolve_binding(binding_path, target_path, goal_id, actor_id) + p = {"schema_version": ROOM_CLAIM_OFFER_RECORD_SCHEMA, "request_id": rid, "request": request, + "intent_digest": _digest(request), "scope_state": "active", "source_registry": str(registry_path.resolve()), + "authority_root": str(authority_root.resolve()), "binding_path": str(binding_path.resolve()), + "target_path": str(target_path.resolve()), "binding_digest": goal_channel_binding_digest(binding), + "created_at": _now(), "delivery": None, "last_result_card": None, "result_delivery_verified": False} + # Validate current canonical eligibility, without creating a claim/receipt. + preview = claim_canonical_todo_if_promoted(registry_path=registry_path, runtime_root=authority_root, + goal_id=goal_id, todo_id=request["todo_id"], role="agent", claimed_by=actor_id, + actor_agent_id=actor_id, dry_run=True, operation_id="offer-preview:" + rid, + expected_provider_revision=request["expected_revision"]) + if preview is None: + raise RoomClaimCallbackError("canonical_authority_required") + card = build_room_claim_card(request, rid) + result.update(request_id=rid, status="planned", authorized_principal_count=len(request["authorized_principals"]), card=card) + if not execute: + return {**result, "ok": True} + _scope(p, broker_root) + with exclusive_file_lock(path, operation="room_claim_offer"): + if path.exists(): + p = _read(path) + if p["scope_state"] != "active": + raise RoomClaimCallbackError("offer_revoked") + else: + write_private_json_atomic(path, p) + binding = _scope(p, broker_root) + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, history_start_at=p["created_at"], + resolve_current_binding=lambda: _scope(p, broker_root), runner=runner) + route = goal_channel_delivery_route(goal_id, session.resolve) + if not session.verify(route): + raise RoomClaimCallbackError("provider_identity_unverified") + sent = session.send(card, "room-claim-offer:" + rid, route) + result["external_write_performed"] = sent.get("external_write_performed") is True + verified = session.readback(str(sent["message_id"])).get("verified") is True + p["delivery"] = {"message_id": sent["message_id"], "chat_id": route["chat_id"], "app_id": route["bot_app_id"], + "profile": route["sender_profile"], "card": card, "readback_verified": verified} + write_private_json_atomic(path, p) + result.update(ok=verified, status="offered" if verified else "delivery_pending", readback_verified=verified) + return result + except GoalChannelDeliveryStageError as exc: + result.update(blocker=exc.blocker, external_write_performed=exc.external_write_performed is not False) + except RoomClaimCallbackError as exc: + result["blocker"] = exc.code + except Exception: + result["blocker"] = "offer_unavailable" + result["status"] = "failed" + return result + + +def is_room_claim_callback(event: Mapping[str, Any]) -> bool: + raw = event.get("action_value") + try: + value = json.loads(raw) if isinstance(raw, str) else raw + except (ValueError, TypeError): + return False + return isinstance(value, Mapping) and value.get("schema_version") == ROOM_CLAIM_ACTION_SCHEMA + + +def handle_room_claim_callback(event: Mapping[str, Any], *, runtime_root: Path, + profile_app_id: str, cli_bin: str, profile: str, + runner: CommandRunner = default_subprocess_runner) -> dict[str, Any]: + try: + raw = event.get("action_value") + action = json.loads(raw) if isinstance(raw, str) else raw + if (event.get("type") != "card.action.trigger" or event.get("action_tag") != "button" or + event.get("host") != "im_message" or not isinstance(action, Mapping) or + set(action) != {"schema_version", "request_id", "intent_digest", "command"} or + action["schema_version"] != ROOM_CLAIM_ACTION_SCHEMA or action["command"] != "claim_todo"): + raise RoomClaimCallbackError("invalid_callback") + token = str(event.get("token") or "") + if not token or len(token) > 2048 or any(ord(c) < 32 for c in token): + raise RoomClaimCallbackError("invalid_callback_token") + path = _path(runtime_root, str(action["request_id"])) + p = _read(path) + with exclusive_file_lock(path, operation="room_claim_callback"): + p = _read(path) + request = p["request"] + delivery = p.get("delivery") + principal = "lark:" + str(event.get("operator_id") or "") + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": p["scope_state"], "principal": principal, "observed_at": _now()}) + if not admission["allowed"]: + raise RoomClaimCallbackError(admission["reason_code"]) + if (action["intent_digest"] != p["intent_digest"] or not isinstance(delivery, Mapping) or + delivery.get("readback_verified") is not True or event.get("message_id") != delivery["message_id"] or + event.get("chat_id") != delivery["chat_id"] or profile_app_id != delivery["app_id"] or profile != delivery["profile"]): + raise RoomClaimCallbackError("callback_delivery_scope_mismatch") + binding = _scope(p, runtime_root) + route = goal_channel_delivery_route(request["goal_id"], lambda _: binding) + if route["bot_app_id"] != profile_app_id or route["sender_profile"] != profile: + raise RoomClaimCallbackError("callback_provider_scope_mismatch") + if not operator_membership_verified(runner=runner, cli_bin=cli_bin, profile=profile, + chat_id=delivery["chat_id"], operator_id=str(event["operator_id"])): + raise RoomClaimCallbackError("operator_identity_unverified") + # Always hydrate the originating Bot message. Visible-only equality + # cannot prove this action's hidden request identity. + observed = read_callback_card_content(runner=runner, cli_bin=cli_bin, profile=profile, + message_id=delivery["message_id"], chat_id=delivery["chat_id"], app_id=profile_app_id) + if isinstance(observed, str): + observed = json.loads(observed) + if observed != delivery["card"] and observed != p.get("last_result_card"): + raise RoomClaimCallbackError("callback_card_drifted") + claimed = run_goal_channel_work(registry_path=Path(p["source_registry"]), + runtime_root=Path(p["authority_root"]), binding_path=Path(p["binding_path"]), target_path=Path(p["target_path"]), + goal_id=request["goal_id"], actor_id=request["actor_id"], command="claim", execute=True, + todo_id=request["todo_id"], expected_revision=request["expected_revision"], + idempotency_key=request["idempotency_key"], runner=runner, publish_receipt=False) + card = build_work_card(claimed) if claimed.get("projection") else { + "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": "Claim readback"}}, + "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": str(claimed.get("public_summary"))}}]} + p["last_result_card"] = card + p["result_delivery_verified"] = False + write_private_json_atomic(path, p) + _scope(p, runtime_root) + if verified_app_id(runner=runner, cli_bin=cli_bin, profile=profile) != profile_app_id: + raise RoomClaimCallbackError("callback_provider_scope_mismatch") + updated = update_callback_card(runner=runner, cli_bin=cli_bin, profile=profile, token=token, + card=card, message_id=delivery["message_id"], chat_id=delivery["chat_id"], app_id=profile_app_id) + p["result_delivery_verified"] = updated.get("readback_verified") is True + write_private_json_atomic(path, p) + return {"schema_version": ROOM_CLAIM_CALLBACK_SCHEMA, "ok": p["result_delivery_verified"], + "request_id": p["request_id"], "claim_status": claimed["status"], + "canonical_claim_accepted": claimed["canonical_claim_accepted"], + "receipt": claimed.get("receipt"), "card_update_verified": p["result_delivery_verified"], + "external_write_performed": updated.get("external_write_performed"), + "callback_ack_is_execution_receipt": False, "execution_authority_granted": False} + except RoomClaimCallbackError: + raise + except Exception as exc: + raise RoomClaimCallbackError("callback_unavailable") from exc + + +def recover_room_claim_results(*, runtime_root: Path, profile_app_id: str, allowed_chat_ids: set[str], + cli_bin: str, profile: str, runner: CommandRunner = default_subprocess_runner, limit: int = 20) -> dict[str, int]: + """Retry only recorded public result-card transport, never a work transition.""" + stats = {"attempted": 0, "delivered": 0, "failed": 0} + for path in sorted((runtime_root / "room-claim-offers").glob("rc_*.json")): + if stats["attempted"] >= min(max(limit, 1), 100): + break + try: + p = _read(path) + d = p.get("delivery") + if (not p.get("last_result_card") or p.get("result_delivery_verified") is True or + not isinstance(d, Mapping) or d["app_id"] != profile_app_id or + d["chat_id"] not in allowed_chat_ids or d["profile"] != profile): + continue + stats["attempted"] += 1 + with exclusive_file_lock(path, operation="room_claim_result_recovery"): + p = _read(path) + d = p["delivery"] + _scope(p, runtime_root) + if p.get("scope_state") != "active" or p.get("result_delivery_verified") is True: + continue + request = p["request"] + admission = effect_runtime_result("goal_channel.work.claim_admission", {"request": request, + "scope_state": p["scope_state"], "principal": request["authorized_principals"][0], "observed_at": _now()}) + if not admission["allowed"] or verified_app_id(runner=runner, cli_bin=cli_bin, profile=profile) != profile_app_id: + continue + result = patch_result_card(runner=runner, cli_bin=cli_bin, profile=profile, + card=p["last_result_card"], message_id=d["message_id"], chat_id=d["chat_id"], app_id=profile_app_id) + p["result_delivery_verified"] = result.get("readback_verified") is True + write_private_json_atomic(path, p) + stats["delivered" if p["result_delivery_verified"] else "failed"] += 1 + except Exception: + stats["failed"] += 1 + return stats diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index ded5300060..21c998078a 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -591,7 +591,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::._source_context::codec_read:load_registry#1", - "line": 316, + "line": 321, "column": 14, "kind": "codec_read", "api": "load_registry", @@ -599,7 +599,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::.handle_goal_channel_command::codec_read:load_registry#1", - "line": 456, + "line": 461, "column": 16, "kind": "codec_read", "api": "load_registry", From b6caaae5db5beda30fbfb3a181bbdcd6c141277e Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 08:01:07 -0700 Subject: [PATCH 06/27] test(goal-channel): qualify authenticated callbacks and native dispatch Signed-off-by: Lihua <1017343802@qq.com> --- .../control_plane/test_room_claim_callback.py | 234 ++++++++++++++++++ .../room_claim_request.test.ts | 27 ++ 2 files changed, 261 insertions(+) create mode 100644 tests/control_plane/test_room_claim_callback.py create mode 100644 tests/control_plane_ts/room_claim_request.test.ts diff --git a/tests/control_plane/test_room_claim_callback.py b/tests/control_plane/test_room_claim_callback.py new file mode 100644 index 0000000000..8e549dd2b9 --- /dev/null +++ b/tests/control_plane/test_room_claim_callback.py @@ -0,0 +1,234 @@ +"""Synthetic authenticated Lark ingress, actual canonical File/SQLite work.""" +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from test_room_work import setup as room_setup, snapshot, GOAL, TODO, ROOT # noqa: F401 +from loopx.extensions.lark import room_claim as ingress +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + + +@pytest.fixture +def offered(room_setup, monkeypatch): # noqa: F811 - pytest fixture dependency + args, room, binding = room_setup + source = json.loads(args["registry_path"].read_text()) + source["registry_role"] = "global-local" + source["goals"][0]["source_registry"] = str(args["registry_path"]) + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(source)) + monkeypatch.setattr(ingress, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + transport_state = {"fail_update": False, "readable": True, "member": True} + + def runner(argv, cwd, timeout): + payload = None + if "auth" in argv: + return room(argv, cwd, timeout) + if "chats" in argv and "get" in argv: + payload = {"data": {"tenant_key": "room-fixture-tenant", "chat_id": "oc_room_fixture"}} + elif "+chat-members-list" in argv and "--member-types" in argv and argv[argv.index("--member-types") + 1] == "user": + payload = {"data": {"items": [{"open_id": "ou_room_owner", "tenant_key": "room-fixture-tenant"}] + if transport_state["member"] else []}} + elif "api" in argv and "GET" in argv: + payload = {"data": {"items": room.messages if transport_state["readable"] else []}} + elif "api" in argv and "POST" in argv: + if transport_state["fail_update"]: + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + data = json.loads(argv[argv.index("--data") + 1]) + mid = data["token"].removeprefix("callback:") + target = next(m for m in room.messages if m["message_id"] == mid) + target["body"]["content"] = json.dumps(data["card"]) + payload = {} + elif "messages" in argv and "patch" in argv: + mid = argv[argv.index("--message-id") + 1] + data = json.loads(argv[argv.index("--data") + 1]) + next(m for m in room.messages if m["message_id"] == mid)["body"]["content"] = data["content"] + payload = {} + if payload is None: + result = room(argv, cwd, timeout) + payload = json.loads(result["stdout"]) + payload["ok"] = True + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + offer_args = dict(registry_path=args["registry_path"], authority_root=args["runtime_root"], + broker_root=args["runtime_root"], binding_path=args["binding_path"], target_path=args["target_path"], + goal_id=GOAL, runner=runner) + revision = snapshot(args)["provider_revision"] + + def offer(actor="agent-a", key="room-offer-fixture", execute=True): + return ingress.run_room_claim_offer(**offer_args, actor_id=actor, command="offer", execute=execute, + todo_id=TODO, expected_revision=revision, idempotency_key=key, + principals=["lark:ou_room_owner"], expires_at="2030-01-01T00:00:00Z") + + def event(result): + mid = next(m["message_id"] for m in room.messages + if json.loads(m["body"]["content"])["body"]["elements"][1]["behaviors"][0]["value"]["request_id"] == result["request_id"]) + card = json.loads(next(m for m in room.messages if m["message_id"] == mid)["body"]["content"]) + return {"type": "card.action.trigger", "action_tag": "button", "host": "im_message", + "operator_id": "ou_room_owner", "message_id": mid, "chat_id": "oc_room_fixture", + "token": "callback:" + mid, "event_id": "room-fixture-event", + "action_value": card["body"]["elements"][1]["behaviors"][0]["value"]} + + callback_args = dict(runtime_root=args["runtime_root"], profile_app_id="cli_room_fixture", + cli_bin="fixture-cli", profile="room-fixture", runner=runner) + return args, room, binding, offer_args, offer, event, callback_args, transport_state + + +def test_offer_preview_and_cli_do_not_claim_or_publish(offered): + args, room, _, _, offer, _, _, _ = offered + before = snapshot(args)["provider_revision"] + preview = offer(execute=False) + assert preview["ok"] and not preview["canonical_claim_accepted"], preview + assert not room.messages and not (args["runtime_root"] / "room-claim-offers").exists() + assert snapshot(args)["provider_revision"] == before + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "offer", "--goal-id", GOAL, "--agent-id", "agent-a", + "--todo-id", TODO, "--expected-revision", before, "--idempotency-key", "cli-offer-fixture", + "--principal", "lark:ou_room_owner", "--expires-at", "2030-01-01T00:00:00Z"], + cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["canonical_claim_accepted"] is False + assert "PRIVATE_" not in proc.stdout and snapshot(args)["provider_revision"] == before + + +def test_authenticated_callback_and_duplicate_return_one_canonical_claim(offered): + args, room, _, _, offer, event, callback_args, _ = offered + before = snapshot(args)["provider_revision"] + prepared = offer() + assert prepared["ok"] and prepared["readback_verified"], json.dumps(prepared) + assert snapshot(args)["provider_revision"] == before + callback = event(prepared) + accepted = ingress.handle_room_claim_callback(callback, **callback_args) + assert accepted["ok"] and accepted["canonical_claim_accepted"], accepted + assert accepted["execution_authority_granted"] is False + assert snapshot(args)["todos"][0]["claimed_by"] == "agent-a" + committed = snapshot(args)["provider_revision"] + replay = ingress.handle_room_claim_callback(callback, **callback_args) + assert replay["ok"] and replay["claim_status"] == "already_applied", replay + assert snapshot(args)["provider_revision"] == committed + assert "PRIVATE_" not in json.dumps(room.messages) and len(room.messages) == 1 + + +def test_two_actor_offers_compete_at_one_revision(offered): + args, _, _, _, offer, event, callback_args, _ = offered + cards = [offer(actor, "compete-" + actor) for actor in ["agent-a", "agent-b"]] + assert all(card["ok"] for card in cards), cards + callbacks = [event(card) for card in cards] + with ThreadPoolExecutor(2) as pool: + receipts = list(pool.map(lambda e: ingress.handle_room_claim_callback(e, **callback_args), callbacks)) + assert sum(r["canonical_claim_accepted"] for r in receipts) == 1, receipts + assert sorted(r["claim_status"] for r in receipts) == ["applied", "conflict"] + assert snapshot(args)["todos"][0]["claimed_by"] in {"agent-a", "agent-b"} + + +@pytest.mark.parametrize("rejection", ["principal", "grant", "actor", "binding", "message", "app", "card", "expiry", "membership", "route"]) +def test_scope_and_stale_callback_rejections_preserve_canonical_state(offered, monkeypatch, rejection): + args, room, binding, offer_args, offer, event, callback_args, transport = offered + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args)["provider_revision"] + if rejection == "principal": + callback["operator_id"] = "ou_unapproved_room_member" + elif rejection == "grant": + revoked = ingress.run_room_claim_offer(**offer_args, actor_id="agent-a", command="revoke", execute=True, + request_id=prepared["request_id"]) + assert revoked["ok"] + elif rejection == "actor": + r = json.loads(args["registry_path"].read_text()) + r["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + args["registry_path"].write_text(json.dumps(r)) + elif rejection == "binding": + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + elif rejection == "message": + callback["message_id"] = "om_another_card" + elif rejection == "app": + callback_args["profile_app_id"] = "cli_another_app" + elif rejection == "card": + card = json.loads(room.messages[0]["body"]["content"]) + card["body"]["elements"][1]["behaviors"][0]["value"]["request_id"] = "rc_" + "0" * 32 + room.messages[0]["body"]["content"] = json.dumps(card) + elif rejection == "expiry": + monkeypatch.setattr(ingress, "_now", lambda: "2030-01-01T00:00:00Z") + elif rejection == "membership": + transport["member"] = False + elif rejection == "route": + r = json.loads((args["runtime_root"] / "registry.global.json").read_text()) + r["goals"][0]["source_registry"] = str(args["registry_path"].parent / "rebound.json") + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(r)) + with pytest.raises(ingress.RoomClaimCallbackError): + ingress.handle_room_claim_callback(callback, **callback_args) + assert snapshot(args)["provider_revision"] == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert "PRIVATE_" not in json.dumps(room.messages) + + +def test_restart_recovers_only_result_transport_without_a_new_transition(offered): + args, _, _, _, offer, event, callback_args, transport = offered + prepared = offer() + assert prepared["ok"], prepared + transport["fail_update"] = True + pending = ingress.handle_room_claim_callback(event(prepared), **callback_args) + assert pending["canonical_claim_accepted"] and not pending["ok"], pending + committed = snapshot(args)["provider_revision"] + transport["fail_update"] = False + recovery = ingress.recover_room_claim_results(**callback_args, allowed_chat_ids={"oc_room_fixture"}) + assert recovery == {"attempted": 1, "delivered": 1, "failed": 0} + assert snapshot(args)["provider_revision"] == committed + assert ingress.recover_room_claim_results(**callback_args, allowed_chat_ids={"oc_room_fixture"})["attempted"] == 0 + + +def test_collector_default_off_and_native_callback_dispatch(offered, tmp_path): + import shutil + from loopx.extensions.lark.event_collector_runtime import run_lark_event_collector + + args, room, binding, _, offer, event, callback_args, _ = offered + project = args["registry_path"].parent.parent + subprocess.run(["git", "init", "-q", str(project)], check=True) + (project / ".gitignore").write_text(".loopx/\nruntime/\n") + cli = tmp_path / "room-event-cli.mjs" + for connection in binding["bindings"][GOAL]["connections"].values(): + connection["identity"]["cli_bin"] = str(cli) + write_goal_channel_binding(args["binding_path"], binding) + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + cli.write_text("const key=process.argv[process.argv.indexOf('consume')+1];" + "if(key==='card.action.trigger'){console.log('[event] ready event_key=card.action.trigger');" + "console.log(" + json.dumps(json.dumps(callback)) + ");}else{setTimeout(()=>{},500);}") + cfg = project / ".loopx/config/lark" + cfg.mkdir(parents=True) + (cfg / "inbox.json").write_text(json.dumps({"schema_version": "lark_event_inbox_config_v0", "enabled": True, + "inbox_dir": ".loopx/inbox/room", "capture_scope": "configured_chat_all", "reply": {"enabled": True, + "sender_profile": "room-fixture", "sender_identity": "bot", "bot_display_name": "Fixture Bot", + "chat_id": "oc_room_fixture", "placement_policy": "source_context", "editorial_style": "bullet_points_preferred"}})) + config = {"schema_version": "lark_event_collector_config_v1", "enabled": True, + "service_name": "loopx-room-fixture", "event_key": "im.message.receive_v1", "identity": "bot", + "supervisor": "systemd", "consume_timeout": "30m", "lark_cli_bin": "lark-cli", + "operation_callbacks": {"enabled": False}, "routes": [{"route_key": "room", + "chat_id": "oc_room_fixture", "event_inbox_config": ".loopx/config/lark/inbox.json"}]} + path = cfg / "collector.json" + path.write_text(json.dumps(config)) + + def process_runner(argv, **kwargs): + if "whoami" in argv: + return subprocess.CompletedProcess(argv, 0, json.dumps({"appId": "cli_room_fixture"}), "") + reply = callback_args["runner"](argv, None, kwargs.get("timeout")) + return subprocess.CompletedProcess(argv, reply["returncode"], reply["stdout"], reply.get("stderr", "")) + + before = snapshot(args)["provider_revision"] + inactive = run_lark_event_collector(project=project, config_path=path, lark_cli_executable=str(cli), + node_executable=shutil.which("node"), runtime_root=args["runtime_root"], runner=process_runner) + assert inactive["ok"] is True + assert inactive.get("operation_callback_listener_started", False) is False + assert snapshot(args)["provider_revision"] == before + config["operation_callbacks"]["enabled"] = True + path.write_text(json.dumps(config)) + active = run_lark_event_collector(project=project, config_path=path, lark_cli_executable=str(cli), + node_executable=shutil.which("node"), runtime_root=args["runtime_root"], runner=process_runner) + assert active["operation_callback_listener_started"] is True + assert active["operation_callback_verified_count"] == 1, active + assert snapshot(args)["todos"][0]["claimed_by"] == "agent-a" + assert len(room.messages) == 1 diff --git a/tests/control_plane_ts/room_claim_request.test.ts b/tests/control_plane_ts/room_claim_request.test.ts new file mode 100644 index 0000000000..acdf953437 --- /dev/null +++ b/tests/control_plane_ts/room_claim_request.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {decodeRoomClaimRequest, admitRoomClaimCallback} from "../../loopx/control_plane/goals/room_claim_request.ts"; + +const request = () => ({schema_version: "loopx_room_claim_request_v0", command: "claim_todo", + goal_id: "goal-fixture", actor_id: "agent-a", todo_id: "todo_fixture", expected_revision: "fixture:1", + idempotency_key: "claim-fixture", authorized_principals: ["lark:ou_fixture"], expires_at: "2030-01-01T00:00:00Z"}); +const admission = (patch = {}) => ({request: request(), scope_state: "active", principal: "lark:ou_fixture", + observed_at: "2026-01-01T00:00:00Z", ...patch}); + +test("claim scope never comes from room membership or recalled fields", () => { + assert.equal(admitRoomClaimCallback(admission()).allowed, true); + assert.equal(admitRoomClaimCallback(admission()).execution_authority_granted, false); + assert.equal(admitRoomClaimCallback(admission({request: {...request(), authorized_principals: ["im:operator-a"]}, principal: "im:operator-a"})).allowed, true); + assert.equal(admitRoomClaimCallback(admission({principal: "lark:ou_other"})).reason_code, "principal_not_authorized"); + assert.equal(admitRoomClaimCallback(admission({scope_state: "revoked"})).reason_code, "offer_revoked"); + assert.equal(admitRoomClaimCallback(admission({observed_at: "2030-01-01T00:00:00Z"})).reason_code, "offer_expired"); +}); + +test("input boundary rejects broad commands, excess principals and ambiguous expiry", () => { + for (const patch of [{command: "execute"}, {authorized_principals: []}, {authorized_principals: ["*"]}, + {authorized_principals: ["lark:ou_fixture", "lark:ou_fixture"]}, + {expires_at: "2030-01-01"}, {expected_revision: ""}, {recalled_approval: true}]) { + assert.throws(() => decodeRoomClaimRequest({...request(), ...patch})); + } + assert.throws(() => admitRoomClaimCallback(admission({scope_state: "implicitly_approved"}))); +}); From 81bc6bcc80335af4a0ad5c318b723bdf862ff25f Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 08:01:20 -0700 Subject: [PATCH 07/27] docs(goal-channel): document one-operation room claim grants Signed-off-by: Lihua <1017343802@qq.com> --- .../agent-im-openviking-collaboration-v0.md | 5 +- docs/reference/goal-channel-room-work.md | 58 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index ce33d0cf0e..0373ac3bab 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -290,13 +290,14 @@ Task [#5198](https://github.com/loopx-project/loopx/issues/5198) tracks composit The first proposed implementation stage is the [canonical room-work Agent CLI](../../reference/goal-channel-room-work.md): compact actor-scoped orientation, revision-guarded claim, historical acceptance plus -current ownership, and existing verified Goal Channel delivery/readback. It +current ownership, scoped IM claim-button offers/callbacks and existing verified +Goal Channel delivery/readback. It reuses promoted File/SQLite authority and the typed Todo owner; it does not replace the shared-service or provisioning owners. Validation uses synthetic Lark transport with disposable real local stores and source CLI readback. Real non-production room qualification, independent hosts, -daemon reconnect, IM callbacks, authorized artifact references and scoped live +daemon reconnect, native callback qualification, authorized artifact references and scoped live OpenViking retrieval remain unqualified. Design acceptance, a proposed PR and synthetic checks do not close those requirements or authorize promotion. diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index 50bb63d337..e20469c959 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -1,10 +1,11 @@ # Canonical room work through the Agent CLI -This first composition stage of [the Agent IM / LoopX / OpenViking RFC](../architecture/rfcs/agent-im-openviking-collaboration-v0.md) +This composition stage of [the Agent IM / LoopX / OpenViking RFC](../architecture/rfcs/agent-im-openviking-collaboration-v0.md) lets a local registered Agent publish compact work orientation and a canonical claim receipt into its existing Lark Goal Channel. It uses one already promoted local File or SQLite authority. It does not qualify independent multi-host -service authority, an IM button callback, or live OpenViking integration. +service authority or live OpenViking integration. A scoped IM claim button +callback is implemented and tested with synthetic transport. The existing Goal Channel connection remains the configuration owner. An exact, enabled Agent connection and a verified project Bot are required; a default or @@ -88,6 +89,58 @@ not queue unbounded writes or fall back to Markdown authority. After reconnect, resolve the current binding and canonical revision again; a revoked connection or identity cannot use an old receipt to resume this facade. +## Offer a claim in the room + +A trusted local Agent CLI may offer exactly one revision-bound `claim_todo` +interaction to explicitly named Lark principals. This is a per-request grant, +not room membership, remote authentication, an execution lease or a remembered +approval. The broker runtime must still route the Goal to the same source +registry and authority root when a callback arrives. + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work offer \ + --goal-id room-goal --agent-id agent-a --todo-id todo_example \ + --expected-revision '' --idempotency-key room-offer-example \ + --principal lark:ou_example --expires-at '' +``` + +Preview does not persist a grant, send a card or claim work. Add `--execute` to +persist the bounded private offer and publish its confirmation button through +the verified Goal Channel. The existing collector's explicit v1 +`operation_callbacks.enabled=true` configuration is required to consume +`card.action.trigger`; offering a card does not start or enable that collector. +Existing operation callbacks and CLI `project|claim` defaults remain unchanged. + +The incoming principal must pass current provider tenant/member verification +and the offer's explicit scope. The callback also rechecks Bot/profile, +originating message and exact action-card content, current Goal source route, +Agent registration, channel binding and expiry. Actor, Todo, revision and key +come from the private offer, never from incoming fields or memory. The canonical +Todo transaction then owns the claim and idempotent receipt; the result replaces +the initiating card after independent provider readback. A callback ACK alone +is not an accepted claim. No claim callback acquires or renews execution authority. + +Revoke that one offer through the same trusted CLI: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work revoke \ + --goal-id room-goal --agent-id agent-a --request-id '' --execute +``` + +Revocation, actor removal, channel retargeting, authority-route replacement and +expiry fail closed on subsequent callbacks, including replay. Revoke serializes +with dispatch for that offer. It does not undo a historical accepted claim. +Private offer files preserve intent/scope and message delivery metadata, not an +independently advanceable copy of Todo state. On restart the existing collector +may recover a recorded public result card; recovery only patches/reads that +message and never executes another canonical claim. The current binding and +offer scope still govern recovery. + +Validation uses synthetic Lark transport and real disposable File/SQLite +providers, including source CLI preview and the production collector dispatch +entrypoint. This implements the callback path in code; native Lark rendering, +console/listener setup and independent-host live qualification remain separate. + ## Read-only context and remaining qualification Keep any authorized OpenViking retrieval in the existing private @@ -124,5 +177,6 @@ Goal Channel configuration remain usable. 这一修复也适用于直接的 promoted Todo claim。 公开卡片仅包含标识、计数与安全回执,不携带任务正文、证据、artifact 或记忆内容。 +已提供单次授权的 IM 领取按钮、显式撤销和结果卡片恢复;默认不启用 collector。 真实房间、多主机、daemon reconnect、授权 artifact 引用和 OpenViking 只读检索仍待联调。 合成 Lark transport 加真实隔离权威的测试不能宣称三方 RFC 已完成。 From a7627a20ab04cb0b68e85f6be4fbb40d27e8e9ce Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 08:13:51 -0700 Subject: [PATCH 08/27] fix(goal-channel): refuse unqualified exact-instance room grants Signed-off-by: Lihua <1017343802@qq.com> --- docs/reference/goal-channel-room-work.md | 5 ++++- loopx/extensions/lark/room_claim.py | 10 +++++++++ .../project_registry_io_manifest_v1.json | 2 +- .../control_plane/test_room_claim_callback.py | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index e20469c959..c3c1405b42 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -95,7 +95,10 @@ A trusted local Agent CLI may offer exactly one revision-bound `claim_todo` interaction to explicitly named Lark principals. This is a per-request grant, not room membership, remote authentication, an execution lease or a remembered approval. The broker runtime must still route the Goal to the same source -registry and authority root when a callback arrives. +registry and authority root when a callback arrives. This local facade supports +the existing legacy registry profile. It rejects source-session exact-instance +profiles until the canonical claim wire is explicitly bound to their GoalRef; +an older offer cannot inherit a recreated instance. ```sh loopx --registry .loopx/registry.json --format json goal-channel work offer \ diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py index ba3d1bec24..a51a675077 100644 --- a/loopx/extensions/lark/room_claim.py +++ b/loopx/extensions/lark/room_claim.py @@ -15,6 +15,7 @@ from ...agent_registry import registered_agent_ids_from_registry from ...control_plane.coordination.local_authority import claim_canonical_todo_if_promoted +from ...control_plane.collaboration.goal_instance_scope import collaboration_goal_scope from ...control_plane.effect_runtime import effect_runtime_result from ...control_plane.runtime.public_safety import validate_public_safe_value from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route @@ -87,6 +88,12 @@ def _scope(p: Mapping[str, Any], broker_root: Path) -> dict[str, Any]: if (Path(route["source_registry"]).resolve() != Path(p["source_registry"]).resolve() or Path(route["source_runtime_root"]).resolve() != Path(p["authority_root"]).resolve()): raise RoomClaimCallbackError("source_route_changed") + with collaboration_goal_scope(Path(p["source_registry"]), goal_id=request["goal_id"], + agents=(request["actor_id"],), require_active=True) as scope: + # This facade currently uses the existing goal_id-bound canonical claim + # wire. An exact-instance profile cannot inherit a legacy offer. + if scope.exact: + raise RoomClaimCallbackError("exact_instance_claim_unqualified") if request["actor_id"] not in registered_agent_ids_from_registry(Path(p["source_registry"]), request["goal_id"]): raise RoomClaimCallbackError("actor_scope_revoked") resolve_extension_activation(LARK_EXTENSION_ID, @@ -120,6 +127,9 @@ def run_room_claim_offer(*, registry_path: Path, authority_root: Path, broker_ro "execution_authority_granted": False} try: validate_public_safe_value({"goal_id": goal_id, "actor_id": actor_id}) + with collaboration_goal_scope(registry_path, goal_id=goal_id, agents=(actor_id,), require_active=True) as scope: + if scope.exact: + raise RoomClaimCallbackError("exact_instance_claim_unqualified") if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): raise RoomClaimCallbackError("actor_scope_revoked") if command == "revoke": diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 46e21f5a25..5ea5f3ac8a 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -943,7 +943,7 @@ }, { "site": "loopx/contract.py::.check_contract::codec_read:load_registry#1", - "line": 1027, + "line": 1063, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane/test_room_claim_callback.py b/tests/control_plane/test_room_claim_callback.py index 8e549dd2b9..8e6c8994f0 100644 --- a/tests/control_plane/test_room_claim_callback.py +++ b/tests/control_plane/test_room_claim_callback.py @@ -232,3 +232,24 @@ def process_runner(argv, **kwargs): assert active["operation_callback_verified_count"] == 1, active assert snapshot(args)["todos"][0]["claimed_by"] == "agent-a" assert len(room.messages) == 1 + + +def test_legacy_offer_cannot_inherit_an_exact_goal_instance(offered): + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + args, _, _, _, offer, event, callback_args, _ = offered + prepared = offer() + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args)["provider_revision"] + payload = json.loads(args["registry_path"].read_text()) + payload.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) + payload["goals"][0].update(goal_instance_id="ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", status="active") + args["registry_path"].unlink() + with source_session_registry_transaction(args["registry_path"], operation="create_fixture_instance", + create=lambda: payload) as transaction: + transaction.commit(transaction.payload_copy()) + with pytest.raises(ingress.RoomClaimCallbackError): + ingress.handle_room_claim_callback(callback, **callback_args) + assert snapshot(args)["provider_revision"] == before + denied = offer(key="exact-profile-offer") + assert not denied["ok"] and denied["blocker"] == "exact_instance_claim_unqualified", denied From 0eb0310955833b3e7e5aeaf4ff8b3c2228079daf Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 16:40:31 -0700 Subject: [PATCH 09/27] feat(goal-channel): restore private scoped context with authority readback Signed-off-by: Lihua <1017343802@qq.com> --- loopx/capabilities/agent_turn_recall/cli.py | 4 +- .../capabilities/agent_turn_recall/runtime.py | 3 +- loopx/cli_commands/goal_channel.py | 21 +++- .../control_plane/effect_runtime_handlers.ts | 3 + loopx/control_plane/goals/room_resume.ts | 68 +++++++++++ loopx/extensions/lark/room_resume.py | 108 ++++++++++++++++++ 6 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 loopx/control_plane/goals/room_resume.ts create mode 100644 loopx/extensions/lark/room_resume.py diff --git a/loopx/capabilities/agent_turn_recall/cli.py b/loopx/capabilities/agent_turn_recall/cli.py index f8a7905554..e8041d9403 100644 --- a/loopx/capabilities/agent_turn_recall/cli.py +++ b/loopx/capabilities/agent_turn_recall/cli.py @@ -100,7 +100,7 @@ def _receipt_path(repo: Path, goal_id: str, agent_id: str) -> Path: ) -def _quota_decision(path_value: str) -> dict[str, Any]: +def load_turn_quota_decision(path_value: str) -> dict[str, Any]: try: if path_value == "-": payload = json.load(sys.stdin) @@ -206,7 +206,7 @@ def handle_agent_turn_recall_command( } else: identity = _identity_scope(config) - quota_decision = _quota_decision(args.quota_decision_json) + quota_decision = load_turn_quota_decision(args.quota_decision_json) _validate_quota_identity( quota_decision, goal_id=args.goal_id, diff --git a/loopx/capabilities/agent_turn_recall/runtime.py b/loopx/capabilities/agent_turn_recall/runtime.py index b6173ff93c..e3f998b28e 100644 --- a/loopx/capabilities/agent_turn_recall/runtime.py +++ b/loopx/capabilities/agent_turn_recall/runtime.py @@ -226,6 +226,7 @@ def run_configured_agent_turn_recall( execute: bool, session_ref: str | None = None, force_refresh: bool = False, + reconcile_pending_ingests: bool = True, observed_at: str | None = None, provider: ContextProvider | None = None, ) -> dict[str, Any]: @@ -243,7 +244,7 @@ def run_configured_agent_turn_recall( status=experiment_status, ) reconciliation: dict[str, Any] | None = None - if execute and config["automation"]["automatic_ingest"] is True: + if execute and reconcile_pending_ingests and config["automation"]["automatic_ingest"] is True: from ..reward_memory.outcome_lifecycle import ( reconcile_pending_turn_outcome_ingests_fail_open, ) diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index 86a62617e9..3412bb5745 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -214,7 +214,7 @@ def register_goal_channel_commands( work = sub.add_parser("work", help="Publish canonical room orientation or claim through the local Agent CLI.") work_sub = work.add_subparsers(dest="goal_channel_work_command", required=True) - for work_command in ("project", "claim", "offer", "revoke"): + for work_command in ("project", "claim", "offer", "revoke", "resume"): work_parser = work_sub.add_parser(work_command) add_subcommand_format(work_parser) _add_common_args(work_parser) @@ -229,6 +229,12 @@ def register_goal_channel_commands( work_parser.add_argument("--expires-at", required=True) if work_command == "revoke": work_parser.add_argument("--request-id", required=True) + if work_command == "resume": + work_parser.add_argument("--turn-instance-id", required=True) + work_parser.add_argument("--quota-decision-json", required=True, + help="Exact admitted quota packet saved in local private state.") + work_parser.add_argument("--artifact-ref", action="append", + help="Carry only this reference if accepted by the current scoped recall; repeat up to eight.") register_goal_channel_runtime_commands(sub, add_subcommand_format) @@ -471,7 +477,18 @@ def handle_goal_channel_command( _, source_registry_path, work_binding_path, source_runtime_root = _source_context( registry=registry, registry_path=registry_path, goal_id=goal_id, binding_path_arg=getattr(args, "binding_path", None)) - if args.goal_channel_work_command in {"offer", "revoke"}: + if args.goal_channel_work_command == "resume": + from ..capabilities.agent_turn_recall.cli import load_turn_quota_decision + from ..extensions.lark.room_resume import run_room_resume + + payload = run_room_resume(registry_path=source_registry_path, authority_root=source_runtime_root, + broker_root=runtime_root, binding_path=work_binding_path, target_path=_target_path(args, runtime_root), + goal_id=goal_id, actor_id=args.agent_id, turn_instance_id=args.turn_instance_id, + quota_decision=load_turn_quota_decision(args.quota_decision_json), execute=execute, + artifact_refs=args.artifact_ref, + read_current_quota=lambda: _quota_packet(registry_path=source_registry_path, + runtime_root_arg=str(source_runtime_root), goal_id=goal_id, agent_id=args.agent_id)) + elif args.goal_channel_work_command in {"offer", "revoke"}: from ..extensions.lark.room_claim import run_room_claim_offer payload = run_room_claim_offer(registry_path=source_registry_path, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 1a12ffcc63..200ce7855b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,5 +1,6 @@ import {decodeRoomClaimRequest, admitRoomClaimCallback} from "./goals/room_claim_request.ts"; import {projectRoomWork} from "./goals/room_work_projection.ts"; +import {validateRoomResumeInput, projectRoomResumeReadback} from "./goals/room_resume.ts"; import {projectDecisionNotice} from "./presentation/decision_notice.ts"; import {normalizeResearchObservation, validateResearchAttribution, projectResearchFrontier} from "./capabilities/explore_research.ts"; import {projectTodoSummary} from "./todos/summary_projection.ts"; @@ -597,6 +598,8 @@ export function createEffectRuntimeHandlers( ["goal_channel.work.project", projectRoomWork], ["goal_channel.work.claim_request", decodeRoomClaimRequest], ["goal_channel.work.claim_admission", admitRoomClaimCallback], + ["goal_channel.work.resume_input", validateRoomResumeInput], + ["goal_channel.work.resume_readback", projectRoomResumeReadback], ["coordination.local_authority.todo_claim", claimLocalCoordinationTodo], ["coordination.local_authority.todo_create", createLocalCoordinationTodo], ["work_items.team_plan.preview", previewTeamPlan], diff --git a/loopx/control_plane/goals/room_resume.ts b/loopx/control_plane/goals/room_resume.ts new file mode 100644 index 0000000000..34718d403d --- /dev/null +++ b/loopx/control_plane/goals/room_resume.ts @@ -0,0 +1,68 @@ +import type {JsonObject} from "../effect_program.ts"; +import {createHash} from "node:crypto"; +import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; + +/** Compare observations; this read model never accepts work or grants access. */ +function quotaKey(value: unknown, goal: string, actor: string): string { + const q = requireJsonObject(value, "resume quota"); + const identity = requireJsonObject(q.agent_identity, "quota actor"); + const selected = requireJsonObject(q.selected_todo, "selected Todo"); + if (q.ok !== true || q.status_health_ok !== true || q.goal_id !== goal || identity.agent_id !== actor || + (selected.claimed_by != null && selected.claimed_by !== actor)) throw new Error("resume quota scope unavailable"); + const interaction = requireJsonObject(q.interaction_contract, "quota interaction"); + const agent = requireJsonObject(interaction.agent_channel, "quota agent channel"); + return JSON.stringify([requireNonEmptyString(selected.todo_id, "selected Todo id"), selected.status ?? null, + selected.claimed_by ?? null, selected.task_class ?? null, selected.action_kind ?? null, + q.should_run === true, q.state ?? null, q.effective_action ?? null, agent.must_attempt === true, + agent.delivery_allowed === true, interaction.mode ?? null]); +} + +export function validateRoomResumeInput(value: unknown): JsonObject { + const p = requireJsonObject(value, "room resume input"); + const goal = requireNonEmptyString(p.goal_id, "goal id"); + const actor = requireNonEmptyString(p.actor_id, "actor id"); + const turn = requireNonEmptyString(p.turn_instance_id, "turn identity"); + const q = requireJsonObject(p.quota, "input quota"); + const receipt = requireJsonObject(q.heartbeat_receipt, "quota receipt"); + if (q.mode !== "should-run" || receipt.turn_instance_id !== turn || + !["committed", "replayed"].includes(String(receipt.status))) throw new Error("resume Turn receipt mismatch"); + const refs = requireStringArray(p.artifact_refs, "requested artifact references"); + if (refs.length > 8 || new Set(refs).size !== refs.length || refs.some(ref => + !/^viking:\/\/[A-Za-z0-9_./:-]+$/.test(ref) || ref.split("/").some(part => part === "." || part === ".."))) { + throw new Error("invalid bounded artifact references"); + } + const matches = quotaKey(q, goal, actor) === quotaKey(p.current_quota, goal, actor); + return {schema_version: "loopx_room_resume_input_check_v0", matches, + reason_code: matches ? null : "quota_observation_changed", execution_authority_granted: false}; +} + +export function projectRoomResumeReadback(value: unknown): JsonObject { + const p = requireJsonObject(value, "room resume readback"); + const goal = requireNonEmptyString(p.goal_id, "goal id"); + const actor = requireNonEmptyString(p.actor_id, "actor id"); + const before = requireJsonObject(p.before_projection, "before work projection"); + const after = requireJsonObject(p.after_projection, "after work projection"); + for (const projection of [before, after]) { + if (projection.goal_id !== goal || projection.actor_id !== actor || projection.authority_state !== "available") { + throw new Error("resume projection scope mismatch"); + } + } + const stable = before.source_revision === after.source_revision && p.before_scope === p.after_scope && + quotaKey(p.before_quota, goal, actor) === quotaKey(p.after_quota, goal, actor); + const recall = requireJsonObject(p.recall, "recall observation"); + const requested = requireStringArray(p.artifact_refs, "requested references"); + const application = recall.application == null ? {} : requireJsonObject(recall.application, "recall application"); + const receipt = application.receipt == null ? {} : requireJsonObject(application.receipt, "recall receipt"); + const accepted = receipt.memory_ref_digests == null ? [] : requireStringArray(receipt.memory_ref_digests, "accepted reference digests"); + const roots = requireStringArray(p.artifact_scope_refs, "configured artifact scopes"); + // A CLI request cannot turn an arbitrary or remembered pointer into read scope. + const authorized = stable && receipt.outcome === "applied" && receipt.current_artifact_verified === true && + receipt.result_readback_verified === true ? requested.filter(ref => + roots.some(root => ref === root || ref.startsWith(root.replace(/\/$/, "") + "/")) && + accepted.includes(createHash("sha256").update(ref).digest("hex").slice(0, 16))) : []; + return {schema_version: "loopx_room_resume_readback_v0", authority_observation_stable: stable, + context_usable: stable && recall.ok === true && recall.status === "applied" && recall.context != null, + artifact_references: authorized.map(ref => ({ref, source: "current_scoped_recall", target_access_granted: false})), + omitted_reference_count: requested.length - authorized.length, + execution_authority_granted: false, memory_grants_authority: false}; +} diff --git a/loopx/extensions/lark/room_resume.py b/loopx/extensions/lark/room_resume.py new file mode 100644 index 0000000000..e28cc0a0b5 --- /dev/null +++ b/loopx/extensions/lark/room_resume.py @@ -0,0 +1,108 @@ +"""Private reconnect context through existing recall, quota and channel owners.""" +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from ...agent_registry import registered_agent_ids_from_registry +from ...capabilities.agent_turn_recall.runtime import run_configured_agent_turn_recall_fail_open +from ...capabilities.context_providers.base import ContextProvider +from ...capabilities.reward_memory.experiment import resolve_reward_memory_experiment, resolve_reward_memory_surface_config +from ...control_plane.collaboration.goal_instance_scope import collaboration_goal_scope +from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ..runtime import default_extension_state_file, resolve_extension_activation +from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route +from .goal_channel_message_delivery import GoalChannelMessageDeliverySession +from .goal_channel_work import _resolve_binding, run_goal_channel_work +from .presentation.kanban import CommandRunner, default_subprocess_runner + +ROOM_RESUME_RESULT_SCHEMA = "loopx_goal_channel_resume_v0" + + +def run_room_resume(*, registry_path: Path, authority_root: Path, broker_root: Path, + binding_path: Path, target_path: Path, goal_id: str, actor_id: str, + quota_decision: Mapping[str, Any], turn_instance_id: str, execute: bool, + read_current_quota: Callable[[], dict[str, Any]], artifact_refs: list[str] | None = None, + runner: CommandRunner = default_subprocess_runner, provider: ContextProvider | None = None) -> dict[str, Any]: + packet: dict[str, Any] = {"schema_version": ROOM_RESUME_RESULT_SCHEMA, "ok": False, + "goal_id": goal_id, "actor_id": actor_id, "turn_instance_id": turn_instance_id, + "status": "rejected", "visibility": "private", "private_context": None, + "artifact_references": [], "provider_call_count": 0, "room_delivery_performed": False, + "external_writes_performed": False, "quota_spend_performed": False, + "execution_authority_granted": False} + refs = artifact_refs or [] + + def scope() -> tuple[dict[str, Any], str, list[str]]: + route = resolve_goal_source_runtime_route(registry_path=broker_root / "registry.global.json", goal_id=goal_id) + if (Path(route["source_registry"]).resolve() != registry_path.resolve() or + Path(route["source_runtime_root"]).resolve() != authority_root.resolve()): + raise ValueError("source route changed") + with collaboration_goal_scope(registry_path, goal_id=goal_id, agents=(actor_id,), require_active=True) as current: + if current.exact: + raise ValueError("exact instance resume unqualified") + if actor_id not in registered_agent_ids_from_registry(registry_path, goal_id): + raise ValueError("actor scope revoked") + binding = _resolve_binding(binding_path, target_path, goal_id, actor_id) + status, config = resolve_reward_memory_experiment(registry_path=registry_path, goal_id=goal_id, agent_id=actor_id) + digest = hashlib.sha256(json.dumps([goal_channel_binding_digest(binding), status, config], + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + roots = [str(item["provider_binding"]["scope_ref"]) for item in + resolve_reward_memory_surface_config(config, "agent_workflow.turn_admission")["recall_corpora"]] if config else [] + if execute: + resolve_extension_activation(LARK_EXTENSION_ID, state_file=default_extension_state_file(authority_root), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, + binding_lock_path=binding_path, target_lock_path=target_path, + history_start_at="1970-01-01T00:00:00Z", + resolve_current_binding=lambda: _resolve_binding(binding_path, target_path, goal_id, actor_id), runner=runner) + if not session.verify(goal_channel_delivery_route(goal_id, session.resolve)): + raise ValueError("provider identity unavailable") + return binding, digest, roots + + def projection() -> dict[str, Any]: + observed = run_goal_channel_work(registry_path=registry_path, runtime_root=authority_root, + binding_path=binding_path, target_path=target_path, goal_id=goal_id, + actor_id=actor_id, command="project", execute=False, runner=runner) + if observed.get("ok") is not True: + raise ValueError("canonical projection unavailable") + return dict(observed["projection"]) + + try: + _, before_scope, artifact_scopes = scope() + before_projection = projection() + before_quota = read_current_quota() + packet.update(current_quota=before_quota, work_projection=before_projection) + checked = effect_runtime_result("goal_channel.work.resume_input", {"goal_id": goal_id, "actor_id": actor_id, + "turn_instance_id": turn_instance_id, "quota": dict(quota_decision), "current_quota": before_quota, + "artifact_refs": refs}) + if not checked["matches"]: + return {**packet, "blocker": checked["reason_code"]} + if not execute: + return {**packet, "ok": True, "status": "planned", "requested_reference_count": len(refs)} + recalled = run_configured_agent_turn_recall_fail_open(registry_path=registry_path, + goal_id=goal_id, agent_id=actor_id, quota_decision=before_quota, turn_instance_id=turn_instance_id, + execute=True, force_refresh=True, reconcile_pending_ingests=False, provider=provider) + packet["provider_call_count"] = recalled.get("provider_call_count", 0) + packet["context_status"] = recalled.get("status") + _, after_scope, _ = scope() + after_projection = projection() + after_quota = read_current_quota() + packet.update(current_quota=after_quota, work_projection=after_projection) + readback = effect_runtime_result("goal_channel.work.resume_readback", {"goal_id": goal_id, "actor_id": actor_id, + "before_scope": before_scope, "after_scope": after_scope, "before_projection": before_projection, + "after_projection": after_projection, "before_quota": before_quota, "after_quota": after_quota, + "recall": recalled, "artifact_refs": refs, "artifact_scope_refs": artifact_scopes}) + packet.update(readback=readback, artifact_references=readback["artifact_references"]) + if not readback["authority_observation_stable"]: + return {**packet, "status": "authority_changed", "blocker": "authority_observation_changed"} + packet.update(ok=True, status="restored" if readback["context_usable"] else "context_unavailable", + private_context=recalled.get("context") if readback["context_usable"] else None) + return packet + except (OSError, RuntimeError, TypeError, ValueError): + return {**packet, "ok": False, "status": "rejected", "private_context": None, + "artifact_references": [], "blocker": "resume_scope_unavailable"} From 11ca722a2712bbb30eb24b3ef7051c9d0eaf47a0 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 16:40:32 -0700 Subject: [PATCH 10/27] test(goal-channel): qualify read-only reconnect and reference boundaries Signed-off-by: Lihua <1017343802@qq.com> --- .../project_registry_io_manifest_v1.json | 4 +- tests/capabilities/test_agent_turn_recall.py | 2 +- tests/control_plane/test_room_resume.py | 266 ++++++++++++++++++ tests/control_plane_ts/room_resume.test.ts | 48 ++++ tsconfig.control-plane.json | 3 + 5 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_room_resume.py create mode 100644 tests/control_plane_ts/room_resume.test.ts diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 269fa84f33..d341700e4e 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -647,7 +647,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::._source_context::codec_read:load_registry#1", - "line": 321, + "line": 327, "column": 14, "kind": "codec_read", "api": "load_registry", @@ -655,7 +655,7 @@ }, { "site": "loopx/cli_commands/goal_channel.py::.handle_goal_channel_command::codec_read:load_registry#1", - "line": 461, + "line": 467, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/tests/capabilities/test_agent_turn_recall.py b/tests/capabilities/test_agent_turn_recall.py index ca8d2ccc4e..92124c4f8b 100644 --- a/tests/capabilities/test_agent_turn_recall.py +++ b/tests/capabilities/test_agent_turn_recall.py @@ -589,7 +589,7 @@ def test_explicit_cli_checkpoint_transport_failure_returns_safe_packet( turn = "turn-cli-failure" decision = quota_decision() | {"goal_id": "goal", "agent_identity": {"agent_id": "pilot"}, "heartbeat_receipt": {"turn_instance_id": turn, "status": "committed"}} - monkeypatch.setattr(recall_cli, "_quota_decision", lambda *_args: decision) + monkeypatch.setattr(recall_cli, "load_turn_quota_decision", lambda *_args: decision) def unavailable(operation: str, _params: Any) -> Any: assert operation == "reward_memory.read_authority.surface_checkpoints" diff --git a/tests/control_plane/test_room_resume.py b/tests/control_plane/test_room_resume.py new file mode 100644 index 0000000000..7f3d970f31 --- /dev/null +++ b/tests/control_plane/test_room_resume.py @@ -0,0 +1,266 @@ +"""Private context, synthetic OV/Lark, real local authority and source CLI.""" +import copy +import argparse +import json +import subprocess +import sys + +import pytest + +from test_room_work import setup as room_setup, snapshot, claim, GOAL, TODO, ROOT # noqa: F401 +from loopx.cli_commands.goal_channel import _quota_packet, handle_goal_channel_command +from loopx.extensions.lark import room_resume as resume +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding +from loopx.capabilities.agent_turn_recall import runtime as recall_runtime +from loopx.capabilities.reward_memory import outcome_lifecycle +from loopx.capabilities.reward_memory.experiment import load_reward_memory_experiment_config +from loopx.capabilities.context_providers.base import ContextProviderItem, ContextProviderRetrieval +from tests.capabilities.test_agent_turn_recall import raw_config, active_record + +TURN = "room-resume-fixture-turn" + + +@pytest.fixture +def reconnect(room_setup, monkeypatch): # noqa: F811 + args, room, binding = room_setup + assert claim(args, "agent-a", snapshot(args)["provider_revision"], "resume-initial-claim")["ok"] + room.messages.clear() + source = json.loads(args["registry_path"].read_text()) + source["goals"][0].update(domain="room-resume-fixture", status="active-read-only", + adapter={"kind": "read_only_project_map_v0", "status": "connected-read-only"}) + args["registry_path"].write_text(json.dumps(source)) + source["registry_role"] = "global-local" + source["goals"][0]["source_registry"] = str(args["registry_path"]) + (args["runtime_root"] / "registry.global.json").write_text(json.dumps(source)) + monkeypatch.setattr(resume, "resolve_extension_activation", lambda *a, **k: {"enabled": True}) + + def fresh(): + return _quota_packet(registry_path=args["registry_path"], runtime_root_arg=str(args["runtime_root"]), + goal_id=GOAL, agent_id="agent-a") + + quota = fresh() + admitted = copy.deepcopy(quota) + admitted.update(mode="should-run", heartbeat_receipt={"turn_instance_id": TURN, "status": "committed"}) + kwargs = dict(registry_path=args["registry_path"], authority_root=args["runtime_root"], + broker_root=args["runtime_root"], binding_path=args["binding_path"], target_path=args["target_path"], + goal_id=GOAL, actor_id="agent-a", quota_decision=admitted, turn_instance_id=TURN, + read_current_quota=fresh, runner=room) + return args, room, binding, kwargs + + +def test_source_cli_preview_is_private_and_performs_no_recall_or_claim(reconnect, tmp_path): + args, room, _, kwargs = reconnect + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=False) + assert result["ok"] and result["status"] == "planned", json.dumps(result) + saved = tmp_path / "admitted.json" + guard = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "quota", "should-run", "--goal-id", GOAL, "--agent-id", "agent-a", + "--codex-app", "--turn-instance-id", TURN, "--available-capability", "network"], + cwd=ROOT, text=True, capture_output=True) + assert guard.returncode == 0, guard.stdout + guard.stderr + admitted = json.loads(guard.stdout) + assert admitted["ok"] and admitted["heartbeat_receipt"]["status"] == "committed", admitted + saved.write_text(guard.stdout) + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", "goal-channel", "work", "resume", "--goal-id", GOAL, "--agent-id", "agent-a", + "--turn-instance-id", TURN, "--quota-decision-json", str(saved)], cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert json.loads(proc.stdout)["status"] == "planned" + assert not room.messages and snapshot(args)["provider_revision"] == before + assert result["provider_call_count"] == 0 and result["private_context"] is None + + +def test_default_off_preserves_canonical_work_without_room_or_memory_writes(reconnect): + args, room, _, kwargs = reconnect + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=True) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] + assert result["provider_call_count"] == 0 and not room.messages + assert snapshot(args)["provider_revision"] == before + assert not result["external_writes_performed"] and not result["execution_authority_granted"] + + +@pytest.fixture +def recalled(reconnect, tmp_path, monkeypatch): + args, room, binding, kwargs = reconnect + raw = raw_config(goal_id=GOAL, agent_id="agent-a", peer_ref="agent:agent-a") + raw["automation"]["automatic_ingest"] = True + path = tmp_path / "recall-config.json" + path.write_text(json.dumps(raw)) + config = load_reward_memory_experiment_config(project=tmp_path, config_path=path.name) + status = {"ok": True, "status": "available", "automatic_recall": True, "automatic_ingest": True} + def resolver(**_): + return status, config + monkeypatch.setattr(resume, "resolve_reward_memory_experiment", resolver) + monkeypatch.setattr(recall_runtime, "resolve_reward_memory_experiment", resolver) + + def forbid_ingest(**_): + raise AssertionError("Read-only room restore must not reconcile memory writes") + monkeypatch.setattr(outcome_lifecycle, "reconcile_pending_turn_outcome_ingests_fail_open", forbid_ingest) + + class Provider: + provider_id = "openviking" + calls = 0 + mutate = None + unavailable = False + content = active_record(config, expires_at="2030-01-01T00:00:00Z") + ref = raw["project_provider_binding"]["corpus_scopes"][0]["scope_ref"] + "/artifact.json" + + def retrieve(self, **parameters): + self.calls += 1 + if self.mutate: + self.mutate() + if self.unavailable: + raise RuntimeError("synthetic unavailable provider") + assert self.ref.startswith(parameters["scope_ref"].rstrip("/") + "/") + return ContextProviderRetrieval(provider="openviking", namespace=parameters["namespace"], + status="completed", query_summary=parameters["query_summary"], observed_at=parameters["observed_at"], + search_performed=True, read_performed=True, + items=(ContextProviderItem(resource_ref=self.ref, summary="Scoped artifact", content=self.content),)) + + provider = Provider() + return args, room, binding, kwargs, provider, config + + +def test_restore_carries_only_current_explicit_scoped_references_without_writes(recalled): + args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] + requested = [provider.ref, "viking://user/other/private.json"] + first = resume.run_room_resume(**kwargs, execute=True, artifact_refs=requested, provider=provider) + assert first["ok"] and first["status"] == "restored", json.dumps(first) + assert first["private_context"]["guidance"] + assert first["artifact_references"] == [{"ref": provider.ref, "source": "current_scoped_recall", "target_access_granted": False}] + assert first["readback"]["omitted_reference_count"] == 1 + assert first["current_quota"]["selected_todo"]["claimed_by"] == "agent-a" + # Reconnect retrieves anew even when a previous private receipt exists. + second = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert second["ok"] and provider.calls == 2 + assert snapshot(args)["provider_revision"] == before and not room.messages + assert not first["execution_authority_granted"] and not first["external_writes_performed"] + + +@pytest.mark.parametrize("change", ["actor", "binding", "route", "memory_scope", "canonical_revision", "user_gate"]) +def test_changes_during_retrieval_discard_context_and_references(recalled, change): + args, room, binding, kwargs, provider, config = recalled + before = snapshot(args)["provider_revision"] + + def mutate(): + if change == "binding": + binding["bindings"][GOAL]["connections"]["agent-a"]["enabled"] = False + write_goal_channel_binding(args["binding_path"], binding) + elif change == "memory_scope": + config["automation"]["automatic_recall"] = False + elif change in {"actor", "route"}: + path = args["registry_path"] if change == "actor" else args["runtime_root"] / "registry.global.json" + source = json.loads(path.read_text()) + if change == "actor": + source["goals"][0]["coordination"]["registered_agents"] = ["agent-b"] + else: + source["goals"][0]["source_registry"] = str(path.parent / "replacement.json") + path.write_text(json.dumps(source)) + else: + operation = ["todo", "update", "--todo-id", TODO, "--text", "Current fixture task changed.", + "--update-operation-id", "resume-concurrent-fixture"] if change == "canonical_revision" else [ + "todo", "add", "--role", "user", "--task-class", "user_gate", "--blocks-agent", "agent-a", + "--bound-agent", "agent-a", "--text", "Approve the fixture action."] + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), + "--format", "json", *operation, "--goal-id", GOAL, "--agent-id", "agent-a"], + cwd=ROOT, text=True, capture_output=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + + provider.mutate = mutate + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert not result["ok"], result + assert result["private_context"] is None and result["artifact_references"] == [] + assert provider.calls == 1 and not room.messages + if change not in {"canonical_revision", "user_gate"}: + assert snapshot(args)["provider_revision"] == before + + +def test_provider_failure_preserves_current_quota_with_reduced_context(recalled): + args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] + provider.unavailable = True + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] + assert result["current_quota"]["selected_todo"]["todo_id"] == TODO + assert not room.messages and snapshot(args)["provider_revision"] == before + + +@pytest.mark.parametrize("invalid", ["actor", "turn", "revision_basis", "provider_identity"]) +def test_invalid_admission_never_contacts_context_provider(recalled, invalid): + args, room, _, kwargs, provider, _ = recalled + kwargs = copy.deepcopy(kwargs) if invalid != "provider_identity" else dict(kwargs) + if invalid == "actor": + kwargs["quota_decision"]["agent_identity"]["agent_id"] = "agent-b" + elif invalid == "turn": + kwargs["turn_instance_id"] = "old-turn" + elif invalid == "revision_basis": + kwargs["quota_decision"]["selected_todo"]["todo_id"] = "todo_other" + else: + room.authenticated = False + before = snapshot(args)["provider_revision"] + result = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert not result["ok"] and provider.calls == 0 + assert result["private_context"] is None and not room.messages + assert snapshot(args)["provider_revision"] == before + + +@pytest.mark.parametrize("invalid", ["expired", "foreign_peer"]) +def test_unqualified_records_cannot_restore_context_or_references(recalled, invalid): + _, room, _, kwargs, provider, _ = recalled + content = json.loads(provider.content) + if invalid == "expired": + content["lifecycle"]["expires_at"] = "2000-01-01T00:00:00Z" + else: + content["scope"]["peer_ref"] = "agent:other" + provider.content = json.dumps(content) + result = resume.run_room_resume(**kwargs, execute=True, artifact_refs=[provider.ref], provider=provider) + assert result["ok"] and result["status"] == "context_unavailable", result + assert result["private_context"] is None and result["artifact_references"] == [] and not room.messages + + +def test_existing_automatic_recall_keeps_its_reconciliation_default(recalled, monkeypatch): + _, _, _, kwargs, provider, _ = recalled + reconciled = [] + monkeypatch.setattr(outcome_lifecycle, "reconcile_pending_turn_outcome_ingests_fail_open", + lambda **options: reconciled.append(options) or {"ok": True, "status": "not_required"}) + recall_runtime.run_configured_agent_turn_recall(registry_path=kwargs["registry_path"], goal_id=GOAL, + agent_id="agent-a", quota_decision=kwargs["quota_decision"], turn_instance_id=TURN, + execute=True, force_refresh=True, provider=provider) + assert len(reconciled) == 1 + assert resume.run_room_resume(**kwargs, execute=True, provider=provider)["ok"] + assert len(reconciled) == 1 + + +def test_executing_cli_dispatch_returns_private_context_without_room_delivery(recalled, tmp_path, monkeypatch): + args, room, _, kwargs, provider, _ = recalled + saved = tmp_path / "quota.json" + saved.write_text(json.dumps(kwargs["quota_decision"])) + actual = resume.run_room_resume + monkeypatch.setattr(resume, "run_room_resume", lambda **options: actual(**options, runner=room, provider=provider)) + namespace = argparse.Namespace(command="goal-channel", goal_channel_command="work", goal_channel_work_command="resume", + goal_id=GOAL, agent_id="agent-a", execute=True, binding_path=None, target_path=None, + turn_instance_id=TURN, quota_decision_json=str(saved), artifact_ref=[provider.ref]) + captured = {} + code = handle_goal_channel_command(namespace, registry_path=args["registry_path"], + runtime_root_arg=str(args["runtime_root"]), print_payload=lambda p, *_: captured.update(p), output_format=lambda _: "json") + assert code == 0 and captured["status"] == "restored", captured + assert captured["private_context"]["guidance"] and captured["artifact_references"] + assert not room.messages and not captured["execution_authority_granted"] + + +def test_exact_instance_profile_fails_closed_before_retrieval(recalled): + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + args, room, _, kwargs, provider, _ = recalled + source = json.loads(args["registry_path"].read_text()) + source.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) + source["goals"][0].update(goal_instance_id="ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", status="active") + args["registry_path"].unlink() + with source_session_registry_transaction(args["registry_path"], operation="create_resume_fixture", create=lambda: source) as tx: + tx.commit(tx.payload_copy()) + result = resume.run_room_resume(**kwargs, execute=True, provider=provider) + assert not result["ok"] and provider.calls == 0 and result["private_context"] is None and not room.messages diff --git a/tests/control_plane_ts/room_resume.test.ts b/tests/control_plane_ts/room_resume.test.ts new file mode 100644 index 0000000000..48f722c096 --- /dev/null +++ b/tests/control_plane_ts/room_resume.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {createHash} from "node:crypto"; +import {validateRoomResumeInput, projectRoomResumeReadback} from "../../loopx/control_plane/goals/room_resume.ts"; + +const quota = {ok: true, status_health_ok: true, goal_id: "goal", mode: "should-run", + agent_identity: {agent_id: "actor"}, heartbeat_receipt: {status: "committed", turn_instance_id: "turn"}, + selected_todo: {todo_id: "todo", claimed_by: "actor", status: "open"}, should_run: true, + interaction_contract: {mode: "run", agent_channel: {must_attempt: true, delivery_allowed: true}}}; +const projection = {goal_id: "goal", actor_id: "actor", authority_state: "available", source_revision: "revision"}; +const ref = "viking://user/example/peers/actor/memories/artifact.json"; +const readback = {goal_id: "goal", actor_id: "actor", before_quota: quota, after_quota: quota, + before_projection: projection, after_projection: projection, before_scope: "scope", after_scope: "scope", + artifact_scope_refs: ["viking://user/example/peers/actor/memories"], + recall: {ok: true, status: "applied", context: {guidance: ["remembered approval"]}, application: {receipt: { + outcome: "applied", current_artifact_verified: true, result_readback_verified: true, + memory_ref_digests: [createHash("sha256").update(ref).digest("hex").slice(0, 16)]}}}, + artifact_refs: [ref, "viking://user/other/private.json"]}; + +test("exact Turn/actor identity and current quota are required before retrieval", () => { + const input = {goal_id: "goal", actor_id: "actor", turn_instance_id: "turn", quota, current_quota: quota, artifact_refs: []}; + assert.equal(validateRoomResumeInput(input).matches, true); + assert.throws(() => validateRoomResumeInput({...input, actor_id: "other"})); + assert.throws(() => validateRoomResumeInput({...input, turn_instance_id: "old"})); + assert.throws(() => validateRoomResumeInput({...input, artifact_refs: ["viking://user/example/../other"]})); + assert.equal(validateRoomResumeInput({...input, current_quota: {...quota, should_run: false}}).matches, false); +}); + +test("only explicitly requested references from current accepted recall may be carried", () => { + const result = projectRoomResumeReadback(readback); + assert.deepEqual(result.artifact_references, [{ref, source: "current_scoped_recall", target_access_granted: false}]); + assert.equal(result.omitted_reference_count, 1); + assert.equal(result.execution_authority_granted, false); + assert.deepEqual(projectRoomResumeReadback({...readback, artifact_scope_refs: ["viking://user/other"]}).artifact_references, []); +}); + +test("reconnect discards context when canonical revision, gate, ownership or scope changes", () => { + for (const delta of [{after_projection: {...projection, source_revision: "new"}}, {after_scope: "revoked"}, + {after_quota: {...quota, should_run: false}}]) { + const result = projectRoomResumeReadback({...readback, ...delta}); + assert.equal(result.context_usable, false); + assert.deepEqual(result.artifact_references, []); + } + assert.throws(() => projectRoomResumeReadback({...readback, after_quota: {...quota, + selected_todo: {todo_id: "todo", claimed_by: "other"}}})); + assert.equal(projectRoomResumeReadback({...readback, recall: {ok: true, status: "provider_unavailable", + context: {guidance: []}}}).context_usable, false); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 4ec2310ab9..9fd0564153 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -13,6 +13,9 @@ "types": ["node"] }, "include": [ + "tests/control_plane_ts/room_resume.test.ts", + "tests/control_plane_ts/room_claim_request.test.ts", + "tests/control_plane_ts/room_work_projection.test.ts", "loopx/control_plane/capabilities/explore_research.ts", "tests/control_plane_ts/explore_research.test.ts", "loopx/control_plane/runtime/usage_statistics*.ts", From 5c3ac84c309b5f0cfb6a5f5af9c594847cb813e7 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 16:40:32 -0700 Subject: [PATCH 11/27] docs(goal-channel): describe private context restore and remaining qualification Signed-off-by: Lihua <1017343802@qq.com> --- .../agent-im-openviking-collaboration-v0.md | 10 +++- docs/reference/goal-channel-room-work.md | 56 ++++++++++++++++--- .../capabilities/agent_turn_recall/README.md | 7 +++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 0373ac3bab..d78c8cc58f 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -295,9 +295,17 @@ Goal Channel delivery/readback. It reuses promoted File/SQLite authority and the typed Todo owner; it does not replace the shared-service or provisioning owners. +The private `work resume` path composes existing scoped Turn Recall with fresh +channel identity, canonical projection and quota observations. It skips memory +ingest, rechecks scope and state after retrieval, discards context on changes, +and carries only explicitly requested scoped record-artifact references covered +by current verified recall receipts. It does not send private context to a room +or accept/renew work. Source-session exact-instance profiles and arbitrary +external artifact targets remain outside this local qualification stage. + Validation uses synthetic Lark transport with disposable real local stores and source CLI readback. Real non-production room qualification, independent hosts, -daemon reconnect, native callback qualification, authorized artifact references and scoped live +daemon reconnect, native callback qualification, external artifact-target access and scoped live OpenViking retrieval remain unqualified. Design acceptance, a proposed PR and synthetic checks do not close those requirements or authorize promotion. diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index c3c1405b42..b139d0b1bd 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -144,15 +144,53 @@ providers, including source CLI preview and the production collector dispatch entrypoint. This implements the callback path in code; native Lark rendering, console/listener setup and independent-host live qualification remain separate. -## Read-only context and remaining qualification - -Keep any authorized OpenViking retrieval in the existing private -[Agent Turn Recall](../../loopx/capabilities/agent_turn_recall/README.md) path. -It consumes the exact selected quota packet, enforces its existing provider -scope, and produces private observations. This room stage never enables recall, -imports context, forwards pointers, writes memory, or treats a recalled approval -as a current gate. Authorized artifact-reference composition and live context -recovery remain later acceptance work on #5198. +## Private read-only reconnect context + +`work resume` composes the existing private +[Agent Turn Recall](../../loopx/capabilities/agent_turn_recall/README.md) path with +current channel identity, canonical projection and quota readback. Use the exact +admitted quota packet saved by the host; the command does not admit another Turn: + +```sh +loopx --registry .loopx/registry.json --format json goal-channel work resume \ + --goal-id room-goal --agent-id agent-a --turn-instance-id '' \ + --quota-decision-json .local/admitted-quota.json +``` + +Preview performs no provider retrieval, receipt write, claim or room send. +Add `--execute` to verify the current Bot identity and restore scoped context. +The existing Reward Memory configuration and Agent enablement receipts remain +the configuration owner; this command never enables or broadens recall. It +explicitly skips pending memory-ingest reconciliation even when ordinary +automatic recall enables that behavior. Normal automatic recall retains its +existing default. Retrieval may write the existing private local recall receipt; +it performs no memory-provider write, quota spend, lease renewal or room delivery. + +Restore always retrieves anew rather than trusting an earlier same-Turn context +receipt. It checks current source route, Agent registration, exact channel, +Bot identity and configured memory scope, then reads canonical state and quota. +After retrieval it checks those observations again. A changed binding, scope, +Todo revision, selected work or gate discards the context and references. A +provider outage or disabled configuration leaves the fresh LoopX observations +available with `status=context_unavailable`; it does not manufacture a user gate. +Exact-instance source-session profiles remain unsupported and fail closed. + +To carry an inspected reference, repeat `--artifact-ref ''` up to +eight times. Only explicitly requested references inside the current configured +read scope and covered by the current accepted recall's verified application +receipt are returned. Unknown, expired or out-of-scope references are omitted. +This stage carries pointers to scoped retrieved record artifacts; it does not +qualify arbitrary external artifact targets or fetch them through a new provider. +A pointer never grants access to its target or proves completion. + +The JSON result has `visibility=private`, `private_context`, bounded +`artifact_references`, fresh `current_quota` and content-minimal `work_projection`. +Do not copy that private packet into a room, public evidence or shared logs. +Remembered approvals cannot satisfy current gates, and restored context grants +no execution authority. Reconcile an uncertain prior claim separately with the +original `work claim` tuple/key; resume itself never replays a work command. + +## Remaining qualification The new checks use synthetic Lark transport and real disposable File/SQLite stores, including source CLI projection and direct CLI ownership readback. diff --git a/loopx/capabilities/agent_turn_recall/README.md b/loopx/capabilities/agent_turn_recall/README.md index 55543d7678..43cbe648d5 100644 --- a/loopx/capabilities/agent_turn_recall/README.md +++ b/loopx/capabilities/agent_turn_recall/README.md @@ -85,6 +85,13 @@ Retrieval relevance and action applicability remain separate. Recalled guidance is conditional private context; the agent must compare it with the exact turn situation and current authority before acting. +The optional Goal Channel `work resume` composition uses a read-only invocation: +it refreshes recall instead of reusing a previous receipt and disables pending +ingest reconciliation for that invocation. Ordinary automatic admission retains +its existing reconciliation behavior. See the +[room-work usage contract](../../../docs/reference/goal-channel-room-work.md#private-read-only-reconnect-context) +for current authority readback and private reference handling. + ## Freshness And Failure Active Reward Memory records may carry `lifecycle.expires_at`. Retrieval ignores From 742672ca2c74fa982d424415d0342830a40d8e6a Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 17:29:36 -0700 Subject: [PATCH 12/27] fix(goal-channel): omit stale observations after restore scope failure Signed-off-by: Lihua <1017343802@qq.com> --- docs/reference/goal-channel-room-work.md | 11 ++++++++++- loopx/control_plane/goals/room_resume.ts | 17 +++++++++++------ loopx/extensions/lark/room_resume.py | 5 +++++ tests/control_plane/test_room_resume.py | 15 +++++++++++++++ tests/control_plane_ts/room_resume.test.ts | 15 +++++++++++++++ 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index b139d0b1bd..308670a1ab 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -171,9 +171,18 @@ receipt. It checks current source route, Agent registration, exact channel, Bot identity and configured memory scope, then reads canonical state and quota. After retrieval it checks those observations again. A changed binding, scope, Todo revision, selected work or gate discards the context and references. A -provider outage or disabled configuration leaves the fresh LoopX observations +failed scope/readback check also omits every earlier quota/projection snapshot; +those observations are not returned as current. A healthy fresh observation +with no selected work can still show the current gate while discarding context. +A provider outage or disabled configuration leaves the fresh LoopX observations available with `status=context_unavailable`; it does not manufacture a user gate. Exact-instance source-session profiles remain unsupported and fail closed. +The current direct canonical Todo facade is profile-gated too: its witnessed +claim wire and provider receipts identify `goal_id`, without an exact GoalRef. +Supporting that profile must first bind the existing Todo head/receipt owner to +the lifetime contract; a room adapter cannot bypass the codec gate or substitute +provider incarnation/revision for Goal identity. See the +[Goal-instance owner boundary](../architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md#roadmap-placement-and-contract-cooperation). To carry an inspected reference, repeat `--artifact-ref ''` up to eight times. Only explicitly requested references inside the current configured diff --git a/loopx/control_plane/goals/room_resume.ts b/loopx/control_plane/goals/room_resume.ts index 34718d403d..198c6768f9 100644 --- a/loopx/control_plane/goals/room_resume.ts +++ b/loopx/control_plane/goals/room_resume.ts @@ -6,12 +6,13 @@ import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../r function quotaKey(value: unknown, goal: string, actor: string): string { const q = requireJsonObject(value, "resume quota"); const identity = requireJsonObject(q.agent_identity, "quota actor"); - const selected = requireJsonObject(q.selected_todo, "selected Todo"); + const selected = q.selected_todo == null ? {} : requireJsonObject(q.selected_todo, "selected Todo"); if (q.ok !== true || q.status_health_ok !== true || q.goal_id !== goal || identity.agent_id !== actor || (selected.claimed_by != null && selected.claimed_by !== actor)) throw new Error("resume quota scope unavailable"); const interaction = requireJsonObject(q.interaction_contract, "quota interaction"); const agent = requireJsonObject(interaction.agent_channel, "quota agent channel"); - return JSON.stringify([requireNonEmptyString(selected.todo_id, "selected Todo id"), selected.status ?? null, + const selectedId = q.selected_todo == null ? null : requireNonEmptyString(selected.todo_id, "selected Todo id"); + return JSON.stringify([selectedId, selected.status ?? null, selected.claimed_by ?? null, selected.task_class ?? null, selected.action_kind ?? null, q.should_run === true, q.state ?? null, q.effective_action ?? null, agent.must_attempt === true, agent.delivery_allowed === true, interaction.mode ?? null]); @@ -24,6 +25,7 @@ export function validateRoomResumeInput(value: unknown): JsonObject { const turn = requireNonEmptyString(p.turn_instance_id, "turn identity"); const q = requireJsonObject(p.quota, "input quota"); const receipt = requireJsonObject(q.heartbeat_receipt, "quota receipt"); + requireNonEmptyString(requireJsonObject(q.selected_todo, "admitted selected Todo").todo_id, "admitted Todo id"); if (q.mode !== "should-run" || receipt.turn_instance_id !== turn || !["committed", "replayed"].includes(String(receipt.status))) throw new Error("resume Turn receipt mismatch"); const refs = requireStringArray(p.artifact_refs, "requested artifact references"); @@ -47,7 +49,9 @@ export function projectRoomResumeReadback(value: unknown): JsonObject { throw new Error("resume projection scope mismatch"); } } - const stable = before.source_revision === after.source_revision && p.before_scope === p.after_scope && + const stable = requireNonEmptyString(before.source_revision, "before revision") === + requireNonEmptyString(after.source_revision, "after revision") && + requireNonEmptyString(p.before_scope, "before scope") === requireNonEmptyString(p.after_scope, "after scope") && quotaKey(p.before_quota, goal, actor) === quotaKey(p.after_quota, goal, actor); const recall = requireJsonObject(p.recall, "recall observation"); const requested = requireStringArray(p.artifact_refs, "requested references"); @@ -56,12 +60,13 @@ export function projectRoomResumeReadback(value: unknown): JsonObject { const accepted = receipt.memory_ref_digests == null ? [] : requireStringArray(receipt.memory_ref_digests, "accepted reference digests"); const roots = requireStringArray(p.artifact_scope_refs, "configured artifact scopes"); // A CLI request cannot turn an arbitrary or remembered pointer into read scope. - const authorized = stable && receipt.outcome === "applied" && receipt.current_artifact_verified === true && - receipt.result_readback_verified === true ? requested.filter(ref => + const applied = recall.ok === true && recall.status === "applied" && receipt.outcome === "applied" && + receipt.current_artifact_verified === true && receipt.result_readback_verified === true; + const authorized = stable && applied ? requested.filter(ref => roots.some(root => ref === root || ref.startsWith(root.replace(/\/$/, "") + "/")) && accepted.includes(createHash("sha256").update(ref).digest("hex").slice(0, 16))) : []; return {schema_version: "loopx_room_resume_readback_v0", authority_observation_stable: stable, - context_usable: stable && recall.ok === true && recall.status === "applied" && recall.context != null, + context_usable: stable && applied && recall.context != null, artifact_references: authorized.map(ref => ({ref, source: "current_scoped_recall", target_access_granted: false})), omitted_reference_count: requested.length - authorized.length, execution_authority_granted: false, memory_grants_authority: false}; diff --git a/loopx/extensions/lark/room_resume.py b/loopx/extensions/lark/room_resume.py index e28cc0a0b5..a2251d820c 100644 --- a/loopx/extensions/lark/room_resume.py +++ b/loopx/extensions/lark/room_resume.py @@ -104,5 +104,10 @@ def projection() -> dict[str, Any]: private_context=recalled.get("context") if readback["context_usable"] else None) return packet except (OSError, RuntimeError, TypeError, ValueError): + # Scope/readback failure invalidates every earlier observation, including + # the private quota packet. Do not label a pre-retrieval read "current". + packet.pop("current_quota", None) + packet.pop("work_projection", None) + packet.pop("readback", None) return {**packet, "ok": False, "status": "rejected", "private_context": None, "artifact_references": [], "blocker": "resume_scope_unavailable"} diff --git a/tests/control_plane/test_room_resume.py b/tests/control_plane/test_room_resume.py index 7f3d970f31..bc07dc65a2 100644 --- a/tests/control_plane/test_room_resume.py +++ b/tests/control_plane/test_room_resume.py @@ -175,6 +175,12 @@ def mutate(): assert not result["ok"], result assert result["private_context"] is None and result["artifact_references"] == [] assert provider.calls == 1 and not room.messages + if change in {"actor", "binding", "route"}: + assert "current_quota" not in result and "work_projection" not in result + if change == "user_gate": + assert result["status"] == "authority_changed" + assert result["current_quota"]["should_run"] is False + assert result["work_projection"]["counts"]["user_gates"] == 1 if change not in {"canonical_revision", "user_gate"}: assert snapshot(args)["provider_revision"] == before @@ -255,7 +261,9 @@ def test_executing_cli_dispatch_returns_private_context_without_room_delivery(re def test_exact_instance_profile_fails_closed_before_retrieval(recalled): from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + from loopx.control_plane.coordination.local_authority import claim_canonical_todo_if_promoted args, room, _, kwargs, provider, _ = recalled + before = snapshot(args)["provider_revision"] source = json.loads(args["registry_path"].read_text()) source.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) source["goals"][0].update(goal_instance_id="ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", status="active") @@ -264,3 +272,10 @@ def test_exact_instance_profile_fails_closed_before_retrieval(recalled): tx.commit(tx.payload_copy()) result = resume.run_room_resume(**kwargs, execute=True, provider=provider) assert not result["ok"] and provider.calls == 0 and result["private_context"] is None and not room.messages + # The underlying current Todo facade is also profile-gated. A room adapter + # must not bypass this owner or infer a Goal instance from its provider key. + with pytest.raises(ValueError, match="source_session_v1"): + claim_canonical_todo_if_promoted(registry_path=args["registry_path"], runtime_root=args["runtime_root"], + goal_id=GOAL, todo_id=TODO, role="agent", claimed_by="agent-a", actor_agent_id="agent-a", + dry_run=False, operation_id="exact-scope-denied", expected_provider_revision=before) + assert snapshot(args)["provider_revision"] == before diff --git a/tests/control_plane_ts/room_resume.test.ts b/tests/control_plane_ts/room_resume.test.ts index 48f722c096..35f043f5ba 100644 --- a/tests/control_plane_ts/room_resume.test.ts +++ b/tests/control_plane_ts/room_resume.test.ts @@ -46,3 +46,18 @@ test("reconnect discards context when canonical revision, gate, ownership or sco assert.equal(projectRoomResumeReadback({...readback, recall: {ok: true, status: "provider_unavailable", context: {guidance: []}}}).context_usable, false); }); + +test("healthy no-work/gated readback is fresh state, while missing scope proof fails closed", () => { + const empty = projectRoomResumeReadback({...readback, after_quota: {...quota, selected_todo: null, should_run: false}}); + assert.equal(empty.authority_observation_stable, false); + assert.equal(empty.context_usable, false); + assert.deepEqual(empty.artifact_references, []); + for (const delta of [{before_scope: null}, {after_scope: null}, + {after_projection: {...projection, source_revision: null}}]) { + assert.throws(() => projectRoomResumeReadback({...readback, ...delta})); + } + const unverified = projectRoomResumeReadback({...readback, recall: {...readback.recall, + application: {receipt: {...readback.recall.application.receipt, result_readback_verified: false}}}}); + assert.equal(unverified.context_usable, false); + assert.deepEqual(unverified.artifact_references, []); +}); From 9036ba8c7fe77f02d1e8e22890fb6cf6d9b08db9 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 18:29:09 -0700 Subject: [PATCH 13/27] fix(coordination): reject unqualified GoalRef on legacy claim contracts Signed-off-by: Lihua <1017343802@qq.com> --- docs/reference/goal-channel-room-work.md | 6 ++++ .../coordination/local_authority_runtime.ts | 4 +++ .../control_plane/coordination/todo_claim.ts | 8 +++++ tests/control_plane/test_room_work.py | 30 +++++++++++++++++++ .../goal_ref_claim_compatibility.test.ts | 18 +++++++++++ tsconfig.control-plane.json | 1 + 6 files changed, 67 insertions(+) create mode 100644 tests/control_plane_ts/goal_ref_claim_compatibility.test.ts diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index 308670a1ab..97e7c15c7e 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -183,6 +183,12 @@ Supporting that profile must first bind the existing Todo head/receipt owner to the lifetime contract; a room adapter cannot bypass the codec gate or substitute provider incarnation/revision for Goal identity. See the [Goal-instance owner boundary](../architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md#roadmap-placement-and-contract-cooperation). +The current Goal-id claim wire now rejects any explicit `goal_ref` property, +including null, before provider or old-receipt access. Previously that field was +ignored, so both a fresh claim and a same-key replay could look lifetime-bound +without actually checking the instance. Existing callers that omit the field +retain their behavior; supplying an unqualified lifetime reference is a +machine-enforced protocol failure, not optional guidance or instance support. To carry an inspected reference, repeat `--artifact-ref ''` up to eight times. Only explicitly requested references inside the current configured diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 1d5cfeb260..726d9e5011 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -63,6 +63,7 @@ import { import { COORDINATION_TODO_CLAIM_RESULT_SCHEMA, executeCoordinationTodoClaim, + unqualifiedGoalRefClaimFailure, } from "./todo_claim.ts"; import { COORDINATION_TODO_CREATE_RESULT_SCHEMA, @@ -1016,6 +1017,9 @@ export async function claimLocalCoordinationTodo( input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA) { throw new Error("local coordination Todo claim request schema mismatch"); } + const scopeFailure = unqualifiedGoalRefClaimFailure(input); + if (scopeFailure !== null) return {...scopeFailure, source_authority: null, + decision_read_from_provider: false, legacy_fallback_used: false}; const authoritySourcesCurrent = registryAuthoritySourceCheck(input, input.schema_version === LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA); if (typeof input.dry_run !== "boolean") { diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index b1ea930a95..41a41a686e 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -142,6 +142,12 @@ function decisionFailure( }; } +/** The legacy claim contract must never imply unimplemented lifetime binding. */ +export function unqualifiedGoalRefClaimFailure(value: object): CoordinationTodoClaimResult | null { + return Object.hasOwn(value, "goal_ref") ? failure("goal_ref_claim_contract_unqualified", + "This claim contract does not bind the head and operation receipt to GoalRef; a qualified lifetime-bound claim owner is required.") : null; +} + function rejectInvalidClaimActor( input: CoordinationTodoClaimInput, ): ClaimRejection { @@ -337,6 +343,8 @@ export async function executeCoordinationTodoClaim( ): Promise { let input: CoordinationTodoClaimInput; try { + const scopeFailure = unqualifiedGoalRefClaimFailure(rawInput); + if (scopeFailure !== null) return scopeFailure; input = { ...rawInput, goal_id: requireAuthorityStoreId(rawInput.goal_id, "goal id"), diff --git a/tests/control_plane/test_room_work.py b/tests/control_plane/test_room_work.py index e9f7577664..10c708d49d 100644 --- a/tests/control_plane/test_room_work.py +++ b/tests/control_plane/test_room_work.py @@ -222,3 +222,33 @@ def revoke_then_respond(argv, cwd, timeout): denied = claim(changed_args, "agent-a", before, "room-preflight-revoked") assert revoked and not denied["canonical_claim_accepted"], denied assert snapshot(args)["provider_revision"] == before and not room.messages + + +@pytest.mark.parametrize("has_receipt", [False, True]) +@pytest.mark.parametrize("goal_ref", [None, "invalid", {"goal_id": GOAL, "goal_instance_id": "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) +@pytest.mark.parametrize("wire_version", [0, 1]) +def test_legacy_claim_wire_cannot_silently_accept_goal_ref(setup, has_receipt, goal_ref, wire_version): + import hashlib + from loopx.control_plane.effect_runtime import effect_runtime_result + args, room, _ = setup + original_revision = snapshot(args)["provider_revision"] + key = "room-unqualified-goal-ref" + if has_receipt: + assert claim(args, "agent-a", original_revision, key)["canonical_claim_accepted"] + before = snapshot(args)["provider_revision"] + packet = {"schema_version": "loopx_local_coordination_todo_claim_request_v1", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "todo_id": TODO, + "role": "agent", "claimed_by": "agent-a", "actor_agent_id": "agent-a", + "registered_agents": ["agent-a", "agent-b"], + "registry_source": {"path": str(args["registry_path"]), + "sha256": hashlib.sha256(args["registry_path"].read_bytes()).hexdigest()}, + "operation_id": key, "expected_provider_revision": original_revision, + "lease_request": None, "observed_at": "2026-09-29T00:00:00Z", "dry_run": False, + "goal_ref": goal_ref} + if wire_version == 0: + packet["schema_version"] = "loopx_local_coordination_todo_claim_request_v0" + packet.pop("registry_source") + result = effect_runtime_result("coordination.local_authority.todo_claim", packet) + assert result["status"] == "failed" and result["reason_code"] == "goal_ref_claim_contract_unqualified", result + assert result["decision_read_from_provider"] is False + assert snapshot(args)["provider_revision"] == before diff --git a/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts b/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts new file mode 100644 index 0000000000..baf12f733c --- /dev/null +++ b/tests/control_plane_ts/goal_ref_claim_compatibility.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {executeCoordinationTodoClaim} from "../../loopx/control_plane/coordination/todo_claim.ts"; + +test("GoalRef-bearing legacy claims reject before provider or historical receipt access", async () => { + const store = new Proxy({} as AuthorityStore, {get() {throw new Error("provider must not be accessed");}}); + const base = {goal_id: "goal", todo_id: "todo", claimed_by: "agent", actor_agent_id: "agent", + registered_agents: ["agent"], expected_role: "agent", operation_id: "old-operation", dry_run: false, + now: new Date("2026-09-29T00:00:00Z")}; + for (const goalRef of [null, "invalid", {goal_id: "goal", goal_instance_id: "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) { + const request = {...base, goal_ref: goalRef}; + const result = await executeCoordinationTodoClaim(store, request); + assert.equal(result.status, "failed"); + assert.equal(result.reason_code, "goal_ref_claim_contract_unqualified"); + assert.equal(result.failure_kind, "protocol_failure"); + } +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 9fd0564153..0e07764cab 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -13,6 +13,7 @@ "types": ["node"] }, "include": [ + "tests/control_plane_ts/goal_ref_claim_compatibility.test.ts", "tests/control_plane_ts/room_resume.test.ts", "tests/control_plane_ts/room_claim_request.test.ts", "tests/control_plane_ts/room_work_projection.test.ts", From c46eaa328cb5a933580f3653293760a96ba18a2c Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 19:21:58 -0700 Subject: [PATCH 14/27] fix(goal-channel): repair offer encoding and recall validation seam Signed-off-by: Lihua <1017343802@qq.com> --- loopx/extensions/lark/room_claim.py | 2 +- tests/capabilities/test_outbound_guidance.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py index a51a675077..fc3515acb5 100644 --- a/loopx/extensions/lark/room_claim.py +++ b/loopx/extensions/lark/room_claim.py @@ -63,7 +63,7 @@ def _read(path: Path) -> dict[str, Any]: if path.is_symlink(): raise RoomClaimCallbackError("offer_unavailable") try: - p = json.loads(path.read_text()) + p = json.loads(path.read_text(encoding="utf-8")) if not isinstance(p, dict) or p.get("schema_version") != ROOM_CLAIM_OFFER_RECORD_SCHEMA: raise ValueError("invalid offer") normalized = effect_runtime_result("goal_channel.work.claim_request", p["request"]) diff --git a/tests/capabilities/test_outbound_guidance.py b/tests/capabilities/test_outbound_guidance.py index 03908df022..c39f2dcbf1 100644 --- a/tests/capabilities/test_outbound_guidance.py +++ b/tests/capabilities/test_outbound_guidance.py @@ -154,7 +154,7 @@ def test_cli_opaque_turn_uses_real_timestamp(tmp_path, monkeypatch): monkeypatch.setattr( cli, "resolve_reward_memory_experiment", lambda **kw: ({}, config) ) - monkeypatch.setattr(cli, "_quota_decision", lambda path: quota) + monkeypatch.setattr(cli, "load_turn_quota_decision", lambda path: quota) observed = [] def run(config, situation, **kw): From 47ec7e10df6c0d1872a47f767ac4e660ff585ca7 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 19:22:03 -0700 Subject: [PATCH 15/27] test(goal-channel): qualify independent source CLI claim processes Signed-off-by: Lihua <1017343802@qq.com> --- tests/control_plane/test_room_work.py | 86 +++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/tests/control_plane/test_room_work.py b/tests/control_plane/test_room_work.py index 10c708d49d..5e8865ccfc 100644 --- a/tests/control_plane/test_room_work.py +++ b/tests/control_plane/test_room_work.py @@ -1,9 +1,15 @@ """Synthetic room transport, real canonical File/SQLite authority and source CLI.""" import json +import io +import multiprocessing +import os import subprocess import sys -from concurrent.futures import ThreadPoolExecutor +import tempfile +from contextlib import redirect_stdout +from functools import partial from pathlib import Path +from unittest.mock import patch import pytest @@ -94,7 +100,65 @@ def claim(args, actor, revision, key, **extra): todo_id=TODO, expected_revision=revision, idempotency_key=key, **extra) -def test_competing_hosts_retry_and_direct_cli_readback(setup): +def _source_cli_claim(args, actor, revision, key, worker_root, results, barrier=None, messages=()): + """Real CLI client/TS runtime; fixture extension config and synthetic Lark IO.""" + worker_root.mkdir() + for variable in ("TMPDIR", "TEMP", "TMP"): + os.environ[variable] = str(worker_root) + tempfile.tempdir = str(worker_root) + from loopx.cli import main + from loopx.control_plane.effect_runtime import effect_runtime_result + + room = Room() + room.messages = list(messages) + argv = ["--registry", str(args["registry_path"]), "--runtime-root", str(args["runtime_root"]), + "--format", "json", "goal-channel", "work", "claim", "--goal-id", GOAL, + "--agent-id", actor, "--todo-id", TODO, "--expected-revision", revision, + "--idempotency-key", key, "--execute"] + try: + if barrier is not None: + barrier.wait(timeout=30) + output = io.StringIO() + # The parent fixture supplies an enabled extension. Spawned clients + # independently reproduce that fixture fact and inject only room IO. + with patch.object(work, "resolve_extension_activation", return_value={"enabled": True}), \ + patch.object(work, "run_goal_channel_work", partial(work.run_goal_channel_work, runner=room)), \ + redirect_stdout(output): + exit_code = main(argv) + runtime_info = list(worker_root.glob("loopx-effect-runtime-*/runtime-*.json")) + assert len(runtime_info) == 1 + runtime_pid = json.loads(runtime_info[0].read_text())["pid"] + results.put({"pid": os.getpid(), "runtime_pid": runtime_pid, "exit_code": exit_code, + "packet": json.loads(output.getvalue()), "messages": room.messages}) + finally: + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + + +def _independent_claims(args, revision, actors, root, messages=()): + context = multiprocessing.get_context("spawn") + results = context.Queue() + barrier = context.Barrier(len(actors)) if len(actors) > 1 else None + clients = [context.Process(target=_source_cli_claim, + args=(args, actor, revision, "room-claim-" + actor, root / actor, results, barrier, messages)) + for actor in actors] + try: + for client in clients: + client.start() + observed = [results.get(timeout=90) for _ in clients] + for client in clients: + client.join(timeout=30) + assert client.exitcode == 0 + return observed + finally: + for client in clients: + if client.is_alive(): + client.terminate() + client.join(timeout=10) + results.close() + results.join_thread() + + +def test_independent_source_cli_claims_retry_and_direct_readback(setup, tmp_path): args, room, _ = setup before = snapshot(args) project = work.run_goal_channel_work(**args, actor_id="agent-a", command="project", execute=False) @@ -102,17 +166,27 @@ def test_competing_hosts_retry_and_direct_cli_readback(setup): assert project["projection"]["counts"] == {"unclaimed": 1, "user_gates": 0} assert not room.messages and "PRIVATE_" not in json.dumps(project) revision = before["provider_revision"] - with ThreadPoolExecutor(2) as pool: - results = list(pool.map(lambda actor: claim(args, actor, revision, "room-claim-" + actor), ["agent-a", "agent-b"])) + process_args = {name: value for name, value in args.items() if name != "runner"} + clients = _independent_claims(process_args, revision, ["agent-a", "agent-b"], tmp_path) + assert len({client["pid"] for client in clients} | {os.getpid()}) == 3 + assert len({client["runtime_pid"] for client in clients}) == 2 + results = [client["packet"] for client in clients] assert sum(r["canonical_claim_accepted"] for r in results) == 1, results assert sorted(r["status"] for r in results) == ["applied", "conflict"], results winner = next(r["actor_id"] for r in results if r["canonical_claim_accepted"]) committed = snapshot(args) - replay = claim(args, winner, revision, "room-claim-" + winner) + winner_client = next(client for client in clients if client["packet"]["actor_id"] == winner) + retry_root = tmp_path / "retry" + retry_root.mkdir() + retry = _independent_claims(process_args, revision, [winner], retry_root, winner_client["messages"])[0] + assert sorted(client["exit_code"] for client in clients) == [0, 1] + assert retry["exit_code"] == 0 + replay = retry["packet"] assert replay["status"] == "already_applied" and replay["readback_verified"], replay assert snapshot(args)["provider_revision"] == committed["provider_revision"] assert snapshot(args)["todos"][0]["claimed_by"] == winner - assert "PRIVATE_" not in json.dumps(room.messages) + assert len(retry["messages"]) == 1 + assert "PRIVATE_" not in json.dumps(clients + [retry]) readback = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(args["registry_path"]), "--format", "json", "todo", "list", "--goal-id", GOAL], cwd=ROOT, text=True, capture_output=True) assert readback.returncode == 0, readback.stdout + readback.stderr From 87ac097259bb7c089b2898a63b7bfaea4f8c6440 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Mon, 28 Sep 2026 19:22:07 -0700 Subject: [PATCH 16/27] docs(goal-channel): clarify local qualification and lifetime ownership Signed-off-by: Lihua <1017343802@qq.com> --- .../rfcs/agent-im-openviking-collaboration-v0.md | 16 ++++++++++++++-- docs/reference/goal-channel-room-work.md | 14 +++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index d78c8cc58f..81ffa98f09 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -303,12 +303,24 @@ by current verified recall receipts. It does not send private context to a room or accept/renew work. Source-session exact-instance profiles and arbitrary external artifact targets remain outside this local qualification stage. -Validation uses synthetic Lark transport with disposable real local stores and -source CLI readback. Real non-production room qualification, independent hosts, +Validation uses synthetic Lark transport with disposable real local stores. +Independent source CLI processes with separate TS runtimes compete for one +File/SQLite claim; a fresh process replays its exact receipt, and direct CLI +readback verifies current ownership. These local clients use trusted registered +Agent identities; independently authenticated remote hosts remain unqualified. +Real non-production room qualification, independent hosts, daemon reconnect, native callback qualification, external artifact-target access and scoped live OpenViking retrieval remain unqualified. Design acceptance, a proposed PR and synthetic checks do not close those requirements or authorize promotion. +The legacy-profile local stage can be qualified without enabling source-session +business effects. Exact GoalRef support must first qualify the existing shared +authority head/receipt owner, retirement and caller/writer fences under the +[Goal-instance contract](goal-instance-identity-and-orphan-recovery-v0.md). +That later profile boundary does not expand this composition stage into a +parallel authority migration. Existing claim wires reject explicit unqualified +GoalRef intent before provider or historical-receipt access. + ## Open Questions 1. Which LoopX projection fields are stable enough for the first public diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index 97e7c15c7e..bbb70fb104 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -208,13 +208,25 @@ original `work claim` tuple/key; resume itself never replays a work command. ## Remaining qualification The new checks use synthetic Lark transport and real disposable File/SQLite -stores, including source CLI projection and direct CLI ownership readback. +stores. Two independent source CLI processes, each served by a separate TS +runtime, compete at the same canonical revision. One accepts the claim; a new +client/runtime recovers its exact receipt without another transition or card. +A separate direct CLI reads current ownership. This proves the supported local +process boundary; each client still uses the trusted local Agent identity model. They are not evidence of a real room, two independently authenticated hosts, a live daemon reconnect, or OpenViking provider qualification. Those checks need an explicitly authorized non-production room, actor bindings and read-only resource scope. Existing shared authority and provisioning tasks retain their ownership; this stage does not promote or deploy them. +Source-session activation is a later owner boundary, not a prerequisite for +qualifying this legacy-profile local stage. Its lifetime-bound head and +operation receipts, retirement serialization and old-writer fencing belong to +the existing [shared authority](../architecture/rfcs/shared-goal-authority-state-provider-v0.md) +and [Goal-instance](../architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md) +contracts. Keep that profile closed until those owners qualify the full path; +adding a GoalRef to a room request cannot provide that qualification. + To disable publication, stop issuing the optional `work ... --execute` commands or disconnect the exact connection through the existing Goal Channel connection owner. This does not delete canonical claims or retract their receipts. Revert From b443d3d95b2461f3f5dd3beb8d99db7addd35197 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 14 Oct 2026 06:27:39 -0400 Subject: [PATCH 17/27] test(digest): pin the direction authority consumer #6338 made goals/direction_authority.ts read BARE_SHA256_PATTERN and ENVELOPED_SHA256_PATTERN from the content-digest owner, as its review required, but merged without adding the module to the guard's pinned consumer set. The single-owner guard's consumer test now fails on main in typescript-core, and on every pull request, because PR CI runs against the branch merged with main. Pin the module. The guard's other half still requires each imported name to be used; both matchers are. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song --- tests/control_plane_ts/content_digest_single_owner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 8d049af41f..c05ec973e3 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -123,6 +123,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/effect_runtime_snapshot.ts", "control_plane/goals/acceptance_authority.ts", "control_plane/goals/acceptance_contract.ts", + "control_plane/goals/direction_authority.ts", "control_plane/goals/goal_amendment_authority.ts", "control_plane/goals/goal_amendment_proposal.ts", "control_plane/goals/operator_actions.ts", From 17173195e55f7cd3e4fab91ab7702d5d4fc23aa0 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 12:58:03 -0700 Subject: [PATCH 18/27] test(browser): reuse scoped backend forwarding for replan cadence Signed-off-by: Lihua <1017343802@qq.com> --- examples/personal-workspace-browser/replan-cadence.mjs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index 4e3da1482b..dd21e56826 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { resolve } from "node:path"; import { outputDir, waitForInputValue } from "./fixture.mjs"; -import { openWorkspacePage } from "./scenario-context.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; import { startCadenceAuthority } from "./automation-cadence.mjs"; // Configuration traffic uses the real revision-checked handlers and disposable @@ -43,12 +43,7 @@ export const replanCadenceScenario = { await page.route(`**/api/chat/${endpoint}**`, async (route) => { const parsed = new URL(route.request().url()); if (parsed.pathname.endsWith("/goal-configuration/apply")) goalApplyCount++; - // Match the shipped Vite proxy: the disposable backend receives - // its own origin rather than the intercepted frontend origin. - const response = await route.fetch({ - url: authority.url + parsed.pathname + parsed.search, - headers: { ...route.request().headers(), origin: authority.url }, - }); + const response = await fetchWorkspaceBackend(route, authority.url); if (heldReadback && parsed.pathname === "/api/chat/goal-configuration" && route.request().method() === "GET") { const pending = heldReadback; pending.start(); From 72b1988c3aaf04d700d0851db1284c9e7a4381ac Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 13:07:27 -0700 Subject: [PATCH 19/27] test(browser): bind cadence companions to the selected package Signed-off-by: Lihua <1017343802@qq.com> --- .../automation-cadence.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/automation-cadence.mjs b/examples/personal-workspace-browser/automation-cadence.mjs index 050d5631f0..a30b566c81 100644 --- a/examples/personal-workspace-browser/automation-cadence.mjs +++ b/examples/personal-workspace-browser/automation-cadence.mjs @@ -5,14 +5,14 @@ import { resolve } from "node:path"; import { createInterface } from "node:readline"; import { promisify } from "node:util"; -import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { testPythonCommand } from "../../scripts/test-python.mjs"; import { outputDir, repoRoot } from "./fixture.mjs"; import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; const execFileAsync = promisify(execFile); async function admitCadenceStart(root, agentId) { - const { stdout } = await execFileAsync(resolveTestPython(), ["-c", ` + const python = testPythonCommand(` import json from pathlib import Path import sys @@ -29,7 +29,8 @@ result = effect_runtime_result("quota.automation_cadence.admit", { "trigger_at_ms": now_ms, }, retry_safe=False) print(json.dumps(result)) -`, root, agentId], { cwd: repoRoot }); +`); + const { stdout } = await execFileAsync(python.command, [...python.args, root, agentId], { cwd: repoRoot }); return JSON.parse(stdout); } @@ -40,7 +41,8 @@ export async function startCadenceAuthority() { await writeFile(resolve(root, "registry.json"), JSON.stringify({ schema_version: "0.1", common_runtime_root: resolve(root, "runtime"), goals: [{ id: "multi-agent-projection" }], })); - const child = spawn(resolveTestPython(), ["-u", "-c", ` + // Match the main server's explicit source/installed package provenance. + const python = testPythonCommand(` from pathlib import Path import sys from loopx.chat_server import ChatHTTPServer, ChatRequestHandler @@ -55,7 +57,8 @@ assert Path(route["target_runtime_root"]).resolve() == server.runtime_root.resol server.verbose = False print(server.server_address[1], flush=True) server.serve_forever() -`, root], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); +`); + const child = spawn(python.command, [...python.args, root], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }); let diagnostic = ""; child.stderr.on("data", (chunk) => { diagnostic = (diagnostic + chunk).slice(-2000); }); const lines = createInterface({ input: child.stdout }); From 4b07062b658953c21e730b45c88290f346a11ba4 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 19:38:44 -0700 Subject: [PATCH 20/27] feat(goal-channel): expose scoped room work in the packaged workspace Signed-off-by: Lihua <1017343802@qq.com> --- .../dashboard/src/data/room-work.ts | 31 +++++ .../src/features/personal-workspace/i18n.tsx | 52 +++++++++ .../personal-workspace/lark-settings-page.tsx | 5 + .../personal-workspace/room-work-panel.tsx | 108 ++++++++++++++++++ .../features/personal-workspace/room-work.css | 11 ++ loopx/chat_lark_api.py | 3 +- loopx/chat_room_work_api.py | 79 +++++++++++++ loopx/chat_server.py | 3 + .../goals/room_work_projection.ts | 9 ++ loopx/extensions/lark/goal_channel_work.py | 15 ++- 10 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/room-work.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/room-work.css create mode 100644 loopx/chat_room_work_api.py diff --git a/apps/presentation/dashboard/src/data/room-work.ts b/apps/presentation/dashboard/src/data/room-work.ts new file mode 100644 index 0000000000..bd7594bd3f --- /dev/null +++ b/apps/presentation/dashboard/src/data/room-work.ts @@ -0,0 +1,31 @@ +import {z} from "zod"; +import {requestJson} from "./chat"; + +const ownerSchema = z.object({todo_id: z.string(), current_owner: z.string().nullable(), current_claim_matches_actor: z.boolean()}); +const roomWorkSchema = z.object({ + ok: z.boolean(), status: z.string(), actor_id: z.string(), connection_id: z.string(), scope_revision: z.string(), + canonical_claim_accepted: z.boolean(), readback_verified: z.boolean(), execution_authority_granted: z.literal(false), claim_available: z.boolean(), + projection: z.object({source_revision: z.string(), generated_at: z.string(), + selected_todo: z.object({todo_id: z.string()}).nullable(), + counts: z.object({unclaimed: z.number(), user_gates: z.number()}), + inspected_todo: ownerSchema.nullable().optional(), + }).optional(), + receipt: ownerSchema.extend({receipt_id: z.string(), acceptance: z.string(), + current_owner: z.string().nullable().optional(), current_claim_matches_actor: z.boolean().optional(), + }).optional(), +}); +export type RoomWork = z.infer; +export const roomClaimIntentSchema = z.object({ + goal_id: z.string(), connection_id: z.string(), scope_revision: z.string(), + todo_id: z.string(), expected_revision: z.string(), idempotency_key: z.string(), +}); +export type RoomClaimIntent = z.infer; + +export async function fetchRoomWork(goalId: string, connectionId: string, todoId?: string) { + const query = new URLSearchParams({goal_id: goalId, connection_id: connectionId}); + if (todoId) query.set("todo_id", todoId); + return roomWorkSchema.parse(await requestJson(`/api/chat/lark/room-work?${query}`)); +} +export async function claimRoomWork(intent: RoomClaimIntent) { + return roomWorkSchema.parse(await requestJson("/api/chat/lark/room-work", {method: "POST", body: JSON.stringify(intent)})); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index d38b114d1c..7b6cbcc016 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -694,6 +694,32 @@ const en = { "lark.captureAll": "All messages in this Goal topic", "lark.captureScope": "Capture scope", "lark.captureScopeDescription": "Controls which Topic messages enter LoopX without expanding Agent permissions.", + "roomWork.readOnly": "Read-only: enable the existing Lark Goal Channel extension and permission before claiming.", + "roomWork.details": "Claim details", + "roomWork.receipt": "Receipt", + "roomWork.messageVerified": "Room receipt verified", + "roomWork.title": "Room work", + "roomWork.close": "Close room work", + "roomWork.unavailable": "Room work is unavailable. Check this connection and task service, then refresh.", + "roomWork.storageUnavailable": "This browser cannot preserve a recovery request. Enable session storage before claiming.", + "roomWork.scopeChanged": "This connection changed. Refresh before making a new claim.", + "roomWork.conflict": "Work changed since this view was read. Refresh before claiming again.", + "roomWork.deliveryPending": "The claim was accepted; its room message still needs recovery.", + "roomWork.unknown": "The response is uncertain. Reconcile this same request before claiming more work.", + "roomWork.boundary": "A claim records ownership only. Execution still requires current gates, quota and any lease. Private recall stays private.", + "roomWork.loading": "Reading current room work…", + "roomWork.agent": "Agent", + "roomWork.available": "Available tasks", + "roomWork.gates": "User decisions", + "roomWork.task": "Task", + "roomWork.none": "None", + "roomWork.observed": "Observed at", + "roomWork.accepted": "Historical claim accepted", + "roomWork.owner": "Current owner at last readback", + "roomWork.pending": "A request is awaiting reconciliation. Recovery reuses its original key.", + "roomWork.refresh": "Refresh current state", + "roomWork.recover": "Reconcile this claim", + "roomWork.claim": "Claim task and send receipt", "lark.closeConnection": "Close connection dialog", "lark.closeCreate": "Close create dialog", "lark.closeSettings": "Close Lark settings", @@ -2170,6 +2196,32 @@ const zhCN: Record = { "lark.captureAll": "接收此 Goal Topic 中的所有消息", "lark.captureScope": "接收范围", "lark.captureScopeDescription": "决定哪些 Topic 消息会进入 LoopX;不会扩大 Agent 权限。", + "roomWork.readOnly": "当前只读:领取前需启用已有飞书 Goal Channel 扩展及权限。", + "roomWork.details": "领取详情", + "roomWork.receipt": "凭据", + "roomWork.messageVerified": "房间凭据已确认", + "roomWork.title": "房间工作", + "roomWork.close": "关闭房间工作", + "roomWork.unavailable": "房间工作暂不可用。请检查此连接及任务服务后刷新。", + "roomWork.storageUnavailable": "浏览器无法保存恢复请求。请启用会话存储后再领取。", + "roomWork.scopeChanged": "连接已变化,请刷新后再发起新的领取。", + "roomWork.conflict": "工作状态已变化,请刷新后再领取。", + "roomWork.deliveryPending": "任务已领取,房间消息仍需恢复。", + "roomWork.unknown": "响应结果不确定,请先核对此次请求,再领取其他工作。", + "roomWork.boundary": "领取只确认归属,不会启动执行或发送私有回忆;执行仍需原有授权、配额及租约。", + "roomWork.loading": "正在读取当前房间工作…", + "roomWork.agent": "Agent", + "roomWork.available": "可领取任务", + "roomWork.gates": "用户待决事项", + "roomWork.task": "任务", + "roomWork.none": "无", + "roomWork.observed": "观察时间", + "roomWork.accepted": "历史领取已接受", + "roomWork.owner": "最近回读的当前归属", + "roomWork.pending": "有一项请求等待核对,恢复会沿用原请求键。", + "roomWork.refresh": "刷新当前状态", + "roomWork.recover": "核对此次领取", + "roomWork.claim": "领取任务并发送凭据", "lark.closeConnection": "关闭连接弹窗", "lark.closeCreate": "关闭创建弹窗", "lark.closeSettings": "关闭 Lark 设置", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx index cade2c603e..a341c1b022 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/lark-settings-page.tsx @@ -1,4 +1,5 @@ import {PrivateConversationPanel} from "./private-conversation-panel"; +import {RoomWorkPanel} from "./room-work-panel"; import { useEffect, useMemo, useRef, useState } from "react"; import { Bot, @@ -207,6 +208,8 @@ export function LarkSettingsPage({ }) { const { t } = useWorkspaceI18n(); const [tab, setTab] = useState("connections"); + const [workConnection, setWorkConnection] = useState(null); + const workTrigger = useRef(null); const [apps, setApps] = useState([]); const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -594,6 +597,7 @@ export function LarkSettingsPage({ {connection.conversation_kind === "manager" ? t(connection.turn_trigger === "human_messages" ? "lark.triggerHuman" : "lark.triggerAddressed") : connection.capture_scope === "addressed_only" ? t("lark.mentionsOnly") : t("lark.allTopicMessages")} {connection.conversation_kind === "manager" ? t("lark.managerConversation") : ingressPresentation(connection.ingress_mode, t).label}{connection.conversation_kind === "manager" ? t("lark.managerConversationDescription") : connection.agent_id ?? ingressPresentation(connection.ingress_mode, t).detail} + {connection.agent_id && connection.conversation_kind !== "manager" ? : null} @@ -604,6 +608,7 @@ export function LarkSettingsPage({ ) : null} + {workConnection ? setWorkConnection(null)} /> : null} {modalOpen ? (
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx new file mode 100644 index 0000000000..cbc236f97f --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/room-work-panel.tsx @@ -0,0 +1,108 @@ +import "./room-work.css"; +import {useEffect, useRef, useState} from "react"; +import {claimRoomWork, fetchRoomWork, roomClaimIntentSchema, type RoomClaimIntent, type RoomWork} from "../../data/room-work"; +import {ChatApiError, type LarkGoalConnection} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; + +/** Browser storage holds only an uncertain request tuple, never work authority. */ +export function RoomWorkPanel({connection, onClose, triggerElement}: {connection: LarkGoalConnection; onClose: () => void; triggerElement: HTMLElement | null}) { + const {t} = useWorkspaceI18n(); + const storageKey = `loopx.room-work:${connection.goal_id}:${connection.connection_id}`; + const [intent, setIntent] = useState(() => { + try { + const saved = roomClaimIntentSchema.safeParse(JSON.parse(sessionStorage.getItem(storageKey) ?? "null")); + return saved.success && saved.data.goal_id === connection.goal_id && saved.data.connection_id === connection.connection_id ? saved.data : null; + } catch {return null;} + }); + const [work, setWork] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const inFlight = useRef(false); + const alive = useRef(true); + const dialog = useRef(null); + useEffect(() => { + const prior = triggerElement ?? document.activeElement; + dialog.current?.focus(); + function keydown(event: KeyboardEvent) { + if (event.key === "Escape") {event.preventDefault(); event.stopPropagation(); onClose();} + if (event.key !== "Tab") return; + const controls = Array.from(dialog.current?.querySelectorAll("button:not(:disabled), summary") ?? []); + const first = controls[0], last = controls.at(-1); + const outside = !dialog.current?.contains(document.activeElement); + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current || outside)) {event.preventDefault(); last?.focus();} + else if (!event.shiftKey && (document.activeElement === last || outside)) {event.preventDefault(); first?.focus();} + } + document.addEventListener("keydown", keydown); + return () => { + document.removeEventListener("keydown", keydown); + if (prior instanceof HTMLElement && prior.isConnected) prior.focus(); + }; + }, []); + + async function refresh() { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); setError(""); + try { + const next = await fetchRoomWork(connection.goal_id, connection.connection_id, intent?.todo_id ?? result?.receipt?.todo_id); + if (alive.current) {setWork(next); if (!next.ok) setError(t("roomWork.unavailable"));} + } catch {if (alive.current) {setWork(null); setError(t("roomWork.unavailable"));}} + finally {inFlight.current = false; if (alive.current) setBusy(false);} + } + useEffect(() => {alive.current = true; void refresh(); return () => {alive.current = false;};}, []); + + async function claim() { + if (inFlight.current) return; + const selected = work?.projection?.selected_todo; + if (!intent && (!work?.ok || !work.claim_available || !selected)) return; + const request = intent ?? {goal_id: connection.goal_id, connection_id: connection.connection_id, + scope_revision: work!.scope_revision, todo_id: selected!.todo_id, + expected_revision: work!.projection!.source_revision, idempotency_key: crypto.randomUUID()}; + // Preserve the original tuple before the effect; a lost response or reload + // can only retry this exact request, never generate another claim key. + try {sessionStorage.setItem(storageKey, JSON.stringify(request));} + catch {setError(t("roomWork.storageUnavailable")); return;} + setIntent(request); inFlight.current = true; setBusy(true); setError(""); + try { + const next = await claimRoomWork(request); + if (!alive.current) return; + setResult(next); setWork(next); + if (next.ok || next.status === "conflict" || next.status === "rejected") { + sessionStorage.removeItem(storageKey); setIntent(null); + } + if (!next.ok) setError(t(next.status === "conflict" ? "roomWork.conflict" : next.canonical_claim_accepted ? "roomWork.deliveryPending" : "roomWork.unavailable")); + } catch (cause) { + if (!alive.current) return; + if (cause instanceof ChatApiError && [400, 409].includes(Number(cause.payload.http_status))) { + sessionStorage.removeItem(storageKey); setIntent(null); setWork(null); + setError(t("roomWork.scopeChanged")); + } else setError(t("roomWork.unknown")); + } finally {inFlight.current = false; if (alive.current) setBusy(false);} + } + const observed = !work?.projection ? null : Object.hasOwn(work.projection, "inspected_todo") + ? work.projection.inspected_todo : result?.receipt; + return
+
+

{t("roomWork.title")}

+

{connection.chat_name} · {connection.goal_title}

+

{t("roomWork.agent")}: {work?.actor_id ?? connection.agent_id}

+

{t("roomWork.boundary")}

+ {work?.ok && !work.claim_available && !result ?

{t("roomWork.readOnly")}

: null} + {error ?

{error}

: null} + {busy ?

{t("roomWork.loading")}

: null} + {work?.projection ?
{t("roomWork.available")}
{work.projection.counts.unclaimed}
+
{t("roomWork.gates")}
{work.projection.counts.user_gates}
: null} + {result?.canonical_claim_accepted && result.receipt ?

{t("roomWork.accepted")}

: null} + {result?.readback_verified ?

{t("roomWork.messageVerified")}

: null} + {observed ?

{t("roomWork.owner")}: {observed.current_owner === undefined ? t("roomWork.unavailable") : observed.current_owner ?? t("roomWork.none")}

: null} + {intent ?

{t("roomWork.pending")}

: null} + {work?.projection ?
{t("roomWork.details")}
+
{t("roomWork.task")}
{intent?.todo_id ?? work.projection.inspected_todo?.todo_id ?? result?.receipt?.todo_id ?? work.projection.selected_todo?.todo_id ?? t("roomWork.none")}
+
{t("roomWork.observed")}
{work.projection.generated_at}
+ {result?.receipt ? <>
{t("roomWork.receipt")}
{result.receipt.receipt_id}
: null} +
: null} +
+ {intent || work?.projection?.selected_todo ? : null}
+
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/room-work.css b/apps/presentation/dashboard/src/features/personal-workspace/room-work.css new file mode 100644 index 0000000000..c3d9a1e1b5 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/room-work.css @@ -0,0 +1,11 @@ +/* Scoped to the existing connection dialog; shared theme tokens own presentation. */ +.personal-room-work p, .personal-room-work dd { overflow-wrap: anywhere; } +.personal-room-work { color: var(--pw-text); background: var(--pw-card); } +.personal-room-work dd { margin-inline-start: 0; } +.personal-room-work footer { flex-wrap: wrap; } + +.personal-room-work .personal-room-work-boundary { color: var(--pw-muted); font-size: var(--text-caption); } +.personal-room-work .personal-room-work-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.personal-room-work-counts dt { color: var(--pw-muted); font-size: var(--text-caption); } +.personal-room-work .personal-room-work-counts dd { font-size: var(--text-heading); line-height: var(--leading-heading); font-variant-numeric: tabular-nums; } +.personal-room-work header button { min-height: 44px; min-width: 44px; } diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 9b3291380c..18a4240f21 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -29,6 +29,7 @@ from .extensions.lark.goal_topic_batch import connect_lark_goal_topics from .extensions.lark.cli_transport import CommandRunner, default_subprocess_runner from .chat_agent import CodexChatAgentError +from .chat_room_work_api import RoomWorkChatRequestMixin from .chat_manager import ( controller_runtime_root, manager_channel, @@ -329,7 +330,7 @@ def session_matches( } -class LarkChatRequestMixin: +class LarkChatRequestMixin(RoomWorkChatRequestMixin): """Loopback HTTP handlers for Goal repository and Lark topic connections.""" server: Any diff --git a/loopx/chat_room_work_api.py b/loopx/chat_room_work_api.py new file mode 100644 index 0000000000..9eba1c7dca --- /dev/null +++ b/loopx/chat_room_work_api.py @@ -0,0 +1,79 @@ +"""Loopback room-work transport; the existing Todo and delivery owners decide effects.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from .control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from .extensions.lark.goal_channel_contracts import binding_for_goal, read_goal_channel_binding +from .extensions.lark.goal_channel_delivery_contract import goal_channel_binding_digest +from .extensions.lark.goal_channel_work import _resolve_binding, run_goal_channel_work +from .extensions.lark import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION, goal_channel_work +from .extensions.runtime import default_extension_state_file + +ROOM_WORK_PATH = "/api/chat/lark/room-work" + + +class RoomWorkChatRequestMixin: + def _room_work(self, *, claim: bool = False) -> None: + try: + if claim: + body = self._read_json() + allowed = {"goal_id", "connection_id", "scope_revision", "todo_id", "expected_revision", "idempotency_key"} + if not isinstance(body, dict) or set(body) != allowed: + raise ValueError("invalid room claim fields") + else: + query = parse_qs(urlparse(self.path).query) + if not {"goal_id", "connection_id"} <= set(query) or set(query) - {"goal_id", "connection_id", "todo_id"} or any(len(v) != 1 for v in query.values()): + raise ValueError("invalid room work target") + body = {k: v[0] for k, v in query.items()} + if any(not isinstance(v, str) or not v.strip() or len(v) > 512 for v in body.values()): + raise ValueError("invalid room work input") + goal_id, connection_id = body["goal_id"], body["connection_id"] + _, binding_path = self._goal_channel_context(goal_id) + route = resolve_goal_source_runtime_route(registry_path=self.server.registry_path, goal_id=goal_id) + selected = binding_for_goal(read_goal_channel_binding(binding_path), goal_id, connection_id=connection_id) + actor = (selected or {}).get("agent_id") + routing = (selected or {}).get("routing") or {} + if not isinstance(routing, dict) or routing.get("conversation_kind") not in (None, "goal"): + raise ValueError("a Goal connection is required") + if not isinstance(actor, str) or not actor or selected.get("enabled") is not True: + raise ValueError("an enabled Agent connection is required") + target_path = self._goal_channel_target_path() + binding = _resolve_binding(binding_path, target_path, goal_id, actor) + if binding.get("connection_id") != connection_id: + raise ValueError("connection does not match the Agent route") + digest = goal_channel_binding_digest(binding) + # An opaque freshness witness, never a claim grant or persisted state. + scope = "sha256:" + hashlib.sha256(json.dumps([ + digest, str(Path(route["source_registry"]).resolve()), + str(Path(route["source_runtime_root"]).resolve()), + ], separators=(",", ":")).encode()).hexdigest() + if claim and body["scope_revision"] != scope: + return self._send_error("The connection changed. Refresh room work before claiming.", + status=409, error_code="room_work_scope_changed") + packet = run_goal_channel_work( + registry_path=Path(route["source_registry"]), runtime_root=Path(route["source_runtime_root"]), + binding_path=binding_path, target_path=target_path, goal_id=goal_id, actor_id=actor, + command="claim" if claim else "project", execute=claim, runner=self._lark_runner(), + todo_id=body.get("todo_id"), expected_revision=body.get("expected_revision"), + idempotency_key=body.get("idempotency_key"), expected_connection_id=connection_id, + expected_binding_digest=digest, + ) + activation = {} + if not claim: + try: + activation = goal_channel_work.resolve_extension_activation(LARK_EXTENSION_ID, + state_file=default_extension_state_file(Path(route["source_runtime_root"])), + required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) + except ValueError: + # Inactive/incompatible extensions leave an explicitly read-only view. + pass + self._send_json({**packet, "connection_id": connection_id, "scope_revision": scope, + "claim_available": packet.get("ok") is True and (activation.get("enabled") is True or + (claim and packet.get("canonical_claim_accepted") is True and packet.get("readback_verified") is True))}) + except (ValueError, KeyError): + self._send_error("Room work is unavailable for this connection. Check its Agent and authority, then refresh.", + status=400, error_code="room_work_unavailable") diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 42ea426056..c66c39a8a8 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -64,6 +64,7 @@ SSH_HOST_CATALOG_PATH, ssh_host_catalog_payload, ) +from .chat_room_work_api import ROOM_WORK_PATH from .chat_lark_api import ( LarkChatRequestMixin, build_goal_repository_contexts as build_goal_repository_contexts, @@ -1528,6 +1529,7 @@ def do_GET(self) -> None: CHAT_LARK_APPS_PATH: self._lark_apps, CHAT_LARK_CHATS_PATH: self._lark_chats, CHAT_LARK_CONNECTIONS_PATH: self._lark_connections, + ROOM_WORK_PATH: self._room_work, PRIVATE_CONVERSATIONS_PATH: self._private_conversations, CHAT_GOAL_CHANNEL_TARGETS_PATH: self._goal_channel_targets, **self._configuration_get_routes(), @@ -1579,6 +1581,7 @@ def do_POST(self) -> None: CHAT_GOAL_CHANNEL_CONFIGURE_PATH: self._goal_channel_configure, CHAT_LARK_APP_SETUPS_PATH: self._lark_setup_start, CHAT_LARK_CONNECTIONS_PATH: self._lark_connect, + ROOM_WORK_PATH: lambda: self._room_work(claim=True), PRIVATE_CONVERSATIONS_PATH: self._private_conversation_connect, PRIVATE_CONVERSATIONS_PATH + "/agent-targets": self._private_conversation_agent_target, **self._configuration_post_routes(), diff --git a/loopx/control_plane/goals/room_work_projection.ts b/loopx/control_plane/goals/room_work_projection.ts index 6ffe69434d..34b2364c96 100644 --- a/loopx/control_plane/goals/room_work_projection.ts +++ b/loopx/control_plane/goals/room_work_projection.ts @@ -1,6 +1,7 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject, requireNonEmptyString, requireStringArray} from "../runtime_decode.ts"; import {evaluateCoordinationTodoClaimDecision} from "../coordination/todo_claim.ts"; +import {normalizeTodoAgent} from "../coordination/todo_agents.ts"; /** Content-minimal room orientation. Claim admission remains in the Todo owner. */ export function projectRoomWork(value: unknown): JsonObject { @@ -39,10 +40,18 @@ export function projectRoomWork(value: unknown): JsonObject { if (decision.status === "accepted") available.push({todo_id: id, claimed_by: null, actionability: "unclaimed"}); } + const inspectedId = input.inspected_todo_id == null ? null : requireNonEmptyString(input.inspected_todo_id, "inspected_todo_id"); + const inspected = inspectedId == null ? null : snapshot.todos.map(item => requireJsonObject(item, "canonical Todo")) + .find(todo => todo.todo_id === inspectedId && todo.role === "agent" && + (todo.bound_agent == null || todo.bound_agent === "" || normalizeTodoAgent(todo.bound_agent, "todo.bound_agent") === actor)); return {schema_version: "loopx_room_work_projection_v0", goal_id: goal, actor_id: actor, authority_state: "available", mode: "read_only", source_revision: revision, generated_at: observed, selected_todo: available[0] ?? null, counts: {unclaimed: available.length, user_gates: gates}, + ...(inspectedId == null ? {} : {inspected_todo: inspected == null ? null : { + todo_id: inspectedId, current_owner: inspected.claimed_by === "" ? null : inspected.claimed_by ?? null, + current_claim_matches_actor: inspected.claimed_by === actor, + }}), truth_contract: {projection_grants_authority: false, memory_grants_authority: false, private_task_content_included: false}}; } diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py index 18b117c771..c733a2f70a 100644 --- a/loopx/extensions/lark/goal_channel_work.py +++ b/loopx/extensions/lark/goal_channel_work.py @@ -23,7 +23,7 @@ from ..runtime import default_extension_state_file, resolve_extension_activation from . import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION from .goal_channel_contracts import binding_for_goal, read_goal_channel_binding -from .goal_channel_delivery_contract import goal_channel_delivery_route +from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route from .goal_channel_message_delivery import ( GoalChannelDeliveryStageError, GoalChannelMessageDeliverySession, resolve_bound_goal_channel, ) @@ -68,7 +68,8 @@ def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_pa target_path: Path, goal_id: str, actor_id: str, command: str, execute: bool, todo_id: str | None = None, expected_revision: str | None = None, idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner, - publish_receipt: bool = True) -> dict[str, Any]: + publish_receipt: bool = True, expected_connection_id: str | None = None, + expected_binding_digest: str | None = None) -> dict[str, Any]: packet: dict[str, Any] = {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "goal_id": goal_id, "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, "status": "failed", "external_write_performed": False, "readback_verified": False, @@ -90,7 +91,12 @@ def current_binding() -> dict[str, Any]: resolve_extension_activation(LARK_EXTENSION_ID, state_file=default_extension_state_file(runtime_root), required_permissions=(LARK_GOAL_CHANNEL_PERMISSION,)) - return _resolve_binding(binding_path, target_path, goal_id, actor_id) + current = _resolve_binding(binding_path, target_path, goal_id, actor_id) + if expected_connection_id is not None and current.get("connection_id") != expected_connection_id: + raise ValueError("connection changed") + if expected_binding_digest is not None and goal_channel_binding_digest(current) != expected_binding_digest: + raise ValueError("binding changed") + return current binding = current_binding() session = GoalChannelMessageDeliverySession(goal_id=goal_id, binding=binding, @@ -137,7 +143,8 @@ def current_binding() -> dict[str, Any]: raise ValueError("canonical promotion required") packet["projection"] = effect_runtime_result("goal_channel.work.project", { "goal_id": goal_id, "actor_id": actor_id, "registered_agents": registered, - "snapshot": snapshot, "observed_at": now_local_iso()}) + "snapshot": snapshot, "observed_at": now_local_iso(), + **({"inspected_todo_id": todo_id} if command == "project" and todo_id is not None else {})}) if command == "claim": current: dict[str, Any] = next((todo for todo in snapshot["todos"] if todo["todo_id"] == todo_id), {}) packet["receipt"]["current_owner"] = current.get("claimed_by") From 763ea5eeadf683cb55070ce6bacf53a8304d8a60 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 19:38:45 -0700 Subject: [PATCH 21/27] test(goal-channel): qualify packaged claim recovery and scope guards Signed-off-by: Lihua <1017343802@qq.com> --- examples/personal-workspace-browser-smoke.mjs | 2 + .../personal-workspace-browser/room-work.mjs | 125 +++++++++++++++ .../room_work_fixture.py | 94 ++++++++++++ tests/control_plane/test_room_work_api.py | 143 ++++++++++++++++++ .../room_work_projection.test.ts | 12 ++ 5 files changed, 376 insertions(+) create mode 100644 examples/personal-workspace-browser/room-work.mjs create mode 100644 examples/personal-workspace-browser/room_work_fixture.py create mode 100644 tests/control_plane/test_room_work_api.py diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 14348a3e96..79ff09d972 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { selectScenarioShard } from "./personal-workspace-browser/shard.mjs"; +import {roomWorkScenario} from "./personal-workspace-browser/room-work.mjs"; import { zcodeGoalScenario } from "./personal-workspace-browser/zcode-goal.mjs"; import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-workspace-create.mjs"; import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; @@ -111,6 +112,7 @@ scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); +scenarioCatalog.push(roomWorkScenario); const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) : selectScenarioShard(scenarioCatalog, process.env.LOOPX_PERSONAL_WORKSPACE_SHARD); diff --git a/examples/personal-workspace-browser/room-work.mjs b/examples/personal-workspace-browser/room-work.mjs new file mode 100644 index 0000000000..24153710e4 --- /dev/null +++ b/examples/personal-workspace-browser/room-work.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import {spawn, spawnSync} from "node:child_process"; +import {mkdtemp, realpath, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {repoRoot, outputDir} from "./fixture.mjs"; +import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; +import {testPythonCommand} from "../../scripts/test-python.mjs"; + +const connection = {agent_id:"agent-a",connection_id:"agent-a",conversation_kind:"goal",app_label:"Fixture Bot",app_ref:"room-fixture", + chat_name:"Product group",enabled:true,capture_scope:"addressed_only",goal_id:"product-release",goal_title:"Product Release", + health_error_code:null,incoming_mode:"mentions",ingress_mode:"async_inbox",event_count:1,last_event_reason:null,last_event_status:"replied_and_acknowledged", + listener_error_code:null,listener_status:"listening",replied_count:1,reply_ready:true,reply_mode:"topic_reply",target_ref:"fixture",topic_name:"Product Release",topic_setup_required:false}; + +export const roomWorkScenario = { + id: "room-work", + async run({browser, url}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "loopx-room-work-browser-"))); + const code = `import runpy; runpy.run_path(${JSON.stringify(resolve(repoRoot,"examples/personal-workspace-browser/room_work_fixture.py"))},run_name="__main__")`; + const python = testPythonCommand(code); + const child = spawn(python.command, [...python.args, root], {cwd:repoRoot, stdio:["ignore","pipe","pipe"]}); + let diagnostics="", context; + child.stderr.on("data", x => {diagnostics=(diagnostics+x).slice(-8000);}); + const exited = new Promise(accept => child.once("exit",accept)); + try { + const ready = await new Promise((accept,reject) => { + const lines=createInterface({input:child.stdout}); + const timer=setTimeout(()=>reject(new Error(`room backend readiness: ${diagnostics}`)),30000); + lines.once("line", line=>{clearTimeout(timer);lines.close();try{accept(JSON.parse(line));}catch(e){reject(e);}}); + child.once("exit", code=>{clearTimeout(timer);reject(new Error(`room backend exited ${code}: ${diagnostics}`));}); + }); + const backend=`http://127.0.0.1:${ready.port}`; + const before=await (await fetch(backend+"/fixture/readback")).json(); + const submitted=[], responses=[]; + context=await openWorkspacePage(browser,url,{beforeGoto:async(api,page)=>{ + api.larkConnections.push(connection); + await page.route("**/api/chat/lark/room-work**",async route=>{ + if(route.request().method()==="POST")submitted.push(route.request().postDataJSON()); + const response=await fetchWorkspaceBackend(route,backend); + responses.push(await response.json());await route.fulfill({response}); + }); + }}); + const {page}=context; + async function open() { + await page.getByRole("button",{name:"设置",exact:true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button",{name:"Lark",exact:true}).click(); + await page.screenshot({path:resolve(outputDir,"room-work-connections.png"),animations:"disabled"}); + await page.getByRole("button",{name:"房间工作",exact:true}).click(); + await page.getByRole("button",{name:"刷新当前状态"}).waitFor(); + await page.getByRole("button",{name:"刷新当前状态"}).waitFor({state:"visible"}); + await page.waitForFunction(()=>!document.querySelector("[role=dialog] button:disabled")); + } + await open(); + await page.screenshot({path:resolve(outputDir,"room-work-before-claim.png"),animations:"disabled"}); + await page.getByRole("dialog").screenshot({path:resolve(outputDir,"room-work-dialog-desktop.png"),animations:"disabled"}); + await page.evaluate(()=>{ + window.__roomWorkSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key,value) { + if (this === sessionStorage && key.startsWith("loopx.room-work:")) throw new DOMException("fixture storage unavailable"); + return window.__roomWorkSetItem.call(this,key,value); + }; + }); + await page.getByRole("button",{name:"领取任务并发送凭据"}).click(); + await page.getByText("浏览器无法保存恢复请求。请启用会话存储后再领取。",{exact:true}).waitFor(); + assert.equal(submitted.length,0,"unrecoverable browser storage cannot start a claim"); + await page.evaluate(()=>{Storage.prototype.setItem = window.__roomWorkSetItem; delete window.__roomWorkSetItem;}); + await fetch(backend+"/fixture/lose-message-response",{method:"POST"}); + await page.getByRole("button",{name:"领取任务并发送凭据"}).evaluate(button=>{button.click();button.click();}); + await page.getByText("任务已领取,房间消息仍需恢复。",{exact:true}).waitFor(); + assert.equal(submitted.length,1,"synchronous duplicate click does not mint another request"); + const accepted=await (await fetch(backend+"/fixture/readback")).json(); + assert.notEqual(accepted.snapshot.provider_revision,before.snapshot.provider_revision); + await page.reload({waitUntil:"networkidle"}); + await page.getByTestId("personal-goal-home").waitFor(); + await open(); + await page.getByRole("button",{name:"核对此次领取"}).click(); + await page.getByText("历史领取已接受",{exact:true}).waitFor(); + await page.getByText("房间凭据已确认",{exact:true}).waitFor(); + assert.deepEqual(submitted[1],submitted[0],"reload recovery reuses exact intent and original key"); + const final=await (await fetch(backend+"/fixture/readback")).json(); + assert.equal(final.messages,1);assert.equal(final.snapshot.provider_revision,accepted.snapshot.provider_revision); + assert.equal(final.snapshot.todos[0].claimed_by,"agent-a"); + const cli=testPythonCommand(`from loopx.cli import main; main(["--registry",${JSON.stringify(ready.registry)},"--format","json","todo","list","--goal-id","product-release"])`); + const direct=spawnSync(cli.command,cli.args,{cwd:repoRoot,encoding:"utf8"}); + assert.equal(direct.status,0,direct.stderr);assert.equal(JSON.parse(direct.stdout).todos[0].claimed_by,"agent-a"); + await page.getByRole("button",{name:"刷新当前状态"}).click(); + await page.getByText("最近回读的当前归属: agent-a",{exact:true}).waitFor(); + assert.equal(JSON.stringify(responses).includes("Synthetic private task text"),false); + assert.ok(responses.every(p=>p.execution_authority_granted===false)); + await page.screenshot({path:resolve(outputDir,"room-work-recovered.png"),animations:"disabled"}); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.screenshot({path:resolve(outputDir,"room-work-mobile.png"),animations:"disabled"}); + await page.getByRole("dialog").screenshot({path:resolve(outputDir,"room-work-dialog-mobile.png"),animations:"disabled"}); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({state:"hidden"}); + await page.waitForFunction(()=>document.activeElement?.textContent === "房间工作"); + await page.getByRole("button",{name:"房间工作",exact:true}).click(); + await page.getByRole("dialog").waitFor(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({state:"hidden"}); + await page.setViewportSize({width:1512,height:982}); + await page.addInitScript(()=>localStorage.setItem("loopx-pw-locale","en")); + await page.reload({waitUntil:"networkidle"}); + await page.getByTestId("personal-goal-home").waitFor(); + await page.getByRole("button",{name:"Settings",exact:true}).click(); + await page.locator(".personal-settings-tabs").getByRole("button",{name:"Lark",exact:true}).click(); + await page.getByRole("button",{name:"Room work",exact:true}).click(); + await page.getByText("Available tasks",{exact:true}).waitFor(); + await page.keyboard.press("Tab"); + assert.equal(await page.getByRole("button",{name:"Close room work"}).evaluate(button=>button===document.activeElement),true); + await page.keyboard.press("Shift+Tab"); + assert.equal(await page.getByRole("button",{name:"Refresh current state"}).evaluate(button=>button===document.activeElement),true); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.screenshot({path:resolve(outputDir,"room-work-english.png"),animations:"disabled"}); + return {coverageEntries:context.coverageEntries,errors:context.errors,note:"Packaged room UI -> real File authority, explicit claim, duplicate click, uncertain delivery plus reload/same-key recovery, one room card and independent CLI current-owner readback; provider transport and connection discovery synthetic."}; + } finally { + await context?.close();child.kill("SIGTERM");const timer=setTimeout(()=>child.kill("SIGKILL"),5000); + try{await exited;}finally{clearTimeout(timer);await rm(root,{recursive:true,force:true,maxRetries:10,retryDelay:100});} + } + }, +}; diff --git a/examples/personal-workspace-browser/room_work_fixture.py b/examples/personal-workspace-browser/room_work_fixture.py new file mode 100644 index 0000000000..ae3fed5719 --- /dev/null +++ b/examples/personal-workspace-browser/room_work_fixture.py @@ -0,0 +1,94 @@ +"""Synthetic Lark transport around a disposable real room-work HTTP backend.""" +import json +import os +import signal +from pathlib import Path +import sys +import tempfile + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.extensions.lark import goal_channel_work as work +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tests/control_plane")) +from canonical_authority_fixture import initialize_canonical_authority + + +class SyntheticRoom: + def __init__(self): + self.messages = [] + self.fail_send = False + + def __call__(self, args, cwd, timeout): + payload = {} + if "auth" in args: + payload = {"appId": "cli_room_fixture", "identities": {"bot": {"available": True, "verified": True, "appName": "Fixture Bot"}}} + elif "+chat-members-list" in args: + payload = {"data": {"bots": [{"app_id": "cli_room_fixture"}]}} + elif "+messages-send" in args: + card = json.loads(args[args.index("--content") + 1]) + message = {"message_id": f"om_room_{len(self.messages)}", "chat_id": "oc_room_fixture", + "sender": {"sender_type": "app", "id": "cli_room_fixture"}, "body": {"content": json.dumps(card)}} + self.messages.append(message) + if self.fail_send: + self.fail_send = False + return {"returncode": 1, "stdout": "", "stderr": "", "timed_out": True} + payload = {"data": {"message_id": message["message_id"]}} + elif "+messages-mget" in args or "+chat-messages-list" in args: + payload = {"data": {"items": self.messages, "has_more": False}} + return {"returncode": 0, "stdout": json.dumps(payload), "stderr": ""} + + +def serve(root): + runtime, registry, state = root / "runtime", root / ".loopx/registry.json", root / "state.md" + registry.parent.mkdir(); state.write_text("## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{"id": "product-release", "repo": str(root), + "state_file": str(state), "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) + todos = [{"schema_version": "todo_item_v0", "todo_id": "todo_room_fixture", "role": "agent", "status": "open", "done": False, + "text": "Synthetic private task text", "archive_state": "active", "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task"}] + projection = build_todo_runtime_shadow_projection(goal_id="product-release", todos=todos, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, "product-release", projection, state_path=state) + binding_path = registry.parent / "goal-channel.json" + binding = {"schema_version": "loopx_goal_channel_lark_binding_v0", "bindings": {"product-release": { + "schema_version": "loopx_goal_channel_connection_set_v0", "connections": {actor: {"goal_id": "product-release", "agent_id": actor, + "provider": "lark", "enabled": True, "channel": {"chat_id": "oc_room_fixture"}, "identity": {"mode": "project_bot", + "sender_identity": "bot", "sender_profile": "room-fixture", "bot_app_id": "cli_room_fixture", "bot_display_name": "Fixture Bot", "cli_bin": "fixture-cli"}} + for actor in ["agent-a", "agent-b"]}}}} + write_goal_channel_binding(binding_path, binding) + room = SyntheticRoom() + # The fixture explicitly enables its synthetic extension; no real profile is changed. + work.resolve_extension_activation = lambda *a, **k: {"enabled": True} + + class Handler(ChatRequestHandler): + def do_GET(self): + if self.path == "/fixture/readback": + return self._send_json({"snapshot": read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="product-release"), + "messages": len(room.messages)}) + return super().do_GET() + + def do_POST(self): + if self.path == "/fixture/lose-message-response": + room.fail_send = True + return self._send_json({"ok": True}) + return super().do_POST() + + server = ChatHTTPServer(("127.0.0.1", 0), Handler) + server.registry_path = registry; server.runtime_root = runtime; server.runtime_root_override = str(runtime) + server.lark_runner = room; server.verbose = False + print(json.dumps({"port": server.server_port, "registry": str(registry), "module": work.__file__}), flush=True) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + try: + server.serve_forever() + finally: + from loopx.control_plane.effect_runtime import effect_runtime_result + effect_runtime_result("runtime.shutdown", {}, retry_safe=False) + server.server_close() + + +if __name__ == "__main__": + root = Path(sys.argv[1]); scratch = root / "tmp"; scratch.mkdir() + os.environ["TMPDIR"] = str(scratch); tempfile.tempdir = str(scratch) + serve(root) diff --git a/tests/control_plane/test_room_work_api.py b/tests/control_plane/test_room_work_api.py new file mode 100644 index 0000000000..debf427e3c --- /dev/null +++ b/tests/control_plane/test_room_work_api.py @@ -0,0 +1,143 @@ +"""Real loopback HTTP and canonical stores; only provider transport is synthetic.""" +import http.client +import json +import threading + +import pytest + +from test_room_work import setup, snapshot, claim # noqa: F401 +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding + + +@pytest.fixture +def api(setup): + args, room, binding = setup + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = args["registry_path"] + server.runtime_root = args["runtime_root"] + server.runtime_root_override = str(args["runtime_root"]) + server.lark_runner = room + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(body=None, *, query="goal_id=room-fixture&connection_id=agent-a", origin=None): + conn = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + headers = {"Content-Type": "application/json"} + if origin is not None: + headers["Origin"] = origin + try: + conn.request("POST" if body is not None else "GET", "/api/chat/lark/room-work" + ("" if body is not None else "?" + query), + body=None if body is None else json.dumps(body), headers=headers) + response = conn.getresponse() + return response.status, json.loads(response.read()) + finally: + conn.close() + try: + yield request, args, room, binding + finally: + server.shutdown(); thread.join(); server.server_close() + + +def intent(readback): + return {"goal_id": "room-fixture", "connection_id": "agent-a", "scope_revision": readback["scope_revision"], + "todo_id": readback["projection"]["selected_todo"]["todo_id"], + "expected_revision": readback["projection"]["source_revision"], "idempotency_key": "http-room-claim"} + + +def test_http_claim_retry_and_independent_current_owner(api): + request, args, room, _ = api + status, preview = request() + assert status == 200 and preview["ok"] and not room.messages + assert "PRIVATE_" not in json.dumps(preview) + body = intent(preview) + status, accepted = request(body) + assert status == 200 and accepted["canonical_claim_accepted"] and accepted["readback_verified"] + assert accepted["execution_authority_granted"] is False + assert accepted["claim_available"] is True + assert "inspected_todo" not in accepted["projection"], "the established claim projection remains unchanged" + revision = snapshot(args)["provider_revision"] + _, retry = request(body) + assert retry["status"] == "already_applied" and len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == revision + _, current = request(query="goal_id=room-fixture&connection_id=agent-a&todo_id=" + body["todo_id"]) + assert current["projection"]["inspected_todo"]["current_owner"] == "agent-a" + assert current["projection"]["counts"]["unclaimed"] == 0 + + +def test_http_unknown_delivery_keeps_exact_request_recoverable(api): + request, args, room, _ = api + _, preview = request(); body = intent(preview) + room.fail_send = True + _, first = request(body) + assert first["canonical_claim_accepted"] and not first["ok"] + revision = snapshot(args)["provider_revision"] + _, replay = request(body) + assert replay["ok"] and replay["status"] == "already_applied" and len(room.messages) == 1 + assert snapshot(args)["provider_revision"] == revision + + +def test_http_stale_revision_and_changed_scope_cannot_claim(api): + request, args, room, binding = api + _, preview = request(); body = intent(preview) + assert claim(args, "agent-b", body["expected_revision"], "peer-claim")["ok"] + revision = snapshot(args)["provider_revision"] + _, stale = request(body) + assert stale["status"] == "conflict" and not stale["canonical_claim_accepted"] + assert snapshot(args)["provider_revision"] == revision + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_another_fixture" + write_goal_channel_binding(args["binding_path"], binding) + assert request(body)[0] == 409 + assert snapshot(args)["provider_revision"] == revision and len(room.messages) == 1 + + +def test_http_client_cannot_supply_actor_or_foreign_connection(api): + request, args, room, _ = api + _, preview = request(); body = intent(preview) + before = snapshot(args)["provider_revision"] + assert request({**body, "actor_id": "agent-b"})[0] == 400 + assert request(query="goal_id=room-fixture&connection_id=missing")[0] == 400 + assert request(body, origin="https://foreign.example")[0] == 403 + room.authenticated = False + _, denied = request(body) + assert not denied["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_inactive_extension_preserves_read_only_view(api, monkeypatch): + from loopx.extensions.lark import goal_channel_work + request, args, room, _ = api + def disabled(*args, **kwargs): + raise ValueError("fixture extension is disabled") + monkeypatch.setattr(goal_channel_work, "resolve_extension_activation", disabled) + before = snapshot(args)["provider_revision"] + status, preview = request() + assert status == 200 and preview["ok"] and preview["claim_available"] is False + _, attempted = request(intent(preview)) + assert not attempted["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_binding_change_after_http_scope_check_is_rechecked_by_work_owner(api, monkeypatch): + from loopx.extensions.lark import goal_channel_work + request, args, room, binding = api + _, preview = request(); body = intent(preview) + before = snapshot(args)["provider_revision"] + def retarget(*a, **k): + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_retargeted_fixture" + write_goal_channel_binding(args["binding_path"], binding) + return {"enabled": True} + monkeypatch.setattr(goal_channel_work, "resolve_extension_activation", retarget) + _, result = request(body) + assert not result["canonical_claim_accepted"] and not room.messages + assert snapshot(args)["provider_revision"] == before + + +def test_manager_connection_cannot_be_used_as_a_worker_route(api): + request, args, room, binding = api + before = snapshot(args)["provider_revision"] + binding["bindings"]["room-fixture"]["connections"]["agent-a"]["routing"] = {"conversation_kind": "manager"} + write_goal_channel_binding(args["binding_path"], binding) + assert request()[0] == 400 + assert snapshot(args)["provider_revision"] == before and not room.messages diff --git a/tests/control_plane_ts/room_work_projection.test.ts b/tests/control_plane_ts/room_work_projection.test.ts index a4f01b79e0..78148b3e8e 100644 --- a/tests/control_plane_ts/room_work_projection.test.ts +++ b/tests/control_plane_ts/room_work_projection.test.ts @@ -30,3 +30,15 @@ test("room orientation refuses revoked identity, partial snapshot and invalid fr assert.throws(() => projectRoomWork(bad)); } }); + +test("optional ownership readback is fresh, minimal and actor scoped", () => { + const input = packet([todo("todo_owned", {claimed_by: "agent-b"}), todo("todo_hidden", {bound_agent: "agent-b"})]); + const plain = projectRoomWork(input); + assert.equal(Object.hasOwn(plain, "inspected_todo"), false, "existing callers retain their result shape"); + const observed = projectRoomWork({...input, inspected_todo_id: "todo_owned"}); + assert.deepEqual(observed.inspected_todo, {todo_id: "todo_owned", current_owner: "agent-b", current_claim_matches_actor: false}); + assert.equal(JSON.stringify(observed).includes("PRIVATE_"), false); + assert.equal(projectRoomWork({...input, inspected_todo_id: "todo_hidden"}).inspected_todo, null); + const legacy = projectRoomWork({...packet([todo("todo_empty", {bound_agent: "", claimed_by: ""})]), inspected_todo_id: "todo_empty"}); + assert.deepEqual(legacy.inspected_todo, {todo_id: "todo_empty", current_owner: null, current_claim_matches_actor: false}); +}); From 6ea11724d81d9af4ce939b40ad870c4bd0f8de85 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 19:38:45 -0700 Subject: [PATCH 22/27] docs(goal-channel): describe packaged room work and qualification limits Signed-off-by: Lihua <1017343802@qq.com> --- .../agent-im-openviking-collaboration-v0.md | 11 +++++ .../assets/room-work/claim-desktop.png | Bin 0 -> 24381 bytes .../assets/room-work/claim-mobile.png | Bin 0 -> 28051 bytes docs/reference/goal-channel-room-work.md | 42 ++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 docs/reference/assets/room-work/claim-desktop.png create mode 100644 docs/reference/assets/room-work/claim-mobile.png diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 81ffa98f09..612cae14aa 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -295,6 +295,17 @@ Goal Channel delivery/readback. It reuses promoted File/SQLite authority and the typed Todo owner; it does not replace the shared-service or provisioning owners. +The proposed packaged workspace companion enters through the existing Lark +connection. It reads canonical room orientation, explicitly claims through the +same owner and reads historical acceptance, current ownership and message +delivery separately. Its HTTP adapter derives the actor from the current +connection and pins the preview's binding/source scope. An uncertain request +survives page reload only as an original retry tuple; the browser gains no +independent work or execution authority. Packaged browser and real File/SQLite +HTTP checks retain synthetic connection/provider transport and independent CLI +readback. These local results do not qualify native room rendering, an installed +App, independently authenticated hosts or live OpenViking. + The private `work resume` path composes existing scoped Turn Recall with fresh channel identity, canonical projection and quota observations. It skips memory ingest, rechecks scope and state after retrieval, discards context on changes, diff --git a/docs/reference/assets/room-work/claim-desktop.png b/docs/reference/assets/room-work/claim-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..dac4116d77da31522728d47cb8c2e27d35bb1e5c GIT binary patch literal 24381 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 XJbU-ykI9W!@CpOY)Y{~skq7a=;Kqj} literal 0 HcmV?d00001 diff --git a/docs/reference/assets/room-work/claim-mobile.png b/docs/reference/assets/room-work/claim-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..9ebcc8d494d93d974d579e1ab34bb253a9bd2f0a GIT binary patch literal 28051 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 Date: Wed, 14 Oct 2026 19:43:20 -0700 Subject: [PATCH 23/27] fix(tests): keep room-work fixture imports and cleanup lint clean Signed-off-by: Lihua <1017343802@qq.com> --- .../room_work_fixture.py | 22 +++++++++++++------ tests/control_plane/test_room_work_api.py | 18 ++++++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/examples/personal-workspace-browser/room_work_fixture.py b/examples/personal-workspace-browser/room_work_fixture.py index ae3fed5719..2df225358f 100644 --- a/examples/personal-workspace-browser/room_work_fixture.py +++ b/examples/personal-workspace-browser/room_work_fixture.py @@ -13,8 +13,6 @@ from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tests/control_plane")) -from canonical_authority_fixture import initialize_canonical_authority class SyntheticRoom: @@ -43,8 +41,12 @@ def __call__(self, args, cwd, timeout): def serve(root): + sys.path.insert(0, str(ROOT / "tests/control_plane")) + from canonical_authority_fixture import initialize_canonical_authority + runtime, registry, state = root / "runtime", root / ".loopx/registry.json", root / "state.md" - registry.parent.mkdir(); state.write_text("## Agent Todo\n") + registry.parent.mkdir() + state.write_text("## Agent Todo\n") registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{"id": "product-release", "repo": str(root), "state_file": str(state), "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) todos = [{"schema_version": "todo_item_v0", "todo_id": "todo_room_fixture", "role": "agent", "status": "open", "done": False, @@ -76,8 +78,11 @@ def do_POST(self): return super().do_POST() server = ChatHTTPServer(("127.0.0.1", 0), Handler) - server.registry_path = registry; server.runtime_root = runtime; server.runtime_root_override = str(runtime) - server.lark_runner = room; server.verbose = False + server.registry_path = registry + server.runtime_root = runtime + server.runtime_root_override = str(runtime) + server.lark_runner = room + server.verbose = False print(json.dumps({"port": server.server_port, "registry": str(registry), "module": work.__file__}), flush=True) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) try: @@ -89,6 +94,9 @@ def do_POST(self): if __name__ == "__main__": - root = Path(sys.argv[1]); scratch = root / "tmp"; scratch.mkdir() - os.environ["TMPDIR"] = str(scratch); tempfile.tempdir = str(scratch) + root = Path(sys.argv[1]) + scratch = root / "tmp" + scratch.mkdir() + os.environ["TMPDIR"] = str(scratch) + tempfile.tempdir = str(scratch) serve(root) diff --git a/tests/control_plane/test_room_work_api.py b/tests/control_plane/test_room_work_api.py index debf427e3c..3682283e7d 100644 --- a/tests/control_plane/test_room_work_api.py +++ b/tests/control_plane/test_room_work_api.py @@ -5,7 +5,7 @@ import pytest -from test_room_work import setup, snapshot, claim # noqa: F401 +from test_room_work import setup as setup, snapshot, claim from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding @@ -37,7 +37,9 @@ def request(body=None, *, query="goal_id=room-fixture&connection_id=agent-a", or try: yield request, args, room, binding finally: - server.shutdown(); thread.join(); server.server_close() + server.shutdown() + thread.join() + server.server_close() def intent(readback): @@ -68,7 +70,8 @@ def test_http_claim_retry_and_independent_current_owner(api): def test_http_unknown_delivery_keeps_exact_request_recoverable(api): request, args, room, _ = api - _, preview = request(); body = intent(preview) + _, preview = request() + body = intent(preview) room.fail_send = True _, first = request(body) assert first["canonical_claim_accepted"] and not first["ok"] @@ -80,7 +83,8 @@ def test_http_unknown_delivery_keeps_exact_request_recoverable(api): def test_http_stale_revision_and_changed_scope_cannot_claim(api): request, args, room, binding = api - _, preview = request(); body = intent(preview) + _, preview = request() + body = intent(preview) assert claim(args, "agent-b", body["expected_revision"], "peer-claim")["ok"] revision = snapshot(args)["provider_revision"] _, stale = request(body) @@ -94,7 +98,8 @@ def test_http_stale_revision_and_changed_scope_cannot_claim(api): def test_http_client_cannot_supply_actor_or_foreign_connection(api): request, args, room, _ = api - _, preview = request(); body = intent(preview) + _, preview = request() + body = intent(preview) before = snapshot(args)["provider_revision"] assert request({**body, "actor_id": "agent-b"})[0] == 400 assert request(query="goal_id=room-fixture&connection_id=missing")[0] == 400 @@ -122,7 +127,8 @@ def disabled(*args, **kwargs): def test_binding_change_after_http_scope_check_is_rechecked_by_work_owner(api, monkeypatch): from loopx.extensions.lark import goal_channel_work request, args, room, binding = api - _, preview = request(); body = intent(preview) + _, preview = request() + body = intent(preview) before = snapshot(args)["provider_revision"] def retarget(*a, **k): binding["bindings"]["room-fixture"]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_retargeted_fixture" From 842ae127a46cebbb796ffef6945b7dc9a55f28f3 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 14 Oct 2026 21:59:07 -0700 Subject: [PATCH 24/27] fix(goal-channel): pin callback offer binding through claim admission Signed-off-by: Lihua <1017343802@qq.com> --- .../agent-im-openviking-collaboration-v0.md | 4 +++ loopx/extensions/lark/room_claim.py | 5 ++- .../control_plane/test_room_claim_callback.py | 35 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 612cae14aa..8066fdd0e1 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -295,6 +295,10 @@ Goal Channel delivery/readback. It reuses promoted File/SQLite authority and the typed Todo owner; it does not replace the shared-service or provisioning owners. +The claim callback retains its original offer binding witness through member +and card reads into the existing locked claim check. Retargeting during either +provider read rejects before canonical ownership or an acceptance receipt changes. + The proposed packaged workspace companion enters through the existing Lark connection. It reads canonical room orientation, explicitly claims through the same owner and reads historical acceptance, current ownership and message diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py index fdf13ccdf1..49998b605f 100644 --- a/loopx/extensions/lark/room_claim.py +++ b/loopx/extensions/lark/room_claim.py @@ -265,7 +265,10 @@ def handle_room_claim_callback(event: Mapping[str, Any], *, runtime_root: Path, runtime_root=Path(p["authority_root"]), binding_path=Path(p["binding_path"]), target_path=Path(p["target_path"]), goal_id=request["goal_id"], actor_id=request["actor_id"], command="claim", execute=True, todo_id=request["todo_id"], expected_revision=request["expected_revision"], - idempotency_key=request["idempotency_key"], runner=runner, publish_receipt=False) + idempotency_key=request["idempotency_key"], runner=runner, publish_receipt=False, + # Provider reads can outlive the offer's original connection. + # Keep its witness through the facade's locked claim check. + expected_binding_digest=p["binding_digest"]) card = build_work_card(claimed) if claimed.get("projection") else { "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": "Claim readback"}}, "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": str(claimed.get("public_summary"))}}]} diff --git a/tests/control_plane/test_room_claim_callback.py b/tests/control_plane/test_room_claim_callback.py index 8e6c8994f0..b419cf7629 100644 --- a/tests/control_plane/test_room_claim_callback.py +++ b/tests/control_plane/test_room_claim_callback.py @@ -165,6 +165,41 @@ def test_scope_and_stale_callback_rejections_preserve_canonical_state(offered, m assert "PRIVATE_" not in json.dumps(room.messages) +@pytest.mark.parametrize("read_stage", ["operator_membership_verified", "read_callback_card_content"]) +def test_callback_retargeted_during_provider_read_cannot_claim(offered, monkeypatch, read_stage): + args, room, binding, _, offer, event, callback_args, _ = offered + operation = "retarget-during-provider-read" + prepared = offer(key=operation) + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args) + messages = json.dumps(room.messages) + def receipt(): + return ingress.effect_runtime_result("coordination.local_authority.operation_receipt", { + "schema_version": "loopx_local_coordination_operation_receipt_request_v0", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "operation_id": operation, + }) + + original_receipt = receipt() + assert original_receipt["status"] == "missing", original_receipt + original_read = getattr(ingress, read_stage) + + def retarget_after_read(**kwargs): + observed = original_read(**kwargs) + binding["bindings"][GOAL]["connections"]["agent-a"]["channel"]["chat_id"] = "oc_retargeted_fixture" + write_goal_channel_binding(args["binding_path"], binding) + return observed + + monkeypatch.setattr(ingress, read_stage, retarget_after_read) + with pytest.raises(ingress.RoomClaimCallbackError, match="Room claim callback") as rejected: + ingress.handle_room_claim_callback(callback, **callback_args) + assert rejected.value.code == "binding_changed" + assert snapshot(args) == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert receipt() == original_receipt + assert json.dumps(room.messages) == messages + + def test_restart_recovers_only_result_transport_without_a_new_transition(offered): args, _, _, _, offer, event, callback_args, transport = offered prepared = offer() From b7b2e1c31882c28d8f0d7a7b02c49513d8953a8f Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Thu, 15 Oct 2026 04:59:44 -0700 Subject: [PATCH 25/27] fix(coordination): retain room source guards through canonical claim Signed-off-by: Lihua <1017343802@qq.com> --- loopx/cli_commands/goal_channel.py | 2 +- .../coordination/local_authority.py | 6 +- .../coordination/local_authority_runtime.ts | 27 +++- .../coordination/shadow_registry_source.ts | 45 +++++- loopx/extensions/lark/goal_channel_work.py | 30 +++- loopx/extensions/lark/room_claim.py | 7 +- .../control_plane/test_room_claim_callback.py | 56 +++++++ tests/control_plane/test_room_work.py | 7 +- .../control_plane_ts/authority_source.test.ts | 139 +++++++++++++++++- tsconfig.control-plane.json | 1 + 10 files changed, 305 insertions(+), 15 deletions(-) diff --git a/loopx/cli_commands/goal_channel.py b/loopx/cli_commands/goal_channel.py index 755742e000..ca2733ff9a 100644 --- a/loopx/cli_commands/goal_channel.py +++ b/loopx/cli_commands/goal_channel.py @@ -530,7 +530,7 @@ def handle_goal_channel_command( target_path=_target_path(args, runtime_root), goal_id=goal_id, actor_id=args.agent_id, command=args.goal_channel_work_command, execute=execute, todo_id=getattr(args, "todo_id", None), expected_revision=getattr(args, "expected_revision", None), - idempotency_key=getattr(args, "idempotency_key", None)) + idempotency_key=getattr(args, "idempotency_key", None), invoked_registry_path=registry_path) print_payload(payload, output_format(args), render_goal_channel_markdown) return 0 if payload.get("ok") else 1 if command == "runtime": diff --git a/loopx/control_plane/coordination/local_authority.py b/loopx/control_plane/coordination/local_authority.py index caece3f5e0..1d0ef34abc 100644 --- a/loopx/control_plane/coordination/local_authority.py +++ b/loopx/control_plane/coordination/local_authority.py @@ -34,6 +34,7 @@ LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA = ( "loopx_local_coordination_todo_claim_request_v1" ) +LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v2" LOCAL_COORDINATION_TODO_CLAIM_METHOD = "coordination.local_authority.todo_claim" @@ -102,6 +103,7 @@ def claim_canonical_todo_if_promoted( task_lease_expected_version: int | None = None, project: Path | None = None, state_file: Path | None = None, + registry_sources: list[dict[str, str]] | None = None, ) -> dict[str, Any] | None: """Route a post-cutover claim to the TypeScript transaction owner.""" @@ -112,7 +114,8 @@ def claim_canonical_todo_if_promoted( result = effect_runtime_result( LOCAL_COORDINATION_TODO_CLAIM_METHOD, { - "schema_version": LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA, + "schema_version": (LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA + if registry_sources is None else LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA), "runtime_root": str(runtime_root.expanduser().resolve(strict=False)), "goal_id": goal_id, "todo_id": todo_id, @@ -121,6 +124,7 @@ def claim_canonical_todo_if_promoted( "actor_agent_id": actor_agent_id, "registered_agents": registered, "registry_source": registry_source, + **({"registry_sources": registry_sources} if registry_sources is not None else {}), "operation_id": ( operation_id if operation_id is not None diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 0e46a25c6c..aebbe19ed6 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1,4 +1,4 @@ -import {requirePromotionRegisteredAgents} from "./shadow_registry_source.ts"; +import {requirePromotionRegisteredAgents, withRegistryAuthoritySources} from "./shadow_registry_source.ts"; import {verifyReentrySettlement} from "./todo_terminal_reentry.ts"; import {readPromotionReceipt, commitPromotionAndReadBack} from './promotion_receipt.ts'; import {reviewedPromotionPlan, promotionPlanDigest, decodeReviewedPromotionOperation, REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA} from './reviewed_promotion_plan.ts'; @@ -106,6 +106,7 @@ import { export const LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v0"; export const LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v1"; +export const LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA = "loopx_local_coordination_todo_claim_request_v2"; export const LOCAL_COORDINATION_TODO_CREATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_create_request_v0"; export const LOCAL_COORDINATION_TODO_CREATE_WITNESSED_REQUEST_SCHEMA = "loopx_local_coordination_todo_create_request_v1"; @@ -1026,12 +1027,34 @@ export async function claimLocalCoordinationTodo( try { const input = requireJsonObject(value, "local coordination Todo claim request"); if (input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_REQUEST_SCHEMA && - input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA) { + input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA && + input.schema_version !== LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA) { throw new Error("local coordination Todo claim request schema mismatch"); } const scopeFailure = unqualifiedGoalRefClaimFailure(input); if (scopeFailure !== null) return {...scopeFailure, source_authority: null, decision_read_from_provider: false, legacy_fallback_used: false}; + if (input.schema_version === LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA) { + try { + const guarded = structuredClone(input); + const sources = guarded.registry_sources; + delete guarded.registry_sources; + guarded.schema_version = LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA; + return await withRegistryAuthoritySources(sources, guarded.registry_source, + () => claimLocalCoordinationTodo(guarded, dependencies)); + } catch (error) { + return {schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA, status: "failed", + reason_code: error instanceof ShadowManagementError ? error.reason_code : "invalid_local_coordination_todo_claim_request", + reason: error instanceof Error ? error.message : "invalid registry source guards", + source_authority: null, decision_read_from_provider: false, legacy_fallback_used: false}; + } + } + if (Object.hasOwn(input, "registry_sources")) { + return {schema_version: COORDINATION_TODO_CLAIM_RESULT_SCHEMA, status: "failed", + reason_code: "invalid_local_coordination_todo_claim_request", failure_kind: "protocol_failure", + reason: "registry_sources requires claim request v2", source_authority: null, + decision_read_from_provider: false, legacy_fallback_used: false}; + } const authoritySourcesCurrent = registryAuthoritySourceCheck(input, input.schema_version === LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA); if (typeof input.dry_run !== "boolean") { diff --git a/loopx/control_plane/coordination/shadow_registry_source.ts b/loopx/control_plane/coordination/shadow_registry_source.ts index 4c6eb68aae..3d2e82e0f8 100644 --- a/loopx/control_plane/coordination/shadow_registry_source.ts +++ b/loopx/control_plane/coordination/shadow_registry_source.ts @@ -3,11 +3,12 @@ * Recovery of an already fenced operation deliberately does not use this owner. */ import {isAbsolute, resolve} from "node:path"; +import {requireJsonObject} from "../runtime_decode.ts"; import type {JsonObject} from "../effect_program.ts"; import {withFileMutationLock} from "../effect_runtime_io.ts"; import {EffectRuntimeLockTimeoutError} from "../effect_runtime_errors.ts"; import {canonicalAuthorityBytes, hasExactAuthorityKeys} from "./authority_store_codec.ts"; -import {registryAuthoritySourceCheck} from "./authority_source.ts"; +import {AUTHORITY_SOURCE_CHANGED, registryAuthoritySourceCheck} from "./authority_source.ts"; import {ShadowManagementError} from "./shadow_management.ts"; import {normalizeRegisteredTodoAgents} from "./todo_agents.ts"; import { BARE_SHA256_PATTERN } from "../content_digest.ts"; @@ -68,3 +69,45 @@ export async function withShadowRegistrySource(snapshot: JsonObject, operatio throw error; } } + +/** Retain the already observed source bytes through a native canonical effect. + * Nonwaiting acquisition follows the existing source-guard rule: release and + * retry on contention rather than waiting in a reverse registry/writer order. + * The native effect owns these guards even if its transport caller exits. + */ +export async function withRegistryAuthoritySources( + value: unknown, primaryValue: unknown, operation: () => Promise, +): Promise { + if (!Array.isArray(value) || value.length < 1 || value.length > 2) { + throw new TypeError("registry_sources must contain one or two source witnesses"); + } + const primary = requireJsonObject(primaryValue, "registry_source"); + registryAuthoritySourceCheck({registry_source: primary}, true); + const sources = value.map(raw => { + const source = requireJsonObject(raw, "registry_sources entry"); + const check = registryAuthoritySourceCheck({registry_source: source}, true); + return {path: source.path as string, sha256: source.sha256 as string, check}; + }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + if (new Set(sources.map(source => source.path)).size !== sources.length || + !sources.some(source => source.path === primary.path && source.sha256 === primary.sha256)) { + throw new TypeError("registry_sources must be distinct and retain the primary registry_source"); + } + let sourcesAcquired = false; + const lock = async (index: number): Promise => { + if (index < sources.length) { + return withFileMutationLock(sources[index]!.path, () => lock(index + 1), 0); + } + sourcesAcquired = true; + for (const source of sources) { + if (!await source.check()) throw new ShadowManagementError(AUTHORITY_SOURCE_CHANGED.code); + } + return operation(); + }; + try { return await lock(0); } + catch (error) { + if (!sourcesAcquired && error instanceof EffectRuntimeLockTimeoutError) { + throw new ShadowManagementError("source_registry_busy_retry"); + } + throw error; + } +} diff --git a/loopx/extensions/lark/goal_channel_work.py b/loopx/extensions/lark/goal_channel_work.py index c733a2f70a..ef5e2ad7fe 100644 --- a/loopx/extensions/lark/goal_channel_work.py +++ b/loopx/extensions/lark/goal_channel_work.py @@ -7,9 +7,9 @@ import hashlib import json -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from pathlib import Path -from typing import Any +from typing import Any, Iterator from ...agent_registry import registered_agent_ids_from_registry from ...control_plane.coordination.local_authority import ( @@ -17,6 +17,8 @@ read_canonical_todos_if_promoted, ) from ...control_plane.effect_runtime import effect_runtime_result +from ...control_plane.coordination.authority_source_capture import authority_registry_source +from ...control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route from ...control_plane.runtime.public_safety import validate_public_safe_value from ...control_plane.runtime.time import now_local_iso from ...file_lock import exclusive_file_lock @@ -32,6 +34,15 @@ GOAL_CHANNEL_WORK_RESULT_SCHEMA = "loopx_goal_channel_work_result_v0" +@contextmanager +def room_work_sources(registry_path: Path, invoked_registry_path: Path | None = None) -> Iterator[list[dict[str, str]]]: + """Capture correlated IO witnesses; native claim v2 owns their effect guards.""" + paths = {registry_path.resolve(), (invoked_registry_path or registry_path).resolve()} + with ExitStack() as reads: + sources = [reads.enter_context(authority_registry_source(path)) for path in sorted(paths)] + yield sources + + def _resolve_binding(binding_path: Path, target_path: Path, goal_id: str, actor: str) -> dict[str, Any]: # The delivery helper supports default routes; this work facade requires an # exact Agent route and must never fall back to a sibling or default lane. @@ -69,7 +80,9 @@ def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_pa todo_id: str | None = None, expected_revision: str | None = None, idempotency_key: str | None = None, runner: CommandRunner = default_subprocess_runner, publish_receipt: bool = True, expected_connection_id: str | None = None, - expected_binding_digest: str | None = None) -> dict[str, Any]: + expected_binding_digest: str | None = None, + invoked_registry_path: Path | None = None, + registry_sources: list[dict[str, str]] | None = None) -> dict[str, Any]: packet: dict[str, Any] = {"schema_version": GOAL_CHANNEL_WORK_RESULT_SCHEMA, "ok": False, "goal_id": goal_id, "actor_id": actor_id, "operation": f"work_{command}", "execute": execute, "status": "failed", "external_write_performed": False, "readback_verified": False, @@ -81,6 +94,14 @@ def run_goal_channel_work(*, registry_path: Path, runtime_root: Path, binding_pa raise ValueError("unsupported work command") if command == "claim" and (not todo_id or not expected_revision or not idempotency_key): raise ValueError("claim identity is incomplete") + if command == "claim" and registry_sources is None: + with room_work_sources(registry_path, invoked_registry_path) as sources: + route = resolve_goal_source_runtime_route( + registry_path=invoked_registry_path or registry_path, goal_id=goal_id) + if (Path(route["source_registry"]).resolve() != registry_path.resolve() or + Path(route["source_runtime_root"]).resolve() != runtime_root.resolve()): + raise ValueError("source route changed") + registry_sources = sources registered = registered_agent_ids_from_registry(registry_path, goal_id) if actor_id not in registered: raise ValueError("actor is not registered") @@ -125,7 +146,8 @@ def current_binding() -> dict[str, Any]: result = claim_canonical_todo_if_promoted(registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, todo_id=str(todo_id), role="agent", claimed_by=actor_id, actor_agent_id=actor_id, dry_run=not execute, - operation_id=str(idempotency_key), expected_provider_revision=str(expected_revision)) + operation_id=str(idempotency_key), expected_provider_revision=str(expected_revision), + registry_sources=registry_sources) if result is None: raise ValueError("canonical promotion required") accepted = result.get("status") in {"applied", "replayed", "recovered", "no_change"} diff --git a/loopx/extensions/lark/room_claim.py b/loopx/extensions/lark/room_claim.py index 49998b605f..1621f1b061 100644 --- a/loopx/extensions/lark/room_claim.py +++ b/loopx/extensions/lark/room_claim.py @@ -25,7 +25,7 @@ from .card_callback import operator_membership_verified, read_callback_card_content, update_callback_card, patch_result_card from .goal_channel_delivery_contract import goal_channel_binding_digest, goal_channel_delivery_route from .goal_channel_message_delivery import GoalChannelMessageDeliverySession, GoalChannelDeliveryStageError -from .goal_channel_work import _resolve_binding, build_work_card, run_goal_channel_work +from .goal_channel_work import _resolve_binding, build_work_card, room_work_sources, run_goal_channel_work from .cli_transport import CommandRunner, default_subprocess_runner from .goal_channel_transport import verified_app_id from .goal_channel_operation import _card_text @@ -246,7 +246,8 @@ def handle_room_claim_callback(event: Mapping[str, Any], *, runtime_root: Path, delivery.get("readback_verified") is not True or event.get("message_id") != delivery["message_id"] or event.get("chat_id") != delivery["chat_id"] or profile_app_id != delivery["app_id"] or profile != delivery["profile"]): raise RoomClaimCallbackError("callback_delivery_scope_mismatch") - binding = _scope(p, runtime_root) + with room_work_sources(Path(p["source_registry"]), runtime_root / "registry.global.json") as sources: + binding = _scope(p, runtime_root) route = goal_channel_delivery_route(request["goal_id"], lambda _: binding) if route["bot_app_id"] != profile_app_id or route["sender_profile"] != profile: raise RoomClaimCallbackError("callback_provider_scope_mismatch") @@ -268,7 +269,7 @@ def handle_room_claim_callback(event: Mapping[str, Any], *, runtime_root: Path, idempotency_key=request["idempotency_key"], runner=runner, publish_receipt=False, # Provider reads can outlive the offer's original connection. # Keep its witness through the facade's locked claim check. - expected_binding_digest=p["binding_digest"]) + expected_binding_digest=p["binding_digest"], registry_sources=sources) card = build_work_card(claimed) if claimed.get("projection") else { "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": "Claim readback"}}, "elements": [{"tag": "div", "text": {"tag": "plain_text", "content": str(claimed.get("public_summary"))}}]} diff --git a/tests/control_plane/test_room_claim_callback.py b/tests/control_plane/test_room_claim_callback.py index b419cf7629..1236068dd7 100644 --- a/tests/control_plane/test_room_claim_callback.py +++ b/tests/control_plane/test_room_claim_callback.py @@ -9,6 +9,7 @@ from test_room_work import setup as room_setup, snapshot, GOAL, TODO, ROOT # noqa: F401 from loopx.extensions.lark import room_claim as ingress from loopx.extensions.lark.goal_channel_contracts import write_goal_channel_binding +from loopx.control_plane.projects.registry_codec import project_registry_transaction @pytest.fixture @@ -200,6 +201,61 @@ def retarget_after_read(**kwargs): assert json.dumps(room.messages) == messages +@pytest.mark.parametrize("read_stage", ["operator_membership_verified", "read_callback_card_content", "claim_effect"]) +def test_callback_source_route_change_cannot_claim_old_source(offered, monkeypatch, read_stage): + args, room, _, _, offer, event, callback_args, _ = offered + operation = "source-route-during-callback" + prepared = offer(key=operation) + assert prepared["ok"], prepared + callback = event(prepared) + before = snapshot(args) + messages = json.dumps(room.messages) + broker = args["runtime_root"] / "registry.global.json" + replacement = args["registry_path"].with_name("another-source.json") + replacement.write_bytes(args["registry_path"].read_bytes()) + + def receipt(): + return ingress.effect_runtime_result("coordination.local_authority.operation_receipt", { + "schema_version": "loopx_local_coordination_operation_receipt_request_v0", + "runtime_root": str(args["runtime_root"]), "goal_id": GOAL, "operation_id": operation, + }) + + original_receipt = receipt() + assert original_receipt["status"] == "missing", original_receipt + + def retarget(): + with project_registry_transaction(broker, operation="fixture_source_retarget") as transaction: + current = transaction.payload_copy() + current["goals"][0]["source_registry"] = str(replacement) + transaction.commit(current) + + if read_stage == "claim_effect": + from loopx.extensions.lark import goal_channel_work + original = goal_channel_work.claim_canonical_todo_if_promoted + + def change_before_native_claim(**kwargs): + retarget() + return original(**kwargs) + + monkeypatch.setattr(goal_channel_work, "claim_canonical_todo_if_promoted", change_before_native_claim) + else: + original = getattr(ingress, read_stage) + + def change_after_read(**kwargs): + result = original(**kwargs) + retarget() + return result + + monkeypatch.setattr(ingress, read_stage, change_after_read) + with pytest.raises(ingress.RoomClaimCallbackError) as rejected: + ingress.handle_room_claim_callback(callback, **callback_args) + assert rejected.value.code == "source_route_changed" + assert snapshot(args) == before + assert not snapshot(args)["todos"][0].get("claimed_by") + assert receipt() == original_receipt + assert json.dumps(room.messages) == messages + + def test_restart_recovers_only_result_transport_without_a_new_transition(offered): args, _, _, _, offer, event, callback_args, transport = offered prepared = offer() diff --git a/tests/control_plane/test_room_work.py b/tests/control_plane/test_room_work.py index 5e8865ccfc..8962a32c56 100644 --- a/tests/control_plane/test_room_work.py +++ b/tests/control_plane/test_room_work.py @@ -300,8 +300,8 @@ def revoke_then_respond(argv, cwd, timeout): @pytest.mark.parametrize("has_receipt", [False, True]) @pytest.mark.parametrize("goal_ref", [None, "invalid", {"goal_id": GOAL, "goal_instance_id": "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) -@pytest.mark.parametrize("wire_version", [0, 1]) -def test_legacy_claim_wire_cannot_silently_accept_goal_ref(setup, has_receipt, goal_ref, wire_version): +@pytest.mark.parametrize("wire_version", [0, 1, 2]) +def test_unqualified_claim_wires_cannot_accept_goal_ref(setup, has_receipt, goal_ref, wire_version): import hashlib from loopx.control_plane.effect_runtime import effect_runtime_result args, room, _ = setup @@ -322,6 +322,9 @@ def test_legacy_claim_wire_cannot_silently_accept_goal_ref(setup, has_receipt, g if wire_version == 0: packet["schema_version"] = "loopx_local_coordination_todo_claim_request_v0" packet.pop("registry_source") + elif wire_version == 2: + packet["schema_version"] = "loopx_local_coordination_todo_claim_request_v2" + packet["registry_sources"] = [packet["registry_source"]] result = effect_runtime_result("coordination.local_authority.todo_claim", packet) assert result["status"] == "failed" and result["reason_code"] == "goal_ref_claim_contract_unqualified", result assert result["decision_read_from_provider"] is False diff --git a/tests/control_plane_ts/authority_source.test.ts b/tests/control_plane_ts/authority_source.test.ts index 1a19f6f908..97af26d41c 100644 --- a/tests/control_plane_ts/authority_source.test.ts +++ b/tests/control_plane_ts/authority_source.test.ts @@ -1,14 +1,25 @@ import assert from "node:assert/strict"; import {createHash} from "node:crypto"; -import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; import test from "node:test"; import {registryAuthoritySourceCheck} from "../../loopx/control_plane/coordination/authority_source.ts"; +import {withRegistryAuthoritySources} from "../../loopx/control_plane/coordination/shadow_registry_source.ts"; +import {mutationLockOwner, withFileMutationLock} from "../../loopx/control_plane/effect_runtime_io.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {coordinationTodoReadModel, TODO_CANONICAL_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {TODO_ITEM_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import type {AuthorityStore, AuthorityStoreCommit} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; import { claimLocalCoordinationTodo, createLocalCoordinationTodo, updateLocalCoordinationTodo, terminalLifecycleLocalCoordinationTodo, pollLocalCoordinationMonitor, LOCAL_COORDINATION_TODO_TERMINAL_LIFECYCLE_REQUEST_SCHEMA, + LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, } from "../../loopx/control_plane/coordination/local_authority_runtime.ts"; test("registry witness captures primitive values across awaited request mutation", async t => { @@ -28,6 +39,132 @@ test("registry witness captures primitive values across awaited request mutation assert.equal(await check(), false); }); +test("claim source guards reject missing, duplicate, omitted primary and legacy wire intent before provider IO", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-claim-wire-")); + t.after(() => rm(root, {recursive: true, force: true})); + const path = join(root, "registry.json"), body = "{}"; + await writeFile(path, body); + const primary = {path, sha256: createHash("sha256").update(body).digest("hex")}; + let opens = 0; + const dependencies = {createStore: (): AuthorityStore => {opens++; throw new Error("unexpected provider IO");}}; + for (const registry_sources of [undefined, null, [], [null], [primary, primary], + [{...primary, path: join(root, "broker.json")}], [{...primary, path: "relative"}], + [{...primary, sha256: "invalid"}], [primary, primary, primary]]) { + const result = await claimLocalCoordinationTodo({ + schema_version: LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, + registry_source: primary, registry_sources, + }, dependencies); + assert.equal(result.status, "failed"); + assert.equal(result.decision_read_from_provider, false); + assert.equal(result.reason_code, "invalid_local_coordination_todo_claim_request"); + } + for (const version of [0, 1]) for (const registry_sources of [null, [primary]]) { + const result = await claimLocalCoordinationTodo({ + schema_version: `loopx_local_coordination_todo_claim_request_v${version}`, registry_sources, + }, dependencies); + assert.equal(result.failure_kind, "protocol_failure"); + assert.equal(result.decision_read_from_provider, false); + } + assert.equal(opens, 0); +}); + +test("source guards copy original witnesses, fail closed on drift or contention and release partial guards", async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-source-guards-")); + t.after(() => rm(root, {recursive: true, force: true})); + const a = {path: join(root, "a.json"), sha256: createHash("sha256").update("{}").digest("hex")}; + const b = {...a, path: join(root, "b.json")}; + await writeFile(a.path, "{}"); await writeFile(b.path, "{}"); + await withFileMutationLock(b.path, async () => { + await assert.rejects(withRegistryAuthoritySources([a, b], a, async () => assert.fail("busy source reached effect")), + {reason_code: "source_registry_busy_retry"}); + assert.equal(await mutationLockOwner(a.path), null, "earlier acquired guard is released on contention"); + }); + const sources = [a, b]; + const result = withRegistryAuthoritySources(sources, a, async () => { + assert.equal((await mutationLockOwner(b.path))?.pid, process.pid); + return "held original"; + }); + sources.length = 0; a.path = join(root, "redirect.json"); b.sha256 = "0".repeat(64); + assert.equal(await result, "held original"); + const original = {...b, sha256: createHash("sha256").update("{}").digest("hex")}; + await writeFile(original.path, "changed"); + await assert.rejects(withRegistryAuthoritySources([original], original, async () => assert.fail("stale source reached effect")), + {reason_code: "authority_source_changed"}); + await rm(original.path); + await assert.rejects(withRegistryAuthoritySources([original], original, async () => assert.fail("missing source reached effect")), + {reason_code: "authority_source_changed"}); +}); + +for (const provider of ["file", "sqlite"] as const) test(`${provider}: native claim retains broker/source guards through real commit and Python writer`, {timeout: 20_000}, async t => { + const root = await mkdtemp(join(tmpdir(), "loopx-claim-serialization-")); + t.after(() => rm(root, {recursive: true, force: true})); + const sources = await Promise.all(["broker.json", "source.json"].map(async name => { + const path = join(root, name), body = JSON.stringify({goals: [], revision: 1}); + await writeFile(path, body); + return {path, sha256: createHash("sha256").update(body).digest("hex")}; + })); + const store: AuthorityStore = provider === "file" ? new FileAuthorityStore(join(root, "authority", "file-v0"), "goal-a") + : new SqliteAuthorityStore(join(root, "authority", "sqlite-v0"), "goal-a"); + const todos = [{schema_version: TODO_ITEM_SCHEMA, todo_id: "todo_a", role: "agent", status: "open", + done: false, text: "Claim the registered work", archive_state: "active", source_section: "Agent Todo", index: 1}]; + assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + events: [], receipts: [], next_projection: {goal_id: "goal-a", handoff_mode: "soft_claim", todos, leases: [], + todo_read_model: coordinationTodoReadModel(todos, TODO_CANONICAL_READ_RECORD_SCHEMA)}})).status, "applied"); + const request = {schema_version: LOCAL_COORDINATION_TODO_CLAIM_GUARDED_REQUEST_SCHEMA, + registry_source: sources[1], registry_sources: sources, runtime_root: root, goal_id: "goal-a", todo_id: "todo_a", + role: "agent", claimed_by: "agent-a", actor_agent_id: "agent-a", registered_agents: ["agent-a"], + operation_id: "claim-a", observed_at: "2026-09-05T04:30:00Z", dry_run: false}; + let entered!: () => void, release!: () => void; + const atCommit = new Promise(resolve => {entered = resolve;}); + const resume = new Promise(resolve => {release = resolve;}); + t.after(release); + const guarded = new Proxy(store, {get(target, key) { + if (key === "commitAuthority") return async (commit: AuthorityStoreCommit) => { + entered(); await resume; return target.commitAuthority(commit); + }; + const value = Reflect.get(target, key); + return typeof value === "function" ? value.bind(target) : value; + }}); + const claim = claimLocalCoordinationTodo(request, {createStore: () => guarded}); + await Promise.race([atCommit, claim.then(result => assert.fail(JSON.stringify(result)))]); + for (const source of sources) assert.equal((await mutationLockOwner(source.path))?.pid, process.pid); + // Use the real administration writer, not an uncoordinated writeFile probe. + const writer = spawn(resolveTestPython(), ["-u", "-c", ` +import sys +from pathlib import Path +from loopx.control_plane.projects.registry_codec import project_registry_transaction +print("attempting", flush=True) +for path in sys.argv[1:]: + with project_registry_transaction(Path(path), operation="source-serialization-test") as tx: + payload = tx.payload_copy() + payload["revision"] = 2 + tx.commit(payload) +print("committed", flush=True) +`, ...sources.map(source => source.path)], {stdio: ["ignore", "pipe", "pipe"]}); + t.after(() => {writer.kill();}); + let output = "", error = ""; + writer.stdout.on("data", chunk => {output += String(chunk);}); + writer.stderr.on("data", chunk => {error += String(chunk);}); + const exit = once(writer, "exit"); + await once(writer.stdout, "data"); + assert.match(output, /attempting/u); + assert.equal(await Promise.race([exit.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 150))]), false); + for (const source of sources) assert.equal(JSON.parse(await readFile(source.path, "utf8")).revision, 1); + assert.deepEqual(await store.readReceipt("claim-a"), {status: "missing"}); + release(); + assert.equal((await claim).status, "applied"); + assert.equal((await store.readReceipt("claim-a")).status, "found"); + assert.equal((await exit)[0], 0, error); + assert.match(output, /committed/u); + for (const source of sources) { + assert.equal(JSON.parse(await readFile(source.path, "utf8")).revision, 2); + assert.equal(await mutationLockOwner(source.path), null); + } + const before = await store.loadAuthority(); + assert.equal((await claimLocalCoordinationTodo(request, {createStore: () => store})).reason_code, "authority_source_changed"); + assert.deepEqual(await store.loadAuthority(), before, "a stale replay cannot cross the retained source contract"); +}); + const wires = [ {invoke: createLocalCoordinationTodo, prefix: "loopx_local_coordination_todo_create_request", legacy: [0], current: 1}, {invoke: claimLocalCoordinationTodo, prefix: "loopx_local_coordination_todo_claim_request", legacy: [0], current: 1}, diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 4dfeb52a11..6247c8090d 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -13,6 +13,7 @@ "types": ["node"] }, "include": [ + "tests/control_plane_ts/authority_source.test.ts", "tests/control_plane_ts/goal_ref_claim_compatibility.test.ts", "tests/control_plane_ts/room_resume.test.ts", "tests/control_plane_ts/room_claim_request.test.ts", From 59cdd8bab5b6f3c147a9281fa33ddd070abf3f84 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Thu, 15 Oct 2026 04:59:57 -0700 Subject: [PATCH 26/27] refactor(presentation): place room work HTTP in its existing owner Signed-off-by: Lihua <1017343802@qq.com> --- loopx/chat_lark_api.py | 2 +- loopx/chat_server.py | 2 +- .../room_work_api.py} | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) rename loopx/{chat_room_work_api.py => presentation/room_work_api.py} (87%) diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 18a4240f21..9b4d763923 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -29,7 +29,7 @@ from .extensions.lark.goal_topic_batch import connect_lark_goal_topics from .extensions.lark.cli_transport import CommandRunner, default_subprocess_runner from .chat_agent import CodexChatAgentError -from .chat_room_work_api import RoomWorkChatRequestMixin +from .presentation.room_work_api import RoomWorkChatRequestMixin from .chat_manager import ( controller_runtime_root, manager_channel, diff --git a/loopx/chat_server.py b/loopx/chat_server.py index c66c39a8a8..af3180bb63 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -64,7 +64,7 @@ SSH_HOST_CATALOG_PATH, ssh_host_catalog_payload, ) -from .chat_room_work_api import ROOM_WORK_PATH +from .presentation.room_work_api import ROOM_WORK_PATH from .chat_lark_api import ( LarkChatRequestMixin, build_goal_repository_contexts as build_goal_repository_contexts, diff --git a/loopx/chat_room_work_api.py b/loopx/presentation/room_work_api.py similarity index 87% rename from loopx/chat_room_work_api.py rename to loopx/presentation/room_work_api.py index 9eba1c7dca..c92e278d12 100644 --- a/loopx/chat_room_work_api.py +++ b/loopx/presentation/room_work_api.py @@ -6,12 +6,12 @@ from pathlib import Path from urllib.parse import parse_qs, urlparse -from .control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route -from .extensions.lark.goal_channel_contracts import binding_for_goal, read_goal_channel_binding -from .extensions.lark.goal_channel_delivery_contract import goal_channel_binding_digest -from .extensions.lark.goal_channel_work import _resolve_binding, run_goal_channel_work -from .extensions.lark import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION, goal_channel_work -from .extensions.runtime import default_extension_state_file +from ..control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +from ..extensions.lark.goal_channel_contracts import binding_for_goal, read_goal_channel_binding +from ..extensions.lark.goal_channel_delivery_contract import goal_channel_binding_digest +from ..extensions.lark.goal_channel_work import _resolve_binding, run_goal_channel_work +from ..extensions.lark import LARK_EXTENSION_ID, LARK_GOAL_CHANNEL_PERMISSION, goal_channel_work +from ..extensions.runtime import default_extension_state_file ROOM_WORK_PATH = "/api/chat/lark/room-work" @@ -60,7 +60,7 @@ def _room_work(self, *, claim: bool = False) -> None: command="claim" if claim else "project", execute=claim, runner=self._lark_runner(), todo_id=body.get("todo_id"), expected_revision=body.get("expected_revision"), idempotency_key=body.get("idempotency_key"), expected_connection_id=connection_id, - expected_binding_digest=digest, + expected_binding_digest=digest, invoked_registry_path=self.server.registry_path, ) activation = {} if not claim: From 57d21398fd629e6e44e172c59b64867c9ca3038f Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Thu, 15 Oct 2026 05:00:10 -0700 Subject: [PATCH 27/27] docs(goal-channel): specify retained source scope at claim commit Signed-off-by: Lihua <1017343802@qq.com> --- .../rfcs/agent-im-openviking-collaboration-v0.md | 5 +++++ docs/reference/goal-channel-room-work.md | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md index 8066fdd0e1..2888f2e500 100644 --- a/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md +++ b/docs/architecture/rfcs/agent-im-openviking-collaboration-v0.md @@ -298,6 +298,11 @@ replace the shared-service or provisioning owners. The claim callback retains its original offer binding witness through member and card reads into the existing locked claim check. Retargeting during either provider read rejects before canonical ownership or an acceptance receipt changes. +Its original broker/source registry witnesses now also survive those reads and +the native claim handoff. The existing cross-runtime source guards serialize +registry administration with the canonical commit. Real File/SQLite regressions +check unchanged full state and exact receipts after pre-commit route replacement; +these bounded checks do not qualify independent hosts or native provider IO. The proposed packaged workspace companion enters through the existing Lark connection. It reads canonical room orientation, explicitly claims through the diff --git a/docs/reference/goal-channel-room-work.md b/docs/reference/goal-channel-room-work.md index 7d27e88c3b..f29e58b1af 100644 --- a/docs/reference/goal-channel-room-work.md +++ b/docs/reference/goal-channel-room-work.md @@ -101,7 +101,12 @@ by direct `todo claim`, then publishes the receipt and current orientation. This explicit command authorizes those two effects. Room membership, delivery, a card or recalled text supplies neither the Agent identity nor write scope. Identity and binding are reread; binding/target revocation serialize with the -claim transaction; the registry witness is rechecked by the typed owner. +claim transaction. The facade retains original broker/source registry witnesses +before provider reads. Claim request v2 holds their existing cross-runtime +mutation guards through the native canonical commit; drift rejects before a +claim or receipt is written, and a busy source requires a fresh retry. Ordinary +direct claim v0/v1 requests keep their existing behavior and reject v2 guard +fields rather than silently dropping that obligation. The provider compares the expected revision inside the claim transaction. Competing commands at one revision cannot both commit. A stale revision returns @@ -165,6 +170,12 @@ Todo transaction then owns the claim and idempotent receipt; the result replaces the initiating card after independent provider readback. A callback ACK alone is not an accepted claim. No claim callback acquires or renews execution authority. +The original broker/source witnesses survive remote member/card reads and the +claim handoff. A route replacement at any of those stages leaves the old-source +Todo, provider revision, exact operation receipt and room messages unchanged. +Normal registry administration serializes with the native claim commit; the +effect runtime owns those guards independently of the Python transport caller. + Revoke that one offer through the same trusted CLI: ```sh