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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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