From a73a822cff54952e3ae43873d818a433ef7f447e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 19 Aug 2026 20:48:10 +0800 Subject: [PATCH 1/6] feat: govern material Goal Chat turns Signed-off-by: huangruiteng --- loopx/__main__.py | 6 + loopx/chat_actions.py | 61 +++-- loopx/chat_monitor_actions.py | 11 +- loopx/chat_runtime.py | 177 ++++++++++++ loopx/chat_server.py | 14 +- loopx/chat_turn_admission.py | 251 ++++++++++++++++++ loopx/cli_commands/support_control.py | 19 ++ loopx/cli_commands/turn.py | 44 ++- loopx/control_plane/turn_driver/__init__.py | 2 + loopx/control_plane/turn_driver/codex_cli.py | 29 ++ loopx/control_plane/turn_driver/executor.py | 2 + .../workspace_progress_validator.py | 106 ++++++++ loopx/dashboard_launcher.py | 2 + loopx/extensions/lark/goal_topic_runtime.py | 10 +- 14 files changed, 692 insertions(+), 42 deletions(-) create mode 100644 loopx/__main__.py create mode 100644 loopx/chat_turn_admission.py create mode 100644 loopx/control_plane/turn_driver/workspace_progress_validator.py diff --git a/loopx/__main__.py b/loopx/__main__.py new file mode 100644 index 000000000..ce409d011 --- /dev/null +++ b/loopx/__main__.py @@ -0,0 +1,6 @@ +"""Run the LoopX CLI from the currently imported package.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index ba50bdeed..471b23300 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -14,6 +14,11 @@ from .chat_action_store import ActionConflictError, ChatActionStore from .chat_monitor_actions import ChatMonitorActionMixin from .chat_store import ChatSessionStore +from .chat_turn_admission import ( + governed_execution_gate, + governed_session_lineage, + submit_governed_goal_turn, +) from .configure_goal import configure_goal from .control_plane.runtime.time import now_utc, parse_timestamp, utc_isoformat from .control_plane.scheduler.monitor_todo import monitor_next_due_at @@ -965,28 +970,23 @@ def _apply_goal_create( )[:600], "quota_state": str(guard.get("state") or "waiting"), } - if agent_id and self.runtime_controller is not None and first_turn_gate is None: - session, _resumed = self.runtime_controller.open_session( + if agent_id and self.runtime_controller is not None and first_turn_gate is None and todo_ids: + session, first_turn, created = submit_governed_goal_turn( + self.runtime_controller, goal_id=goal_id, - agent_id=agent_id, + endpoint_id=agent_id, + governed_agent_id=agent_id, + todo_id=todo_ids[0], work_dir=project, objective=objective, - mode="resume_latest", - channel_id=f"goal.{goal_id}", - agent_goal_id=goal_id, - ) - session_id = _opaque(session.get("session_id"), field="session_id") - first_turn, created = self.runtime_controller.submit_turn( - session_id=session_id, client_turn_id=f"goal-start-{proposal_id}", message=( f"开始推进 Goal {goal_id}。先核对目标边界和现有 Todo," f"首个 Todo:{';'.join(str(item) for item in (parameters.get('initial_todos') or [])[:3]) or '按目标边界建立首个可验证进展'}。" "然后直接推进并报告可验证结果;遇到权限边界时停止并提出明确 Gate。" ), - work_dir=project, - objective=objective, ) + session_id = _opaque(session.get("session_id"), field="session_id") turn_result = { "turn_id": _opaque(first_turn.get("turn_id"), field="turn_id"), "status": str(first_turn.get("status") or "queued"), @@ -1001,6 +1001,12 @@ def _apply_goal_create( "turn_id": turn_result["turn_id"], }, ) + elif agent_id and first_turn_gate is None and not todo_ids: + first_turn_gate = governed_execution_gate("todo_required") + self.store.save_checkpoint( + proposal_id, step="first_turn_gated", + receipt={"outcome": "first_turn_gated", "gate": first_turn_gate}, + ) elif first_turn_gate is not None: self.store.save_checkpoint( proposal_id, @@ -1477,23 +1483,18 @@ def apply(self, proposal_id: str) -> dict[str, Any]: if not project.is_dir(): raise ValueError("the Goal project root is unavailable") self._agent_eligibility(execution_agent_id, project=project) - session, _resumed = self.runtime_controller.open_session( + session, turn, created = submit_governed_goal_turn( + self.runtime_controller, goal_id=str(parameters["goal_id"]), - agent_id=execution_agent_id, + endpoint_id=execution_agent_id, + governed_agent_id=str(parameters["agent_id"]), + todo_id=todo_id, work_dir=project, objective=str(parameters["text"]), - mode="resume_latest", - channel_id=f"task.{todo_id}", - agent_goal_id=str(parameters["goal_id"]), - ) - session_id = _opaque(session.get("session_id"), field="session_id") - turn, created = self.runtime_controller.submit_turn( - session_id=session_id, client_turn_id=f"task-start-{proposal_id}", message=str(parameters["text"]), - work_dir=project, - objective=str(parameters["text"]), ) + session_id = _opaque(session.get("session_id"), field="session_id") turn_id = _opaque(turn.get("turn_id"), field="turn_id") self.store.save_checkpoint( proposal_id, @@ -1503,7 +1504,7 @@ def apply(self, proposal_id: str) -> dict[str, Any]: receipt["resource_ids"].update( {"session_id": session_id, "turn_id": turn_id} ) - receipt["outcome"] = "task_execution_started" + receipt["outcome"] = "governed_task_execution_started" turn_result = { "session_id": session_id, "turn_id": turn_id, @@ -1534,12 +1535,18 @@ def apply(self, proposal_id: str) -> dict[str, Any]: if not project.is_dir(): raise ValueError("the Goal project root is unavailable") client_turn_id = str(parameters.get("client_turn_id") or f"action-{proposal_id}") - turn, created = self.runtime_controller.submit_turn( + lineage = governed_session_lineage(self.store, session_id=str(parameters["session_id"]), goal_id=str(parameters["goal_id"])) + if lineage is None: + raise ProtectedActionGate( + "run.correct", gate=governed_execution_gate("governed_turn_required") + ) + turn, created = self.runtime_controller.submit_governed_turn( session_id=str(parameters["session_id"]), client_turn_id=client_turn_id, message=str(parameters["message"]), + todo_id=lineage["todo_id"], + governed_agent_id=lineage["agent_id"], work_dir=project, - objective=str(goal.get("domain") or parameters["goal_id"]), ) turn_id = _opaque(turn.get("turn_id"), field="turn_id") receipt = { @@ -1576,5 +1583,5 @@ def _turn_from_receipt(receipt: Any) -> dict[str, Any] | None: "session_id": str(resource_ids["session_id"]) if resource_ids.get("session_id") else None, "turn_id": str(resource_ids["turn_id"]), "status": "accepted", - "created": receipt.get("outcome") in {"turn_created", "task_execution_started"}, + "created": receipt.get("outcome") in {"turn_created", "task_execution_started", "governed_task_execution_started"}, } diff --git a/loopx/chat_monitor_actions.py b/loopx/chat_monitor_actions.py index 3d45ac372..5bb120273 100644 --- a/loopx/chat_monitor_actions.py +++ b/loopx/chat_monitor_actions.py @@ -65,19 +65,20 @@ def _apply_monitor_update( or f"Run monitor {parameters['todo_id']}" ), mode="resume_latest", - channel_id=f"task.{parameters['todo_id']}", + channel_id=f"goal.{goal_id}", agent_goal_id=goal_id, ) session_id = _opaque(session.get("session_id"), field="session_id") - turn, created = self.runtime_controller.submit_turn( + turn, created = self.runtime_controller.submit_governed_turn( session_id=session_id, client_turn_id=f"action-{proposal_id}", message=( f"Run continuous monitor Todo {parameters['todo_id']} now and " "write back only verified material change." ), + todo_id=str(parameters["todo_id"]), + governed_agent_id=str(parameters["agent_id"]), work_dir=project, - objective=str(goal.get("domain") or goal_id), ) turn_id = _opaque(turn.get("turn_id"), field="turn_id") receipt = { @@ -85,9 +86,9 @@ def _apply_monitor_update( {"proposal_id": proposal_id, "turn_id": turn_id} )[:32], "outcome": ( - "monitor_turn_created" + "governed_monitor_turn_created" if created - else "monitor_turn_already_exists" + else "governed_monitor_turn_already_exists" ), "projection_verified": True, "resource_ids": { diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 23018fc20..5d79e66da 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -32,6 +32,19 @@ def close_session(self) -> None: ... def healthcheck(self) -> bool: ... +class GovernedTurnRunner(Protocol): + def __call__( + self, + *, + goal_id: str, + agent_id: str, + todo_id: str, + upstream_thread_id: str, + work_dir: Path, + turn_instance_id: str, + ) -> dict[str, Any]: ... + + @dataclass class CodexAppServerAdapter: session: CodexChatAgentSession @@ -200,6 +213,7 @@ def __init__( idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, endpoint_registry: AgentEndpointRegistry | None = None, + governed_turn_runner: GovernedTurnRunner | None = None, ) -> None: self.store = store self.codex_bin = codex_bin @@ -208,6 +222,7 @@ def __init__( self.idle_timeout_sec = idle_timeout_sec self.hard_timeout_sec = hard_timeout_sec self.endpoint_registry = endpoint_registry or AgentEndpointRegistry(store.root) + self.governed_turn_runner = governed_turn_runner self.adapters: dict[str, ChatRuntimeAdapter] = {} self.cancelled_turns: set[tuple[str, str]] = set() self.turn_event_buffers: dict[tuple[str, str], _TurnEventBuffer] = {} @@ -525,6 +540,168 @@ def submit_turn( worker.start() return turn, True + def submit_governed_turn( + self, + *, + session_id: str, + client_turn_id: str, + message: str, + todo_id: str, + governed_agent_id: str, + work_dir: Path, + ) -> tuple[dict[str, Any], bool]: + """Queue one material Turn against the canonical long-lived Chat session.""" + + if self.governed_turn_runner is None: + raise ValueError("governed LoopX Turn admission is unavailable") + if not todo_id.strip() or not governed_agent_id.strip(): + raise ValueError("governed Chat execution requires exact Todo and Agent identity") + session = self.store.load_session(session_id) + if session is None: + raise KeyError("chat session was not found") + goal_id = str(session.get("goal_id") or "") + if str(session.get("channel_id") or "") != f"goal.{goal_id}": + raise ValueError("material Chat execution requires the canonical Goal session") + if session.get("agent_id") != "codex" or session.get("upstream_mode") != "chat": + raise ValueError( + "governed Chat execution requires a resumable Codex Goal session" + ) + turn, created = self.store.create_turn( + session_id, + client_turn_id=client_turn_id, + message=message, + ) + if not created: + return turn, False + worker = threading.Thread( + target=self._run_governed_turn, + kwargs={ + "session_id": session_id, + "turn_id": str(turn["turn_id"]), + "todo_id": todo_id, + "governed_agent_id": governed_agent_id, + "work_dir": work_dir, + }, + daemon=True, + ) + with self.lock: + self.turn_done_events[(session_id, str(turn["turn_id"]))] = threading.Event() + worker.start() + return turn, True + + def _detach_adapter(self, session_id: str) -> None: + """Release app-server transport while retaining its resumable thread id.""" + + with self.lock: + adapter = self.adapters.pop(session_id, None) + if adapter is not None: + adapter.close_session() + + def _run_governed_turn( + self, + *, + session_id: str, + turn_id: str, + todo_id: str, + governed_agent_id: str, + work_dir: Path, + ) -> None: + started = utc_now() + self.store.update_turn(session_id, turn_id, status="starting", started_at=started) + self.store.append_event( + session_id, + turn_id, + kind="governance.admission_started", + payload={"todo_id": todo_id}, + ) + try: + session = self.store.load_session(session_id) + if session is None: + raise KeyError("chat session was not found") + runner = self.governed_turn_runner + if runner is None: + raise ValueError("governed LoopX Turn admission is unavailable") + self._detach_adapter(session_id) + self.store.update_turn(session_id, turn_id, status="running") + payload = runner( + goal_id=str(session["goal_id"]), + agent_id=governed_agent_id, + todo_id=todo_id, + upstream_thread_id=str(session["upstream_thread_id"]), + work_dir=work_dir, + turn_instance_id=f"chat-{turn_id}", + ) + summary = str(payload.get("summary") or "本次 LoopX Turn 已完成受治理执行。") + next_action = str(payload.get("next_action") or "").strip() + message = summary + (f"\n\n下一步:{next_action}" if next_action else "") + governance = { + "schema_version": "loopx_chat_turn_governance_v0", + "mode": "governed", + "todo_id": todo_id, + "agent_id": governed_agent_id, + "turn_key": payload.get("resume_turn_key"), + "journal_ref": payload.get("journal_ref"), + "status": payload.get("status"), + "result_kind": payload.get("result_kind"), + "validation": payload.get("validation"), + "effects": payload.get("effects"), + "quota_slot_spend_count": payload.get("quota_slot_spend_count"), + } + response = { + "schema_version": "loopx_chat_agent_response_v1", + "message": message, + "proposals": [], + "gate": None, + "governance": governance, + } + self.store.append_message( + session_id, + role="agent", + text=message, + turn_id=turn_id, + ) + completed = utc_now() + self.store.update_turn( + session_id, + turn_id, + status="completed", + response=response, + completed_at=completed, + last_activity_at=completed, + ) + self.store.append_event( + session_id, + turn_id, + kind="governance.settled", + payload={"governance": governance}, + ) + self.store.append_event( + session_id, + turn_id, + kind="turn.completed", + payload={"response": response}, + ) + self.store.update_session( + session_id, + status="ready", + active_turn_id=None, + last_activity_at=completed, + last_error_code=None, + ) + except Exception as exc: # noqa: BLE001 - governed runner is a typed boundary. + self._fail_turn( + session_id, + turn_id, + "governed_turn_failed", + str(exc), + status="failed", + ) + finally: + with self.lock: + done_event = self.turn_done_events.pop((session_id, turn_id), None) + if done_event is not None: + done_event.set() + def _run_turn( self, *, diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 144729b54..038652f5c 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -23,6 +23,7 @@ from .chat_action_store import ACTION_KINDS, ActionConflictError, ChatActionStore from .chat_runtime import ChatRuntimeController, TERMINAL_TURN_STATES from .chat_store import ChatSessionStore +from .chat_turn_admission import LoopXChatTurnAdmission from .chat_lark_api import ( LarkChatRequestMixin, build_goal_repository_contexts as build_goal_repository_contexts, @@ -1373,6 +1374,7 @@ def serve_chat( startup_timeout_sec: float = 30.0, idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, + available_capabilities: list[str] | None = None, assets_dir: Path | None = None, open_browser: bool = False, verbose: bool = False, @@ -1418,6 +1420,13 @@ def serve_chat( ) server.chat_store = ChatSessionStore(runtime_root) server.action_store = ChatActionStore(runtime_root / "chat" / "actions") + governed_turn_runner = LoopXChatTurnAdmission( + registry_path=resolved_registry_path, + runtime_root_override=resolved_runtime_root_override, + codex_bin=codex_bin, + timeout_seconds=hard_timeout_sec, + available_capabilities=available_capabilities or (), + ) server.runtime_controller = ChatRuntimeController( store=server.chat_store, codex_bin=codex_bin, @@ -1425,13 +1434,14 @@ def serve_chat( startup_timeout_sec=startup_timeout_sec, idle_timeout_sec=idle_timeout_sec, hard_timeout_sec=hard_timeout_sec, + governed_turn_runner=governed_turn_runner, ) server.action_service = ChatActionService( store=server.action_store, - registry_path=registry_path, + registry_path=resolved_registry_path, chat_store=server.chat_store, runtime_controller=server.runtime_controller, - workspace_roots=scan_roots, + workspace_roots=resolved_scan_roots, ) server.lark_goal_topic_runtime = LarkGoalTopicRuntimeService( snapshot_provider=lambda: build_lark_goal_topic_runtime_snapshot( diff --git a/loopx/chat_turn_admission.py b/loopx/chat_turn_admission.py new file mode 100644 index 000000000..cb68378f8 --- /dev/null +++ b/loopx/chat_turn_admission.py @@ -0,0 +1,251 @@ +"""Bridge a material LoopX Chat action into the canonical governed Turn CLI.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from .control_plane.turn_driver.workspace_progress_validator import ( + workspace_progress_digest, +) + + +class ChatTurnAdmissionError(RuntimeError): + """A material Chat turn failed before a committed governed receipt.""" + + def __init__(self, message: str, *, payload: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.payload = dict(payload or {}) + + +def governed_session_lineage( + action_store: Any, + *, + session_id: str, + goal_id: str, +) -> dict[str, str] | None: + """Resolve the latest applied material Todo lineage for one Goal Session.""" + + for proposal in action_store.list(goal_id=goal_id, status="applied"): + receipt = proposal.get("receipt") + receipt = receipt if isinstance(receipt, Mapping) else {} + resources = receipt.get("resource_ids") + resources = resources if isinstance(resources, Mapping) else {} + if str(resources.get("session_id") or "") != session_id: + continue + todo_id = str(resources.get("todo_id") or "") + if not todo_id: + todo_ids = resources.get("todo_ids") + if isinstance(todo_ids, list) and todo_ids: + todo_id = str(todo_ids[0] or "") + agent_id = str(resources.get("agent_id") or "") + if todo_id and agent_id: + return {"todo_id": todo_id, "agent_id": agent_id} + return None + + +def governed_execution_gate(kind: str) -> dict[str, str]: + gates = { + "todo_required": { + "kind": "todo_required", + "summary": "A governed first Agent Turn requires one explicit initial Todo.", + "next_action": "Create and assign one bounded Todo, then start execution from the Goal Chat.", + }, + "governed_turn_required": { + "kind": "governed_turn_required", + "summary": "Run correction requires a prior governed Todo on this Goal Session.", + "next_action": "Start one explicitly assigned Todo, then retry the correction on the same Session.", + }, + } + try: + return dict(gates[kind]) + except KeyError as exc: + raise ValueError("unsupported governed execution gate") from exc + + +def submit_governed_goal_turn( + runtime_controller: Any, + *, + goal_id: str, + endpoint_id: str, + governed_agent_id: str, + todo_id: str, + work_dir: Path, + objective: str, + client_turn_id: str, + message: str, +) -> tuple[dict[str, Any], dict[str, Any], bool]: + """Open the canonical Goal Session and queue one governed material Turn.""" + + session, _resumed = runtime_controller.open_session( + goal_id=goal_id, + agent_id=endpoint_id, + work_dir=work_dir, + objective=objective, + mode="resume_latest", + channel_id=f"goal.{goal_id}", + agent_goal_id=goal_id, + ) + turn, created = runtime_controller.submit_governed_turn( + session_id=str(session["session_id"]), + client_turn_id=client_turn_id, + message=message, + todo_id=todo_id, + governed_agent_id=governed_agent_id, + work_dir=work_dir, + ) + return session, turn, created + + +class LoopXChatTurnAdmission: + """Run one Chat-requested Todo through fresh admission and settlement.""" + + def __init__( + self, + *, + registry_path: Path, + runtime_root_override: str | Path | None = None, + loopx_bin: str | None = None, + codex_bin: str = "codex", + timeout_seconds: float = 900.0, + available_capabilities: Sequence[str] = (), + ) -> None: + self.registry_path = registry_path.expanduser().resolve() + self.runtime_root_override = ( + str(Path(runtime_root_override).expanduser().resolve()) + if runtime_root_override is not None + else None + ) + self.loopx_bin = loopx_bin + self.codex_bin = codex_bin + self.timeout_seconds = max(30.0, timeout_seconds) + self.available_capabilities = tuple( + sorted( + { + str(item).strip() + for item in available_capabilities + if str(item).strip() + } + ) + ) + + def _command_prefix(self) -> list[str]: + if self.loopx_bin is None: + return [sys.executable, "-m", "loopx"] + resolved = ( + self.loopx_bin + if "/" in self.loopx_bin and Path(self.loopx_bin).is_file() + else shutil.which(self.loopx_bin) + ) + if not resolved: + raise ChatTurnAdmissionError( + "LoopX CLI is unavailable for governed Chat execution" + ) + return [str(resolved)] + + def __call__( + self, + *, + goal_id: str, + agent_id: str, + todo_id: str, + upstream_thread_id: str, + work_dir: Path, + turn_instance_id: str, + ) -> dict[str, Any]: + project = work_dir.expanduser().resolve() + baseline_hash = workspace_progress_digest(project) + validator_argv = [ + sys.executable, + "-m", + "loopx.control_plane.turn_driver.workspace_progress_validator", + "--baseline-hash", + baseline_hash, + ] + command = [ + *self._command_prefix(), + "--format", + "json", + "--registry", + str(self.registry_path), + ] + if self.runtime_root_override: + command.extend(["--runtime-root", self.runtime_root_override]) + command.extend( + [ + "turn", + "run-once", + "--goal-id", + goal_id, + "--agent-id", + agent_id, + "--host", + "codex-cli", + "--execution-mode", + "isolated-headless", + "--scheduler-owner", + "agent_cli_loop", + "--expected-todo-id", + todo_id, + "--turn-instance-id", + turn_instance_id, + "--project", + str(project), + "--codex-bin", + self.codex_bin, + "--codex-sandbox", + "workspace-write", + "--codex-resume-session-id", + upstream_thread_id, + "--validation-command-json", + json.dumps(validator_argv, separators=(",", ":")), + "--validation-failure-kind", + "repair_required", + "--scan-root", + str(project), + "--timeout-seconds", + str(self.timeout_seconds), + "--execute", + ] + ) + for capability in self.available_capabilities: + command.extend(["--available-capability", capability]) + try: + completed = subprocess.run( + command, + cwd=project, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=self.timeout_seconds + 30.0, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ChatTurnAdmissionError( + "governed LoopX Chat execution could not complete" + ) from exc + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise ChatTurnAdmissionError( + "governed LoopX Chat execution returned no typed receipt" + ) from exc + if not isinstance(payload, dict): + raise ChatTurnAdmissionError( + "governed LoopX Chat execution returned an invalid receipt" + ) + if completed.returncode != 0 or payload.get("ok") is not True: + raise ChatTurnAdmissionError( + str( + payload.get("reason") + or payload.get("error") + or "governed LoopX Chat execution failed" + ), + payload=payload, + ) + return payload diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index a290b608b..af1ab0b7d 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -490,6 +490,15 @@ def register_support_control_commands( default=900.0, help="Absolute maximum seconds for one Agent turn.", ) + chat_parser.add_argument( + "--available-capability", + action="append", + default=[], + help=( + "Capability available to governed material Turns. Repeat for multiple " + "capabilities; ordinary read-only Chat replies do not consume this list." + ), + ) chat_parser.add_argument( "--assets-dir", help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", @@ -557,6 +566,12 @@ def register_support_control_commands( "runtime discovery order." ), ) + dashboard_parser.add_argument( + "--available-capability", + action="append", + default=[], + help="Capability available to governed material Turns. Repeatable.", + ) dashboard_parser.add_argument( "--assets-dir", help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", @@ -1024,6 +1039,9 @@ def handle_support_control_command( codex_bin=getattr(args, "codex_bin", "codex"), claude_bin=getattr(args, "claude_bin", "claude"), lark_cli_bin=getattr(args, "lark_cli_bin", None), + available_capabilities=list( + getattr(args, "available_capability", []) or [] + ), assets_dir=Path(args.assets_dir).expanduser().resolve() if getattr(args, "assets_dir", None) else None, verbose=getattr(args, "verbose", False), open_browser=not getattr(args, "no_open", False), @@ -1055,6 +1073,7 @@ def handle_support_control_command( codex_bin=args.codex_bin, claude_bin=args.claude_bin, lark_cli_bin=args.lark_cli_bin, + available_capabilities=list(args.available_capability or []), startup_timeout_sec=max(0.1, float(args.startup_timeout_seconds)), idle_timeout_sec=max(0.1, float(args.idle_timeout_seconds)), hard_timeout_sec=max(0.1, float(args.hard_timeout_seconds)), diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 629f2821c..2b1315699 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -26,6 +26,7 @@ LOOPX_TURN_EXECUTION_SCHEMA_VERSION, LOOPX_TURN_JOURNAL_INSPECTION_SCHEMA_VERSION, LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION, + bind_codex_cli_session, build_loopx_turn_command_validator, build_loopx_turn_plan, codex_cli_session_binding, @@ -159,6 +160,13 @@ def register_turn_commands( help="Codex CLI executable used by the built-in codex-cli host.", ) run_once.add_argument("--codex-model") + run_once.add_argument( + "--codex-resume-session-id", + help=( + "Owner-local opaque Codex thread to resume for the freshly selected " + "Todo. Requires --host codex-cli and --execute." + ), + ) run_once.add_argument( "--codex-sandbox", choices=["read-only", "workspace-write"], @@ -230,6 +238,12 @@ def _add_turn_decision_arguments( "same semantic action." ), ) + parser.add_argument( + "--expected-todo-id", + help=( + "Fail closed unless the fresh quota decision selects this exact Todo." + ), + ) parser.add_argument( "--resume-goal-id", help="Goal identity bound to an available opaque host session.", @@ -431,8 +445,36 @@ def handle_turn_command( decision, scheduler_execution_context=scheduler_context, ) + selected_todo = selected_turn_todo(turn_envelope) + if args.expected_todo_id and selected_todo.get("todo_id") != args.expected_todo_id: + raise ValueError( + "fresh LoopX Turn admission selected a different Todo than expected" + ) + codex_resume_session_id = getattr(args, "codex_resume_session_id", None) + if codex_resume_session_id: + if args.turn_command != "run-once" or args.host != "codex-cli": + raise ValueError( + "--codex-resume-session-id requires turn run-once --host codex-cli" + ) + if not args.execute: + raise ValueError("--codex-resume-session-id requires --execute") + if supplied_resume_fields: + raise ValueError( + "--codex-resume-session-id cannot be combined with host session identity flags" + ) + if args.resume_turn_key: + raise ValueError( + "--codex-resume-session-id cannot be combined with --resume-turn-key" + ) + session_binding = bind_codex_cli_session( + runtime_root, + turn_envelope, + session_id=codex_resume_session_id, + ) if args.turn_command == "run-once" and args.host == "codex-cli" and not supplied_resume_fields: - session_binding = codex_cli_session_binding(runtime_root, turn_envelope) + session_binding = session_binding or codex_cli_session_binding( + runtime_root, turn_envelope + ) payload = build_loopx_turn_plan( turn_envelope, host=args.host, diff --git a/loopx/control_plane/turn_driver/__init__.py b/loopx/control_plane/turn_driver/__init__.py index e5d2e9076..73739574c 100644 --- a/loopx/control_plane/turn_driver/__init__.py +++ b/loopx/control_plane/turn_driver/__init__.py @@ -2,6 +2,7 @@ from .codex_cli import ( CODEX_CLI_SESSION_SCHEMA_VERSION, + bind_codex_cli_session, codex_cli_result_schema, codex_cli_session_binding, codex_cli_session_id_from_jsonl, @@ -62,6 +63,7 @@ "LoopXTurnResultKind", "LoopXTurnRoute", "ValidatedTurnReceipt", + "bind_codex_cli_session", "build_loopx_turn_command_validator", "build_loopx_turn_host_request", "build_loopx_turn_plan", diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 11c38547a..22f938bfe 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -127,6 +127,35 @@ def codex_cli_session_binding( } +def bind_codex_cli_session( + runtime_root: Path, + turn_envelope: Mapping[str, Any], + *, + session_id: str, +) -> dict[str, str]: + """Bind one existing opaque Codex thread to the current governed Todo. + + The binding is owner-local transport state. It does not grant Goal or Todo + authority: callers must build the binding from a fresh TurnEnvelope, and the + Turn driver rechecks the full goal/agent/todo lineage before host execution. + """ + + request = {"turn_envelope": dict(turn_envelope)} + lineage = _lineage(request) + normalized_session_id = _valid_session_id(session_id) + if not normalized_session_id: + raise ValueError("Codex CLI resume session id is invalid") + _store_codex_cli_session( + runtime_root, + lineage=lineage, + session_id=normalized_session_id, + ) + return { + "schema_version": "loopx_turn_session_binding_v0", + **lineage, + } + + def _store_codex_cli_session( runtime_root: Path, *, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 459910c13..31fc06dc2 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -822,6 +822,7 @@ def _execution_payload( turn_key = str(transaction.get("turn_key") or "") planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} writeback = _mapping(journal.get("writeback")) + host_result = _mapping(journal.get("host_result")) todo_completion = _mapping(writeback.get("completion")) quota_spent = effects.get("quota_spent") is True or "quota_spend" in list( journal.get("completed_phases") or [] @@ -843,6 +844,7 @@ def _execution_payload( "execution_mode": planned_host.get("execution_mode"), "host": journal.get("host"), "result_kind": journal.get("result_kind"), + **({key: host_result.get(key) for key in ("summary", "recommended_action", "next_action")} if host_result else {}), "validation": journal.get("task_validation"), "receipt": journal.get("receipt"), "scheduler": journal.get("scheduler"), diff --git a/loopx/control_plane/turn_driver/workspace_progress_validator.py b/loopx/control_plane/turn_driver/workspace_progress_validator.py new file mode 100644 index 000000000..b7f145089 --- /dev/null +++ b/loopx/control_plane/turn_driver/workspace_progress_validator.py @@ -0,0 +1,106 @@ +"""Independent bounded workspace-progress validation for Chat-started Turns.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from hashlib import sha256 +from pathlib import Path + +MAX_UNTRACKED_BYTES = 32 * 1024 * 1024 +_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def _git_bytes(project: Path, *args: str) -> bytes: + completed = subprocess.run( + ["git", *args], + cwd=project, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise ValueError( + "workspace progress validation requires a readable Git worktree" + ) + return completed.stdout + + +def workspace_progress_digest(project: Path) -> str: + """Hash the reviewable Git workspace state without exposing its contents.""" + + root = project.expanduser().resolve() + digest = sha256() + for args in ( + ("rev-parse", "HEAD"), + ("status", "--porcelain=v1", "-z", "--untracked-files=all"), + ("diff", "--binary", "--no-ext-diff"), + ("diff", "--cached", "--binary", "--no-ext-diff"), + ): + digest.update(_git_bytes(root, *args)) + digest.update(b"\0") + + untracked = _git_bytes(root, "ls-files", "--others", "--exclude-standard", "-z") + remaining = MAX_UNTRACKED_BYTES + for raw_name in untracked.split(b"\0"): + if not raw_name: + continue + digest.update(raw_name) + path = root / raw_name.decode("utf-8", errors="surrogateescape") + if path.is_symlink(): + digest.update(b"symlink\0") + digest.update(str(path.readlink()).encode("utf-8", errors="surrogateescape")) + continue + if not path.is_file(): + continue + size = path.stat().st_size + digest.update(str(size).encode("ascii")) + if remaining <= 0: + continue + with path.open("rb") as handle: + content = handle.read(min(size, remaining)) + digest.update(content) + remaining -= len(content) + return "sha256:" + digest.hexdigest() + + +def validate_workspace_progress(project: Path, *, baseline_hash: str) -> bool: + if _SHA256_RE.fullmatch(baseline_hash) is None: + return False + if workspace_progress_digest(project) == baseline_hash: + return False + for args in (("diff", "--check"), ("diff", "--cached", "--check")): + checked = subprocess.run( + ["git", *args], + cwd=project.expanduser().resolve(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if checked.returncode != 0: + return False + return True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--baseline-hash", required=True) + args = parser.parse_args(argv) + # The Turn driver supplies the normalized result on stdin. Consume it so a + # future pipe writer cannot block even though this validator needs only the + # independently observed workspace state. + sys.stdin.buffer.read() + try: + return ( + 0 + if validate_workspace_progress(Path.cwd(), baseline_hash=args.baseline_hash) + else 1 + ) + except (OSError, ValueError): + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/dashboard_launcher.py b/loopx/dashboard_launcher.py index b2b2b8157..7497e4125 100644 --- a/loopx/dashboard_launcher.py +++ b/loopx/dashboard_launcher.py @@ -28,6 +28,7 @@ def launch_dashboard( codex_bin: str = "codex", claude_bin: str = "claude", lark_cli_bin: str | None = None, + available_capabilities: list[str] | None = None, assets_dir: Path | None = None, verbose: bool = False, open_browser: bool = True, @@ -58,6 +59,7 @@ def launch_dashboard( codex_bin=codex_bin, claude_bin=claude_bin, lark_cli_bin=lark_cli_bin, + available_capabilities=available_capabilities, assets_dir=resolved_assets, verbose=verbose, open_browser=open_browser, diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 36ac13d7d..5eec5bcdd 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -562,22 +562,18 @@ def answer_lark_goal_topic( """Run one addressed Topic message through the durable Goal Chat session.""" goal_id = str(route.get("goal_id") or "") - channel_id = "lark." + _opaque_digest( - route.get("app_ref"), - route.get("target_ref"), - route.get("topic_root_message_id"), - ) session, _resumed = runtime_controller.open_session( goal_id=goal_id, agent_id="codex", work_dir=Path(work_dir).expanduser().resolve(), objective=str(objective or goal_id), mode="resume_latest", - channel_id=channel_id, + channel_id=f"goal.{goal_id}", agent_goal_id=goal_id, ) message = ( - "这是来自已绑定 Lark Goal Topic 的用户消息。请直接回答当前问题;" + "这是来自已绑定 Lark Goal Topic 的用户消息。你就是这个 Goal 的 working Agent," + "请在当前长程会话中直接回答;" "任何 Goal、Todo 或其他持久状态修改只生成预览,等待用户在 LoopX 明确确认后应用。\n\n" f"用户消息:{str(text or '').strip()}" ) From cfa4d129b50c30541f403639060c24c4d0d0c25d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 19 Aug 2026 20:48:26 +0800 Subject: [PATCH 2/6] feat: add collaboration Goal status Signed-off-by: huangruiteng --- loopx/cli_commands/status.py | 95 +++-- loopx/cli_commands/status_registration.py | 14 + .../goals/collaboration_status.py | 381 ++++++++++++++++++ .../renderers/collaboration_status.py | 96 +++++ 4 files changed, 561 insertions(+), 25 deletions(-) create mode 100644 loopx/control_plane/goals/collaboration_status.py create mode 100644 loopx/presentation/renderers/collaboration_status.py diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index cd18f75b8..d57d7a018 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -6,6 +6,7 @@ from typing import Any from ..contract import check_contract, render_contract_markdown +from ..control_plane.goals.collaboration_status import build_collaboration_status from ..control_plane.runtime.status_projection_cache import ( load_status_projection_cache, resolve_status_projection_cache_runtime_root, @@ -23,11 +24,17 @@ ) from ..diagnose import collect_diagnosis, render_diagnosis_markdown from ..handoff_budget import build_handoff_interface_budget +from ..presentation.renderers.collaboration_status import ( + render_collaboration_status_markdown, +) from ..presentation.renderers.status_markdown import render_status_markdown from ..quota import build_quota_should_run from ..review_packet import build_review_packet, render_review_packet_markdown from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status -from .status_registration import default_public_scan_root, register_status_commands +from .status_registration import ( # noqa: F401 - re-exported by cli_commands. + default_public_scan_root, + register_status_commands, +) PrintPayload = Callable[ [dict[str, object], str, Callable[[dict[str, object]], str]], @@ -170,6 +177,18 @@ def handle_status_command( output_format: FormatSelector, print_payload: PrintPayload, ) -> int: + if args.collaboration and not str(args.goal_id or "").strip(): + payload = build_collaboration_status( + {"ok": False, "attention_queue": {"items": []}}, + goal_id="", + max_age_seconds=args.collaboration_max_age_seconds, + ) + print_payload( + payload, + output_format(args), + render_collaboration_status_markdown, + ) + return 1 try: scan_roots = _scan_roots(args) display_limit = max(0, args.limit) @@ -232,31 +251,57 @@ def handle_status_command( agent_id=args.agent_id, ) compact_agent_lane_todo_index_for_status_display(payload) + if args.collaboration: + cache = payload.get("projection_cache") + cache = cache if isinstance(cache, dict) else {} + snapshot_generated_at = ( + str(cache.get("generated_at")) + if cache.get("hit") is True and cache.get("generated_at") + else None + ) + payload = build_collaboration_status( + payload, + goal_id=str(args.goal_id), + snapshot_generated_at=snapshot_generated_at, + max_age_seconds=args.collaboration_max_age_seconds, + ) except Exception as exc: - payload = { - "ok": False, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error": str(exc), - "attention_queue": { - "available": False, - "item_count": 1, - "needs_user_or_controller": 0, - "needs_codex": 1, - "watching_external_evidence": 0, - "items": [ - { - "goal_id": "loopx-status", - "status": "status_collection_failed", - "waiting_on": "codex", - "severity": "high", - "recommended_action": str(exc), - "source": "status", - } - ], - }, - } - print_payload(payload, output_format(args), render_status_markdown) + if args.collaboration: + payload = build_collaboration_status( + {"ok": False, "attention_queue": {"items": []}}, + goal_id=str(args.goal_id or ""), + max_age_seconds=args.collaboration_max_age_seconds, + ) + else: + payload = { + "ok": False, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error": str(exc), + "attention_queue": { + "available": False, + "item_count": 1, + "needs_user_or_controller": 0, + "needs_codex": 1, + "watching_external_evidence": 0, + "items": [ + { + "goal_id": "loopx-status", + "status": "status_collection_failed", + "waiting_on": "codex", + "severity": "high", + "recommended_action": str(exc), + "source": "status", + } + ], + }, + } + renderer = ( + render_collaboration_status_markdown + if args.collaboration + else render_status_markdown + ) + print_payload(payload, output_format(args), renderer) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/status_registration.py b/loopx/cli_commands/status_registration.py index f383188f0..04ad5499a 100644 --- a/loopx/cli_commands/status_registration.py +++ b/loopx/cli_commands/status_registration.py @@ -112,6 +112,20 @@ def register_status_commands( default=120, help="Freshness window for --use-projection-cache. Defaults to 120 seconds.", ) + status_parser.add_argument( + "--collaboration", + action="store_true", + help=( + "Render one Goal as loopx_collaboration_status_v0 for an authorized " + "peer channel. Requires --goal-id." + ), + ) + status_parser.add_argument( + "--collaboration-max-age-seconds", + type=int, + default=300, + help="Fail closed when a collaboration snapshot is older than this window.", + ) diagnose_parser = subparsers.add_parser( "diagnose", diff --git a/loopx/control_plane/goals/collaboration_status.py b/loopx/control_plane/goals/collaboration_status.py new file mode 100644 index 000000000..ca39b4b28 --- /dev/null +++ b/loopx/control_plane/goals/collaboration_status.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any + +from ..runtime.public_safety import public_safe_compact_text +from ..runtime.time import parse_timestamp, utc_isoformat +from .goal_channel_projection import GOAL_CHANNEL_PROJECTION_SCHEMA_VERSION + +COLLABORATION_STATUS_SCHEMA_VERSION = "loopx_collaboration_status_v0" +COLLABORATION_VISIBILITY = "internal_collaboration" +DEFAULT_MAX_AGE_SECONDS = 300 +DEFAULT_TODO_ITEM_LIMIT = 3 + +_AUTH_MATERIAL_PATTERN = re.compile( + r"(?i)(?:" + r"\bauthorization\s*[:=]|" + r"\bcookie\s*[:=]|" + r"\b(?:app|client)[_-]?secret\s*[:=]|" + r"-----BEGIN [A-Z ]*PRIVATE KEY-----" + r")" +) +_LOCAL_PATH_PATTERN = re.compile( + r"(?i)(?:^|[\s`'\"(])(?:/(?:home|users|volumes|private|tmp|var/tmp|data\d*)/|[a-z]:\\)" +) + + +def _as_mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _as_mappings(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return [] + return [dict(item) for item in value if isinstance(item, Mapping)] + + +def _collaboration_text(value: Any, *, limit: int) -> str | None: + text = public_safe_compact_text(value, limit=limit) + if ( + text is None + or _AUTH_MATERIAL_PATTERN.search(text) + or _LOCAL_PATH_PATTERN.search(text) + ): + return None + return text + + +def _first_text(*values: Any, limit: int) -> str | None: + for value in values: + text = _collaboration_text(value, limit=limit) + if text: + return text + return None + + +def _count(summary: Mapping[str, Any], *keys: str) -> int | None: + for key in keys: + value = summary.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int) and value >= 0: + return value + return None + + +def _todo_item( + item: Mapping[str, Any], + *, + role: str, + redacted_fields: list[str], +) -> dict[str, Any] | None: + title = _first_text(item.get("title"), item.get("text"), limit=260) + if not title: + redacted_fields.append(f"todos.{role}.items[].title") + return None + result: dict[str, Any] = {"title": title} + for key, limit in ( + ("todo_id", 120), + ("priority", 20), + ("status", 20), + ("claimed_by", 120), + ("bound_agent", 120), + ("task_repository", 240), + ): + value = _collaboration_text(item.get(key), limit=limit) + if value: + result[key] = value + elif item.get(key): + redacted_fields.append(f"todos.{role}.items[].{key}") + return result + + +def _summary_projection( + *, + role: str, + canonical_summary: Mapping[str, Any], + compact_summary: Mapping[str, Any], + projected_items: Sequence[Mapping[str, Any]], + item_limit: int, + blockers: list[dict[str, str]], + redacted_fields: list[str], +) -> dict[str, Any]: + open_count = _count(canonical_summary, "open_count", "open") + if open_count is None: + blockers.append( + { + "code": f"{role}_todo_summary_missing", + "message": f"{role} Todo summary has no valid open count", + } + ) + open_count = 0 + compact_open_count = _count(compact_summary, "open", "open_count") + if compact_summary and compact_open_count is None: + blockers.append( + { + "code": f"{role}_todo_compact_count_missing", + "message": f"{role} compact Todo summary has no valid open count", + } + ) + elif compact_open_count is not None and compact_open_count != open_count: + blockers.append( + { + "code": f"{role}_todo_count_conflict", + "message": f"{role} Todo projections disagree on the open count", + } + ) + if len(projected_items) > open_count: + blockers.append( + { + "code": f"{role}_todo_projection_overflow", + "message": f"{role} projected Todo rows exceed the canonical open count", + } + ) + + items: list[dict[str, Any]] = [] + for item in list(projected_items)[: max(0, item_limit)]: + compact = _todo_item(item, role=role, redacted_fields=redacted_fields) + if compact: + items.append(compact) + return { + "open_count": open_count, + "visible_count": len(items), + "truncated": open_count > len(projected_items) + or len(projected_items) > len(items), + "items": items, + } + + +def _freshness( + *, + snapshot_generated_at: Any, + now: datetime, + max_age_seconds: int, + blockers: list[dict[str, str]], +) -> dict[str, Any]: + parsed = parse_timestamp(snapshot_generated_at) + if parsed is None: + blockers.append( + { + "code": "snapshot_time_invalid", + "message": "collaboration status snapshot time is missing or invalid", + } + ) + return { + "state": "invalid", + "generated_at": None, + "max_age_seconds": max(0, int(max_age_seconds)), + } + age_seconds = (now - parsed).total_seconds() + safe_max_age = max(0, int(max_age_seconds)) + state = "fresh" + if age_seconds < -60: + state = "invalid" + blockers.append( + { + "code": "snapshot_time_in_future", + "message": "collaboration status snapshot time is unexpectedly in the future", + } + ) + elif age_seconds > safe_max_age: + state = "stale" + blockers.append( + { + "code": "snapshot_stale", + "message": "collaboration status snapshot exceeded its freshness window", + } + ) + return { + "state": state, + "generated_at": utc_isoformat(parsed), + "age_seconds": round(max(0.0, age_seconds), 3), + "max_age_seconds": safe_max_age, + } + + +def build_collaboration_status( + status_payload: Mapping[str, Any], + *, + goal_id: str, + snapshot_generated_at: str | None = None, + now: datetime | None = None, + max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS, + item_limit: int = DEFAULT_TODO_ITEM_LIMIT, +) -> dict[str, Any]: + """Build a collaboration-safe, read-only status for one LoopX Goal. + + Runtime values may retain real work context intended for authorized peers. + The projection omits credential-like material, provider payloads, and local + paths while preserving the LoopX status/Todo counts as task truth. + """ + + safe_goal_id = str(goal_id or "").strip() + blockers: list[dict[str, str]] = [] + redacted_fields: list[str] = [] + queue = _as_mapping(status_payload.get("attention_queue")) + matching_items = [ + item + for item in _as_mappings(queue.get("items")) + if str(item.get("goal_id") or "") == safe_goal_id + ] + if not safe_goal_id: + blockers.append( + {"code": "goal_id_required", "message": "a Goal id is required"} + ) + if len(matching_items) != 1: + blockers.append( + { + "code": "goal_status_not_unique", + "message": "exactly one matching Goal status item is required", + } + ) + item = matching_items[0] if len(matching_items) == 1 else {} + projection = _as_mapping(item.get("goal_channel_projection")) + project_asset = _as_mapping(item.get("project_asset")) + + if status_payload.get("ok") is not True: + blockers.append( + { + "code": "status_contract_unhealthy", + "message": "the source LoopX status contract is not healthy", + } + ) + if projection.get("schema_version") != GOAL_CHANNEL_PROJECTION_SCHEMA_VERSION: + blockers.append( + { + "code": "goal_projection_missing", + "message": "the Goal channel projection is missing or incompatible", + } + ) + if projection.get("mode") != "read_only": + blockers.append( + { + "code": "goal_projection_not_read_only", + "message": "the Goal channel projection is not read-only", + } + ) + truth = _as_mapping(projection.get("truth_contract")) + if ( + truth.get("event_ledger_is_source_of_truth") is not True + or truth.get("projection_is_writable") is not False + or truth.get("write_authority") != "none" + ): + blockers.append( + { + "code": "truth_contract_invalid", + "message": "the projection does not preserve the LoopX truth contract", + } + ) + + canonical_user = _as_mapping(item.get("user_todos")) + canonical_agent = _as_mapping(item.get("agent_todos")) + user_items = _as_mappings(projection.get("user_todos")) + agent_items = _as_mappings(projection.get("agent_todos")) + user_todos = _summary_projection( + role="user", + canonical_summary=canonical_user, + compact_summary=_as_mapping(project_asset.get("user_todos")), + projected_items=user_items, + item_limit=item_limit, + blockers=blockers, + redacted_fields=redacted_fields, + ) + agent_todos = _summary_projection( + role="agent", + canonical_summary=canonical_agent, + compact_summary=_as_mapping(project_asset.get("agent_todos")), + projected_items=agent_items, + item_limit=item_limit, + blockers=blockers, + redacted_fields=redacted_fields, + ) + + current_time = now or datetime.now(UTC).replace(microsecond=0) + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=UTC) + else: + current_time = current_time.astimezone(UTC) + effective_generated_at = snapshot_generated_at or utc_isoformat(current_time) + freshness = _freshness( + snapshot_generated_at=effective_generated_at, + now=current_time, + max_age_seconds=max_age_seconds, + blockers=blockers, + ) + + display_name = _first_text( + projection.get("display_name"), + item.get("display_name"), + safe_goal_id, + limit=140, + ) + latest_status = _first_text( + projection.get("latest_status"), item.get("status"), limit=160 + ) + next_action = _first_text( + projection.get("next_action"), + project_asset.get("next_action"), + item.get("recommended_action"), + limit=360, + ) + if not display_name: + redacted_fields.append("goal.display_name") + if not latest_status: + redacted_fields.append("state.status") + if not next_action: + redacted_fields.append("state.next_action") + + visible_agent_items = agent_todos["items"] + visible_user_items = user_todos["items"] + focus = _first_text( + visible_agent_items[0].get("title") if visible_agent_items else None, + visible_user_items[0].get("title") if visible_user_items else None, + next_action, + limit=300, + ) + open_gates = _as_mappings(projection.get("open_gates")) + gate_summary = _first_text( + visible_user_items[0].get("title") if visible_user_items else None, + open_gates[0].get("kind") if open_gates else None, + limit=240, + ) + + projected_goal_id = _collaboration_text(safe_goal_id, limit=140) + if safe_goal_id and not projected_goal_id: + redacted_fields.append("goal.goal_id") + publishable = not blockers + return { + "schema_version": COLLABORATION_STATUS_SCHEMA_VERSION, + "ok": publishable, + "publishable": publishable, + "visibility": COLLABORATION_VISIBILITY, + "goal": { + "goal_id": projected_goal_id or "goal", + "display_name": display_name or projected_goal_id or "goal", + }, + "state": { + "status": latest_status, + "waiting_on": _first_text( + projection.get("waiting_on"), item.get("waiting_on"), limit=100 + ), + "focus": focus, + "next_action": next_action, + }, + "todos": {"user": user_todos, "agent": agent_todos}, + "owner_gate": { + "open": bool(open_gates or user_todos["open_count"]), + "count": max(len(open_gates), int(user_todos["open_count"])), + "summary": gate_summary, + }, + "freshness": freshness, + "redacted_fields": sorted(set(redacted_fields)), + "blockers": blockers, + "truth_contract": { + "source": "LoopX event ledger and derived status projections", + "projection_is_writable": False, + "write_authority": "none", + }, + } diff --git a/loopx/presentation/renderers/collaboration_status.py b/loopx/presentation/renderers/collaboration_status.py new file mode 100644 index 000000000..9a7a152d7 --- /dev/null +++ b/loopx/presentation/renderers/collaboration_status.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...control_plane.goals.collaboration_status import ( + COLLABORATION_STATUS_SCHEMA_VERSION, +) + + +def _mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _todo_line(label: str, summary: Mapping[str, Any]) -> str: + open_count = int(summary.get("open_count") or 0) + visible_count = int(summary.get("visible_count") or 0) + suffix = f",展示 {visible_count}" if summary.get("truncated") else "" + return f"- {label} Todo:{open_count} 个开放{suffix}" + + +def render_collaboration_status_markdown(payload: dict[str, object]) -> str: + """Render a concise status suitable for an authorized peer channel.""" + + if payload.get("schema_version") != COLLABORATION_STATUS_SCHEMA_VERSION: + raise ValueError("unsupported collaboration status schema_version") + goal = _mapping(payload.get("goal")) + freshness = _mapping(payload.get("freshness")) + if payload.get("publishable") is not True: + blockers = payload.get("blockers") + blocker_rows = blockers if isinstance(blockers, list) else [] + codes = [ + str(item.get("code")) + for item in blocker_rows + if isinstance(item, Mapping) and item.get("code") + ] + reason = "、".join(codes[:4]) or "projection_unavailable" + return "\n".join( + [ + "# LoopX 协作状态", + "", + f"- 目标:{goal.get('display_name') or goal.get('goal_id') or 'goal'}", + "- 当前状态投影不可用,请刷新 LoopX 状态后重试。", + f"- 原因:{reason}", + ] + ) + + state = _mapping(payload.get("state")) + todos = _mapping(payload.get("todos")) + user_todos = _mapping(todos.get("user")) + agent_todos = _mapping(todos.get("agent")) + owner_gate = _mapping(payload.get("owner_gate")) + truth = _mapping(payload.get("truth_contract")) + lines = [ + "# LoopX 协作状态", + "", + f"- 目标:{goal.get('display_name') or goal.get('goal_id') or 'goal'}", + f"- 状态:{state.get('status') or 'unknown'}", + f"- 当前焦点:{state.get('focus') or '暂无可显示焦点'}", + f"- 下一步:{state.get('next_action') or '暂无可显示下一步'}", + _todo_line("Agent", agent_todos), + _todo_line("Owner", user_todos), + ( + f"- Owner gate:开放({owner_gate.get('summary') or '需要 owner 处理'})" + if owner_gate.get("open") + else "- Owner gate:无" + ), + ( + "- 快照:" + f"{freshness.get('state') or 'unknown'} · " + f"{freshness.get('generated_at') or 'unknown'}" + ), + (f"- 真相源:{truth.get('source') or 'LoopX'};该投影只读,不接受状态写回。"), + ] + for role, summary in (("Agent", agent_todos), ("Owner", user_todos)): + items = summary.get("items") + if not isinstance(items, list) or not items: + continue + lines.extend(["", f"## {role} Todo"]) + for item in items: + if not isinstance(item, Mapping): + continue + prefix = f"[{item.get('priority')}] " if item.get("priority") else "" + owner = item.get("claimed_by") or item.get("bound_agent") + suffix = f"({owner})" if owner else "" + lines.append(f"- {prefix}{item.get('title')}{suffix}") + references = [ + str(item.get(key)) + for key in ("todo_id", "task_repository") + if item.get(key) + ] + if references: + lines.append(f" - ref:{' · '.join(references)}") + if payload.get("redacted_fields"): + lines.extend(["", "- 注:检测到鉴权材料或本机信息,相关字段已省略。"]) + return "\n".join(lines) From 3b279322b727cedf46def81e23e80d1ef9a090e4 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 19 Aug 2026 20:48:54 +0800 Subject: [PATCH 3/6] test: cover working Agent Goal Chat Signed-off-by: huangruiteng --- apps/presentation/dashboard/README.md | 42 ++++ examples/loopx-chat-actions-smoke.py | 26 +- .../test_collaboration_status_projection.py | 177 ++++++++++++++ .../test_lark_goal_topic_runtime.py | 3 +- tests/test_chat_turn_admission.py | 230 ++++++++++++++++++ tests/test_dashboard_command.py | 22 ++ tests/test_loopx_turn_codex_cli.py | 26 ++ 7 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 tests/control_plane/test_collaboration_status_projection.py create mode 100644 tests/test_chat_turn_admission.py diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 3d1bfa886..9ab87b30a 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -238,6 +238,48 @@ loopx serve-status --port 8765 --enable-reward-write-api The write flag is loopback-only. Without it, the dashboard can validate a reward draft but cannot append feedback. +## Working-Agent Goal Chat + +`loopx chat` keeps one canonical Codex conversation per Goal and resumes that +same upstream thread across ordinary replies and governed execution: + +```bash +loopx chat \ + --goal-id \ + --global-registry \ + --available-capability network +``` + +Ordinary Goal and bound Goal Topic messages remain read-only Chat turns. They +do not spend a governed Turn quota slot. A user-confirmed typed action that +starts or corrects a Todo enters `loopx turn run-once` with a fresh exact-Todo +guard, releases the app-server transport, resumes the same Codex thread through +the Codex CLI, validates workspace progress independently, writes the bounded +result back, and settles quota once. The next read-only message resumes that +same thread again. + +`--available-capability` is repeatable. It declares what the current host can +provide to the fresh Turn guard; it does not grant credentials or bypass Todo, +repository, validation, writeback, or quota policy. Governed execution fails +closed unless the Goal session is a resumable Codex session. Other Agent +endpoints remain available for read-only Chat until they implement an +equivalent governed resume adapter. + +For an authorized peer channel or bot response, render a bounded, read-only +Goal snapshot directly from LoopX state: + +```bash +loopx status \ + --goal-id \ + --collaboration \ + --collaboration-max-age-seconds 300 +``` + +The `loopx_collaboration_status_v0` projection preserves exact open Todo counts +and bounded owner/repository context while omitting credential-like material, +provider payloads, and local paths. It fails closed on stale snapshots, +conflicting counts, or a writable truth contract. + ## Load Static Status Use a local static export: diff --git a/examples/loopx-chat-actions-smoke.py b/examples/loopx-chat-actions-smoke.py index 30f2eeb16..6c3484fb3 100644 --- a/examples/loopx-chat-actions-smoke.py +++ b/examples/loopx-chat-actions-smoke.py @@ -121,6 +121,10 @@ def submit_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: self.submissions.append(dict(kwargs)) return ({"turn_id": "turn-correction-1", "status": "queued"}, True) + def submit_governed_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: + self.submissions.append({**dict(kwargs), "governed": True}) + return ({"turn_id": "turn-governed-1", "status": "queued"}, True) + def capabilities(self) -> list[dict[str, object]]: return [ { @@ -154,6 +158,9 @@ class FailingRuntimeController(FakeRuntimeController): def submit_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: raise RuntimeError("temporary runtime failure") + def submit_governed_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: + raise RuntimeError("temporary governed runtime failure") + def write_registry_fixture(root: Path) -> tuple[Path, Path]: project = root / "project" @@ -344,7 +351,9 @@ def assert_http_action_api(root: Path) -> None: assert todo_resources["session_id"], todo_resources assert todo_resources["turn_id"], todo_resources assert applied["turn"]["session_id"] == todo_resources["session_id"], applied - assert runtime_controller.opened_sessions[-1]["channel_id"] == f"task.{todo_resources['todo_id']}" + assert runtime_controller.opened_sessions[-1]["channel_id"] == "goal.goal-one" + assert runtime_controller.submissions[-1]["governed"] is True + assert runtime_controller.submissions[-1]["todo_id"] == todo_resources["todo_id"] assert state_path.read_text(encoding="utf-8").count("Verify the typed action API") == 1 assert "claimed_by=codex" in state_path.read_text(encoding="utf-8") code, repeated = request_json( @@ -364,8 +373,10 @@ def assert_http_action_api(root: Path) -> None: ) assert code == 202, correction_applied assert correction_applied["proposal"]["status"] == "applied", correction_applied - assert correction_applied["turn"]["turn_id"] == "turn-correction-1", correction_applied + assert correction_applied["turn"]["turn_id"] == "turn-governed-1", correction_applied assert runtime_controller.submissions[1]["session_id"] == session["session_id"] + assert runtime_controller.submissions[1]["governed"] is True + assert runtime_controller.submissions[1]["todo_id"] == todo_resources["todo_id"] goal_proposal = previews["goal.create"] code, goal_applied = request_json( @@ -788,7 +799,10 @@ def assert_http_action_api(root: Path) -> None: body={}, ) assert code == 202, run_applied - assert run_applied["proposal"]["receipt"]["outcome"] == "monitor_turn_created", run_applied + assert ( + run_applied["proposal"]["receipt"]["outcome"] + == "governed_monitor_turn_created" + ), run_applied assert runtime_controller.submissions[-1]["session_id"] == session["session_id"] code, fresh_run_preview = request_json( @@ -817,8 +831,10 @@ def assert_http_action_api(root: Path) -> None: assert code == 202, fresh_run_applied fresh_resources = fresh_run_applied["proposal"]["receipt"]["resource_ids"] assert fresh_resources["session_id"], fresh_run_applied - assert runtime_controller.opened_sessions[-1]["channel_id"] == f"task.{monitor_todo_id}" + assert runtime_controller.opened_sessions[-1]["channel_id"] == "goal.goal-one" assert runtime_controller.submissions[-1]["session_id"] == fresh_resources["session_id"] + assert runtime_controller.submissions[-1]["governed"] is True + assert runtime_controller.submissions[-1]["todo_id"] == monitor_todo_id code, stop_preview = request_json( f"{base_url}/api/actions/preview", @@ -938,7 +954,7 @@ def assert_http_action_api(root: Path) -> None: persisted_payload = action_store.path.read_text(encoding="utf-8") assert str(root) not in persisted_payload, persisted_payload - assert str(project := registry_path.parent.parent) not in persisted_payload, persisted_payload + assert str(registry_path.parent.parent) not in persisted_payload, persisted_payload cancellable_code, cancellable = request_json( f"{base_url}/api/actions/preview", diff --git a/tests/control_plane/test_collaboration_status_projection.py b/tests/control_plane/test_collaboration_status_projection.py new file mode 100644 index 000000000..da3750cc8 --- /dev/null +++ b/tests/control_plane/test_collaboration_status_projection.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from loopx.cli import build_parser +from loopx.control_plane.goals.collaboration_status import ( + build_collaboration_status, +) +from loopx.presentation.renderers.collaboration_status import ( + render_collaboration_status_markdown, +) + +NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC) + + +def _todo( + index: int, + *, + title: str | None = None, + claimed_by: str = "agent-example", +) -> dict[str, Any]: + return { + "todo_id": f"todo_example_{index}", + "priority": "P0", + "status": "open", + "title": title or f"Deliver collaboration milestone {index}", + "claimed_by": claimed_by, + "task_repository": "git:example.com/example/project", + } + + +def _status_payload( + *, + agent_open: int = 3, + compact_agent_open: int | None = None, + agent_items: list[dict[str, Any]] | None = None, + truth_writable: bool = False, +) -> dict[str, Any]: + items = agent_items if agent_items is not None else [_todo(1), _todo(2)] + compact_open = agent_open if compact_agent_open is None else compact_agent_open + return { + "ok": True, + "attention_queue": { + "item_count": 1, + "items": [ + { + "goal_id": "example-goal", + "status": "working", + "waiting_on": "agent", + "recommended_action": "Finish the next verified milestone.", + "user_todos": {"open_count": 0}, + "agent_todos": {"open_count": agent_open}, + "project_asset": { + "user_todos": {"open": 0}, + "agent_todos": {"open": compact_open}, + }, + "goal_channel_projection": { + "schema_version": "goal_channel_projection_v0", + "goal_id": "example-goal", + "mode": "read_only", + "display_name": "Example delivery goal", + "latest_status": "working", + "waiting_on": "agent", + "next_action": "Finish the next verified milestone.", + "user_todos": [], + "agent_todos": items, + "open_gates": [], + "truth_contract": { + "event_ledger_is_source_of_truth": True, + "projection_is_writable": truth_writable, + "write_authority": "none", + }, + }, + } + ], + }, + } + + +def _build(payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + return build_collaboration_status( + payload, + goal_id="example-goal", + now=NOW, + snapshot_generated_at="2026-08-19T09:59:30Z", + **kwargs, + ) + + +def test_projection_keeps_peer_context_and_exact_open_counts() -> None: + packet = _build(_status_payload()) + + assert packet["schema_version"] == "loopx_collaboration_status_v0" + assert packet["visibility"] == "internal_collaboration" + assert packet["publishable"] is True + assert packet["freshness"]["state"] == "fresh" + assert packet["todos"]["agent"] == { + "open_count": 3, + "visible_count": 2, + "truncated": True, + "items": [_todo(1), _todo(2)], + } + rendered = render_collaboration_status_markdown(packet) + assert "Example delivery goal" in rendered + assert "Deliver collaboration milestone 1" in rendered + assert "agent-example" in rendered + assert "todo_example_1" in rendered + assert "git:example.com/example/project" in rendered + + +def test_projection_fails_closed_when_todo_counts_conflict() -> None: + packet = _build(_status_payload(compact_agent_open=2)) + + assert packet["publishable"] is False + assert {item["code"] for item in packet["blockers"]} == { + "agent_todo_count_conflict" + } + assert "agent_todo_count_conflict" in render_collaboration_status_markdown(packet) + + +def test_projection_fails_closed_when_snapshot_is_stale() -> None: + packet = build_collaboration_status( + _status_payload(), + goal_id="example-goal", + now=NOW, + snapshot_generated_at="2026-08-19T09:00:00Z", + max_age_seconds=300, + ) + + assert packet["publishable"] is False + assert packet["freshness"]["state"] == "stale" + assert [item["code"] for item in packet["blockers"]] == ["snapshot_stale"] + + +def test_projection_redacts_secrets_and_paths_without_hiding_counts() -> None: + secret_title = "Rotate app_secret=synthetic-secret-value" + local_owner = "/" + "home/example/private-agent" + packet = _build( + _status_payload( + agent_open=1, + agent_items=[_todo(1, title=secret_title, claimed_by=local_owner)], + ) + ) + + assert packet["publishable"] is True + assert packet["todos"]["agent"]["open_count"] == 1 + assert packet["todos"]["agent"]["visible_count"] == 0 + assert packet["todos"]["agent"]["truncated"] is True + assert "todos.agent.items[].title" in packet["redacted_fields"] + serialized = repr(packet) + assert secret_title not in serialized + assert local_owner not in serialized + + +def test_projection_rejects_a_writable_truth_contract() -> None: + packet = _build(_status_payload(truth_writable=True)) + + assert packet["publishable"] is False + assert [item["code"] for item in packet["blockers"]] == ["truth_contract_invalid"] + + +def test_status_cli_registers_collaboration_projection_options() -> None: + args = build_parser().parse_args( + [ + "status", + "--goal-id", + "example-goal", + "--collaboration", + "--collaboration-max-age-seconds", + "90", + ] + ) + + assert args.collaboration is True + assert args.goal_id == "example-goal" + assert args.collaboration_max_age_seconds == 90 diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 07b090c17..e4f5e2bb5 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -226,9 +226,10 @@ def wait_for_turn(self, **_kwargs: Any): assert first == "当前运行的是 LoopX 开发版。" assert second == first assert runtime.open_calls[0]["mode"] == "resume_latest" - assert runtime.open_calls[0]["channel_id"].startswith("lark.") + assert runtime.open_calls[0]["channel_id"] == "goal.goal-alpha" assert runtime.open_calls[0]["channel_id"] == runtime.open_calls[1]["channel_id"] assert runtime.submit_calls[0]["client_turn_id"].startswith("lark.") + assert "working Agent" in runtime.submit_calls[0]["message"] assert "只生成预览" in runtime.submit_calls[0]["message"] diff --git a/tests/test_chat_turn_admission.py b/tests/test_chat_turn_admission.py new file mode 100644 index 000000000..ab4d8ee7b --- /dev/null +++ b/tests/test_chat_turn_admission.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest + +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore +from loopx.chat_turn_admission import LoopXChatTurnAdmission +from loopx.control_plane.turn_driver.workspace_progress_validator import ( + validate_workspace_progress, + workspace_progress_digest, +) + + +def _git(project: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=project, check=True, capture_output=True) + + +def _git_fixture(project: Path) -> None: + project.mkdir() + _git(project, "init", "-q") + _git(project, "config", "user.email", "fixture@example.com") + _git(project, "config", "user.name", "Fixture") + (project / "README.md").write_text("fixture\n", encoding="utf-8") + _git(project, "add", "README.md") + _git(project, "commit", "-qm", "fixture") + + +def test_workspace_progress_validator_requires_a_clean_delta(tmp_path: Path) -> None: + project = tmp_path / "project" + _git_fixture(project) + baseline = workspace_progress_digest(project) + + assert validate_workspace_progress(project, baseline_hash=baseline) is False + + (project / "README.md").write_text("fixture\nprogress\n", encoding="utf-8") + assert validate_workspace_progress(project, baseline_hash=baseline) is True + + (project / "README.md").write_text("fixture \n", encoding="utf-8") + assert validate_workspace_progress(project, baseline_hash=baseline) is False + + _git(project, "add", "README.md") + assert validate_workspace_progress(project, baseline_hash=baseline) is False + + +def test_workspace_progress_digest_tracks_symlink_without_following_it( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + _git_fixture(project) + link = project / "external-link" + os.symlink(tmp_path / "outside-a", link) + first = workspace_progress_digest(project) + + link.unlink() + os.symlink(tmp_path / "outside-b", link) + + assert workspace_progress_digest(project) != first + + +def test_chat_admission_invokes_fresh_exact_todo_resume( + tmp_path: Path, monkeypatch: object +) -> None: + project = tmp_path / "project" + _git_fixture(project) + registry = tmp_path / "registry.json" + registry.write_text("{}\n", encoding="utf-8") + observed: list[list[str]] = [] + real_run = subprocess.run + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + if command[0] == "git": + return real_run(command, **kwargs) + observed.append(command) + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "ok": True, + "status": "committed", + "result_kind": "validated_progress", + "resume_turn_key": "sha256:" + "a" * 64, + "journal_ref": "turn:aaaaaaaaaaaaaaaa", + "summary": "One bounded change was validated.", + "next_action": "Continue from fresh state.", + "validation": {"status": "passed"}, + "effects": {"state_written": True, "quota_spent": True}, + "quota_slot_spend_count": 1, + } + ), + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) # type: ignore[attr-defined] + admission = LoopXChatTurnAdmission( + registry_path=registry, + runtime_root_override=tmp_path / "runtime", + loopx_bin="/bin/true", + codex_bin="codex", + available_capabilities=("network",), + ) + + payload = admission( + goal_id="goal-fixture", + agent_id="codex-fixture", + todo_id="todo-fixture", + upstream_thread_id="thread-long-lived", + work_dir=project, + turn_instance_id="chat-turn-fixture", + ) + + assert payload["status"] == "committed" + command = observed[0] + assert command[command.index("--expected-todo-id") + 1] == "todo-fixture" + assert ( + command[command.index("--codex-resume-session-id") + 1] == "thread-long-lived" + ) + assert command[command.index("--turn-instance-id") + 1] == "chat-turn-fixture" + assert command[-2:] == ["--available-capability", "network"] + + +def test_governed_chat_turn_persists_settlement_on_same_session(tmp_path: Path) -> None: + store = ChatSessionStore(tmp_path / "runtime") + session = store.create_session( + goal_id="goal-fixture", + agent_id="codex", + adapter_kind="fixture", + upstream_thread_id="thread-long-lived", + upstream_mode="chat", + channel_id="goal.goal-fixture", + ) + calls: list[dict[str, object]] = [] + + def runner(**kwargs: object) -> dict[str, object]: + calls.append(dict(kwargs)) + return { + "ok": True, + "status": "committed", + "result_kind": "validated_progress", + "resume_turn_key": "sha256:" + "b" * 64, + "journal_ref": "turn:bbbbbbbbbbbbbbbb", + "summary": "One material Chat turn advanced.", + "next_action": "Read fresh LoopX state.", + "validation": {"status": "passed"}, + "effects": {"state_written": True, "quota_spent": True}, + "quota_slot_spend_count": 1, + } + + controller = ChatRuntimeController( + store=store, + codex_bin="codex", + governed_turn_runner=runner, + ) + turn, created = controller.submit_governed_turn( + session_id=str(session["session_id"]), + client_turn_id="material-fixture", + message="Advance the fixture.", + todo_id="todo-fixture", + governed_agent_id="codex-peer-fixture", + work_dir=tmp_path, + ) + completed = controller.wait_for_turn( + session_id=str(session["session_id"]), + turn_id=str(turn["turn_id"]), + timeout_sec=2, + ) + + assert created is True + assert calls[0]["upstream_thread_id"] == "thread-long-lived" + assert calls[0]["todo_id"] == "todo-fixture" + assert calls[0]["agent_id"] == "codex-peer-fixture" + assert completed["response"]["governance"] == { + "schema_version": "loopx_chat_turn_governance_v0", + "mode": "governed", + "todo_id": "todo-fixture", + "agent_id": "codex-peer-fixture", + "turn_key": "sha256:" + "b" * 64, + "journal_ref": "turn:bbbbbbbbbbbbbbbb", + "status": "committed", + "result_kind": "validated_progress", + "validation": {"status": "passed"}, + "effects": {"state_written": True, "quota_spent": True}, + "quota_slot_spend_count": 1, + } + restored = store.load_session(str(session["session_id"])) + deadline = time.monotonic() + 1 + while ( + restored is not None + and restored["status"] != "ready" + and time.monotonic() < deadline + ): + time.sleep(0.01) + restored = store.load_session(str(session["session_id"])) + assert restored is not None + assert restored["upstream_thread_id"] == "thread-long-lived" + assert restored["status"] == "ready" + + +def test_governed_chat_turn_rejects_a_non_codex_transport(tmp_path: Path) -> None: + store = ChatSessionStore(tmp_path / "runtime") + session = store.create_session( + goal_id="goal-fixture", + agent_id="claude-code", + adapter_kind="fixture", + upstream_thread_id="thread-other-provider", + channel_id="goal.goal-fixture", + ) + controller = ChatRuntimeController( + store=store, + codex_bin="codex", + governed_turn_runner=lambda **_kwargs: {}, + ) + + with pytest.raises(ValueError, match="resumable Codex Goal session"): + controller.submit_governed_turn( + session_id=str(session["session_id"]), + client_turn_id="material-fixture", + message="Advance the fixture.", + todo_id="todo-fixture", + governed_agent_id="codex-peer-fixture", + work_dir=tmp_path, + ) diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py index ad5e37437..6fb635f68 100644 --- a/tests/test_dashboard_command.py +++ b/tests/test_dashboard_command.py @@ -26,6 +26,28 @@ def test_chat_command_accepts_explicit_lark_cli_binary() -> None: assert args.lark_cli_bin == "custom-lark-cli" +def test_chat_command_passes_governed_turn_capabilities_to_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + monkeypatch.setattr(support_control, "serve_chat", lambda **kwargs: calls.append(kwargs)) + + assert ( + main( + [ + "chat", + "--available-capability", + "network", + "--available-capability", + "browser", + "--no-open", + ] + ) + == 0 + ) + assert calls[0]["available_capabilities"] == ["network", "browser"] + + def test_chat_command_passes_explicit_lark_cli_binary_to_server( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 9f9889820..1c728b080 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -10,6 +10,7 @@ from loopx.control_plane.turn_driver.codex_cli import ( CODEX_CLI_SESSION_SCHEMA_VERSION, + bind_codex_cli_session, codex_cli_result_schema, codex_cli_session_binding, load_codex_cli_session, @@ -126,6 +127,31 @@ def test_codex_cli_result_schema_requires_only_bounded_contract_fields() -> None } +def test_existing_chat_thread_can_be_bound_to_fresh_todo_lineage( + tmp_path: Path, +) -> None: + request = _request() + envelope = request["turn_envelope"] + assert isinstance(envelope, dict) + + binding = bind_codex_cli_session( + tmp_path, + envelope, + session_id="thread-long-lived", + ) + + assert binding == { + "schema_version": "loopx_turn_session_binding_v0", + "goal_id": "fixture-goal", + "agent_id": "codex-fixture", + "todo_id": "todo_fixture0001", + } + stored = codex_cli_session_binding(tmp_path, envelope) + assert stored == binding + session_file = next(tmp_path.glob("goals/*/turn-sessions/*.json")) + assert stat.S_IMODE(session_file.stat().st_mode) == 0o600 + + def test_codex_cli_host_starts_then_resumes_opaque_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From f3e647d59216931635af033128e14512f6b3c385 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 20 Aug 2026 00:02:28 +0800 Subject: [PATCH 4/6] docs: propose attached App Session frontend Signed-off-by: huangruiteng --- docs/architecture/rfcs/README.md | 4 + .../rfcs/attached-app-session-frontend-v0.md | 252 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 docs/architecture/rfcs/attached-app-session-frontend-v0.md diff --git a/docs/architecture/rfcs/README.md b/docs/architecture/rfcs/README.md index 0d35400d4..c1a549465 100644 --- a/docs/architecture/rfcs/README.md +++ b/docs/architecture/rfcs/README.md @@ -71,6 +71,10 @@ promote a proposal beyond that status. ## Draft Integration Proposals +- [Attached App Session Frontend v0](attached-app-session-frontend-v0.md): + attach the LoopX frontend to an already-running Codex App session, preserve + its automation-prompt/visible-host execution driver, and defer managed CLI + Turn launch to a separate future mode. - [Agent IM, LoopX, and OpenViking collaboration v0](agent-im-openviking-collaboration-v0.md): separate runtime delivery, durable control state, and scoped context while preserving direct agent-to-LoopX interaction. diff --git a/docs/architecture/rfcs/attached-app-session-frontend-v0.md b/docs/architecture/rfcs/attached-app-session-frontend-v0.md new file mode 100644 index 000000000..a9d3aeedc --- /dev/null +++ b/docs/architecture/rfcs/attached-app-session-frontend-v0.md @@ -0,0 +1,252 @@ +# RFC: Attached App Session Frontend v0 + +- Status: Draft +- Decision boundary: attach a LoopX frontend to an already-running Codex App + session +- Smallest useful slice: one local Codex app-server session, one LoopX goal, + and one existing automation-prompt or visible-host loop + +## Summary + +LoopX should support an **attached App Session** product mode. In this mode, a +Codex App or app-server session already exists and already owns its transport, +conversation history, interruption, and resume lifecycle. The LoopX frontend +attaches to that session, projects the relevant Goal and Todo state, and keeps +user interaction on the existing app-server connection. + +The existing automation prompt or visible host loop remains the execution +driver. It reads the current LoopX interaction contract, advances the selected +Todo, validates progress, writes state back, and accounts for quota through the +normal LoopX command surface. The frontend must not stop the app-server +transport and resume the same thread through a separately launched CLI merely +to classify the work as governed. + +A future frontend may also launch LoopX-managed Turns on Codex CLI, Claude CLI, +or another host adapter. That is a separate product mode with a different +process owner and lifecycle. This RFC deliberately scopes the first delivery to +attachment only. + +## Problem + +Users may already have a long-running Codex App session with valuable context, +an installed LoopX automation prompt, and an active Goal. A frontend that wants +to show or control that work has two choices: + +1. attach to the existing session; or +2. close or bypass it and launch another agent runtime. + +The second choice creates the wrong ownership boundary for the short-term +product: + +- the frontend can accidentally create two executors for one Goal; +- the visible conversation and the process doing the work may diverge; +- interruption, resume, and sandbox behavior can change across transports; +- a correction message may be recorded in one session but executed in another; +- transport switching becomes coupled to an unreliable interpretation of user + prose; and +- the existing automation prompt is treated as an incomplete chat path even + though it is already a supported LoopX execution driver. + +The immediate need is therefore not a universal runtime launcher. It is a safe, +explicit way to attach the frontend to work that is already running. + +## Decision + +The first frontend execution mode is `attached_app_session`. + +Its defining properties are: + +- **External session ownership.** Codex App or app-server created the session. + LoopX does not replace its process or opaque upstream thread. +- **Explicit attachment.** The operator chooses a known local session. LoopX + does not infer attachment from free-form text. +- **One interaction transport.** Questions, corrections, and work instructions + continue through the attached app-server session. +- **Existing LoopX driver.** An automation prompt, visible host loop, or the + equivalent host-specific interaction contract drives work in that session. +- **LoopX task truth.** Goal, Todo, gate, claim, quota, evidence, and terminal + state remain authoritative in LoopX. Chat prose and transcripts are not task + write receipts. +- **Projection, not duplication.** The frontend projects LoopX state and + session capabilities without storing a second task lifecycle. +- **No silent fallback.** If the attached session becomes unavailable, the + frontend reports it as disconnected or stale. It does not silently launch a + managed CLI Turn. + +## Product Flow + +### Discover + +A host-local broker lists attachable sessions as bounded descriptors. A public +descriptor may include: + +- a public-safe session reference; +- host kind and lifecycle state; +- Goal and Agent binding when known; +- workspace identity as an opaque or redacted reference; +- whether message streaming, resume, and interrupt are available; and +- freshness and last-activity timestamps. + +The browser must not receive raw app-server process handles, credentials, +environment variables, local absolute paths, or an unrestricted transcript. + +### Attach + +The operator explicitly selects one descriptor. The broker verifies that the +session is still live and that its Goal, Agent, workspace, and trust boundary +match the requested frontend context. A successful attachment creates a +frontend binding; it does not create a new Agent session. + +### Interact + +All user messages continue through app-server. The frontend does not maintain +an ordinary-chat-versus-material-chat classifier. The Agent and its installed +LoopX interaction contract decide which canonical LoopX commands or typed +actions are needed. + +Read-only answers may leave LoopX task state unchanged. Material progress is +accepted only after the existing driver produces the required validation, +state writeback, and quota receipt. The fact that a message contains words such +as "start", "continue", or "fix" is never execution authority. + +### Project + +The frontend joins two read models: + +1. session state from the attached host; and +2. Goal/Todo/status state from LoopX. + +The session supplies conversation and transport liveness. LoopX supplies the +authoritative work frontier and accepted progress. A frontend row may link the +two through public-safe ids, but neither source is copied into the other as a +new canonical lifecycle. + +### Detach + +Detaching removes the frontend binding only. It does not terminate the Agent +session, delete its automation, complete a Todo, spend quota, or change Goal +state. Terminating the session remains an explicit host action. + +## Execution And Accounting Boundary + +An attached session is not an unmanaged bypass. Its execution driver must still +obey the current LoopX interaction contract: + +```text +fresh LoopX state + -> quota / gate / selected Todo decision + -> existing App Session executes one bounded segment + -> validation and evidence + -> canonical state writeback + -> quota spend after validated writeback + -> next interaction contract +``` + +This lifecycle is product-equivalent to a managed Turn in the properties the +frontend needs to display: selected work, running/waiting/gated status, +validated progress, accounting, and a next action. It is not implementation- +equivalent to `turn run-once`, and it does not need to share that command's +process ownership or journal representation. + +The frontend should consume a bounded projection of these common properties. +This RFC does not introduce a generic executor registry or require both driver +families to share one executor. + +## State And Identity Boundaries + +Three identities must remain distinct: + +| Identity | Owner | Purpose | +|---|---|---| +| App session/thread | Host-local Codex App adapter | Conversation, streaming, interrupt, resume | +| Goal/Agent/Todo | LoopX control plane | Work selection, authority, gates, accounting, termination | +| Frontend attachment | LoopX frontend broker | Link one visible surface to one live host session | + +The attachment may reference the other identities but cannot grant new +capabilities or task authority. A stale or mismatched Goal, Agent, workspace, +or trust binding fails closed. + +## Safety And Privacy + +- Keep opaque upstream session handles and process metadata in owner-local + storage. +- Do not commit or emit credentials, environment values, local paths, raw + transcripts, provider payloads, or host logs. +- Require a loopback or otherwise authenticated broker boundary for session + discovery and attachment. +- Recheck session liveness and binding freshness before each control action. +- Treat attachment as observation and routing authority, not permission to + bypass the session sandbox or LoopX gates. +- Preserve the original session's approval, sandbox, workspace, and capability + policy. +- Fail closed when the host cannot prove that the selected descriptor still + names the same live session. + +## Non-Goals + +- Launching a new Codex CLI or Claude CLI process from the frontend. +- Implementing or changing `turn run-once`. +- Routing an attached app-server session through a hidden managed Turn. +- Building a universal host-adapter abstraction before a second product mode + has an accepted implementation slice. +- Inferring material authority from natural-language messages. +- Making the session transcript, frontend database, or app-server state the + source of truth for Goal/Todo lifecycle. +- Copying private deployment or collaboration context into public fixtures or + documentation. + +## Smallest Useful Implementation Slice + +After this RFC is accepted, the first implementation should be limited to: + +1. one host-local Codex app-server session descriptor source; +2. explicit attach and detach actions; +3. reuse of the existing app-server message, stream, resume, and interrupt + transport; +4. a bounded LoopX Goal/status projection beside the session; +5. liveness and binding-freshness checks; and +6. no managed Turn launch path. + +The implementation should reuse current Chat Session and status projections +where their ownership matches. It should remove or defer code whose only +purpose is to detach app-server and launch `turn run-once` for the same user +interaction. + +## Validation Criteria + +The first implementation is acceptable only when a focused test or smoke proves +all of the following: + +- attaching to a running session starts no second Agent process; +- three consecutive user messages use the same upstream App session; +- interrupt and resume preserve the attached session identity; +- an automation-prompt-driven bounded work segment updates LoopX state and is + visible in the frontend without a managed Turn launch; +- a read-only exchange does not create a task transition or quota spend; +- detaching leaves the underlying session and Goal unchanged; +- stale or mismatched descriptors fail closed; and +- public packets and committed fixtures contain no opaque handles, credentials, + raw transcripts, or local paths. + +## Future Compatibility: Managed Turn Mode + +A later RFC or accepted extension of this RFC may add `managed_turn`, in which +LoopX launches and owns a Codex CLI, Claude CLI, or another host adapter. That +mode may reuse frontend concepts such as status, selected Todo, interruption, +resume, validation, and receipts. + +Compatibility does not require the attached mode to adopt managed Turn process +ownership. The two modes may share public projection algebra while retaining +separate executors and lifecycle contracts. + +## Open Questions + +1. Which existing host-local registry should own attachable session + descriptors? +2. What is the minimum public-safe session reference that supports reconnect + without exposing an upstream thread id? +3. Which app-server events prove liveness, interruption, and terminal state? +4. Should the frontend attach to only sessions already bound to a LoopX Goal, + or offer an explicit Goal-binding preview for an unbound session? +5. Which existing status projection is the narrowest stable input for the + attached-session view? From d7f512da9213dc72dfce03e523b8301ce5b743ee Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 20 Aug 2026 00:48:50 +0800 Subject: [PATCH 5/6] revert: keep Goal Chat proposal RFC-only Signed-off-by: huangruiteng --- apps/presentation/dashboard/README.md | 42 -- examples/loopx-chat-actions-smoke.py | 26 +- loopx/__main__.py | 6 - loopx/chat_actions.py | 61 ++- loopx/chat_monitor_actions.py | 11 +- loopx/chat_runtime.py | 177 -------- loopx/chat_server.py | 14 +- loopx/chat_turn_admission.py | 251 ------------ loopx/cli_commands/status.py | 95 ++--- loopx/cli_commands/status_registration.py | 14 - loopx/cli_commands/support_control.py | 19 - loopx/cli_commands/turn.py | 44 +- .../goals/collaboration_status.py | 381 ------------------ loopx/control_plane/turn_driver/__init__.py | 2 - loopx/control_plane/turn_driver/codex_cli.py | 29 -- loopx/control_plane/turn_driver/executor.py | 2 - .../workspace_progress_validator.py | 106 ----- loopx/dashboard_launcher.py | 2 - loopx/extensions/lark/goal_topic_runtime.py | 10 +- .../renderers/collaboration_status.py | 96 ----- .../test_collaboration_status_projection.py | 177 -------- .../test_lark_goal_topic_runtime.py | 3 +- tests/test_chat_turn_admission.py | 230 ----------- tests/test_dashboard_command.py | 22 - tests/test_loopx_turn_codex_cli.py | 26 -- 25 files changed, 73 insertions(+), 1773 deletions(-) delete mode 100644 loopx/__main__.py delete mode 100644 loopx/chat_turn_admission.py delete mode 100644 loopx/control_plane/goals/collaboration_status.py delete mode 100644 loopx/control_plane/turn_driver/workspace_progress_validator.py delete mode 100644 loopx/presentation/renderers/collaboration_status.py delete mode 100644 tests/control_plane/test_collaboration_status_projection.py delete mode 100644 tests/test_chat_turn_admission.py diff --git a/apps/presentation/dashboard/README.md b/apps/presentation/dashboard/README.md index 9ab87b30a..3d1bfa886 100644 --- a/apps/presentation/dashboard/README.md +++ b/apps/presentation/dashboard/README.md @@ -238,48 +238,6 @@ loopx serve-status --port 8765 --enable-reward-write-api The write flag is loopback-only. Without it, the dashboard can validate a reward draft but cannot append feedback. -## Working-Agent Goal Chat - -`loopx chat` keeps one canonical Codex conversation per Goal and resumes that -same upstream thread across ordinary replies and governed execution: - -```bash -loopx chat \ - --goal-id \ - --global-registry \ - --available-capability network -``` - -Ordinary Goal and bound Goal Topic messages remain read-only Chat turns. They -do not spend a governed Turn quota slot. A user-confirmed typed action that -starts or corrects a Todo enters `loopx turn run-once` with a fresh exact-Todo -guard, releases the app-server transport, resumes the same Codex thread through -the Codex CLI, validates workspace progress independently, writes the bounded -result back, and settles quota once. The next read-only message resumes that -same thread again. - -`--available-capability` is repeatable. It declares what the current host can -provide to the fresh Turn guard; it does not grant credentials or bypass Todo, -repository, validation, writeback, or quota policy. Governed execution fails -closed unless the Goal session is a resumable Codex session. Other Agent -endpoints remain available for read-only Chat until they implement an -equivalent governed resume adapter. - -For an authorized peer channel or bot response, render a bounded, read-only -Goal snapshot directly from LoopX state: - -```bash -loopx status \ - --goal-id \ - --collaboration \ - --collaboration-max-age-seconds 300 -``` - -The `loopx_collaboration_status_v0` projection preserves exact open Todo counts -and bounded owner/repository context while omitting credential-like material, -provider payloads, and local paths. It fails closed on stale snapshots, -conflicting counts, or a writable truth contract. - ## Load Static Status Use a local static export: diff --git a/examples/loopx-chat-actions-smoke.py b/examples/loopx-chat-actions-smoke.py index 6c3484fb3..30f2eeb16 100644 --- a/examples/loopx-chat-actions-smoke.py +++ b/examples/loopx-chat-actions-smoke.py @@ -121,10 +121,6 @@ def submit_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: self.submissions.append(dict(kwargs)) return ({"turn_id": "turn-correction-1", "status": "queued"}, True) - def submit_governed_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: - self.submissions.append({**dict(kwargs), "governed": True}) - return ({"turn_id": "turn-governed-1", "status": "queued"}, True) - def capabilities(self) -> list[dict[str, object]]: return [ { @@ -158,9 +154,6 @@ class FailingRuntimeController(FakeRuntimeController): def submit_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: raise RuntimeError("temporary runtime failure") - def submit_governed_turn(self, **kwargs: object) -> tuple[dict[str, object], bool]: - raise RuntimeError("temporary governed runtime failure") - def write_registry_fixture(root: Path) -> tuple[Path, Path]: project = root / "project" @@ -351,9 +344,7 @@ def assert_http_action_api(root: Path) -> None: assert todo_resources["session_id"], todo_resources assert todo_resources["turn_id"], todo_resources assert applied["turn"]["session_id"] == todo_resources["session_id"], applied - assert runtime_controller.opened_sessions[-1]["channel_id"] == "goal.goal-one" - assert runtime_controller.submissions[-1]["governed"] is True - assert runtime_controller.submissions[-1]["todo_id"] == todo_resources["todo_id"] + assert runtime_controller.opened_sessions[-1]["channel_id"] == f"task.{todo_resources['todo_id']}" assert state_path.read_text(encoding="utf-8").count("Verify the typed action API") == 1 assert "claimed_by=codex" in state_path.read_text(encoding="utf-8") code, repeated = request_json( @@ -373,10 +364,8 @@ def assert_http_action_api(root: Path) -> None: ) assert code == 202, correction_applied assert correction_applied["proposal"]["status"] == "applied", correction_applied - assert correction_applied["turn"]["turn_id"] == "turn-governed-1", correction_applied + assert correction_applied["turn"]["turn_id"] == "turn-correction-1", correction_applied assert runtime_controller.submissions[1]["session_id"] == session["session_id"] - assert runtime_controller.submissions[1]["governed"] is True - assert runtime_controller.submissions[1]["todo_id"] == todo_resources["todo_id"] goal_proposal = previews["goal.create"] code, goal_applied = request_json( @@ -799,10 +788,7 @@ def assert_http_action_api(root: Path) -> None: body={}, ) assert code == 202, run_applied - assert ( - run_applied["proposal"]["receipt"]["outcome"] - == "governed_monitor_turn_created" - ), run_applied + assert run_applied["proposal"]["receipt"]["outcome"] == "monitor_turn_created", run_applied assert runtime_controller.submissions[-1]["session_id"] == session["session_id"] code, fresh_run_preview = request_json( @@ -831,10 +817,8 @@ def assert_http_action_api(root: Path) -> None: assert code == 202, fresh_run_applied fresh_resources = fresh_run_applied["proposal"]["receipt"]["resource_ids"] assert fresh_resources["session_id"], fresh_run_applied - assert runtime_controller.opened_sessions[-1]["channel_id"] == "goal.goal-one" + assert runtime_controller.opened_sessions[-1]["channel_id"] == f"task.{monitor_todo_id}" assert runtime_controller.submissions[-1]["session_id"] == fresh_resources["session_id"] - assert runtime_controller.submissions[-1]["governed"] is True - assert runtime_controller.submissions[-1]["todo_id"] == monitor_todo_id code, stop_preview = request_json( f"{base_url}/api/actions/preview", @@ -954,7 +938,7 @@ def assert_http_action_api(root: Path) -> None: persisted_payload = action_store.path.read_text(encoding="utf-8") assert str(root) not in persisted_payload, persisted_payload - assert str(registry_path.parent.parent) not in persisted_payload, persisted_payload + assert str(project := registry_path.parent.parent) not in persisted_payload, persisted_payload cancellable_code, cancellable = request_json( f"{base_url}/api/actions/preview", diff --git a/loopx/__main__.py b/loopx/__main__.py deleted file mode 100644 index ce409d011..000000000 --- a/loopx/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Run the LoopX CLI from the currently imported package.""" - -from .cli import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 471b23300..ba50bdeed 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -14,11 +14,6 @@ from .chat_action_store import ActionConflictError, ChatActionStore from .chat_monitor_actions import ChatMonitorActionMixin from .chat_store import ChatSessionStore -from .chat_turn_admission import ( - governed_execution_gate, - governed_session_lineage, - submit_governed_goal_turn, -) from .configure_goal import configure_goal from .control_plane.runtime.time import now_utc, parse_timestamp, utc_isoformat from .control_plane.scheduler.monitor_todo import monitor_next_due_at @@ -970,23 +965,28 @@ def _apply_goal_create( )[:600], "quota_state": str(guard.get("state") or "waiting"), } - if agent_id and self.runtime_controller is not None and first_turn_gate is None and todo_ids: - session, first_turn, created = submit_governed_goal_turn( - self.runtime_controller, + if agent_id and self.runtime_controller is not None and first_turn_gate is None: + session, _resumed = self.runtime_controller.open_session( goal_id=goal_id, - endpoint_id=agent_id, - governed_agent_id=agent_id, - todo_id=todo_ids[0], + agent_id=agent_id, work_dir=project, objective=objective, + mode="resume_latest", + channel_id=f"goal.{goal_id}", + agent_goal_id=goal_id, + ) + session_id = _opaque(session.get("session_id"), field="session_id") + first_turn, created = self.runtime_controller.submit_turn( + session_id=session_id, client_turn_id=f"goal-start-{proposal_id}", message=( f"开始推进 Goal {goal_id}。先核对目标边界和现有 Todo," f"首个 Todo:{';'.join(str(item) for item in (parameters.get('initial_todos') or [])[:3]) or '按目标边界建立首个可验证进展'}。" "然后直接推进并报告可验证结果;遇到权限边界时停止并提出明确 Gate。" ), + work_dir=project, + objective=objective, ) - session_id = _opaque(session.get("session_id"), field="session_id") turn_result = { "turn_id": _opaque(first_turn.get("turn_id"), field="turn_id"), "status": str(first_turn.get("status") or "queued"), @@ -1001,12 +1001,6 @@ def _apply_goal_create( "turn_id": turn_result["turn_id"], }, ) - elif agent_id and first_turn_gate is None and not todo_ids: - first_turn_gate = governed_execution_gate("todo_required") - self.store.save_checkpoint( - proposal_id, step="first_turn_gated", - receipt={"outcome": "first_turn_gated", "gate": first_turn_gate}, - ) elif first_turn_gate is not None: self.store.save_checkpoint( proposal_id, @@ -1483,18 +1477,23 @@ def apply(self, proposal_id: str) -> dict[str, Any]: if not project.is_dir(): raise ValueError("the Goal project root is unavailable") self._agent_eligibility(execution_agent_id, project=project) - session, turn, created = submit_governed_goal_turn( - self.runtime_controller, + session, _resumed = self.runtime_controller.open_session( goal_id=str(parameters["goal_id"]), - endpoint_id=execution_agent_id, - governed_agent_id=str(parameters["agent_id"]), - todo_id=todo_id, + agent_id=execution_agent_id, work_dir=project, objective=str(parameters["text"]), + mode="resume_latest", + channel_id=f"task.{todo_id}", + agent_goal_id=str(parameters["goal_id"]), + ) + session_id = _opaque(session.get("session_id"), field="session_id") + turn, created = self.runtime_controller.submit_turn( + session_id=session_id, client_turn_id=f"task-start-{proposal_id}", message=str(parameters["text"]), + work_dir=project, + objective=str(parameters["text"]), ) - session_id = _opaque(session.get("session_id"), field="session_id") turn_id = _opaque(turn.get("turn_id"), field="turn_id") self.store.save_checkpoint( proposal_id, @@ -1504,7 +1503,7 @@ def apply(self, proposal_id: str) -> dict[str, Any]: receipt["resource_ids"].update( {"session_id": session_id, "turn_id": turn_id} ) - receipt["outcome"] = "governed_task_execution_started" + receipt["outcome"] = "task_execution_started" turn_result = { "session_id": session_id, "turn_id": turn_id, @@ -1535,18 +1534,12 @@ def apply(self, proposal_id: str) -> dict[str, Any]: if not project.is_dir(): raise ValueError("the Goal project root is unavailable") client_turn_id = str(parameters.get("client_turn_id") or f"action-{proposal_id}") - lineage = governed_session_lineage(self.store, session_id=str(parameters["session_id"]), goal_id=str(parameters["goal_id"])) - if lineage is None: - raise ProtectedActionGate( - "run.correct", gate=governed_execution_gate("governed_turn_required") - ) - turn, created = self.runtime_controller.submit_governed_turn( + turn, created = self.runtime_controller.submit_turn( session_id=str(parameters["session_id"]), client_turn_id=client_turn_id, message=str(parameters["message"]), - todo_id=lineage["todo_id"], - governed_agent_id=lineage["agent_id"], work_dir=project, + objective=str(goal.get("domain") or parameters["goal_id"]), ) turn_id = _opaque(turn.get("turn_id"), field="turn_id") receipt = { @@ -1583,5 +1576,5 @@ def _turn_from_receipt(receipt: Any) -> dict[str, Any] | None: "session_id": str(resource_ids["session_id"]) if resource_ids.get("session_id") else None, "turn_id": str(resource_ids["turn_id"]), "status": "accepted", - "created": receipt.get("outcome") in {"turn_created", "task_execution_started", "governed_task_execution_started"}, + "created": receipt.get("outcome") in {"turn_created", "task_execution_started"}, } diff --git a/loopx/chat_monitor_actions.py b/loopx/chat_monitor_actions.py index 5bb120273..3d45ac372 100644 --- a/loopx/chat_monitor_actions.py +++ b/loopx/chat_monitor_actions.py @@ -65,20 +65,19 @@ def _apply_monitor_update( or f"Run monitor {parameters['todo_id']}" ), mode="resume_latest", - channel_id=f"goal.{goal_id}", + channel_id=f"task.{parameters['todo_id']}", agent_goal_id=goal_id, ) session_id = _opaque(session.get("session_id"), field="session_id") - turn, created = self.runtime_controller.submit_governed_turn( + turn, created = self.runtime_controller.submit_turn( session_id=session_id, client_turn_id=f"action-{proposal_id}", message=( f"Run continuous monitor Todo {parameters['todo_id']} now and " "write back only verified material change." ), - todo_id=str(parameters["todo_id"]), - governed_agent_id=str(parameters["agent_id"]), work_dir=project, + objective=str(goal.get("domain") or goal_id), ) turn_id = _opaque(turn.get("turn_id"), field="turn_id") receipt = { @@ -86,9 +85,9 @@ def _apply_monitor_update( {"proposal_id": proposal_id, "turn_id": turn_id} )[:32], "outcome": ( - "governed_monitor_turn_created" + "monitor_turn_created" if created - else "governed_monitor_turn_already_exists" + else "monitor_turn_already_exists" ), "projection_verified": True, "resource_ids": { diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 5d79e66da..23018fc20 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -32,19 +32,6 @@ def close_session(self) -> None: ... def healthcheck(self) -> bool: ... -class GovernedTurnRunner(Protocol): - def __call__( - self, - *, - goal_id: str, - agent_id: str, - todo_id: str, - upstream_thread_id: str, - work_dir: Path, - turn_instance_id: str, - ) -> dict[str, Any]: ... - - @dataclass class CodexAppServerAdapter: session: CodexChatAgentSession @@ -213,7 +200,6 @@ def __init__( idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, endpoint_registry: AgentEndpointRegistry | None = None, - governed_turn_runner: GovernedTurnRunner | None = None, ) -> None: self.store = store self.codex_bin = codex_bin @@ -222,7 +208,6 @@ def __init__( self.idle_timeout_sec = idle_timeout_sec self.hard_timeout_sec = hard_timeout_sec self.endpoint_registry = endpoint_registry or AgentEndpointRegistry(store.root) - self.governed_turn_runner = governed_turn_runner self.adapters: dict[str, ChatRuntimeAdapter] = {} self.cancelled_turns: set[tuple[str, str]] = set() self.turn_event_buffers: dict[tuple[str, str], _TurnEventBuffer] = {} @@ -540,168 +525,6 @@ def submit_turn( worker.start() return turn, True - def submit_governed_turn( - self, - *, - session_id: str, - client_turn_id: str, - message: str, - todo_id: str, - governed_agent_id: str, - work_dir: Path, - ) -> tuple[dict[str, Any], bool]: - """Queue one material Turn against the canonical long-lived Chat session.""" - - if self.governed_turn_runner is None: - raise ValueError("governed LoopX Turn admission is unavailable") - if not todo_id.strip() or not governed_agent_id.strip(): - raise ValueError("governed Chat execution requires exact Todo and Agent identity") - session = self.store.load_session(session_id) - if session is None: - raise KeyError("chat session was not found") - goal_id = str(session.get("goal_id") or "") - if str(session.get("channel_id") or "") != f"goal.{goal_id}": - raise ValueError("material Chat execution requires the canonical Goal session") - if session.get("agent_id") != "codex" or session.get("upstream_mode") != "chat": - raise ValueError( - "governed Chat execution requires a resumable Codex Goal session" - ) - turn, created = self.store.create_turn( - session_id, - client_turn_id=client_turn_id, - message=message, - ) - if not created: - return turn, False - worker = threading.Thread( - target=self._run_governed_turn, - kwargs={ - "session_id": session_id, - "turn_id": str(turn["turn_id"]), - "todo_id": todo_id, - "governed_agent_id": governed_agent_id, - "work_dir": work_dir, - }, - daemon=True, - ) - with self.lock: - self.turn_done_events[(session_id, str(turn["turn_id"]))] = threading.Event() - worker.start() - return turn, True - - def _detach_adapter(self, session_id: str) -> None: - """Release app-server transport while retaining its resumable thread id.""" - - with self.lock: - adapter = self.adapters.pop(session_id, None) - if adapter is not None: - adapter.close_session() - - def _run_governed_turn( - self, - *, - session_id: str, - turn_id: str, - todo_id: str, - governed_agent_id: str, - work_dir: Path, - ) -> None: - started = utc_now() - self.store.update_turn(session_id, turn_id, status="starting", started_at=started) - self.store.append_event( - session_id, - turn_id, - kind="governance.admission_started", - payload={"todo_id": todo_id}, - ) - try: - session = self.store.load_session(session_id) - if session is None: - raise KeyError("chat session was not found") - runner = self.governed_turn_runner - if runner is None: - raise ValueError("governed LoopX Turn admission is unavailable") - self._detach_adapter(session_id) - self.store.update_turn(session_id, turn_id, status="running") - payload = runner( - goal_id=str(session["goal_id"]), - agent_id=governed_agent_id, - todo_id=todo_id, - upstream_thread_id=str(session["upstream_thread_id"]), - work_dir=work_dir, - turn_instance_id=f"chat-{turn_id}", - ) - summary = str(payload.get("summary") or "本次 LoopX Turn 已完成受治理执行。") - next_action = str(payload.get("next_action") or "").strip() - message = summary + (f"\n\n下一步:{next_action}" if next_action else "") - governance = { - "schema_version": "loopx_chat_turn_governance_v0", - "mode": "governed", - "todo_id": todo_id, - "agent_id": governed_agent_id, - "turn_key": payload.get("resume_turn_key"), - "journal_ref": payload.get("journal_ref"), - "status": payload.get("status"), - "result_kind": payload.get("result_kind"), - "validation": payload.get("validation"), - "effects": payload.get("effects"), - "quota_slot_spend_count": payload.get("quota_slot_spend_count"), - } - response = { - "schema_version": "loopx_chat_agent_response_v1", - "message": message, - "proposals": [], - "gate": None, - "governance": governance, - } - self.store.append_message( - session_id, - role="agent", - text=message, - turn_id=turn_id, - ) - completed = utc_now() - self.store.update_turn( - session_id, - turn_id, - status="completed", - response=response, - completed_at=completed, - last_activity_at=completed, - ) - self.store.append_event( - session_id, - turn_id, - kind="governance.settled", - payload={"governance": governance}, - ) - self.store.append_event( - session_id, - turn_id, - kind="turn.completed", - payload={"response": response}, - ) - self.store.update_session( - session_id, - status="ready", - active_turn_id=None, - last_activity_at=completed, - last_error_code=None, - ) - except Exception as exc: # noqa: BLE001 - governed runner is a typed boundary. - self._fail_turn( - session_id, - turn_id, - "governed_turn_failed", - str(exc), - status="failed", - ) - finally: - with self.lock: - done_event = self.turn_done_events.pop((session_id, turn_id), None) - if done_event is not None: - done_event.set() - def _run_turn( self, *, diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 038652f5c..144729b54 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -23,7 +23,6 @@ from .chat_action_store import ACTION_KINDS, ActionConflictError, ChatActionStore from .chat_runtime import ChatRuntimeController, TERMINAL_TURN_STATES from .chat_store import ChatSessionStore -from .chat_turn_admission import LoopXChatTurnAdmission from .chat_lark_api import ( LarkChatRequestMixin, build_goal_repository_contexts as build_goal_repository_contexts, @@ -1374,7 +1373,6 @@ def serve_chat( startup_timeout_sec: float = 30.0, idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, - available_capabilities: list[str] | None = None, assets_dir: Path | None = None, open_browser: bool = False, verbose: bool = False, @@ -1420,13 +1418,6 @@ def serve_chat( ) server.chat_store = ChatSessionStore(runtime_root) server.action_store = ChatActionStore(runtime_root / "chat" / "actions") - governed_turn_runner = LoopXChatTurnAdmission( - registry_path=resolved_registry_path, - runtime_root_override=resolved_runtime_root_override, - codex_bin=codex_bin, - timeout_seconds=hard_timeout_sec, - available_capabilities=available_capabilities or (), - ) server.runtime_controller = ChatRuntimeController( store=server.chat_store, codex_bin=codex_bin, @@ -1434,14 +1425,13 @@ def serve_chat( startup_timeout_sec=startup_timeout_sec, idle_timeout_sec=idle_timeout_sec, hard_timeout_sec=hard_timeout_sec, - governed_turn_runner=governed_turn_runner, ) server.action_service = ChatActionService( store=server.action_store, - registry_path=resolved_registry_path, + registry_path=registry_path, chat_store=server.chat_store, runtime_controller=server.runtime_controller, - workspace_roots=resolved_scan_roots, + workspace_roots=scan_roots, ) server.lark_goal_topic_runtime = LarkGoalTopicRuntimeService( snapshot_provider=lambda: build_lark_goal_topic_runtime_snapshot( diff --git a/loopx/chat_turn_admission.py b/loopx/chat_turn_admission.py deleted file mode 100644 index cb68378f8..000000000 --- a/loopx/chat_turn_admission.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Bridge a material LoopX Chat action into the canonical governed Turn CLI.""" - -from __future__ import annotations - -import json -import shutil -import subprocess -import sys -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import Any - -from .control_plane.turn_driver.workspace_progress_validator import ( - workspace_progress_digest, -) - - -class ChatTurnAdmissionError(RuntimeError): - """A material Chat turn failed before a committed governed receipt.""" - - def __init__(self, message: str, *, payload: dict[str, Any] | None = None) -> None: - super().__init__(message) - self.payload = dict(payload or {}) - - -def governed_session_lineage( - action_store: Any, - *, - session_id: str, - goal_id: str, -) -> dict[str, str] | None: - """Resolve the latest applied material Todo lineage for one Goal Session.""" - - for proposal in action_store.list(goal_id=goal_id, status="applied"): - receipt = proposal.get("receipt") - receipt = receipt if isinstance(receipt, Mapping) else {} - resources = receipt.get("resource_ids") - resources = resources if isinstance(resources, Mapping) else {} - if str(resources.get("session_id") or "") != session_id: - continue - todo_id = str(resources.get("todo_id") or "") - if not todo_id: - todo_ids = resources.get("todo_ids") - if isinstance(todo_ids, list) and todo_ids: - todo_id = str(todo_ids[0] or "") - agent_id = str(resources.get("agent_id") or "") - if todo_id and agent_id: - return {"todo_id": todo_id, "agent_id": agent_id} - return None - - -def governed_execution_gate(kind: str) -> dict[str, str]: - gates = { - "todo_required": { - "kind": "todo_required", - "summary": "A governed first Agent Turn requires one explicit initial Todo.", - "next_action": "Create and assign one bounded Todo, then start execution from the Goal Chat.", - }, - "governed_turn_required": { - "kind": "governed_turn_required", - "summary": "Run correction requires a prior governed Todo on this Goal Session.", - "next_action": "Start one explicitly assigned Todo, then retry the correction on the same Session.", - }, - } - try: - return dict(gates[kind]) - except KeyError as exc: - raise ValueError("unsupported governed execution gate") from exc - - -def submit_governed_goal_turn( - runtime_controller: Any, - *, - goal_id: str, - endpoint_id: str, - governed_agent_id: str, - todo_id: str, - work_dir: Path, - objective: str, - client_turn_id: str, - message: str, -) -> tuple[dict[str, Any], dict[str, Any], bool]: - """Open the canonical Goal Session and queue one governed material Turn.""" - - session, _resumed = runtime_controller.open_session( - goal_id=goal_id, - agent_id=endpoint_id, - work_dir=work_dir, - objective=objective, - mode="resume_latest", - channel_id=f"goal.{goal_id}", - agent_goal_id=goal_id, - ) - turn, created = runtime_controller.submit_governed_turn( - session_id=str(session["session_id"]), - client_turn_id=client_turn_id, - message=message, - todo_id=todo_id, - governed_agent_id=governed_agent_id, - work_dir=work_dir, - ) - return session, turn, created - - -class LoopXChatTurnAdmission: - """Run one Chat-requested Todo through fresh admission and settlement.""" - - def __init__( - self, - *, - registry_path: Path, - runtime_root_override: str | Path | None = None, - loopx_bin: str | None = None, - codex_bin: str = "codex", - timeout_seconds: float = 900.0, - available_capabilities: Sequence[str] = (), - ) -> None: - self.registry_path = registry_path.expanduser().resolve() - self.runtime_root_override = ( - str(Path(runtime_root_override).expanduser().resolve()) - if runtime_root_override is not None - else None - ) - self.loopx_bin = loopx_bin - self.codex_bin = codex_bin - self.timeout_seconds = max(30.0, timeout_seconds) - self.available_capabilities = tuple( - sorted( - { - str(item).strip() - for item in available_capabilities - if str(item).strip() - } - ) - ) - - def _command_prefix(self) -> list[str]: - if self.loopx_bin is None: - return [sys.executable, "-m", "loopx"] - resolved = ( - self.loopx_bin - if "/" in self.loopx_bin and Path(self.loopx_bin).is_file() - else shutil.which(self.loopx_bin) - ) - if not resolved: - raise ChatTurnAdmissionError( - "LoopX CLI is unavailable for governed Chat execution" - ) - return [str(resolved)] - - def __call__( - self, - *, - goal_id: str, - agent_id: str, - todo_id: str, - upstream_thread_id: str, - work_dir: Path, - turn_instance_id: str, - ) -> dict[str, Any]: - project = work_dir.expanduser().resolve() - baseline_hash = workspace_progress_digest(project) - validator_argv = [ - sys.executable, - "-m", - "loopx.control_plane.turn_driver.workspace_progress_validator", - "--baseline-hash", - baseline_hash, - ] - command = [ - *self._command_prefix(), - "--format", - "json", - "--registry", - str(self.registry_path), - ] - if self.runtime_root_override: - command.extend(["--runtime-root", self.runtime_root_override]) - command.extend( - [ - "turn", - "run-once", - "--goal-id", - goal_id, - "--agent-id", - agent_id, - "--host", - "codex-cli", - "--execution-mode", - "isolated-headless", - "--scheduler-owner", - "agent_cli_loop", - "--expected-todo-id", - todo_id, - "--turn-instance-id", - turn_instance_id, - "--project", - str(project), - "--codex-bin", - self.codex_bin, - "--codex-sandbox", - "workspace-write", - "--codex-resume-session-id", - upstream_thread_id, - "--validation-command-json", - json.dumps(validator_argv, separators=(",", ":")), - "--validation-failure-kind", - "repair_required", - "--scan-root", - str(project), - "--timeout-seconds", - str(self.timeout_seconds), - "--execute", - ] - ) - for capability in self.available_capabilities: - command.extend(["--available-capability", capability]) - try: - completed = subprocess.run( - command, - cwd=project, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - timeout=self.timeout_seconds + 30.0, - check=False, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - raise ChatTurnAdmissionError( - "governed LoopX Chat execution could not complete" - ) from exc - try: - payload = json.loads(completed.stdout) - except json.JSONDecodeError as exc: - raise ChatTurnAdmissionError( - "governed LoopX Chat execution returned no typed receipt" - ) from exc - if not isinstance(payload, dict): - raise ChatTurnAdmissionError( - "governed LoopX Chat execution returned an invalid receipt" - ) - if completed.returncode != 0 or payload.get("ok") is not True: - raise ChatTurnAdmissionError( - str( - payload.get("reason") - or payload.get("error") - or "governed LoopX Chat execution failed" - ), - payload=payload, - ) - return payload diff --git a/loopx/cli_commands/status.py b/loopx/cli_commands/status.py index d57d7a018..cd18f75b8 100644 --- a/loopx/cli_commands/status.py +++ b/loopx/cli_commands/status.py @@ -6,7 +6,6 @@ from typing import Any from ..contract import check_contract, render_contract_markdown -from ..control_plane.goals.collaboration_status import build_collaboration_status from ..control_plane.runtime.status_projection_cache import ( load_status_projection_cache, resolve_status_projection_cache_runtime_root, @@ -24,17 +23,11 @@ ) from ..diagnose import collect_diagnosis, render_diagnosis_markdown from ..handoff_budget import build_handoff_interface_budget -from ..presentation.renderers.collaboration_status import ( - render_collaboration_status_markdown, -) from ..presentation.renderers.status_markdown import render_status_markdown from ..quota import build_quota_should_run from ..review_packet import build_review_packet, render_review_packet_markdown from ..status import AUTONOMOUS_REPLAN_PERIODIC_LOOKBACK, collect_status -from .status_registration import ( # noqa: F401 - re-exported by cli_commands. - default_public_scan_root, - register_status_commands, -) +from .status_registration import default_public_scan_root, register_status_commands PrintPayload = Callable[ [dict[str, object], str, Callable[[dict[str, object]], str]], @@ -177,18 +170,6 @@ def handle_status_command( output_format: FormatSelector, print_payload: PrintPayload, ) -> int: - if args.collaboration and not str(args.goal_id or "").strip(): - payload = build_collaboration_status( - {"ok": False, "attention_queue": {"items": []}}, - goal_id="", - max_age_seconds=args.collaboration_max_age_seconds, - ) - print_payload( - payload, - output_format(args), - render_collaboration_status_markdown, - ) - return 1 try: scan_roots = _scan_roots(args) display_limit = max(0, args.limit) @@ -251,57 +232,31 @@ def handle_status_command( agent_id=args.agent_id, ) compact_agent_lane_todo_index_for_status_display(payload) - if args.collaboration: - cache = payload.get("projection_cache") - cache = cache if isinstance(cache, dict) else {} - snapshot_generated_at = ( - str(cache.get("generated_at")) - if cache.get("hit") is True and cache.get("generated_at") - else None - ) - payload = build_collaboration_status( - payload, - goal_id=str(args.goal_id), - snapshot_generated_at=snapshot_generated_at, - max_age_seconds=args.collaboration_max_age_seconds, - ) except Exception as exc: - if args.collaboration: - payload = build_collaboration_status( - {"ok": False, "attention_queue": {"items": []}}, - goal_id=str(args.goal_id or ""), - max_age_seconds=args.collaboration_max_age_seconds, - ) - else: - payload = { - "ok": False, - "registry": str(registry_path), - "runtime_root": runtime_root_arg, - "error": str(exc), - "attention_queue": { - "available": False, - "item_count": 1, - "needs_user_or_controller": 0, - "needs_codex": 1, - "watching_external_evidence": 0, - "items": [ - { - "goal_id": "loopx-status", - "status": "status_collection_failed", - "waiting_on": "codex", - "severity": "high", - "recommended_action": str(exc), - "source": "status", - } - ], - }, - } - renderer = ( - render_collaboration_status_markdown - if args.collaboration - else render_status_markdown - ) - print_payload(payload, output_format(args), renderer) + payload = { + "ok": False, + "registry": str(registry_path), + "runtime_root": runtime_root_arg, + "error": str(exc), + "attention_queue": { + "available": False, + "item_count": 1, + "needs_user_or_controller": 0, + "needs_codex": 1, + "watching_external_evidence": 0, + "items": [ + { + "goal_id": "loopx-status", + "status": "status_collection_failed", + "waiting_on": "codex", + "severity": "high", + "recommended_action": str(exc), + "source": "status", + } + ], + }, + } + print_payload(payload, output_format(args), render_status_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/status_registration.py b/loopx/cli_commands/status_registration.py index 04ad5499a..f383188f0 100644 --- a/loopx/cli_commands/status_registration.py +++ b/loopx/cli_commands/status_registration.py @@ -112,20 +112,6 @@ def register_status_commands( default=120, help="Freshness window for --use-projection-cache. Defaults to 120 seconds.", ) - status_parser.add_argument( - "--collaboration", - action="store_true", - help=( - "Render one Goal as loopx_collaboration_status_v0 for an authorized " - "peer channel. Requires --goal-id." - ), - ) - status_parser.add_argument( - "--collaboration-max-age-seconds", - type=int, - default=300, - help="Fail closed when a collaboration snapshot is older than this window.", - ) diagnose_parser = subparsers.add_parser( "diagnose", diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index af1ab0b7d..a290b608b 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -490,15 +490,6 @@ def register_support_control_commands( default=900.0, help="Absolute maximum seconds for one Agent turn.", ) - chat_parser.add_argument( - "--available-capability", - action="append", - default=[], - help=( - "Capability available to governed material Turns. Repeat for multiple " - "capabilities; ordinary read-only Chat replies do not consume this list." - ), - ) chat_parser.add_argument( "--assets-dir", help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", @@ -566,12 +557,6 @@ def register_support_control_commands( "runtime discovery order." ), ) - dashboard_parser.add_argument( - "--available-capability", - action="append", - default=[], - help="Capability available to governed material Turns. Repeatable.", - ) dashboard_parser.add_argument( "--assets-dir", help="Optional LoopX Chat web bundle directory. Defaults to packaged assets.", @@ -1039,9 +1024,6 @@ def handle_support_control_command( codex_bin=getattr(args, "codex_bin", "codex"), claude_bin=getattr(args, "claude_bin", "claude"), lark_cli_bin=getattr(args, "lark_cli_bin", None), - available_capabilities=list( - getattr(args, "available_capability", []) or [] - ), assets_dir=Path(args.assets_dir).expanduser().resolve() if getattr(args, "assets_dir", None) else None, verbose=getattr(args, "verbose", False), open_browser=not getattr(args, "no_open", False), @@ -1073,7 +1055,6 @@ def handle_support_control_command( codex_bin=args.codex_bin, claude_bin=args.claude_bin, lark_cli_bin=args.lark_cli_bin, - available_capabilities=list(args.available_capability or []), startup_timeout_sec=max(0.1, float(args.startup_timeout_seconds)), idle_timeout_sec=max(0.1, float(args.idle_timeout_seconds)), hard_timeout_sec=max(0.1, float(args.hard_timeout_seconds)), diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 2b1315699..629f2821c 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -26,7 +26,6 @@ LOOPX_TURN_EXECUTION_SCHEMA_VERSION, LOOPX_TURN_JOURNAL_INSPECTION_SCHEMA_VERSION, LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION, - bind_codex_cli_session, build_loopx_turn_command_validator, build_loopx_turn_plan, codex_cli_session_binding, @@ -160,13 +159,6 @@ def register_turn_commands( help="Codex CLI executable used by the built-in codex-cli host.", ) run_once.add_argument("--codex-model") - run_once.add_argument( - "--codex-resume-session-id", - help=( - "Owner-local opaque Codex thread to resume for the freshly selected " - "Todo. Requires --host codex-cli and --execute." - ), - ) run_once.add_argument( "--codex-sandbox", choices=["read-only", "workspace-write"], @@ -238,12 +230,6 @@ def _add_turn_decision_arguments( "same semantic action." ), ) - parser.add_argument( - "--expected-todo-id", - help=( - "Fail closed unless the fresh quota decision selects this exact Todo." - ), - ) parser.add_argument( "--resume-goal-id", help="Goal identity bound to an available opaque host session.", @@ -445,36 +431,8 @@ def handle_turn_command( decision, scheduler_execution_context=scheduler_context, ) - selected_todo = selected_turn_todo(turn_envelope) - if args.expected_todo_id and selected_todo.get("todo_id") != args.expected_todo_id: - raise ValueError( - "fresh LoopX Turn admission selected a different Todo than expected" - ) - codex_resume_session_id = getattr(args, "codex_resume_session_id", None) - if codex_resume_session_id: - if args.turn_command != "run-once" or args.host != "codex-cli": - raise ValueError( - "--codex-resume-session-id requires turn run-once --host codex-cli" - ) - if not args.execute: - raise ValueError("--codex-resume-session-id requires --execute") - if supplied_resume_fields: - raise ValueError( - "--codex-resume-session-id cannot be combined with host session identity flags" - ) - if args.resume_turn_key: - raise ValueError( - "--codex-resume-session-id cannot be combined with --resume-turn-key" - ) - session_binding = bind_codex_cli_session( - runtime_root, - turn_envelope, - session_id=codex_resume_session_id, - ) if args.turn_command == "run-once" and args.host == "codex-cli" and not supplied_resume_fields: - session_binding = session_binding or codex_cli_session_binding( - runtime_root, turn_envelope - ) + session_binding = codex_cli_session_binding(runtime_root, turn_envelope) payload = build_loopx_turn_plan( turn_envelope, host=args.host, diff --git a/loopx/control_plane/goals/collaboration_status.py b/loopx/control_plane/goals/collaboration_status.py deleted file mode 100644 index ca39b4b28..000000000 --- a/loopx/control_plane/goals/collaboration_status.py +++ /dev/null @@ -1,381 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -from typing import Any - -from ..runtime.public_safety import public_safe_compact_text -from ..runtime.time import parse_timestamp, utc_isoformat -from .goal_channel_projection import GOAL_CHANNEL_PROJECTION_SCHEMA_VERSION - -COLLABORATION_STATUS_SCHEMA_VERSION = "loopx_collaboration_status_v0" -COLLABORATION_VISIBILITY = "internal_collaboration" -DEFAULT_MAX_AGE_SECONDS = 300 -DEFAULT_TODO_ITEM_LIMIT = 3 - -_AUTH_MATERIAL_PATTERN = re.compile( - r"(?i)(?:" - r"\bauthorization\s*[:=]|" - r"\bcookie\s*[:=]|" - r"\b(?:app|client)[_-]?secret\s*[:=]|" - r"-----BEGIN [A-Z ]*PRIVATE KEY-----" - r")" -) -_LOCAL_PATH_PATTERN = re.compile( - r"(?i)(?:^|[\s`'\"(])(?:/(?:home|users|volumes|private|tmp|var/tmp|data\d*)/|[a-z]:\\)" -) - - -def _as_mapping(value: Any) -> dict[str, Any]: - return dict(value) if isinstance(value, Mapping) else {} - - -def _as_mappings(value: Any) -> list[dict[str, Any]]: - if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): - return [] - return [dict(item) for item in value if isinstance(item, Mapping)] - - -def _collaboration_text(value: Any, *, limit: int) -> str | None: - text = public_safe_compact_text(value, limit=limit) - if ( - text is None - or _AUTH_MATERIAL_PATTERN.search(text) - or _LOCAL_PATH_PATTERN.search(text) - ): - return None - return text - - -def _first_text(*values: Any, limit: int) -> str | None: - for value in values: - text = _collaboration_text(value, limit=limit) - if text: - return text - return None - - -def _count(summary: Mapping[str, Any], *keys: str) -> int | None: - for key in keys: - value = summary.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int) and value >= 0: - return value - return None - - -def _todo_item( - item: Mapping[str, Any], - *, - role: str, - redacted_fields: list[str], -) -> dict[str, Any] | None: - title = _first_text(item.get("title"), item.get("text"), limit=260) - if not title: - redacted_fields.append(f"todos.{role}.items[].title") - return None - result: dict[str, Any] = {"title": title} - for key, limit in ( - ("todo_id", 120), - ("priority", 20), - ("status", 20), - ("claimed_by", 120), - ("bound_agent", 120), - ("task_repository", 240), - ): - value = _collaboration_text(item.get(key), limit=limit) - if value: - result[key] = value - elif item.get(key): - redacted_fields.append(f"todos.{role}.items[].{key}") - return result - - -def _summary_projection( - *, - role: str, - canonical_summary: Mapping[str, Any], - compact_summary: Mapping[str, Any], - projected_items: Sequence[Mapping[str, Any]], - item_limit: int, - blockers: list[dict[str, str]], - redacted_fields: list[str], -) -> dict[str, Any]: - open_count = _count(canonical_summary, "open_count", "open") - if open_count is None: - blockers.append( - { - "code": f"{role}_todo_summary_missing", - "message": f"{role} Todo summary has no valid open count", - } - ) - open_count = 0 - compact_open_count = _count(compact_summary, "open", "open_count") - if compact_summary and compact_open_count is None: - blockers.append( - { - "code": f"{role}_todo_compact_count_missing", - "message": f"{role} compact Todo summary has no valid open count", - } - ) - elif compact_open_count is not None and compact_open_count != open_count: - blockers.append( - { - "code": f"{role}_todo_count_conflict", - "message": f"{role} Todo projections disagree on the open count", - } - ) - if len(projected_items) > open_count: - blockers.append( - { - "code": f"{role}_todo_projection_overflow", - "message": f"{role} projected Todo rows exceed the canonical open count", - } - ) - - items: list[dict[str, Any]] = [] - for item in list(projected_items)[: max(0, item_limit)]: - compact = _todo_item(item, role=role, redacted_fields=redacted_fields) - if compact: - items.append(compact) - return { - "open_count": open_count, - "visible_count": len(items), - "truncated": open_count > len(projected_items) - or len(projected_items) > len(items), - "items": items, - } - - -def _freshness( - *, - snapshot_generated_at: Any, - now: datetime, - max_age_seconds: int, - blockers: list[dict[str, str]], -) -> dict[str, Any]: - parsed = parse_timestamp(snapshot_generated_at) - if parsed is None: - blockers.append( - { - "code": "snapshot_time_invalid", - "message": "collaboration status snapshot time is missing or invalid", - } - ) - return { - "state": "invalid", - "generated_at": None, - "max_age_seconds": max(0, int(max_age_seconds)), - } - age_seconds = (now - parsed).total_seconds() - safe_max_age = max(0, int(max_age_seconds)) - state = "fresh" - if age_seconds < -60: - state = "invalid" - blockers.append( - { - "code": "snapshot_time_in_future", - "message": "collaboration status snapshot time is unexpectedly in the future", - } - ) - elif age_seconds > safe_max_age: - state = "stale" - blockers.append( - { - "code": "snapshot_stale", - "message": "collaboration status snapshot exceeded its freshness window", - } - ) - return { - "state": state, - "generated_at": utc_isoformat(parsed), - "age_seconds": round(max(0.0, age_seconds), 3), - "max_age_seconds": safe_max_age, - } - - -def build_collaboration_status( - status_payload: Mapping[str, Any], - *, - goal_id: str, - snapshot_generated_at: str | None = None, - now: datetime | None = None, - max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS, - item_limit: int = DEFAULT_TODO_ITEM_LIMIT, -) -> dict[str, Any]: - """Build a collaboration-safe, read-only status for one LoopX Goal. - - Runtime values may retain real work context intended for authorized peers. - The projection omits credential-like material, provider payloads, and local - paths while preserving the LoopX status/Todo counts as task truth. - """ - - safe_goal_id = str(goal_id or "").strip() - blockers: list[dict[str, str]] = [] - redacted_fields: list[str] = [] - queue = _as_mapping(status_payload.get("attention_queue")) - matching_items = [ - item - for item in _as_mappings(queue.get("items")) - if str(item.get("goal_id") or "") == safe_goal_id - ] - if not safe_goal_id: - blockers.append( - {"code": "goal_id_required", "message": "a Goal id is required"} - ) - if len(matching_items) != 1: - blockers.append( - { - "code": "goal_status_not_unique", - "message": "exactly one matching Goal status item is required", - } - ) - item = matching_items[0] if len(matching_items) == 1 else {} - projection = _as_mapping(item.get("goal_channel_projection")) - project_asset = _as_mapping(item.get("project_asset")) - - if status_payload.get("ok") is not True: - blockers.append( - { - "code": "status_contract_unhealthy", - "message": "the source LoopX status contract is not healthy", - } - ) - if projection.get("schema_version") != GOAL_CHANNEL_PROJECTION_SCHEMA_VERSION: - blockers.append( - { - "code": "goal_projection_missing", - "message": "the Goal channel projection is missing or incompatible", - } - ) - if projection.get("mode") != "read_only": - blockers.append( - { - "code": "goal_projection_not_read_only", - "message": "the Goal channel projection is not read-only", - } - ) - truth = _as_mapping(projection.get("truth_contract")) - if ( - truth.get("event_ledger_is_source_of_truth") is not True - or truth.get("projection_is_writable") is not False - or truth.get("write_authority") != "none" - ): - blockers.append( - { - "code": "truth_contract_invalid", - "message": "the projection does not preserve the LoopX truth contract", - } - ) - - canonical_user = _as_mapping(item.get("user_todos")) - canonical_agent = _as_mapping(item.get("agent_todos")) - user_items = _as_mappings(projection.get("user_todos")) - agent_items = _as_mappings(projection.get("agent_todos")) - user_todos = _summary_projection( - role="user", - canonical_summary=canonical_user, - compact_summary=_as_mapping(project_asset.get("user_todos")), - projected_items=user_items, - item_limit=item_limit, - blockers=blockers, - redacted_fields=redacted_fields, - ) - agent_todos = _summary_projection( - role="agent", - canonical_summary=canonical_agent, - compact_summary=_as_mapping(project_asset.get("agent_todos")), - projected_items=agent_items, - item_limit=item_limit, - blockers=blockers, - redacted_fields=redacted_fields, - ) - - current_time = now or datetime.now(UTC).replace(microsecond=0) - if current_time.tzinfo is None: - current_time = current_time.replace(tzinfo=UTC) - else: - current_time = current_time.astimezone(UTC) - effective_generated_at = snapshot_generated_at or utc_isoformat(current_time) - freshness = _freshness( - snapshot_generated_at=effective_generated_at, - now=current_time, - max_age_seconds=max_age_seconds, - blockers=blockers, - ) - - display_name = _first_text( - projection.get("display_name"), - item.get("display_name"), - safe_goal_id, - limit=140, - ) - latest_status = _first_text( - projection.get("latest_status"), item.get("status"), limit=160 - ) - next_action = _first_text( - projection.get("next_action"), - project_asset.get("next_action"), - item.get("recommended_action"), - limit=360, - ) - if not display_name: - redacted_fields.append("goal.display_name") - if not latest_status: - redacted_fields.append("state.status") - if not next_action: - redacted_fields.append("state.next_action") - - visible_agent_items = agent_todos["items"] - visible_user_items = user_todos["items"] - focus = _first_text( - visible_agent_items[0].get("title") if visible_agent_items else None, - visible_user_items[0].get("title") if visible_user_items else None, - next_action, - limit=300, - ) - open_gates = _as_mappings(projection.get("open_gates")) - gate_summary = _first_text( - visible_user_items[0].get("title") if visible_user_items else None, - open_gates[0].get("kind") if open_gates else None, - limit=240, - ) - - projected_goal_id = _collaboration_text(safe_goal_id, limit=140) - if safe_goal_id and not projected_goal_id: - redacted_fields.append("goal.goal_id") - publishable = not blockers - return { - "schema_version": COLLABORATION_STATUS_SCHEMA_VERSION, - "ok": publishable, - "publishable": publishable, - "visibility": COLLABORATION_VISIBILITY, - "goal": { - "goal_id": projected_goal_id or "goal", - "display_name": display_name or projected_goal_id or "goal", - }, - "state": { - "status": latest_status, - "waiting_on": _first_text( - projection.get("waiting_on"), item.get("waiting_on"), limit=100 - ), - "focus": focus, - "next_action": next_action, - }, - "todos": {"user": user_todos, "agent": agent_todos}, - "owner_gate": { - "open": bool(open_gates or user_todos["open_count"]), - "count": max(len(open_gates), int(user_todos["open_count"])), - "summary": gate_summary, - }, - "freshness": freshness, - "redacted_fields": sorted(set(redacted_fields)), - "blockers": blockers, - "truth_contract": { - "source": "LoopX event ledger and derived status projections", - "projection_is_writable": False, - "write_authority": "none", - }, - } diff --git a/loopx/control_plane/turn_driver/__init__.py b/loopx/control_plane/turn_driver/__init__.py index 73739574c..e5d2e9076 100644 --- a/loopx/control_plane/turn_driver/__init__.py +++ b/loopx/control_plane/turn_driver/__init__.py @@ -2,7 +2,6 @@ from .codex_cli import ( CODEX_CLI_SESSION_SCHEMA_VERSION, - bind_codex_cli_session, codex_cli_result_schema, codex_cli_session_binding, codex_cli_session_id_from_jsonl, @@ -63,7 +62,6 @@ "LoopXTurnResultKind", "LoopXTurnRoute", "ValidatedTurnReceipt", - "bind_codex_cli_session", "build_loopx_turn_command_validator", "build_loopx_turn_host_request", "build_loopx_turn_plan", diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 22f938bfe..11c38547a 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -127,35 +127,6 @@ def codex_cli_session_binding( } -def bind_codex_cli_session( - runtime_root: Path, - turn_envelope: Mapping[str, Any], - *, - session_id: str, -) -> dict[str, str]: - """Bind one existing opaque Codex thread to the current governed Todo. - - The binding is owner-local transport state. It does not grant Goal or Todo - authority: callers must build the binding from a fresh TurnEnvelope, and the - Turn driver rechecks the full goal/agent/todo lineage before host execution. - """ - - request = {"turn_envelope": dict(turn_envelope)} - lineage = _lineage(request) - normalized_session_id = _valid_session_id(session_id) - if not normalized_session_id: - raise ValueError("Codex CLI resume session id is invalid") - _store_codex_cli_session( - runtime_root, - lineage=lineage, - session_id=normalized_session_id, - ) - return { - "schema_version": "loopx_turn_session_binding_v0", - **lineage, - } - - def _store_codex_cli_session( runtime_root: Path, *, diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 31fc06dc2..459910c13 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -822,7 +822,6 @@ def _execution_payload( turn_key = str(transaction.get("turn_key") or "") planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} writeback = _mapping(journal.get("writeback")) - host_result = _mapping(journal.get("host_result")) todo_completion = _mapping(writeback.get("completion")) quota_spent = effects.get("quota_spent") is True or "quota_spend" in list( journal.get("completed_phases") or [] @@ -844,7 +843,6 @@ def _execution_payload( "execution_mode": planned_host.get("execution_mode"), "host": journal.get("host"), "result_kind": journal.get("result_kind"), - **({key: host_result.get(key) for key in ("summary", "recommended_action", "next_action")} if host_result else {}), "validation": journal.get("task_validation"), "receipt": journal.get("receipt"), "scheduler": journal.get("scheduler"), diff --git a/loopx/control_plane/turn_driver/workspace_progress_validator.py b/loopx/control_plane/turn_driver/workspace_progress_validator.py deleted file mode 100644 index b7f145089..000000000 --- a/loopx/control_plane/turn_driver/workspace_progress_validator.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Independent bounded workspace-progress validation for Chat-started Turns.""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from hashlib import sha256 -from pathlib import Path - -MAX_UNTRACKED_BYTES = 32 * 1024 * 1024 -_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") - - -def _git_bytes(project: Path, *args: str) -> bytes: - completed = subprocess.run( - ["git", *args], - cwd=project, - capture_output=True, - check=False, - ) - if completed.returncode != 0: - raise ValueError( - "workspace progress validation requires a readable Git worktree" - ) - return completed.stdout - - -def workspace_progress_digest(project: Path) -> str: - """Hash the reviewable Git workspace state without exposing its contents.""" - - root = project.expanduser().resolve() - digest = sha256() - for args in ( - ("rev-parse", "HEAD"), - ("status", "--porcelain=v1", "-z", "--untracked-files=all"), - ("diff", "--binary", "--no-ext-diff"), - ("diff", "--cached", "--binary", "--no-ext-diff"), - ): - digest.update(_git_bytes(root, *args)) - digest.update(b"\0") - - untracked = _git_bytes(root, "ls-files", "--others", "--exclude-standard", "-z") - remaining = MAX_UNTRACKED_BYTES - for raw_name in untracked.split(b"\0"): - if not raw_name: - continue - digest.update(raw_name) - path = root / raw_name.decode("utf-8", errors="surrogateescape") - if path.is_symlink(): - digest.update(b"symlink\0") - digest.update(str(path.readlink()).encode("utf-8", errors="surrogateescape")) - continue - if not path.is_file(): - continue - size = path.stat().st_size - digest.update(str(size).encode("ascii")) - if remaining <= 0: - continue - with path.open("rb") as handle: - content = handle.read(min(size, remaining)) - digest.update(content) - remaining -= len(content) - return "sha256:" + digest.hexdigest() - - -def validate_workspace_progress(project: Path, *, baseline_hash: str) -> bool: - if _SHA256_RE.fullmatch(baseline_hash) is None: - return False - if workspace_progress_digest(project) == baseline_hash: - return False - for args in (("diff", "--check"), ("diff", "--cached", "--check")): - checked = subprocess.run( - ["git", *args], - cwd=project.expanduser().resolve(), - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if checked.returncode != 0: - return False - return True - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--baseline-hash", required=True) - args = parser.parse_args(argv) - # The Turn driver supplies the normalized result on stdin. Consume it so a - # future pipe writer cannot block even though this validator needs only the - # independently observed workspace state. - sys.stdin.buffer.read() - try: - return ( - 0 - if validate_workspace_progress(Path.cwd(), baseline_hash=args.baseline_hash) - else 1 - ) - except (OSError, ValueError): - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/loopx/dashboard_launcher.py b/loopx/dashboard_launcher.py index 7497e4125..b2b2b8157 100644 --- a/loopx/dashboard_launcher.py +++ b/loopx/dashboard_launcher.py @@ -28,7 +28,6 @@ def launch_dashboard( codex_bin: str = "codex", claude_bin: str = "claude", lark_cli_bin: str | None = None, - available_capabilities: list[str] | None = None, assets_dir: Path | None = None, verbose: bool = False, open_browser: bool = True, @@ -59,7 +58,6 @@ def launch_dashboard( codex_bin=codex_bin, claude_bin=claude_bin, lark_cli_bin=lark_cli_bin, - available_capabilities=available_capabilities, assets_dir=resolved_assets, verbose=verbose, open_browser=open_browser, diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 5eec5bcdd..36ac13d7d 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -562,18 +562,22 @@ def answer_lark_goal_topic( """Run one addressed Topic message through the durable Goal Chat session.""" goal_id = str(route.get("goal_id") or "") + channel_id = "lark." + _opaque_digest( + route.get("app_ref"), + route.get("target_ref"), + route.get("topic_root_message_id"), + ) session, _resumed = runtime_controller.open_session( goal_id=goal_id, agent_id="codex", work_dir=Path(work_dir).expanduser().resolve(), objective=str(objective or goal_id), mode="resume_latest", - channel_id=f"goal.{goal_id}", + channel_id=channel_id, agent_goal_id=goal_id, ) message = ( - "这是来自已绑定 Lark Goal Topic 的用户消息。你就是这个 Goal 的 working Agent," - "请在当前长程会话中直接回答;" + "这是来自已绑定 Lark Goal Topic 的用户消息。请直接回答当前问题;" "任何 Goal、Todo 或其他持久状态修改只生成预览,等待用户在 LoopX 明确确认后应用。\n\n" f"用户消息:{str(text or '').strip()}" ) diff --git a/loopx/presentation/renderers/collaboration_status.py b/loopx/presentation/renderers/collaboration_status.py deleted file mode 100644 index 9a7a152d7..000000000 --- a/loopx/presentation/renderers/collaboration_status.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from ...control_plane.goals.collaboration_status import ( - COLLABORATION_STATUS_SCHEMA_VERSION, -) - - -def _mapping(value: Any) -> dict[str, Any]: - return dict(value) if isinstance(value, Mapping) else {} - - -def _todo_line(label: str, summary: Mapping[str, Any]) -> str: - open_count = int(summary.get("open_count") or 0) - visible_count = int(summary.get("visible_count") or 0) - suffix = f",展示 {visible_count}" if summary.get("truncated") else "" - return f"- {label} Todo:{open_count} 个开放{suffix}" - - -def render_collaboration_status_markdown(payload: dict[str, object]) -> str: - """Render a concise status suitable for an authorized peer channel.""" - - if payload.get("schema_version") != COLLABORATION_STATUS_SCHEMA_VERSION: - raise ValueError("unsupported collaboration status schema_version") - goal = _mapping(payload.get("goal")) - freshness = _mapping(payload.get("freshness")) - if payload.get("publishable") is not True: - blockers = payload.get("blockers") - blocker_rows = blockers if isinstance(blockers, list) else [] - codes = [ - str(item.get("code")) - for item in blocker_rows - if isinstance(item, Mapping) and item.get("code") - ] - reason = "、".join(codes[:4]) or "projection_unavailable" - return "\n".join( - [ - "# LoopX 协作状态", - "", - f"- 目标:{goal.get('display_name') or goal.get('goal_id') or 'goal'}", - "- 当前状态投影不可用,请刷新 LoopX 状态后重试。", - f"- 原因:{reason}", - ] - ) - - state = _mapping(payload.get("state")) - todos = _mapping(payload.get("todos")) - user_todos = _mapping(todos.get("user")) - agent_todos = _mapping(todos.get("agent")) - owner_gate = _mapping(payload.get("owner_gate")) - truth = _mapping(payload.get("truth_contract")) - lines = [ - "# LoopX 协作状态", - "", - f"- 目标:{goal.get('display_name') or goal.get('goal_id') or 'goal'}", - f"- 状态:{state.get('status') or 'unknown'}", - f"- 当前焦点:{state.get('focus') or '暂无可显示焦点'}", - f"- 下一步:{state.get('next_action') or '暂无可显示下一步'}", - _todo_line("Agent", agent_todos), - _todo_line("Owner", user_todos), - ( - f"- Owner gate:开放({owner_gate.get('summary') or '需要 owner 处理'})" - if owner_gate.get("open") - else "- Owner gate:无" - ), - ( - "- 快照:" - f"{freshness.get('state') or 'unknown'} · " - f"{freshness.get('generated_at') or 'unknown'}" - ), - (f"- 真相源:{truth.get('source') or 'LoopX'};该投影只读,不接受状态写回。"), - ] - for role, summary in (("Agent", agent_todos), ("Owner", user_todos)): - items = summary.get("items") - if not isinstance(items, list) or not items: - continue - lines.extend(["", f"## {role} Todo"]) - for item in items: - if not isinstance(item, Mapping): - continue - prefix = f"[{item.get('priority')}] " if item.get("priority") else "" - owner = item.get("claimed_by") or item.get("bound_agent") - suffix = f"({owner})" if owner else "" - lines.append(f"- {prefix}{item.get('title')}{suffix}") - references = [ - str(item.get(key)) - for key in ("todo_id", "task_repository") - if item.get(key) - ] - if references: - lines.append(f" - ref:{' · '.join(references)}") - if payload.get("redacted_fields"): - lines.extend(["", "- 注:检测到鉴权材料或本机信息,相关字段已省略。"]) - return "\n".join(lines) diff --git a/tests/control_plane/test_collaboration_status_projection.py b/tests/control_plane/test_collaboration_status_projection.py deleted file mode 100644 index da3750cc8..000000000 --- a/tests/control_plane/test_collaboration_status_projection.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from loopx.cli import build_parser -from loopx.control_plane.goals.collaboration_status import ( - build_collaboration_status, -) -from loopx.presentation.renderers.collaboration_status import ( - render_collaboration_status_markdown, -) - -NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC) - - -def _todo( - index: int, - *, - title: str | None = None, - claimed_by: str = "agent-example", -) -> dict[str, Any]: - return { - "todo_id": f"todo_example_{index}", - "priority": "P0", - "status": "open", - "title": title or f"Deliver collaboration milestone {index}", - "claimed_by": claimed_by, - "task_repository": "git:example.com/example/project", - } - - -def _status_payload( - *, - agent_open: int = 3, - compact_agent_open: int | None = None, - agent_items: list[dict[str, Any]] | None = None, - truth_writable: bool = False, -) -> dict[str, Any]: - items = agent_items if agent_items is not None else [_todo(1), _todo(2)] - compact_open = agent_open if compact_agent_open is None else compact_agent_open - return { - "ok": True, - "attention_queue": { - "item_count": 1, - "items": [ - { - "goal_id": "example-goal", - "status": "working", - "waiting_on": "agent", - "recommended_action": "Finish the next verified milestone.", - "user_todos": {"open_count": 0}, - "agent_todos": {"open_count": agent_open}, - "project_asset": { - "user_todos": {"open": 0}, - "agent_todos": {"open": compact_open}, - }, - "goal_channel_projection": { - "schema_version": "goal_channel_projection_v0", - "goal_id": "example-goal", - "mode": "read_only", - "display_name": "Example delivery goal", - "latest_status": "working", - "waiting_on": "agent", - "next_action": "Finish the next verified milestone.", - "user_todos": [], - "agent_todos": items, - "open_gates": [], - "truth_contract": { - "event_ledger_is_source_of_truth": True, - "projection_is_writable": truth_writable, - "write_authority": "none", - }, - }, - } - ], - }, - } - - -def _build(payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]: - return build_collaboration_status( - payload, - goal_id="example-goal", - now=NOW, - snapshot_generated_at="2026-08-19T09:59:30Z", - **kwargs, - ) - - -def test_projection_keeps_peer_context_and_exact_open_counts() -> None: - packet = _build(_status_payload()) - - assert packet["schema_version"] == "loopx_collaboration_status_v0" - assert packet["visibility"] == "internal_collaboration" - assert packet["publishable"] is True - assert packet["freshness"]["state"] == "fresh" - assert packet["todos"]["agent"] == { - "open_count": 3, - "visible_count": 2, - "truncated": True, - "items": [_todo(1), _todo(2)], - } - rendered = render_collaboration_status_markdown(packet) - assert "Example delivery goal" in rendered - assert "Deliver collaboration milestone 1" in rendered - assert "agent-example" in rendered - assert "todo_example_1" in rendered - assert "git:example.com/example/project" in rendered - - -def test_projection_fails_closed_when_todo_counts_conflict() -> None: - packet = _build(_status_payload(compact_agent_open=2)) - - assert packet["publishable"] is False - assert {item["code"] for item in packet["blockers"]} == { - "agent_todo_count_conflict" - } - assert "agent_todo_count_conflict" in render_collaboration_status_markdown(packet) - - -def test_projection_fails_closed_when_snapshot_is_stale() -> None: - packet = build_collaboration_status( - _status_payload(), - goal_id="example-goal", - now=NOW, - snapshot_generated_at="2026-08-19T09:00:00Z", - max_age_seconds=300, - ) - - assert packet["publishable"] is False - assert packet["freshness"]["state"] == "stale" - assert [item["code"] for item in packet["blockers"]] == ["snapshot_stale"] - - -def test_projection_redacts_secrets_and_paths_without_hiding_counts() -> None: - secret_title = "Rotate app_secret=synthetic-secret-value" - local_owner = "/" + "home/example/private-agent" - packet = _build( - _status_payload( - agent_open=1, - agent_items=[_todo(1, title=secret_title, claimed_by=local_owner)], - ) - ) - - assert packet["publishable"] is True - assert packet["todos"]["agent"]["open_count"] == 1 - assert packet["todos"]["agent"]["visible_count"] == 0 - assert packet["todos"]["agent"]["truncated"] is True - assert "todos.agent.items[].title" in packet["redacted_fields"] - serialized = repr(packet) - assert secret_title not in serialized - assert local_owner not in serialized - - -def test_projection_rejects_a_writable_truth_contract() -> None: - packet = _build(_status_payload(truth_writable=True)) - - assert packet["publishable"] is False - assert [item["code"] for item in packet["blockers"]] == ["truth_contract_invalid"] - - -def test_status_cli_registers_collaboration_projection_options() -> None: - args = build_parser().parse_args( - [ - "status", - "--goal-id", - "example-goal", - "--collaboration", - "--collaboration-max-age-seconds", - "90", - ] - ) - - assert args.collaboration is True - assert args.goal_id == "example-goal" - assert args.collaboration_max_age_seconds == 90 diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index e4f5e2bb5..07b090c17 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -226,10 +226,9 @@ def wait_for_turn(self, **_kwargs: Any): assert first == "当前运行的是 LoopX 开发版。" assert second == first assert runtime.open_calls[0]["mode"] == "resume_latest" - assert runtime.open_calls[0]["channel_id"] == "goal.goal-alpha" + assert runtime.open_calls[0]["channel_id"].startswith("lark.") assert runtime.open_calls[0]["channel_id"] == runtime.open_calls[1]["channel_id"] assert runtime.submit_calls[0]["client_turn_id"].startswith("lark.") - assert "working Agent" in runtime.submit_calls[0]["message"] assert "只生成预览" in runtime.submit_calls[0]["message"] diff --git a/tests/test_chat_turn_admission.py b/tests/test_chat_turn_admission.py deleted file mode 100644 index ab4d8ee7b..000000000 --- a/tests/test_chat_turn_admission.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import time -from pathlib import Path - -import pytest - -from loopx.chat_runtime import ChatRuntimeController -from loopx.chat_store import ChatSessionStore -from loopx.chat_turn_admission import LoopXChatTurnAdmission -from loopx.control_plane.turn_driver.workspace_progress_validator import ( - validate_workspace_progress, - workspace_progress_digest, -) - - -def _git(project: Path, *args: str) -> None: - subprocess.run(["git", *args], cwd=project, check=True, capture_output=True) - - -def _git_fixture(project: Path) -> None: - project.mkdir() - _git(project, "init", "-q") - _git(project, "config", "user.email", "fixture@example.com") - _git(project, "config", "user.name", "Fixture") - (project / "README.md").write_text("fixture\n", encoding="utf-8") - _git(project, "add", "README.md") - _git(project, "commit", "-qm", "fixture") - - -def test_workspace_progress_validator_requires_a_clean_delta(tmp_path: Path) -> None: - project = tmp_path / "project" - _git_fixture(project) - baseline = workspace_progress_digest(project) - - assert validate_workspace_progress(project, baseline_hash=baseline) is False - - (project / "README.md").write_text("fixture\nprogress\n", encoding="utf-8") - assert validate_workspace_progress(project, baseline_hash=baseline) is True - - (project / "README.md").write_text("fixture \n", encoding="utf-8") - assert validate_workspace_progress(project, baseline_hash=baseline) is False - - _git(project, "add", "README.md") - assert validate_workspace_progress(project, baseline_hash=baseline) is False - - -def test_workspace_progress_digest_tracks_symlink_without_following_it( - tmp_path: Path, -) -> None: - project = tmp_path / "project" - _git_fixture(project) - link = project / "external-link" - os.symlink(tmp_path / "outside-a", link) - first = workspace_progress_digest(project) - - link.unlink() - os.symlink(tmp_path / "outside-b", link) - - assert workspace_progress_digest(project) != first - - -def test_chat_admission_invokes_fresh_exact_todo_resume( - tmp_path: Path, monkeypatch: object -) -> None: - project = tmp_path / "project" - _git_fixture(project) - registry = tmp_path / "registry.json" - registry.write_text("{}\n", encoding="utf-8") - observed: list[list[str]] = [] - real_run = subprocess.run - - def fake_run( - command: list[str], **kwargs: object - ) -> subprocess.CompletedProcess[str]: - if command[0] == "git": - return real_run(command, **kwargs) - observed.append(command) - return subprocess.CompletedProcess( - command, - 0, - stdout=json.dumps( - { - "ok": True, - "status": "committed", - "result_kind": "validated_progress", - "resume_turn_key": "sha256:" + "a" * 64, - "journal_ref": "turn:aaaaaaaaaaaaaaaa", - "summary": "One bounded change was validated.", - "next_action": "Continue from fresh state.", - "validation": {"status": "passed"}, - "effects": {"state_written": True, "quota_spent": True}, - "quota_slot_spend_count": 1, - } - ), - stderr="", - ) - - monkeypatch.setattr(subprocess, "run", fake_run) # type: ignore[attr-defined] - admission = LoopXChatTurnAdmission( - registry_path=registry, - runtime_root_override=tmp_path / "runtime", - loopx_bin="/bin/true", - codex_bin="codex", - available_capabilities=("network",), - ) - - payload = admission( - goal_id="goal-fixture", - agent_id="codex-fixture", - todo_id="todo-fixture", - upstream_thread_id="thread-long-lived", - work_dir=project, - turn_instance_id="chat-turn-fixture", - ) - - assert payload["status"] == "committed" - command = observed[0] - assert command[command.index("--expected-todo-id") + 1] == "todo-fixture" - assert ( - command[command.index("--codex-resume-session-id") + 1] == "thread-long-lived" - ) - assert command[command.index("--turn-instance-id") + 1] == "chat-turn-fixture" - assert command[-2:] == ["--available-capability", "network"] - - -def test_governed_chat_turn_persists_settlement_on_same_session(tmp_path: Path) -> None: - store = ChatSessionStore(tmp_path / "runtime") - session = store.create_session( - goal_id="goal-fixture", - agent_id="codex", - adapter_kind="fixture", - upstream_thread_id="thread-long-lived", - upstream_mode="chat", - channel_id="goal.goal-fixture", - ) - calls: list[dict[str, object]] = [] - - def runner(**kwargs: object) -> dict[str, object]: - calls.append(dict(kwargs)) - return { - "ok": True, - "status": "committed", - "result_kind": "validated_progress", - "resume_turn_key": "sha256:" + "b" * 64, - "journal_ref": "turn:bbbbbbbbbbbbbbbb", - "summary": "One material Chat turn advanced.", - "next_action": "Read fresh LoopX state.", - "validation": {"status": "passed"}, - "effects": {"state_written": True, "quota_spent": True}, - "quota_slot_spend_count": 1, - } - - controller = ChatRuntimeController( - store=store, - codex_bin="codex", - governed_turn_runner=runner, - ) - turn, created = controller.submit_governed_turn( - session_id=str(session["session_id"]), - client_turn_id="material-fixture", - message="Advance the fixture.", - todo_id="todo-fixture", - governed_agent_id="codex-peer-fixture", - work_dir=tmp_path, - ) - completed = controller.wait_for_turn( - session_id=str(session["session_id"]), - turn_id=str(turn["turn_id"]), - timeout_sec=2, - ) - - assert created is True - assert calls[0]["upstream_thread_id"] == "thread-long-lived" - assert calls[0]["todo_id"] == "todo-fixture" - assert calls[0]["agent_id"] == "codex-peer-fixture" - assert completed["response"]["governance"] == { - "schema_version": "loopx_chat_turn_governance_v0", - "mode": "governed", - "todo_id": "todo-fixture", - "agent_id": "codex-peer-fixture", - "turn_key": "sha256:" + "b" * 64, - "journal_ref": "turn:bbbbbbbbbbbbbbbb", - "status": "committed", - "result_kind": "validated_progress", - "validation": {"status": "passed"}, - "effects": {"state_written": True, "quota_spent": True}, - "quota_slot_spend_count": 1, - } - restored = store.load_session(str(session["session_id"])) - deadline = time.monotonic() + 1 - while ( - restored is not None - and restored["status"] != "ready" - and time.monotonic() < deadline - ): - time.sleep(0.01) - restored = store.load_session(str(session["session_id"])) - assert restored is not None - assert restored["upstream_thread_id"] == "thread-long-lived" - assert restored["status"] == "ready" - - -def test_governed_chat_turn_rejects_a_non_codex_transport(tmp_path: Path) -> None: - store = ChatSessionStore(tmp_path / "runtime") - session = store.create_session( - goal_id="goal-fixture", - agent_id="claude-code", - adapter_kind="fixture", - upstream_thread_id="thread-other-provider", - channel_id="goal.goal-fixture", - ) - controller = ChatRuntimeController( - store=store, - codex_bin="codex", - governed_turn_runner=lambda **_kwargs: {}, - ) - - with pytest.raises(ValueError, match="resumable Codex Goal session"): - controller.submit_governed_turn( - session_id=str(session["session_id"]), - client_turn_id="material-fixture", - message="Advance the fixture.", - todo_id="todo-fixture", - governed_agent_id="codex-peer-fixture", - work_dir=tmp_path, - ) diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py index 6fb635f68..ad5e37437 100644 --- a/tests/test_dashboard_command.py +++ b/tests/test_dashboard_command.py @@ -26,28 +26,6 @@ def test_chat_command_accepts_explicit_lark_cli_binary() -> None: assert args.lark_cli_bin == "custom-lark-cli" -def test_chat_command_passes_governed_turn_capabilities_to_server( - monkeypatch: pytest.MonkeyPatch, -) -> None: - calls: list[dict[str, object]] = [] - monkeypatch.setattr(support_control, "serve_chat", lambda **kwargs: calls.append(kwargs)) - - assert ( - main( - [ - "chat", - "--available-capability", - "network", - "--available-capability", - "browser", - "--no-open", - ] - ) - == 0 - ) - assert calls[0]["available_capabilities"] == ["network", "browser"] - - def test_chat_command_passes_explicit_lark_cli_binary_to_server( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 1c728b080..9f9889820 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -10,7 +10,6 @@ from loopx.control_plane.turn_driver.codex_cli import ( CODEX_CLI_SESSION_SCHEMA_VERSION, - bind_codex_cli_session, codex_cli_result_schema, codex_cli_session_binding, load_codex_cli_session, @@ -127,31 +126,6 @@ def test_codex_cli_result_schema_requires_only_bounded_contract_fields() -> None } -def test_existing_chat_thread_can_be_bound_to_fresh_todo_lineage( - tmp_path: Path, -) -> None: - request = _request() - envelope = request["turn_envelope"] - assert isinstance(envelope, dict) - - binding = bind_codex_cli_session( - tmp_path, - envelope, - session_id="thread-long-lived", - ) - - assert binding == { - "schema_version": "loopx_turn_session_binding_v0", - "goal_id": "fixture-goal", - "agent_id": "codex-fixture", - "todo_id": "todo_fixture0001", - } - stored = codex_cli_session_binding(tmp_path, envelope) - assert stored == binding - session_file = next(tmp_path.glob("goals/*/turn-sessions/*.json")) - assert stat.S_IMODE(session_file.stat().st_mode) == 0o600 - - def test_codex_cli_host_starts_then_resumes_opaque_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From c463e196339dd34eeb87784c01b70d2940103c9e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 20 Aug 2026 20:01:34 +0800 Subject: [PATCH 6/6] docs: define desktop execution frontends Signed-off-by: huangruiteng --- docs/architecture/rfcs/README.md | 18 +- .../rfcs/attached-app-session-frontend-v0.md | 252 ------ .../rfcs/desktop-execution-frontends-v0.md | 818 ++++++++++++++++++ 3 files changed, 829 insertions(+), 259 deletions(-) delete mode 100644 docs/architecture/rfcs/attached-app-session-frontend-v0.md create mode 100644 docs/architecture/rfcs/desktop-execution-frontends-v0.md diff --git a/docs/architecture/rfcs/README.md b/docs/architecture/rfcs/README.md index c1a549465..9174c64fe 100644 --- a/docs/architecture/rfcs/README.md +++ b/docs/architecture/rfcs/README.md @@ -71,17 +71,21 @@ promote a proposal beyond that status. ## Draft Integration Proposals -- [Attached App Session Frontend v0](attached-app-session-frontend-v0.md): - attach the LoopX frontend to an already-running Codex App session, preserve - its automation-prompt/visible-host execution driver, and defer managed CLI - Turn launch to a separate future mode. +- [LoopX Desktop Execution Frontends v0](desktop-execution-frontends-v0.md): + support both attachment to an externally owned Codex App session and a + LoopX-managed Pi or DeepSeek Harness runtime driven by bounded Turns, with a + provider-neutral contract and Ark Agent Plan as the default product profile; + converge Web and Agent-scoped Lark Bot connections on the same ordered + working session, and route group messages or document comments through a + provider-neutral external Connector contract. - [Agent IM, LoopX, and OpenViking collaboration v0](agent-im-openviking-collaboration-v0.md): separate runtime delivery, durable control state, and scoped context while preserving direct agent-to-LoopX interaction. - [Goal Channel collaboration v0](goal-channel-collaboration-v0.md) - ([中文版](goal-channel-collaboration-v0.zh-CN.md)): bind one external - collaboration channel to one LoopX goal while preserving LoopX as the source - of truth. + ([中文版](goal-channel-collaboration-v0.zh-CN.md)): bind external + collaboration surfaces to one LoopX goal while preserving LoopX as the + source of truth; interactive chat is refined by the Desktop Execution + Frontends RFC to route through an explicit Agent working session. - [Per-Goal Usage, Token, and Cost Surfacing v0](goal-usage-token-cost-v0.md): capture per-goal token, cost, and duration in core `usage_summary` and surface it in the existing dashboard behind a provider-neutral capture layer. diff --git a/docs/architecture/rfcs/attached-app-session-frontend-v0.md b/docs/architecture/rfcs/attached-app-session-frontend-v0.md deleted file mode 100644 index a9d3aeedc..000000000 --- a/docs/architecture/rfcs/attached-app-session-frontend-v0.md +++ /dev/null @@ -1,252 +0,0 @@ -# RFC: Attached App Session Frontend v0 - -- Status: Draft -- Decision boundary: attach a LoopX frontend to an already-running Codex App - session -- Smallest useful slice: one local Codex app-server session, one LoopX goal, - and one existing automation-prompt or visible-host loop - -## Summary - -LoopX should support an **attached App Session** product mode. In this mode, a -Codex App or app-server session already exists and already owns its transport, -conversation history, interruption, and resume lifecycle. The LoopX frontend -attaches to that session, projects the relevant Goal and Todo state, and keeps -user interaction on the existing app-server connection. - -The existing automation prompt or visible host loop remains the execution -driver. It reads the current LoopX interaction contract, advances the selected -Todo, validates progress, writes state back, and accounts for quota through the -normal LoopX command surface. The frontend must not stop the app-server -transport and resume the same thread through a separately launched CLI merely -to classify the work as governed. - -A future frontend may also launch LoopX-managed Turns on Codex CLI, Claude CLI, -or another host adapter. That is a separate product mode with a different -process owner and lifecycle. This RFC deliberately scopes the first delivery to -attachment only. - -## Problem - -Users may already have a long-running Codex App session with valuable context, -an installed LoopX automation prompt, and an active Goal. A frontend that wants -to show or control that work has two choices: - -1. attach to the existing session; or -2. close or bypass it and launch another agent runtime. - -The second choice creates the wrong ownership boundary for the short-term -product: - -- the frontend can accidentally create two executors for one Goal; -- the visible conversation and the process doing the work may diverge; -- interruption, resume, and sandbox behavior can change across transports; -- a correction message may be recorded in one session but executed in another; -- transport switching becomes coupled to an unreliable interpretation of user - prose; and -- the existing automation prompt is treated as an incomplete chat path even - though it is already a supported LoopX execution driver. - -The immediate need is therefore not a universal runtime launcher. It is a safe, -explicit way to attach the frontend to work that is already running. - -## Decision - -The first frontend execution mode is `attached_app_session`. - -Its defining properties are: - -- **External session ownership.** Codex App or app-server created the session. - LoopX does not replace its process or opaque upstream thread. -- **Explicit attachment.** The operator chooses a known local session. LoopX - does not infer attachment from free-form text. -- **One interaction transport.** Questions, corrections, and work instructions - continue through the attached app-server session. -- **Existing LoopX driver.** An automation prompt, visible host loop, or the - equivalent host-specific interaction contract drives work in that session. -- **LoopX task truth.** Goal, Todo, gate, claim, quota, evidence, and terminal - state remain authoritative in LoopX. Chat prose and transcripts are not task - write receipts. -- **Projection, not duplication.** The frontend projects LoopX state and - session capabilities without storing a second task lifecycle. -- **No silent fallback.** If the attached session becomes unavailable, the - frontend reports it as disconnected or stale. It does not silently launch a - managed CLI Turn. - -## Product Flow - -### Discover - -A host-local broker lists attachable sessions as bounded descriptors. A public -descriptor may include: - -- a public-safe session reference; -- host kind and lifecycle state; -- Goal and Agent binding when known; -- workspace identity as an opaque or redacted reference; -- whether message streaming, resume, and interrupt are available; and -- freshness and last-activity timestamps. - -The browser must not receive raw app-server process handles, credentials, -environment variables, local absolute paths, or an unrestricted transcript. - -### Attach - -The operator explicitly selects one descriptor. The broker verifies that the -session is still live and that its Goal, Agent, workspace, and trust boundary -match the requested frontend context. A successful attachment creates a -frontend binding; it does not create a new Agent session. - -### Interact - -All user messages continue through app-server. The frontend does not maintain -an ordinary-chat-versus-material-chat classifier. The Agent and its installed -LoopX interaction contract decide which canonical LoopX commands or typed -actions are needed. - -Read-only answers may leave LoopX task state unchanged. Material progress is -accepted only after the existing driver produces the required validation, -state writeback, and quota receipt. The fact that a message contains words such -as "start", "continue", or "fix" is never execution authority. - -### Project - -The frontend joins two read models: - -1. session state from the attached host; and -2. Goal/Todo/status state from LoopX. - -The session supplies conversation and transport liveness. LoopX supplies the -authoritative work frontier and accepted progress. A frontend row may link the -two through public-safe ids, but neither source is copied into the other as a -new canonical lifecycle. - -### Detach - -Detaching removes the frontend binding only. It does not terminate the Agent -session, delete its automation, complete a Todo, spend quota, or change Goal -state. Terminating the session remains an explicit host action. - -## Execution And Accounting Boundary - -An attached session is not an unmanaged bypass. Its execution driver must still -obey the current LoopX interaction contract: - -```text -fresh LoopX state - -> quota / gate / selected Todo decision - -> existing App Session executes one bounded segment - -> validation and evidence - -> canonical state writeback - -> quota spend after validated writeback - -> next interaction contract -``` - -This lifecycle is product-equivalent to a managed Turn in the properties the -frontend needs to display: selected work, running/waiting/gated status, -validated progress, accounting, and a next action. It is not implementation- -equivalent to `turn run-once`, and it does not need to share that command's -process ownership or journal representation. - -The frontend should consume a bounded projection of these common properties. -This RFC does not introduce a generic executor registry or require both driver -families to share one executor. - -## State And Identity Boundaries - -Three identities must remain distinct: - -| Identity | Owner | Purpose | -|---|---|---| -| App session/thread | Host-local Codex App adapter | Conversation, streaming, interrupt, resume | -| Goal/Agent/Todo | LoopX control plane | Work selection, authority, gates, accounting, termination | -| Frontend attachment | LoopX frontend broker | Link one visible surface to one live host session | - -The attachment may reference the other identities but cannot grant new -capabilities or task authority. A stale or mismatched Goal, Agent, workspace, -or trust binding fails closed. - -## Safety And Privacy - -- Keep opaque upstream session handles and process metadata in owner-local - storage. -- Do not commit or emit credentials, environment values, local paths, raw - transcripts, provider payloads, or host logs. -- Require a loopback or otherwise authenticated broker boundary for session - discovery and attachment. -- Recheck session liveness and binding freshness before each control action. -- Treat attachment as observation and routing authority, not permission to - bypass the session sandbox or LoopX gates. -- Preserve the original session's approval, sandbox, workspace, and capability - policy. -- Fail closed when the host cannot prove that the selected descriptor still - names the same live session. - -## Non-Goals - -- Launching a new Codex CLI or Claude CLI process from the frontend. -- Implementing or changing `turn run-once`. -- Routing an attached app-server session through a hidden managed Turn. -- Building a universal host-adapter abstraction before a second product mode - has an accepted implementation slice. -- Inferring material authority from natural-language messages. -- Making the session transcript, frontend database, or app-server state the - source of truth for Goal/Todo lifecycle. -- Copying private deployment or collaboration context into public fixtures or - documentation. - -## Smallest Useful Implementation Slice - -After this RFC is accepted, the first implementation should be limited to: - -1. one host-local Codex app-server session descriptor source; -2. explicit attach and detach actions; -3. reuse of the existing app-server message, stream, resume, and interrupt - transport; -4. a bounded LoopX Goal/status projection beside the session; -5. liveness and binding-freshness checks; and -6. no managed Turn launch path. - -The implementation should reuse current Chat Session and status projections -where their ownership matches. It should remove or defer code whose only -purpose is to detach app-server and launch `turn run-once` for the same user -interaction. - -## Validation Criteria - -The first implementation is acceptable only when a focused test or smoke proves -all of the following: - -- attaching to a running session starts no second Agent process; -- three consecutive user messages use the same upstream App session; -- interrupt and resume preserve the attached session identity; -- an automation-prompt-driven bounded work segment updates LoopX state and is - visible in the frontend without a managed Turn launch; -- a read-only exchange does not create a task transition or quota spend; -- detaching leaves the underlying session and Goal unchanged; -- stale or mismatched descriptors fail closed; and -- public packets and committed fixtures contain no opaque handles, credentials, - raw transcripts, or local paths. - -## Future Compatibility: Managed Turn Mode - -A later RFC or accepted extension of this RFC may add `managed_turn`, in which -LoopX launches and owns a Codex CLI, Claude CLI, or another host adapter. That -mode may reuse frontend concepts such as status, selected Todo, interruption, -resume, validation, and receipts. - -Compatibility does not require the attached mode to adopt managed Turn process -ownership. The two modes may share public projection algebra while retaining -separate executors and lifecycle contracts. - -## Open Questions - -1. Which existing host-local registry should own attachable session - descriptors? -2. What is the minimum public-safe session reference that supports reconnect - without exposing an upstream thread id? -3. Which app-server events prove liveness, interruption, and terminal state? -4. Should the frontend attach to only sessions already bound to a LoopX Goal, - or offer an explicit Goal-binding preview for an unbound session? -5. Which existing status projection is the narrowest stable input for the - attached-session view? diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md new file mode 100644 index 000000000..f1137eb26 --- /dev/null +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -0,0 +1,818 @@ +# RFC: LoopX Desktop Execution Frontends v0 + +- Status: Draft +- Decision boundary: support both attachment to an externally owned Agent + session and an end-to-end LoopX-managed desktop runtime +- Initial attached runtime: Codex App / app-server +- Initial managed runtimes: Pi and DeepSeek Harness (`dsh`) +- Default managed provider profile: Volcengine Ark Agent Plan + +## Summary + +LoopX Desktop should support two explicit execution frontend modes: + +1. **Attached App Session.** The operator attaches LoopX Desktop to an + already-running Codex App or app-server session. The external host keeps + process, conversation, interruption, resume, and execution-loop ownership. +2. **Managed Agent Runtime.** LoopX Desktop launches and supervises Pi or + DeepSeek Harness, selects an explicit provider profile, and advances work + through bounded `loopx_turn_v0` transactions. The default distribution + profile uses Volcengine Ark Agent Plan, while the runtime and provider + contracts remain replaceable. + +Both modes present the same LoopX Goal, Todo, gate, quota, evidence, and status +truth. They do not share process ownership. The frontend must never infer a +mode switch from chat prose or silently start a second executor. + +Execution mode is independent from external connectors. Web Chat and a Lark +Bot may both connect to the same Agent-owned working session, while Lark group +messages or document comments may use an explicit ordered queue or +asynchronous inbox until that session can accept input. A Goal may contain +multiple Agents; each Agent has its own working-session binding, at most one +active runtime session, and explicit connector bindings without introducing a +manager Agent or hard-coding a connector to Codex. + +Not every connector is a conversation transport. A Lark group can be a live +transport or an asynchronous event source. A document body is registered +authority material, while its comment stream is a separate event source with +its own cursor, capture policy, reply capability, and acknowledgement state. +LoopX must not equate fetching a document body with observing its comments. + +The managed mode does not require a host-native Goal loop. A desktop-owned +runtime supervisor repeatedly asks LoopX whether another bounded Turn is +eligible, invokes the selected runtime adapter, validates its result, and +commits accepted state. `loopx_turn_v0` remains one transaction rather than a +second recurring scheduler. + +## Problem + +LoopX already has the pieces of two different products: + +- a control-plane kernel with authoritative Goal and Todo state, gates, quota, + validation, writeback, and scheduling hints; +- a desktop frontend and local application shell; +- visible-host integration, including the opt-in Pi Goal extension; +- a host-neutral bounded Turn protocol; and +- a DeepSeek Harness adapter that can execute a Turn through `dsh`. + +What is missing is an accepted desktop ownership model that connects these +pieces without collapsing two valid workflows into one. + +Some users already have a long-running App session with valuable context and +an installed LoopX automation prompt. Replacing that session with a newly +launched CLI would create two conversations, change runtime policy, and risk +two executors advancing the same Goal. + +Other users want a complete desktop product: choose a Goal, configure a +provider, start an Agent, converse with it, interrupt it, close the application, +and resume the same working session later. Requiring them to start a separate +App or install a host-native Goal loop defeats that product shape. + +The frontend therefore needs two explicit modes with a shared projection and +separate lifecycle contracts. + +## Decision + +LoopX Desktop exposes a tagged execution frontend: + +```text +desktop_execution_frontend_v0 = + attached_app_session + | managed_agent_runtime +``` + +The mode is chosen explicitly when a frontend binding is created. It is +persisted with the binding and is visible in status. A reconnect may restore +the same mode and session identity, but it may not change modes implicitly. + +Conversation transport is a second, orthogonal tag, and collaboration event +sources are a third: + +```text +execution mode: attached_app_session | managed_agent_runtime +transport: web_chat | lark_bot +event source: lark_group_message | lark_document_comment | ... +ownership: one Goal -> many Agents -> at most one active session per Agent +``` + +Adding or removing a transport or event source does not create, replace, or +migrate an execution session. Changing execution mode is a separate explicit +operation. + +### Common invariants + +Both modes preserve the following boundaries: + +- **LoopX owns work truth.** Goal, Todo, claim, gate, quota, evidence, accepted + progress, and terminal state remain authoritative in LoopX. +- **The runtime owns execution mechanics.** Codex App, Pi, or `dsh` owns its + model/tool loop and opaque upstream session state. +- **The Agent owns the working-session route.** A Goal may have multiple + Agents. Runtime, transport, and event-source bindings resolve through an + explicit Agent id, never through an ambiguous Goal-wide default. +- **The provider owns inference.** Provider credentials, endpoints, model + availability, and raw payloads are not LoopX task state. +- **Desktop owns presentation and supervision.** It projects work and runtime + state, routes user input, and, in managed mode only, starts and supervises + the runtime process. +- **One binding has at most one active executor.** Ingress is serialized and + duplicate starts fail closed. +- **Conversation is not a write receipt.** Material state changes require the + relevant LoopX validation and writeback contract. + +### Mode comparison + +| Boundary | Attached App Session | Managed Agent Runtime | +|---|---|---| +| Process owner | External App / app-server | LoopX Desktop runtime supervisor | +| Initial runtime | Codex App / app-server | Pi or `dsh` | +| Session creation | Before attachment | Created by Desktop | +| Conversation transport | Existing app-server connection | Managed runtime adapter | +| Execution-loop driver | Existing automation prompt or visible-host loop | Desktop outer loop plus bounded LoopX Turns | +| Host-native Goal required | Allowed, not imposed by Desktop | No | +| Provider configuration | Inherited from external session | Explicit managed provider profile | +| Disconnect behavior | Report stale/disconnected | Reconcile process and offer resume/restart | +| Mode fallback | Never automatic | Never attaches to an unrelated session | + +## Agent-scoped Web and Lark convergence + +The short-term collaboration product is not a separate status Bot. It is a +second frontend transport for an Agent's real working session: + +```text +LoopX Goal + -> Agent A + -> working session A (attached or managed) + -> Web Chat + -> Lark Bot connection A + -> Agent B + -> working session B (attached or managed) + -> Web Chat + -> Lark Bot connection B +``` + +In v0, each Agent may have at most one active `lark_bot` connection. This is a +logical Agent-to-connection binding; it does not require a unique Lark +application credential for every Agent. One Bot application may serve multiple +connections if the local broker preserves explicit Agent and channel routing. + +### One ordered working conversation + +In live-steering and queued-session modes, Web and Lark messages enter one +serialized ingress stream for the selected Agent session. Each message records +public-safe transport metadata such as `origin=web` or `origin=lark`, but origin +does not select a different Agent, conversation history, executor, or LoopX +state machine. + +The session router assigns ordering before delivery to the runtime. A +simultaneous Web and Lark message may wait, interrupt through an explicit +control action, or fail closed according to session policy; it may not create +two concurrent Agent attempts. Responses may be projected to both surfaces +according to connection policy while preserving one canonical sequence. + +An asynchronous inbox event is different: it remains owner-private external +input until the selected Agent drains and interprets it. Only the accepted +Agent-facing message or resulting durable effect joins the working-session +sequence. Provider collection alone does not create conversation history, +task authority, a Turn, or quota spend. + +### Agent binding, not Goal-wide or runtime-specific binding + +A Lark connection binds to a concrete Agent within a Goal. If a Goal has +multiple Agents and the connection does not identify one, routing fails closed. +The Bot talks directly to that working Agent; it does not first ask a manager +Agent to classify or relay the message. The binding is not hard-coded to Codex: +the Agent's execution session may be an attached Codex App today or a managed +Pi/`dsh` session later. + +### Product projection + +The selected Agent's Goal Chat header should show a bounded connection state, +for example `Lark Bot · connected`, `listening`, `stale`, or `disconnected`, +and provide a direct management entry. The management view owns explicit +attach, detach, channel selection, freshness, and reconnect actions. + +`loopx_collaboration_status_v0` may provide a useful read-only card, but it is +not the core abstraction. The core objects are the Agent-scoped frontend +connection and the converged working session. + +## Agent-scoped external Connector model + +Lark group ingress and Lark document comments are two instances of one +provider-neutral Connector boundary. A Connector binds an external source to +one registered Agent and advertises only the operations it can actually +perform: + +```text +agent_external_connector_v0 = { + goal_ref, + agent_ref, + provider_kind, + source_kind, + source_ref, // opaque owner-local reference + capture_policy, + ingress_policy, + response_policy, + cursor_ref, + lifecycle, + capabilities[] +} +``` + +The same provider may expose several source kinds. For example, a Lark group +source may advertise live delivery, history catch-up, thread reply, and ACK, +while a document-comment source may advertise incremental listing, anchor and +reply-chain readback, comment reply, and resolved-state observation. Missing +capabilities remain unavailable; LoopX does not emulate them by scraping an +unrelated surface. + +### Authority material versus collaboration events + +A durable document and its comments have different authority semantics: + +- the document body is registered as a Goal authority material with freshness, + revision, owner status, and conflict policy; +- a comment is owner-private external input addressed to an Agent, not an + accepted requirement, Todo mutation, or repository fact by itself; and +- incorporating a comment requires an explicit durable effect such as a Todo + update, accepted design revision, no-follow-up rationale, or owner gate. + +Reading the body does not advance the comment cursor. Listing comments does not +make the document authoritative. A comment that contradicts accepted state is +recorded as a pending decision or evidence gap rather than silently changing +Goal truth. + +### Capture, replay, and acknowledgement + +Every event-source Connector owns a stable provider event id, incremental +cursor or equivalent checkpoint, bounded catch-up policy, and idempotency key. +Real-time subscription and history catch-up feed the same deduplicated inbox so +that events created before attachment or during downtime are not silently +lost. A source may be filtered by mention, author, document, comment state, +anchor, or configured source scope without changing its delivery mode. + +The Agent processes one accepted event with this ordering: + +```text +capture and deduplicate + -> mark processing + -> read fresh Goal and authority state + -> record durable effect or explicit no-follow-up rationale + -> send an optional response through a declared Connector capability + -> verify provider readback + -> ACK and advance the source cursor +``` + +No ACK or cursor advance may precede the durable effect and required verified +response. A crash replays the same event idempotently. Private bodies, authors, +provider ids, source references, and comment text remain in owner-local inbox +storage; status and quota see content-free urgency only. + +### Delivery into the working Agent + +Connector capture and Agent delivery remain orthogonal. A live group message +may steer the current working session, wait in its ordered queue, or wake an +asynchronous Agent inbox. A document comment normally enters through +`async_inbox`, but the same event may be submitted into a verified live session +when an explicit policy permits it. In all cases it targets the existing bound +Agent and never starts a shadow manager or a fresh conversation implicitly. + +### Short-term Goal Channel bridge + +The existing Goal Channel transport may provide the first Lark delivery path, +provided that its Goal-level connection is refined with an explicit target +Agent and is routed into that Agent's existing ordered session. This bridge is +an incremental implementation path, not permission to keep a second IM-only +conversation lifecycle. + +If accepted, this RFC supersedes the Goal Channel draft's one-Goal-to-one-Lark- +binding constraint for interactive chat. Goal-wide Kanban, lifecycle +notifications, and shared collaboration artifacts may remain Goal-scoped; +inbound working conversation is Agent-scoped. + +## Agent-scoped Bot ingress modes + +An Agent-to-Bot connection needs three explicit ingress semantics. They are +delivery policies for one bound Agent, not three Agents and not a +natural-language classifier: + +```text +agent_bot_ingress_mode_v0 = + live_steering + | session_queue + | async_inbox +``` + +The three policies solve different availability conditions: + +| Mode | Delivery target | Availability model | Durable boundary | +|---|---|---|---| +| `live_steering` | The currently attached or managed working session | Session is live and accepts ordered ingress | Existing session/event store; no second Agent session | +| `session_queue` | The same Agent working session when it next accepts input | Runtime exists but is busy, reconnecting, or temporarily offline | Owner-local ordered ingress queue keyed by Agent and session | +| `async_inbox` | The next eligible LoopX Agent turn after an explicit drain | No Agent process needs to remain alive | Existing provider-owned event inbox plus content-free quota urgency | + +### Capture, ingress, and reply are orthogonal + +Provider selection and Agent delivery must not reuse one overloaded flag. The +initial Lark group shape is: + +```text +capture_scope: mentions | configured_chat_all +ingress_mode: live_steering | session_queue | async_inbox +reply_mode: source_thread | topic_reply | configured_mirror +``` + +`capture_scope` answers which provider events are eligible. `ingress_mode` +answers how one eligible event reaches the Agent. `reply_mode` answers where a +verified response is delivered. The existing `incoming_mode=mentions|all` +expresses capture scope only; it is not proof of session attachment. + +Fallback is explicit and defaults to fail closed. A `live_steering` +connection may opt into `session_queue` or `async_inbox` when the session is +unavailable, but it may not silently start another runtime or write the same +event to multiple modes. The selected mode, fallback decision, and dedupe key +produce one content-free ingress receipt. + +### Live steering + +`live_steering` submits into a verified Agent working-session binding. It +shares the Web ingress serializer, upstream resume identity, interrupt policy, +workspace, runtime, trust, and capability boundary. If that binding is stale, +ambiguous, terminal, or owned by another Agent, delivery fails closed. + +Steering is transport, not task authority. A read-only exchange may remain a +normal session turn. A material effect still requires the fresh LoopX +decision, validation, writeback, and settlement appropriate to the attached or +managed execution mode. + +### Session queue + +`session_queue` is a broker-owned buffer for a known Agent working session. It +preserves stable event dedupe, per-session order, bounded size, expiry, +backpressure, cancellation, and crash-safe dispatch. It is not the LoopX Todo +queue and may not mutate Goal priority, claim work, or grant capabilities. + +When the same session becomes ready, the broker submits the oldest eligible +entry through the normal serialized ingress. A missing or replaced session +requires an explicit rebind or dead-letter decision; it does not silently +route the entry to a fresh Agent history. + +### Asynchronous inbox + +`async_inbox` reuses the existing Lark event inbox and collector rather than +keeping an Agent process alive. The collector writes owner-private bounded +events. LoopX projects only `operator_inbox_urgency_v0`: pending/question/ +mention/reply counts, oldest age, and `reply_due`, never message bodies, +senders, provider ids, private paths, or chat ids. + +When `reply_due=true`, the inbox lane preempts ordinary advancement and monitor +work. The selected Agent drains bounded content, interprets it against fresh +Goal state, writes any durable effect first, sends at most one idempotent +source-thread reply with provider readback, and only then ACKs. Drain alone is +read-only; collection or ACK is never semantic authority. + +The Goal Topic compatibility runtime currently composes provider collection, +an Inbox file, a Goal Chat answer, reply, and ACK inline. That path is useful +evidence but is not Agent-scoped convergence when it opens a generic Agent +session or fails to register inbox urgency on the bound Goal. The implementation +must split provider collection from ingress policy, require the registered +Agent id, and either submit through a verified working-session binding or +publish the inbox pointer to the canonical quota path. + +### Initial product ordering + +The first integration should enable `async_inbox` for environments where an +already-running App session cannot yet accept brokered input. This gives a +restart-safe, Agent-owned path without pretending that attachment exists. +`live_steering` follows with the attached App Session broker. `session_queue` +then closes busy/offline ordering and backpressure for both attached and +managed runtimes. A product may expose all three options at once, but each +incoming event selects exactly one effective mode. + +## Mode A: Attached App Session + +### Discover and attach + +A host-local broker lists attachable sessions as bounded descriptors. A public +descriptor may include: + +- a public-safe session reference; +- host kind and lifecycle state; +- Goal and Agent binding when known; +- workspace identity as an opaque or redacted reference; +- message, streaming, interrupt, and resume capabilities; and +- freshness and last-activity timestamps. + +The operator explicitly selects one descriptor. The broker verifies that the +session is still live and that its Goal, Agent, workspace, and trust boundary +match the requested frontend context. A successful attachment creates a +frontend binding; it does not create an Agent process or a second upstream +session. + +### Interact + +All user messages continue through app-server. The frontend does not maintain +an ordinary-chat-versus-material-chat classifier. The working Agent and its +installed LoopX interaction contract decide which canonical commands or typed +actions are needed. + +The existing automation prompt or visible-host loop remains the driver. It may +read fresh LoopX state, select a Todo, execute a bounded segment, validate the +result, write state back, and account quota through the normal LoopX command +surface. The frontend projects that state; it does not wrap every chat message +in `turn run-once`. + +### Detach + +Detaching removes only the frontend binding. It does not terminate the App +session, delete its automation, complete a Todo, spend quota, or change Goal +state. If the attached session disappears, Desktop reports it as stale or +disconnected and does not silently launch a managed runtime. + +## Mode B: Managed Agent Runtime + +### Product flow + +The managed desktop path is end to end: + +1. select or create a LoopX Goal and working Agent binding; +2. select Pi or `dsh` as the runtime; +3. select a managed provider profile, with Ark Agent Plan as the default + distribution profile; +4. validate runtime installation, provider authentication, and advertised + capabilities; +5. launch one runtime and create one opaque resumable session; +6. send user input to that same session; +7. advance material work through bounded LoopX Turns; +8. project conversation, runtime liveness, Goal/Todo state, validation, quota, + and the next scheduler action in one view; and +9. interrupt, close, reopen, and resume without silently creating a new + session. + +### Managed loop controller + +Managed mode uses a Desktop-owned runtime supervisor as the outer loop: + +```text +fresh LoopX state + -> gate, quota, and selected Todo decision + -> create one idempotent loopx_turn_v0 envelope + -> Pi or dsh executes one bounded attempt + -> independent validation + -> canonical LoopX writeback + -> quota spend only after accepted writeback + -> scheduler hint: continue, wait, replan, or stop + -> Desktop supervisor decides whether to request another Turn +``` + +`loopx_turn_v0` stays a bounded transaction. It does not become an eternal +loop or a second scheduler. The supervisor is responsible for process +liveness, one-Turn-at-a-time serialization, cancellation, backoff, wakeup, +crash recovery, and session resume. LoopX remains responsible for whether work +is eligible and whether an outcome is accepted. + +This mode does not depend on a runtime's native Goal abstraction. The existing +Pi Goal extension remains a supported visible-host integration, but managed Pi +may reuse Pi's Agent/session/tool surfaces without using that extension as the +desktop scheduler. Likewise, the existing `dsh` Turn connector is a useful +starting point; the desktop contract must not depend on an unaccepted native +plugin implementation. + +### Runtime adapter contract + +Pi and `dsh` implement the same narrow managed-runtime contract without +pretending that their internal loops are identical. At minimum it provides: + +- install and version probe; +- capability discovery; +- create, resume, interrupt, and close session; +- submit one bounded host request; +- stream public-safe progress and final result events; +- return an opaque owner-local session reference; and +- map runtime failures into stable LoopX/Desktop error classes. + +The adapter may keep native transcripts, checkpoints, and tool logs in its own +owner-local storage. LoopX stores only the identifiers and receipts required +for reconciliation, validation, and resume. + +### Provider profile contract + +Runtime choice and provider choice are orthogonal. Ark Agent Plan is the +default managed product profile, not a special case embedded throughout the +LoopX kernel. + +A provider profile must expose or resolve: + +- provider and route identifiers; +- an owner-local credential reference; +- supported model discovery; +- API surface and streaming support; +- input/output modalities and tool-call support; +- reasoning or thinking modes when advertised; +- context and output limits when advertised; +- usage and rate-limit telemetry when available; and +- a redacted health-check result. + +Capability discovery is versioned evidence. Unknown or conflicting provider +capabilities remain unknown until an explicit probe resolves them. Desktop +must not silently fall back to a different model, route, provider, or billing +plan. + +Ark Agent Plan has its own supported-model, credential, and usage boundary. +The adapter must therefore validate the Plan route instead of assuming that a +model supported by a standard Ark endpoint is automatically available through +the Plan profile. Credentials and raw provider responses remain owner-local. + +## State and identity boundaries + +The frontend stores one Agent-scoped working-session binding whose public +projection is sufficient to reconnect and explain ownership. Transport +connections reference that binding instead of owning another conversation: + +```json +{ + "schema_version": "desktop_execution_session_v0", + "mode": "attached_app_session | managed_agent_runtime", + "goal_ref": "public-safe LoopX goal reference", + "agent_ref": "public-safe LoopX agent reference", + "runtime_kind": "codex_app | pi | dsh", + "runtime_session_ref": "opaque owner-local reference", + "provider_profile_ref": "managed mode only", + "lifecycle": "starting | ready | running | waiting | interrupted | stale | terminal", + "capability_snapshot_ref": "versioned public-safe projection", + "frontend_connections": [ + { + "kind": "web_chat | lark_bot | lark_document_comments", + "surface_role": "conversation_transport | collaboration_event_source", + "connection_ref": "public-safe broker reference", + "capture_scope": "provider-specific typed policy", + "ingress_mode": "live_steering | session_queue | async_inbox", + "reply_mode": "none | source_thread | source_comment | configured_mirror", + "cursor_ref": "owner-local event-source cursor", + "state": "connected | listening | stale | disconnected" + } + ] +} +``` + +The schema is illustrative, not a commitment to expose opaque values to the +browser. At minimum, these identities remain distinct: + +| Identity | Owner | Purpose | +|---|---|---| +| Goal/Todo | LoopX control plane | Work selection, authority, gates, accounting, termination | +| Agent working-session binding | LoopX control plane and Desktop broker | Scope one Agent's runtime and ordered conversation within a Goal | +| Upstream runtime session | Codex App, Pi, or `dsh` adapter | Conversation, model/tool execution, native resume | +| Provider profile | Owner-local provider store | Authentication, route, model, capability and usage boundary | +| Frontend connection | LoopX Desktop broker | Attach Web or Lark transport to one Agent working session | +| Turn journal | LoopX Turn | Idempotent bounded execution, validation, writeback and settlement evidence | + +An attached descriptor or managed session reference cannot grant new Goal +authority. A stale or mismatched Goal, Agent, workspace, runtime, provider, or +trust binding fails closed. + +## Safety and privacy + +- Keep opaque session handles, credentials, environment values, process + metadata, raw transcripts, provider payloads, tool logs, and local paths in + owner-local storage. +- Require an authenticated local broker boundary for discovery, attachment, + runtime launch, session control, and provider configuration. +- Authenticate Lark callbacks, map each channel to an explicit Agent binding, + and reject ambiguous or replayed ingress before it reaches the Agent session. +- Keep document authority registration separate from document-comment event + capture, and require explicit source and Agent bindings for both. +- Preserve the effective sandbox, workspace, approval, network, and capability + policy in both modes; managed mode must make that policy visible before + launch. +- Recheck binding freshness before control actions and before every managed + Turn writeback. +- Use an idempotent Turn key and allow at most one in-flight Turn for a managed + session. +- Spend quota only after independent validation and accepted state writeback. +- Do not copy private deployment or collaboration context into public fixtures, + screenshots, examples, or documentation. +- Use synthetic provider fixtures for committed tests and make live provider + tests explicit and opt-in. + +## Delivery slices + +### Slice A: Agent-scoped Lark connection + +1. model an explicit Agent working-session binding within a Goal; +2. refine the existing Goal Channel connection with a required target Agent; +3. separate capture scope, ingress mode, and reply mode in the connection; +4. enable `async_inbox` first by registering its Goal-bound config pointer, + projecting `reply_due`, and requiring drain/writeback/reply/readback/ACK; +5. add `live_steering` only through a verified working-session binding and the + same serialized ingress used by Web; +6. add `session_queue` with stable dedupe, ordering, bounded backpressure, and + explicit stale-session handling; +7. let each Agent attach at most one active Lark Bot connection; +8. show connection, capture, ingress, fallback, listening, and pending state in + the selected Agent's Goal Chat header with a direct management entry; and +9. use the existing working Agent directly, without a manager Agent or a + separate IM conversation lifecycle. + +This is the first collaboration slice. It makes the current Goal Channel +useful immediately while establishing the session convergence needed by both +execution modes. + +### Slice A2: document-comment awareness + +1. define the provider-neutral Agent Connector and event-inbox contract; +2. register a document body as redacted Goal authority material independently + from its comment stream; +3. bind one or more configured document-comment sources to a registered Agent; +4. support bounded initial catch-up plus incremental cursor-based reads without + losing comments created before attachment or during downtime; +5. preserve comment anchors and reply-chain context in owner-local storage; +6. route actionable comments through the same durable-effect-before-response- + before-ACK lifecycle as group inbox events; +7. expose comment reply and provider readback only when declared by the + Connector; and +8. project only content-free pending, age, failure, and freshness state to + LoopX status and quota. + +This slice generalizes the group-specific inbox after Slice A proves the Agent +binding and acknowledgement lifecycle. It does not make external comments +authoritative or require the document provider to become a task database. + +### Slice B: attached Codex App + +1. one host-local app-server session descriptor source; +2. explicit attach and detach actions; +3. reuse of existing message, stream, interrupt, and resume transport; +4. bounded LoopX Goal/status projection beside the session; and +5. no second Agent process or managed fallback. + +This is the short path for adopting Desktop around work that is already +running. + +### Slice C: managed reference vertical + +1. a provider-neutral managed-runtime and managed-provider interface; +2. one Desktop runtime supervisor with start, interrupt, close, reconcile, and + resume; +3. `dsh` as the first reference runtime by reusing its accepted Turn adapter; +4. Ark Agent Plan as the default configured provider profile; +5. one resumable conversation and one-at-a-time bounded Turn execution; and +6. joined runtime, Turn, and LoopX status in Desktop. + +`dsh` is the reference ordering because it already has a bounded Turn +connector; this ordering does not make it the permanent default runtime. + +### Slice D: Pi parity + +1. a Pi managed-runtime adapter using Pi Agent/session/tool surfaces; +2. the same Ark Agent Plan provider profile and capability handshake; +3. parity for launch, conversation, stream, interrupt, resume, and Turn result; +4. proof that managed Pi progresses without the Pi Goal extension installed; + and +5. preservation of the existing opt-in Pi Goal extension for visible-host use. + +The dual-runtime managed mode is complete only after both Slice C and Slice D +pass the shared conformance suite. + +## Validation criteria + +### Shared + +- one binding has at most one active executor and serialized user ingress; +- one Goal may route different Lark connections to different Agents without + cross-session delivery; +- LoopX remains authoritative for Goal/Todo lifecycle in every frontend mode; +- a read-only exchange creates no task transition or quota spend; +- stale or mismatched identity and capability bindings fail closed; and +- committed packets contain no credentials, raw transcripts, provider + payloads, opaque handles, or real local paths. + +### Attached mode + +- attaching to a running session starts no second Agent process; +- three consecutive user messages use the same upstream App session; +- interrupt and resume preserve that session identity; +- automation-prompt-driven work updates LoopX state and is projected without + a managed Turn launch; +- detaching leaves the underlying session and Goal unchanged; and +- loss of the App session never triggers silent managed fallback. + +### Web and Lark convergence + +- two Agents in one Goal can attach separate Lark connections and each message + reaches only the explicitly bound Agent; +- capture scope, ingress mode, and reply mode are independently configured and + an event produces exactly one effective ingress receipt; +- three interleaved Web and Lark messages enter one deterministic working- + session order in `live_steering`/`session_queue` and preserve origin metadata; +- Web and Lark resume the same Agent session rather than creating parallel + histories or executors; +- an unavailable steering session fails closed unless an explicit queue or + inbox fallback is configured; fallback never duplicates delivery; +- queued ingress is ordered, bounded, restart-safe, and remains bound to the + same Agent/session without becoming a LoopX Todo; +- a real inbox mention or direct question projects content-free `reply_due`, + preempts ordinary work, and is ACKed only after durable effect plus verified + source-thread reply; duplicate drain/reply/ACK is idempotent; +- a Goal Chat header projects connection freshness and links to explicit + management actions; +- ambiguous Goal-only routing and replayed Lark callbacks fail closed; and +- attaching or detaching Lark does not change the Agent's execution mode or + LoopX Goal/Todo state. + +### External Connector awareness + +- a group event and a document comment bound to the same Agent are captured as + distinct provider event types and deduplicated independently; +- initial catch-up discovers an actionable event created before attachment, + then real-time delivery or polling continues from the committed cursor; +- fetching a document body neither acknowledges comments nor marks them + incorporated; +- incorporating a comment creates an auditable Todo/design/no-follow-up effect + before any reply and cursor advance; +- a comment assertion does not become accepted capability or requirement fact + until its configured evidence or owner boundary is satisfied; +- reply-capable Connectors verify provider readback, while read-only Connectors + record an explicit no-response outcome; and +- status, quota, and public fixtures contain no comment bodies, author ids, + private source references, or provider cursor values. + +### Managed mode + +- Desktop launches exactly one selected runtime and reuses the same opaque + session across three user messages and multiple bounded Turns; +- the runtime progresses with no host-native Goal loop installed; +- every material attempt has a selected Todo, idempotent Turn identity, + independent validation, accepted writeback, and post-writeback settlement; +- interruption and application restart preserve or explicitly reconcile the + session instead of silently creating another one; +- crash replay does not duplicate state writeback or quota spend; +- Pi and `dsh` pass the same lifecycle and Turn-result conformance suite; +- the Ark Agent Plan profile validates its own supported-model and usage + boundary and fails closed on missing authentication or unsupported + capability; and +- provider or model changes are explicit rebinding operations, never silent + fallback. + +## Non-goals + +- Replacing Pi or `dsh` with a new model/tool execution kernel. +- Removing existing automation-prompt, native Goal, or visible-host modes. +- Wrapping each attached chat message in a managed Turn. +- Making `turn run-once` an eternal scheduler or desktop process supervisor. +- Building a universal runtime abstraction beyond the behavior required by Pi + and `dsh`. +- Hard-coding a permanent model capability table in LoopX. +- Treating a standard Ark route and an Ark Agent Plan route as globally + interchangeable. +- Making transcripts, Desktop storage, or provider responses authoritative for + Goal/Todo lifecycle. +- Automatically migrating an attached session into managed mode. +- Introducing a manager Agent between Lark and the bound working Agent. +- Requiring one physical Lark application credential per Agent; v0 requires a + logical Agent-scoped connection and explicit routing. +- Inferring ingress mode from message prose or using `mentions|all` as proof of + a live working-session attachment. +- Treating the session ingress queue as the LoopX Todo queue, or storing raw + inbox content in quota/status. +- Treating a document body fetch as comment awareness, treating document + comments as accepted Goal truth, or advancing a comment cursor before the + durable effect and required response are verified. + +## Related surfaces and proposals + +- [Runtime connector catalog](../../integrations/runtime-connector-catalog.md) +- [LoopX Turn v0](../../reference/protocols/loopx-turn-v0.md) +- [DeepSeek Harness connector](../../integrations/deepseek-harness-connector.md) +- [Pi Goal mode](../../../loopx/pi_goal_mode/README.md) +- [Goal Channel collaboration v0](goal-channel-collaboration-v0.md) +- [Volcengine Ark Agent Plan documentation](https://www.volcengine.com/docs/82379/1928262) +- [Volcengine Ark API overview](https://api.volcengine.com/api-docs/view?serviceCode=ark&version=2024-01-01) + +## Open questions + +1. Which existing host-local registry should own attachable App descriptors and + managed runtime session records? +2. Should the first managed Pi adapter embed Pi's Agent API or supervise its + CLI protocol? +3. What is the smallest common streaming and tool-event surface that Pi and + `dsh` can expose without leaking native transcripts? +4. Which Ark Agent Plan inference API surface should be the first supported + provider adapter, and which capability probes are mandatory before launch? +5. Which Agent lifecycle operation explicitly rotates or replaces its working + runtime session while preserving auditable conversation boundaries? +6. How should optional Pi and `dsh` runtime dependencies be installed and + upgraded by Desktop? +7. After v0, should an Agent support multiple Lark channel connections, and + what projection policy should control which responses are mirrored to each + transport? +8. Which bounded owner-local store should back `session_queue`, and when may an + explicit rebind preserve queued entries across a replaced runtime session? +9. Should `async_inbox` remain a selectable steady-state mode after + `live_steering` ships, or primarily serve offline and non-resident Agents? +10. Which provider-neutral cursor contract can cover webhook delivery, + incremental comment listing, and bounded initial catch-up without leaking + provider identifiers into public state? +11. Should a document-comment Connector support automatic resolved-state + transitions, or require an explicit human or capability-owned action in + the first version?