From e3857f62fdec7f89e7005bf4d0cdb0969c282861 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:34:37 +0800 Subject: [PATCH 01/33] refactor(turn): consume one complete source through execution and recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/runtime/checkpoint.py | 2 +- loopx/ark_managed_agent_host.py | 2 +- .../native_chat/steward_managed_mode.py | 23 +- .../capabilities/native_chat/steward_turn.py | 14 +- loopx/cli_commands/quota_capture.py | 40 +- loopx/cli_commands/turn.py | 40 +- loopx/cli_commands/turn_decision.py | 24 +- loopx/cli_commands/turn_host_request.py | 9 +- loopx/cli_commands/turn_inspection.py | 6 +- loopx/cli_commands/turn_run_once.py | 52 +- loopx/cli_commands/turn_todo_writeback.py | 52 +- loopx/control_plane/capability_hooks.py | 7 + .../collaboration/conversation_binding.ts | 2 +- .../collaboration/steward_managed_mode.ts | 3 +- .../control_plane/effect_runtime_handlers.ts | 1 + loopx/control_plane/quota/context_delivery.ts | 8 +- .../quota/diagnostic_projection.ts | 79 +- .../quota/execution_preparation.py | 6 + .../quota/progressive_context.ts | 69 +- loopx/control_plane/quota/turn_envelope.py | 217 +++-- loopx/control_plane/quota/turn_envelope.ts | 853 +++++++++++++++--- loopx/control_plane/reward_memory.py | 14 +- loopx/control_plane/runtime_decode.ts | 37 + loopx/control_plane/turn_driver/codex_cli.py | 88 +- .../turn_driver/codex_context.py | 20 +- .../turn_driver/codex_operation_host.py | 20 +- .../turn_driver/codex_sessions.py | 49 +- .../turn_driver/decision_capture.py | 61 +- loopx/control_plane/turn_driver/driver.py | 230 +++-- .../turn_driver/dsh_operation_host.py | 10 +- loopx/control_plane/turn_driver/executor.py | 139 ++- .../turn_driver/host_candidate.py | 258 ++++-- .../turn_driver/journal_store.py | 16 +- loopx/control_plane/turn_driver/lane_fence.py | 6 + .../turn_driver/loop_controller.py | 22 +- .../turn_driver/loop_controller.ts | 16 +- .../control_plane/turn_driver/managed_step.py | 3 +- loopx/control_plane/turn_driver/settlement.py | 13 +- .../subagent_execution_topology.py | 4 +- .../control_plane/turn_driver/turn_journal.ts | 30 +- .../turn_driver/turn_journal_effects.ts | 40 +- .../turn_driver/turn_journal_query.ts | 17 +- .../turn_driver/turn_journal_runtime.py | 4 + .../work_items/interaction_contract.ts | 57 +- loopx/dsh_goal_mode/turn_host_adapter.py | 66 +- loopx/extensions/codex_native_child.py | 12 +- loopx/extensions/turn_host.py | 4 +- packages/loopx-ark-turn/pyproject.toml | 2 +- .../src/loopx_ark_turn/config.py | 17 +- packages/loopx-memt-turn/pyproject.toml | 2 +- .../src/loopx_memt_turn/host.py | 19 +- scripts/qualify-native-goal-release.py | 1 + 52 files changed, 2077 insertions(+), 709 deletions(-) diff --git a/benchmark/runtime/checkpoint.py b/benchmark/runtime/checkpoint.py index 407d486e45..17097f5c14 100644 --- a/benchmark/runtime/checkpoint.py +++ b/benchmark/runtime/checkpoint.py @@ -274,7 +274,7 @@ def capture_before_replan(env: dict[str, str], receipt: dict[str, Any], body: st view_path.write_text(guard_run.stdout, encoding="utf-8") view_path.chmod(0o600) reading = (f"Read the complete CLI view at {view_path} before work, then follow its interaction_contract " - "and the next step in the replan review when present. ") + "and replan review/writeback reads when present. ") preloaded_body = ("Host checkpoint preflight has already executed this wake's original quota guard once. " + reading + "Do not repeat the initial guard below; use its selection or recovery command when required.\n\n" + body) diff --git a/loopx/ark_managed_agent_host.py b/loopx/ark_managed_agent_host.py index a31e9932d1..35aae70589 100644 --- a/loopx/ark_managed_agent_host.py +++ b/loopx/ark_managed_agent_host.py @@ -49,7 +49,7 @@ def build_ark_managed_agent_host_contract() -> dict[str, Any]: "quota_should_run.interaction_contract.cli_channel." "runtime_capability_reentry" ), - "cli_projection_ref": "quota_should_run.runtime_capability_reentry", + "cli_projection_ref": "quota_should_run.interaction_contract.cli_channel.runtime_capability_reentry", "packet_schema_version": RUNTIME_CAPABILITY_REENTRY_SCHEMA_VERSION, "delivery_channel": "quota_tool_result", "goal_prompt_mutated": False, diff --git a/loopx/capabilities/native_chat/steward_managed_mode.py b/loopx/capabilities/native_chat/steward_managed_mode.py index a08ff07c9d..1fe8028c5c 100644 --- a/loopx/capabilities/native_chat/steward_managed_mode.py +++ b/loopx/capabilities/native_chat/steward_managed_mode.py @@ -442,11 +442,19 @@ def _validation_source(self, request: dict[str, Any], todo_id: str) -> dict[str, raise ManagedTaskValidationUnavailable("the selected task needs its existing independent argv validation declaration") return rows[0] - def prepare_validation(self, request: dict[str, Any], native_request: dict[str, Any]) -> None: - from ...control_plane.turn_driver.driver import selected_turn_todo + def prepare_validation(self, request: dict[str, Any], native_request: dict[str, Any], *, + _checked_work_identity: dict[str, str] | None = None) -> None: + from ...control_plane.turn_driver.driver import selected_turn_todo, turn_host_lineage from ...control_plane.turn_driver.command_validation import build_loopx_turn_command_validator from ...control_plane.todos.completion_validation_store import read_completion_validation_declaration - selected = selected_turn_todo(native_request.get("turn_envelope") or {}) + # Dispatch can reuse its just-checked identity within this invocation; + # direct callers still check the complete source through the same owner. + if "work_contract" in native_request: + selected = _checked_work_identity + if selected is None: + selected = turn_host_lineage(native_request) + else: + selected = selected_turn_todo(native_request.get("turn_envelope") or {}) if not selected.get("todo_id"): raise ManagedTaskValidationUnavailable("select an existing native task with independent validation") self.validation_basis = self._validation_source(request, selected["todo_id"]) @@ -467,11 +475,16 @@ def validator(self, request: dict[str, Any], *, turn_id: str): The captured canonical digest and fresh source must agree before and after the existing command validator reads the task's real artifact. """ - from ...control_plane.turn_driver.driver import selected_turn_todo + from ...control_plane.turn_driver.driver import ( + COMPLETE_TURN_PLAN_SCHEMA_VERSION, selected_turn_todo, turn_host_lineage, + ) def validate(plan, result): actor = request["actor"] - selected = selected_turn_todo(plan.get("turn_envelope") or {}) + # Model work is a reentry boundary. Recheck the complete source; + # only an absent contract retains historical envelope decoding. + selected = (turn_host_lineage(plan) if "work_contract" in plan or plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION + else selected_turn_todo(plan.get("turn_envelope") or {})) basis = self.validation_basis if not basis or not self.task_validator or basis["todo_id"] != selected["todo_id"]: raise ValueError("the original task validation source was not captured before model work") diff --git a/loopx/capabilities/native_chat/steward_turn.py b/loopx/capabilities/native_chat/steward_turn.py index 623ac4c0b6..0cc5519c48 100644 --- a/loopx/capabilities/native_chat/steward_turn.py +++ b/loopx/capabilities/native_chat/steward_turn.py @@ -279,12 +279,16 @@ def report_selection_recovery(self, original: dict[str, Any], *, if len(keys) != 1 or not isinstance(next(iter(keys)), str): return None key = keys.pop() + from ...control_plane.effect_runtime import source_number_tokens + + journal = load_turn_journal(turn_journal_path(reports.root, goal_id=actor["goal_id"], turn_key=key)) # Transport canonical observations to the shared typed admission # owner. Neither a missing model event nor Python prose decides. proof["probe"] = {"turn": probe_turn, "inspection": inspect_loopx_turn_journal(reports.root, goal_id=actor["goal_id"], agent_id=actor["agent_id"], turn_key=key), - "journal": load_turn_journal(turn_journal_path(reports.root, goal_id=actor["goal_id"], turn_key=key))} + "journal": journal, + "source_number_tokens": source_number_tokens((journal.get("plan") or {}).get("work_contract"))} return proof def _report_launch_recovery(self, original: dict[str, Any]) -> dict[str, Any] | None: @@ -676,7 +680,10 @@ def current(observed=None): def session_binding(envelope: Mapping[str, Any]) -> dict[str, Any]: current() - if envelope.get("goal_id") != actor["goal_id"] or envelope.get("agent_id") != actor["agent_id"]: + identity = (envelope.get("agent_identity") if envelope.get("schema_version") == "loopx_turn_envelope_v1" + else envelope) + if (envelope.get("goal_id") != actor["goal_id"] or not isinstance(identity, Mapping) + or identity.get("agent_id") != actor["agent_id"]): raise ValueError("native Turn belongs to another actor") return {"schema_version": "loopx_turn_session_binding_v0", "goal_id": actor["goal_id"], "agent_id": actor["agent_id"]} @@ -690,7 +697,8 @@ def run(native_request: Mapping[str, Any], launch_scope: Any) -> dict[str, Any]: "next_action": "Retain the original report; its native governance route is no longer admitted."}) if managed_turn and not governance_work: try: - managed_turn.prepare_validation(request, native_request) + managed_turn.prepare_validation(request, native_request, + _checked_work_identity=authority.get("_work_identity")) except ManagedTaskValidationUnavailable: managed_turn.validation_unavailable = True return build_result(native_request, {"result_kind": "user_action_required", diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index f8108f1996..f4c195ab39 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -31,6 +31,21 @@ ) +def _complete_selection_transport(payload: dict[str, Any], args: argparse.Namespace, *, + capture_root: Path) -> str | None: + """Bind display argv without editing an already signed complete source.""" + interaction = payload.get("interaction_contract") + cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None + selection = cli.get("selection_command") if isinstance(cli, dict) else None + if not isinstance(selection, dict): + return None + presentation = {**payload, "interaction_contract": {**interaction, + "cli_channel": {**cli, "selection_command": dict(selection)}}} + bind_capture_selection_transport(presentation, args, capture_root=capture_root) + cli = (presentation.get("interaction_contract") or {}).get("cli_channel") or {} + return (cli.get("selection_command") or {}).get("command_args_template") + + def native_context_activation(args: argparse.Namespace) -> dict[str, Any]: from ..control_plane.turn_driver.codex_context import context_delivery_for_arguments @@ -675,8 +690,14 @@ def progressive_context_capture_view(payload: dict[str, Any], args: argparse.Nam if directory is None: root = Path(_private_transport_root(runtime_root)) directory = Path(tempfile.mkdtemp(prefix="context-", dir=root)) - bind_capture_selection_transport(payload, args, capture_root=directory.parent) - required, reference = capture_progressive_context(payload, directory=directory, surface=surface) + transport = None + if payload.get("schema_version") == "loopx_turn_envelope_v1": + surface = "complete_turn" + transport = _complete_selection_transport(payload, args, capture_root=directory.parent) + else: + bind_capture_selection_transport(payload, args, capture_root=directory.parent) + required, reference = capture_progressive_context(payload, directory=directory, surface=surface, + selection_command_args_template=transport) view = native_context_capture_view(required, directory, captured_recovery=reference, metadata={"context_delivery": native_context_activation(args), **({"capture_sha256": args.capture_sha256} if getattr(args, "capture_sha256", None) else {})}, @@ -756,6 +777,11 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str return progressive_context_capture_view(payload, args, runtime_root=Path(getattr(args, "_context_delivery_machine_runtime", path.parent)), directory=None, reader_argv=reader_argv) + except EffectRuntimeRejected as exc: + if payload.get("schema_version") == "loopx_turn_envelope_v1": + failure = QuotaDecisionTransportError(str(exc)) + return {"ok": False, "error_code": failure.error_code, "reason": str(failure), + "capture_sha256": args.capture_sha256, "recovery": full_reference} except (QuotaDecisionTransportError, OSError, ValueError, EffectRuntimeRemoteError, EffectRuntimeStartupError): pass # The complete original reference remains the fallback below. try: @@ -793,12 +819,16 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str # Carry the now-active transport into its selection presentation, not # into the immutable source or a second admission. Native lifecycle # still owns when the following full/delta view can be published. - bind_capture_selection_transport(payload, args, capture_root=root) + complete = payload.get("schema_version") == "loopx_turn_envelope_v1" + transport = _complete_selection_transport(payload, args, capture_root=root) if complete else None + if not complete: + bind_capture_selection_transport(payload, args, capture_root=root) prepared = prepare_codex_context(payload, directory=directory, capture_directory=cache, - goal_id=args.goal_id, agent_id=args.agent_id, surface="quota_packet", + goal_id=args.goal_id, agent_id=args.agent_id, surface="complete_turn" if complete else "quota_packet", capture_id=hashlib.sha256(raw).hexdigest(), continuity_generation=getattr(args, "_context_delivery_generation", None), - progressive_enabled=activation["progressive_enabled"], hook_session=hook_session) + progressive_enabled=activation["progressive_enabled"], selection_command_args_template=transport, + hook_session=hook_session) projected = prepared.delivery native_prepared = True if projected.get("schema_version") == "context_delivery_v1": diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 7a4ac7350e..743885faad 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -12,7 +12,7 @@ ) from ..control_plane.operator_provider import operator_provider_environ from ..control_plane.quota.effective_action import EffectiveAction -from ..control_plane.quota.turn_envelope import build_turn_envelope +from ..control_plane.quota.turn_envelope import compose_turn_decision from ..control_plane.runtime.status_projection_cache import ( resolve_status_projection_cache_runtime_root, ) @@ -164,13 +164,8 @@ def handle_turn_command( runtime_root, goal_id=args.goal_id, turn_key=args.resume_turn_key, + agent_id=args.agent_id, ) - raw_envelope = payload.get("turn_envelope") - envelope = raw_envelope if isinstance(raw_envelope, dict) else {} - if envelope.get("agent_id") != args.agent_id: - raise ValueError( - "LoopX Turn resume journal belongs to another agent" - ) goal_admission = FirstPartyHostGoalAdmission.for_plan( registry_path=registry_path, goal_id=args.goal_id, @@ -250,12 +245,8 @@ def handle_turn_command( ) operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector scheduler_context = decision_owner.scheduler_execution_context - decision = decision_owner.resolve() + decision = compose_turn_decision(decision_owner.resolve(), scheduler_execution_context=scheduler_context) resume_requested, session_binding = resolve_turn_resume_session_binding(args) - turn_envelope = build_turn_envelope( - decision, - scheduler_execution_context=scheduler_context, - ) # Operation-equipped app-server sessions retain their Todo-bound # approval/handoff contract; this option controls exec conversation reuse. session_scope = (args.session_scope if args.host in {"codex-cli", "codex-app-server"} @@ -263,30 +254,24 @@ def handle_turn_command( if args.host in {"codex-cli", "codex-app-server"} and delegated_context and delegated_context.get("request_subject") is not None: session_scope = "agent" if native_session_binding_resolver is not None: - session_binding = native_session_binding_resolver(turn_envelope) + session_binding = native_session_binding_resolver(decision) if ( args.turn_command == "run-once" and (args.host == "codex-cli" or dsh_operation_module) and not resume_requested and not args.resume_turn_key and args.iteration_context != "fresh" - and turn_envelope.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value + and decision.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value ): from ..control_plane.turn_driver.codex_cli import codex_cli_session_binding - session_binding = ( - codex_cli_session_binding( - runtime_root, - turn_envelope, - goal_admission=strict_goal_admission, - session_scope=session_scope, - host=args.host, - ) - if strict_goal_admission is not None - else codex_cli_session_binding(runtime_root, turn_envelope, session_scope=session_scope, host=args.host) + session_binding = codex_cli_session_binding( + runtime_root, work_contract=decision, + goal_admission=strict_goal_admission, + session_scope=session_scope, host=args.host, ) payload = build_loopx_turn_plan( - turn_envelope, + decision, host=args.host, execution_mode=args.execution_mode, scheduler_owner=args.scheduler_owner, @@ -333,6 +318,11 @@ def handle_turn_command( capability_action["command"] = shlex.join(capability_action["command_argv"]) if args.turn_command == "plan": if not args.include_transaction_detail: + # The current plan has one source, so inspection retains it. + # Execution still requires its explicit surface, even if a + # caller restores omitted session/transaction metadata. + payload = dict(payload) + payload["boundary"] = {**payload.get("boundary", {}), "inspection_only": True} payload.pop("session", None) payload.pop("transaction", None) boundary = payload.get("boundary") diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index 0bbdaf7ac3..2b811148c2 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -2,7 +2,7 @@ ``run-once`` and ``managed-step`` must agree on what "the current governing decision" means: read the same live status, apply the same controller advisory -primary, and sign the same ``loopx_turn_envelope_v0``. Duplicating that chain +primary, and compose the same complete ``loopx_turn_envelope_v1`` source. Duplicating that chain would let the two drift, so both owners build through this module. The owner carries the inputs the decision was derived from as well, because a @@ -12,7 +12,7 @@ inputs is free to drift from the decision it is settling. Nothing here executes, writes, or spends: it only projects the control plane's -current decision into the envelope the loop controller consumes. +current decision into the complete source the loop controller consumes. """ from __future__ import annotations @@ -33,7 +33,7 @@ from ..control_plane.quota.live_decision import build_live_quota_should_run_decision from ..control_plane.quota.effective_action import EffectiveAction from ..control_plane.quota.turn_envelope import ( - TurnControllerInput, build_turn_controller_input, build_turn_envelope, + compose_turn_decision, ) from ..control_plane.quota.settlement import SettlementIdentity, build_turn_scoped_cli_settlement_plan from ..control_plane.quota.heartbeat_receipt import ( @@ -42,7 +42,7 @@ heartbeat_receipt_settlement_todo_id, ) from ..control_plane.goals.first_party_host_admission import FirstPartyHostRuntimeRejected -from ..control_plane.turn_driver.driver import build_loopx_turn_plan, selected_turn_todo +from ..control_plane.turn_driver.driver import build_loopx_turn_plan from ..control_plane.scheduler.execution_context import ( SchedulerExecutionContextResolution, scheduler_execution_context_for_turn, @@ -172,6 +172,7 @@ def build_turn_decision( ) -> dict[str, Any]: decision = build_live_quota_should_run_decision( status_payload, + inline_goal_source=True, goal_id=args.goal_id, agent_id=args.agent_id, available_capabilities=args.available_capabilities, @@ -365,6 +366,9 @@ def current_turn_host_launch_decision( An observed replan packet is context, not a second settlement binding. Both native callers transport the obligation recorded in this identity. """ + # A replan observation may accompany a Todo-bound Turn. Only the recorded + # settlement identity can bind admission to an obligation; passing the + # observed obligation as well would erase the original Todo selection. current = build_live_quota_should_run_decision( collect_turn_status_payload(args, registry_path=registry_path, runtime_root_arg=str(runtime_root)), @@ -399,20 +403,20 @@ def current_turn_host_launch_decision( if (current.get("should_run") is not True or current.get("effective_action") != planned_effective_action): return launch - current_envelope = build_turn_envelope( + current_contract = compose_turn_decision( current, scheduler_execution_context=scheduler_execution_context, ) # Reuse the existing side-effect-free Host route projection, including # delivery, user-gate and protocol checks. No plan/session is persisted. plan = build_loopx_turn_plan( - current_envelope, host=args.host, execution_mode=args.execution_mode, + current_contract, host=args.host, execution_mode=args.execution_mode, scheduler_owner=args.scheduler_owner, turn_instance_id=identity.turn_instance_id, goal_ref=goal_ref, ) route = plan["route"] if route.get("would_invoke_host") is not True: raise FirstPartyHostRuntimeRejected("goal_launch_not_allowed") - if identity.request_subject is None and selected_turn_todo(current_envelope).get("todo_id") != identity.todo_id: + if identity.request_subject is None and (plan["route"].get("selected_todo") or {}).get("todo_id") != identity.todo_id: raise FirstPartyHostRuntimeRejected("current_turn_selection_changed") if (identity.replan_obligation_id or identity.request_subject is not None) and plan["transaction"]["settlement_plan"]["identity"] != identity.as_dict(): raise FirstPartyHostRuntimeRejected("current_turn_selection_changed") @@ -492,8 +496,8 @@ def build_fresh_envelope_for_managed_step( registry_path: Path, runtime_root: Path, runtime_root_arg: str | None, -) -> TurnControllerInput: - """Project the current envelope and its route together for managed-step. +) -> dict[str, Any]: + """Compose the same current source consumed by an executing Turn. Read-only: no Turn-start hooks are dispatched and no Turn instance is minted, because the managed step only decides whether the outer scheduler @@ -506,7 +510,7 @@ def build_fresh_envelope_for_managed_step( runtime_root=runtime_root, runtime_root_arg=runtime_root_arg, ) - return build_turn_controller_input( + return compose_turn_decision( owner.resolve(), scheduler_execution_context=owner.scheduler_execution_context, ) diff --git a/loopx/cli_commands/turn_host_request.py b/loopx/cli_commands/turn_host_request.py index 3e79bf5bd2..8827a70875 100644 --- a/loopx/cli_commands/turn_host_request.py +++ b/loopx/cli_commands/turn_host_request.py @@ -70,12 +70,9 @@ def prepare_turn_host_request( timeout_seconds=args.validation_timeout_seconds, failure_recovery_kind=args.validation_failure_kind, ) - envelope = ( - cast(dict[str, Any], payload.get("turn_envelope")) - if isinstance(payload.get("turn_envelope"), dict) - else {} - ) - return project, raw_argv, task_validator, envelope + from ..control_plane.turn_driver.driver import read_turn_execution_context + + return project, raw_argv, task_validator, read_turn_execution_context(payload) def build_codex_cli_host_runner( diff --git a/loopx/cli_commands/turn_inspection.py b/loopx/cli_commands/turn_inspection.py index ed6c5105b6..1b6dca1fef 100644 --- a/loopx/cli_commands/turn_inspection.py +++ b/loopx/cli_commands/turn_inspection.py @@ -43,12 +43,14 @@ def handle_turn_journal_inspection( registry_path=registry_path, runtime_root_override=runtime_root_arg, ) + contract_fields = {} scope = "todo" session_host = "codex-cli" if args.retry_failed_turn: plan = load_loopx_turn_plan_from_journal( runtime_root, goal_id=args.goal_id, turn_key=args.turn_key, ) + contract_fields = {"work_contract": plan["work_contract"]} if "work_contract" in plan else {} scope = str(((plan.get("session") or {}).get("context_policy") or {}).get("binding_scope") or "todo") # The retained plan, not the operator's current default host, owns # the failed Turn's session namespace. Legacy plans remain Codex. @@ -82,9 +84,9 @@ def handle_turn_journal_inspection( session_binding_resolver=( lambda turn_envelope: codex_cli_session_binding( runtime_root, - turn_envelope, session_scope=scope, host=session_host, + **(contract_fields or {"turn_envelope": turn_envelope}), ) ), ) @@ -156,9 +158,9 @@ def handle_turn_journal_inspection( session_binding_resolver=( lambda turn_envelope: codex_cli_session_binding( runtime_root, - turn_envelope, session_scope=scope, host=session_host, + **(contract_fields or {"turn_envelope": turn_envelope}), ) ), ) diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 11db4e23cf..723ec0778c 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -34,7 +34,6 @@ inspect_loopx_turn_journal, run_codex_cli_host, run_loopx_turn_once, - selected_turn_todo, ) from ..quota import spend_quota_slot from ..state_refresh import refresh_state_run @@ -45,7 +44,7 @@ from .turn_dsh_host import build_dsh_host_runner, build_dsh_operation_host_runner from .turn_rendering import build_turn_error_payload from .turn_post_writeback import drain_committed_turn_post_writeback_hooks -from .turn_todo_writeback import captured_todo_text_sha256, write_turn_validated_completion +from .turn_todo_writeback import write_turn_validated_completion EXACT_SETTLEMENT_READBACK_NOT_FOUND = ( "exact settlement readback unexpectedly returned not-found" @@ -79,16 +78,14 @@ def execute_turn_run_once( registry_path=registry_path, goal_id=args.goal_id, planned_goal_ref=goal_ref, ) - project, raw_argv, task_validator, envelope = prepare_turn_host_request( + project, raw_argv, task_validator, execution_context = prepare_turn_host_request( args, payload, native_host_runner=native_host_runner, native_session_binding_resolver=native_session_binding_resolver, native_task_validator=native_task_validator, validator_builder=build_loopx_turn_command_validator, ) - selected_todo = selected_turn_todo(envelope) - expected_todo_text_sha256 = captured_todo_text_sha256( - envelope, todo_id=str(selected_todo.get("todo_id") or "") - ) + selected_todo = execution_context["selected_todo"] + expected_todo_text_sha256 = execution_context["sha256"] transaction = ( payload.get("transaction") if isinstance(payload.get("transaction"), Mapping) @@ -126,13 +123,10 @@ def execute_turn_run_once( and persisted_effect_id != settlement_identity.effect_id ): raise ValueError("Turn settlement identity effect_id is inconsistent") - stable_envelope: Mapping[str, Any] = ( - envelope if isinstance(envelope, Mapping) else {} - ) - replan_guard_scoped = "replan_action_packet" in stable_envelope + replan_guard_scoped = execution_context["replan_guard_scoped"] replan_obligation_id = ( replan_obligation_id_from_packet( - stable_envelope.get("replan_action_packet") + execution_context["replan_action_packet"] ) if replan_guard_scoped else None @@ -191,11 +185,7 @@ def append_settlement_event( if event_payload.get("rollout_event_log_error"): raise OSError(f"failed to persist {event_kind} settlement receipt") - writeback_contract = ( - envelope.get("writeback") - if isinstance(envelope.get("writeback"), dict) - else {} - ) + writeback_contract = execution_context["writeback"] delivery_workspace_path = ( project if isinstance( @@ -754,7 +744,7 @@ def scheduler(_spend_payload: dict[str, object]) -> dict[str, object]: def native_launch_decision(current_registry: Path) -> Mapping[str, Any]: return current_turn_host_launch_decision(args, registry_path=current_registry, runtime_root=runtime_root, identity=settlement_identity, - planned_effective_action=payload["turn_envelope"].get("effective_action"), + planned_effective_action=execution_context["effective_action"], scheduler_execution_context=payload.get("scheduler_execution_context") or {}, operator_inbox_urgency_projector=operator_inbox_urgency_projector, turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref) @@ -778,7 +768,7 @@ def current_launch_decision(current_registry: Path) -> Mapping[str, Any]: return current_turn_host_launch_decision( args, registry_path=current_registry, runtime_root=runtime_root, identity=settlement_identity, - planned_effective_action=payload["turn_envelope"].get("effective_action"), + planned_effective_action=execution_context["effective_action"], scheduler_execution_context=payload.get("scheduler_execution_context") or {}, operator_inbox_urgency_projector=operator_inbox_urgency_projector, # Keep this Turn's already dispatched required reads at launch. @@ -798,19 +788,11 @@ def resolve_built_in_session_binding( turn_envelope: Mapping[str, Any], ) -> dict[str, str] | None: session_scope = str((payload.get("session", {}).get("context_policy") or {}).get("binding_scope") or "todo") - return ( - codex_cli_session_binding( - runtime_root, - turn_envelope, - goal_admission=goal_admission, - session_scope=session_scope, - ) - if goal_admission is not None - else codex_cli_session_binding( - runtime_root, - turn_envelope, - session_scope=session_scope, - ) + return codex_cli_session_binding( + runtime_root, goal_admission=goal_admission, + session_scope=session_scope, + **({"work_contract": payload["work_contract"]} if "work_contract" in payload + else {"turn_envelope": turn_envelope}), ) session_binding_resolver = resolve_built_in_session_binding @@ -824,7 +806,9 @@ def resolve_built_in_session_binding( def resolve_dsh_operation_session(envelope: Mapping[str, Any]) -> dict[str, str] | None: return codex_cli_session_binding( - runtime_root, envelope, goal_admission=goal_admission, host="dsh" + runtime_root, goal_admission=goal_admission, host="dsh", + **({"work_contract": payload["work_contract"]} if "work_contract" in payload + else {"turn_envelope": envelope}), ) session_binding_resolver = resolve_dsh_operation_session @@ -849,7 +833,7 @@ def on_managed_start_admitted() -> None: if settlement_identity.request_subject is not None: require_current_request_operation(request_locator, settlement_identity, registry_path=registry_path, runtime_root=runtime_root, workspace=project) - replan_packet = stable_envelope.get("replan_action_packet") + replan_packet = execution_context["replan_action_packet"] capability_guard = ( replan_packet.get("capability_guard") if isinstance(replan_packet, Mapping) diff --git a/loopx/cli_commands/turn_todo_writeback.py b/loopx/cli_commands/turn_todo_writeback.py index 1e0052d96a..ab547b1b6e 100644 --- a/loopx/cli_commands/turn_todo_writeback.py +++ b/loopx/cli_commands/turn_todo_writeback.py @@ -1,10 +1,9 @@ -"""Todo writeback adapters that preserve the Turn's effective runtime root.""" +"""Native Todo completion writeback transport.""" from __future__ import annotations from pathlib import Path from typing import Any, Mapping -from hashlib import sha256 from ..control_plane.todos.mutation_api import complete_goal_todo from ..control_plane.coordination.local_authority import ( @@ -12,55 +11,6 @@ ) -def captured_todo_text_sha256( - envelope: Mapping[str, Any], *, todo_id: str -) -> str | None: - """Resolve admitted full-source references; hash bytes without rereading live state. - - Older journal envelopes have no verification marker and retain their - original completion request. New producers mark source validation before - body deduplication, so a display preview alone can never become the source. - """ - action = envelope.get("action") or {} - selected = action.get("selected_todo") or {} - if "text_source_verified" not in selected: - return None - if ( - selected["text_source_verified"] is not True - or selected.get("todo_id") != todo_id - ): - raise ValueError("Turn task source does not match its selected Todo") - context = envelope.get("work_context") or {} - if not isinstance(context, Mapping) or not isinstance( - context.get("sources", []), list - ): - raise ValueError("Turn completion requires its admitted full task source") - records = [] - for source in context.get("sources", []): - if not isinstance(source, Mapping) or source.get("source") != "selected_todo": - continue - content = source.get("content") - record = content.get("todo") if isinstance(content, Mapping) else None - if ( - isinstance(record, Mapping) - and record.get("todo_id") == todo_id - and isinstance(record.get("text"), str) - ): - records.append(record["text"]) - if len(records) > 1: - raise ValueError("Turn task source is ambiguous") - text = records[0] if records else None - if text is None and ( - not context or context.get("selected_todo_ref") == "selected_todo" - ): - text = selected.get("text") - if text is None and selected.get("text_ref") == "action.recommended_action": - text = action.get("recommended_action") - if not isinstance(text, str) or not text: - raise ValueError("Turn completion requires its admitted full task source") - return sha256(text.encode("utf-8")).hexdigest() - - def write_turn_validated_completion( *, registry_path: Path, diff --git a/loopx/control_plane/capability_hooks.py b/loopx/control_plane/capability_hooks.py index 7b1c482d7f..42a5fea290 100644 --- a/loopx/control_plane/capability_hooks.py +++ b/loopx/control_plane/capability_hooks.py @@ -194,6 +194,13 @@ def contract(self) -> dict[str, Any]: def turn_context_source(payload: Mapping[str, Any]) -> Mapping[str, Any]: """Use the host envelope or the native quota identity, without new authority.""" + if "work_contract" in payload: + from .quota.turn_envelope import turn_plan_host_source + from .quota.turn_envelope import complete_turn_host_source + + source = complete_turn_host_source(turn_plan_host_source(payload), operation="plan_work") + assert source is not None + return source envelope = payload.get("turn_envelope") if isinstance(envelope, Mapping): return envelope diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index 51d692daf0..f8bf5df9e6 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -1163,7 +1163,7 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre || !Number.isFinite(now) || !Number.isFinite(ended) || now < ended + 60_000) return no; if (recovery.native_observation == null) return candidate; const proof = requireJsonObject(recovery.native_observation, "exhausted original request inventory"); - if (typeof proof.thread_id !== "string" || !nativeThreads.includes(proof.thread_id) || proof.turn_id !== failed.upstream_turn_id + if (!nativeThreads.some(thread => thread === proof.thread_id) || proof.turn_id !== failed.upstream_turn_id || proof.only_original_user_item !== true || proof.codex_error_info !== "usageLimitExceeded") return candidate; const capacity = nativeRecoveryCapacity(recovery, row, current, candidate); return {...capacity, recovery_eligible: capacity.recovery_eligible === true && capacity.answer_executor === "codex"}; diff --git a/loopx/control_plane/collaboration/steward_managed_mode.ts b/loopx/control_plane/collaboration/steward_managed_mode.ts index 852500b675..947ea1c2a3 100644 --- a/loopx/control_plane/collaboration/steward_managed_mode.ts +++ b/loopx/control_plane/collaboration/steward_managed_mode.ts @@ -12,6 +12,7 @@ import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; import {boundConversationExecutorMatches, goalAgentConversationIdentity, resolveConversationScope} from "./conversation_scope.ts"; import {readTurnSelectionRejection} from "../turn_driver/selection_rejection.ts"; import {EffectiveAction} from "../quota/effective_action.generated.ts"; +import {turnPlanWorkSource} from "../quota/turn_envelope.ts"; /** A stopped observation may get one new Chat carrier, never a new native * observation identity. The host independently reads the old worker lock, @@ -179,7 +180,7 @@ function admitReportSelectionRecovery(params: JsonObject, request: JsonObject, s const recorded = requireJsonObject(inspection.recorded_effects, "probe effects"); const plan = requireJsonObject(journal.plan, "probe plan"); const route = requireJsonObject(plan.route, "probe route"); - const envelope = requireJsonObject(plan.turn_envelope, "probe envelope"); + const envelope = turnPlanWorkSource(plan, probe.source_number_tokens).work; const action = requireJsonObject(envelope.action, "probe action"); const selected = requireJsonObject(action.selected_todo, "probe selected task"); const identity = requireJsonObject(requireJsonObject(requireJsonObject(plan.transaction, "probe transaction") diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index f9d7223893..b9cffda8da 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -242,6 +242,7 @@ function turnJournalInspectionRequest( turn_key: requiredString(request.turn_key, "turn_journal.inspect turn_key"), retry_failed: request.retry_failed === true, session_recovery_check: sessionRecoveryCheck, + source_number_tokens: request.source_number_tokens, }; } diff --git a/loopx/control_plane/quota/context_delivery.ts b/loopx/control_plane/quota/context_delivery.ts index dfec0e2f61..9c46fee8e5 100644 --- a/loopx/control_plane/quota/context_delivery.ts +++ b/loopx/control_plane/quota/context_delivery.ts @@ -1,6 +1,3 @@ -import {contextSourceIdentity, contextProjectionChanges, contextProjectionIdentity, - type ProjectionSourceValues} from "./diagnostic_projection.ts"; -import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; /** Presentation of immutable quota, Turn and work-context captures. No admission or state writes. * Confirmed final input and explicit native-hook continuity assumptions remain * distinct; successful capture handoff alone is never retained-input proof. */ @@ -9,6 +6,9 @@ import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_d import {canonicalAuthorityJson, canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {reusableTurnContext} from "../capability_hooks.ts"; import {COORDINATION_STATE_CONTRACT} from "../coordination/coordination_state_contract.generated.ts"; +import {contextSourceIdentity, contextProjectionChanges, contextProjectionIdentity, + type ProjectionSourceValues} from "./diagnostic_projection.ts"; +import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; const VERSION = "context_delivery_v1"; const MAX_ANCESTRY = 32; @@ -170,7 +170,7 @@ function splitContext(context: JsonObject, binding: JsonObject): Split { /** The projection surface is a transport binding, never a new admission mode. * Keep one exact codec for quota and Turn; source-only callers use the same codec. */ -const SURFACES = ["work_context", "quota_packet", "turn_envelope"] as const; +const SURFACES = ["work_context", "quota_packet", "turn_envelope", "complete_turn"] as const; type Surface = typeof SURFACES[number]; type Delta = {value: unknown} | {from: Path; edit?: Delta} | {changed?: Record; values?: Record; removed?: string[]} | {items: Record; values?: Record; length?: number}; diff --git a/loopx/control_plane/quota/diagnostic_projection.ts b/loopx/control_plane/quota/diagnostic_projection.ts index b9e9496f69..905948cb2d 100644 --- a/loopx/control_plane/quota/diagnostic_projection.ts +++ b/loopx/control_plane/quota/diagnostic_projection.ts @@ -8,6 +8,7 @@ import type {JsonObject} from "../effect_program.ts"; import {jsonObject, sourceNumberTokens} from "../runtime_decode.ts"; import {TODO_CANONICAL_READ_RECORD_FIELDS} from "../coordination/coordination_state_contract.ts"; import {canonicalAuthorityJson, authorityUnicodeCompare} from "../coordination/authority_store_codec.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; type ProjectionPath = (string | number)[]; type ProjectionChange = {path: ProjectionPath; value: unknown} | @@ -33,12 +34,24 @@ export function contextSourceIdentity(source: JsonObject, supplied?: unknown): (value: unknown, path: ProjectionPath) => string { if (supplied === undefined) return value => JSON.stringify(canonicalAuthorityJson(value)); const tokens = sourceNumberTokens(source, supplied); + if (!tokens.length) return value => JSON.stringify([canonicalAuthorityJson(value), []]); const paths = contextObjectPaths(source); + const byPath = new Map(tokens.map(item => [JSON.stringify(item.path), item.token])); return (value, path) => { - const original = value !== null && typeof value === "object" ? paths.get(value) : undefined; - const prefix = original ?? path; - const relative = tokens.filter(item => prefix.every((key, i) => item.path[i] === key)) - .map(item => [item.path.slice(prefix.length), item.token]); + const relative: [ProjectionPath, string][] = []; + // Follow retained objects, including containers moved by source edits. + // Removed carriers must not leave ghost numeric facts in the new digest. + const visit = (item: unknown, origin: ProjectionPath, target: ProjectionPath): void => { + if (item !== null && typeof item === "object") { + const original = paths.get(item) ?? origin; + if (Array.isArray(item)) item.forEach((child, i) => visit(child, [...original, i], [...target, i])); + else Object.entries(item).forEach(([key, child]) => visit(child, [...original, key], [...target, key])); + } else if (typeof item === "number") { + const token = byPath.get(JSON.stringify(origin)); + if (token !== undefined && Object.is(JSON.parse(token), item)) relative.push([target, token]); + } + }; + visit(value, path, []); // Sort encoded relative paths, so original object insertion order is not // mistaken for a content difference. Keep token strings separate from data. relative.sort((left, right) => authorityUnicodeCompare(JSON.stringify(left[0]), JSON.stringify(right[0]))); @@ -123,6 +136,64 @@ const DIAGNOSTIC_LANES = new Set([ "recent_completed_advancement_items", "completed_without_successor_items", "other_agent_bound_user_action_items", ]); + +/** Complete contracts keep one literal copy of equal summary lanes. A reference + * names an array in this same signed document, never a fresh Todo read. Only + * registered lanes participate; counts, candidate collections and unknown fields + * retain their original values. Consumers resolve before applying display caps. */ +export function resolveQuotaSummaryLanes(payload: JsonObject): JsonObject { + let result = payload; + for (const role of ["agent", "user"] as const) { + const root = `${role}_todo_summary`, summary = jsonObject(payload[root]); + if (!summary || summary.schema_version !== undefined && summary.schema_version !== "todo_summary_v0") continue; + let expanded = summary; + for (const lane of DIAGNOSTIC_LANES) { + const key = `${lane}_ref`; + if (!Object.hasOwn(summary, key)) continue; + const reference = summary[key]; + const prefix = `$.${root}.`; + const owner = typeof reference === "string" && reference.startsWith(prefix) + ? reference.slice(prefix.length) : ""; + if (Object.hasOwn(summary, lane) || !DIAGNOSTIC_LANES.has(owner) + || !Array.isArray(summary[owner]) || Object.hasOwn(summary, `${owner}_ref`)) { + throw new EffectRuntimeRequestError("Todo summary lane reference requires one literal array in the same summary"); + } + expanded = {...expanded, [lane]: summary[owner]}; + delete expanded[key]; + } + if (expanded !== summary) result = {...result, [root]: expanded}; + } + return result; +} + +export function referenceQuotaSummaryLanes(payload: JsonObject, + identity = contextSourceIdentity(payload)): JsonObject { + let result = resolveQuotaSummaryLanes(payload); + for (const role of ["agent", "user"] as const) { + const root = `${role}_todo_summary`, summary = jsonObject(result[root]); + if (!summary || summary.schema_version !== undefined && summary.schema_version !== "todo_summary_v0") continue; + const owners = new Map(); + let shared = summary; + // Retain the normally visible lane first. Full-source composition shares + // exact arrays, including any future item fields, without selecting work. + const preferred = role === "agent" ? "first_executable_items" : "first_open_items"; + for (const lane of [preferred, ...DIAGNOSTIC_LANES].filter((key, index, keys) => keys.indexOf(key) === index)) { + const value = summary[lane]; + if (!Array.isArray(value) || value.length === 0 || Object.hasOwn(summary, `${lane}_ref`)) continue; + const encoded = identity(value, [root, lane]); + const owner = owners.get(encoded); + if (!owner) { owners.set(encoded, lane); continue; } + const reference = `$.${root}.${owner}`; + const inlineBytes = Buffer.byteLength(JSON.stringify({[lane]: value}), "utf8"); + const referenceBytes = Buffer.byteLength(JSON.stringify({[`${lane}_ref`]: reference}), "utf8"); + if (inlineBytes <= referenceBytes) continue; + shared = {...shared, [`${lane}_ref`]: reference}; + delete shared[lane]; + } + if (shared !== summary) result = {...result, [root]: shared}; + } + return result; +} // The read codec adds these display fields to canonical records. Retain them // on visible items; they do not make a known diagnostic list a future schema. const ITEM_FIELDS = new Set([...TODO_CANONICAL_READ_RECORD_FIELDS, "index", "content_revision"]); diff --git a/loopx/control_plane/quota/execution_preparation.py b/loopx/control_plane/quota/execution_preparation.py index 3d9d840902..2b7de1d905 100644 --- a/loopx/control_plane/quota/execution_preparation.py +++ b/loopx/control_plane/quota/execution_preparation.py @@ -115,6 +115,12 @@ def apply_selected_todo_execution_preparation( or action_portfolio_requires_explicit_selection(payload) ): return + # Consume the same explicit non-delivery fact as the typed interaction + # owner. Planning/control-plane work does not execute the selected Todo; + # its lifecycle or lease cannot replace that original operation. Omitted + # facts retain the existing preparation checks for ordinary delivery. + if obligation is not None and obligation.get("delivery_allowed") is False: + return preparation = project_selected_todo_execution_preparation( runtime_root=runtime_root, goal_id=goal_id, diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 410efa97cb..d8d3edde20 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -245,12 +245,12 @@ export function progressiveContextPlan(request: JsonObject): JsonObject { const pathStartsWith = (path: Path, start: Path) => start.every((key, i) => path[i] === key); -function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSourceValues): +function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSourceValues, + plan = progressiveContextPlan({payload: request.context, surface: request.surface})): {context: JsonObject; tokens: [Path, string][]; projected: boolean} { const context = requireJsonObject(request.context, "current projection"); const details = requireJsonObject(request.native_projection, "supporting material reference"); const identity = contextSourceIdentity(request, request.source_number_tokens); - const plan = progressiveContextPlan({payload: context, surface: request.surface}); const deferred = plan.deferred as JsonObject[], references = (plan.references ?? []) as JsonObject[]; const instructions = plan.instructions as JsonObject[]; const at = (root: unknown, path: Path): unknown => path.reduce((row, key) => @@ -335,6 +335,14 @@ function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSour + "Uncertain or missing necessary facts must be recovered before dependent action; Agent review is not independent acceptance."}; } } + // A signed source and its presentation have different content hashes even + // when the only edit is a same-view Todo reference. Keep an address for the + // original and include its cost when deciding whether the projection wins. + const signedSource = request.surface === "complete_turn"; + const sourceNavigation = typeof details.command_template === "string" + ? {command_template: details.command_template} : {path: details.path, sha256: details.sha256}; + if (signedSource && required !== context && required.context_details === undefined) + required.context_details = sourceNavigation; const remaining = tokens.filter(([path]) => !removed.some(start => pathStartsWith(path, start))); // Unchanged source spellings cancel. Removed registered records contain only // safe integral numbers; sparse tokens retain their original float spelling. @@ -342,7 +350,21 @@ function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSour path[0] === "context" ? size + token.length - JSON.stringify(at(request, path)).length : size, 0); const projected = required !== context && Buffer.byteLength(JSON.stringify(required)) - Buffer.byteLength(JSON.stringify(context)) + adjustment(remaining) - adjustment(tokens) < 0; - return {context: projected ? required : context, tokens: projected ? remaining : tokens, projected}; + // A saved capture may predate the caller's native transport. Keep its signed + // source immutable; bind only this display recipe after the optional shrink + // decision, so caller options neither bypass source validation nor defeat its + // no-growth guard. The next invocation still runs the ordinary CLI guard. + const selectionPath = ["interaction_contract", "cli_channel", "selection_command", "command_args_template"]; + const transport = details.selection_command_args_template; + const bindTransport = typeof transport === "string" && typeof at(context, selectionPath) === "string" + && transport !== at(context, selectionPath); + if (!projected) { required = context; copies.clear(); } + if (bindTransport) { + parent(selectionPath)[selectionPath.at(-1)!] = transport; + if (signedSource && required.context_details === undefined) + required.context_details = sourceNavigation; + } + return {context: required, tokens: projected ? remaining : tokens, projected: projected || bindTransport}; } function nativeContextContinuity(codec: JsonObject): JsonObject { @@ -364,30 +386,32 @@ function nativeContextContinuity(codec: JsonObject): JsonObject { /** Plan and encode one native capture without sending its intermediate view * back through Python. Capture publication and native locking remain Host IO. */ export function prepareProgressiveContextDelivery(request: JsonObject, legacyFields: readonly string[], - decisionFields: readonly string[]): JsonObject { + decisionFields: readonly string[], plan?: JsonObject): JsonObject { if (request.include_initial_read_plan !== undefined) requireBoolean(request.include_initial_read_plan, "include_initial_read_plan"); const sourceValues: ProjectionSourceValues = new WeakMap(); - const required = requiredNativeContext(request, sourceValues); + const required = requiredNativeContext(request, sourceValues, plan); const codec = nativeContextContinuity({...request, context: required.context, ...(request.source_number_tokens === undefined ? {} : {source_number_tokens: required.tokens})}); const delivery = encodeContextDelivery(codec, legacyFields, sourceValues, fileNavigationPaths(required.context)); const observe = request.observe_first_read === true && !!jsonObject(required.context.selected_todo)?.todo_id; - const pointers = observe ? initialContextReadPointers(required.context, decisionFields) : []; - const observed = pointers.length ? observedFirstRead(codec, pointers) : undefined; + const navigation = request.include_initial_read_plan === true || observe + ? nativeContextReadPlan(required.context, decisionFields) : undefined; + const observed = observe && navigation?.initial + ? observedFirstRead(codec, initialContextReadPointers(required.context, decisionFields)) : undefined; const {operation: _operation, source_edits: _edits, source_number_tokens: _numbers, ...source} = request; return contextProjectionChanges(source, {context: required.context, delivery, projected: required.projected, ...(observed ? {observed_first_read: observed} : {}), read_sections: inlineReadSections(request, required.context), ...(request.include_initial_read_plan === true - ? {initial_read_plan: nativeContextReadPlan(required.context, decisionFields)} : {})}, sourceValues); + ? {initial_read_plan: navigation} : {})}, sourceValues); } /** Stateless presentation uses the same plan without inventing a native session, * retained baseline or admission identity. Hosts may add delta independently. */ -export function prepareProgressiveContext(request: JsonObject): JsonObject { +export function prepareProgressiveContext(request: JsonObject, plan?: JsonObject): JsonObject { const values: ProjectionSourceValues = new WeakMap(); - const required = requiredNativeContext(request, values); + const required = requiredNativeContext(request, values, plan); const {operation: _operation, source_edits: _edits, source_number_tokens: _numbers, ...source} = request; return contextProjectionChanges(source, {context: required.context, projected: required.projected}, values); } @@ -424,6 +448,27 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: replan.settlement_only !== true && typeof replan.review === "string" && replan.review) { include("replan_action_packet", "review"); include("replan_action_packet", "guidance"); + } else if (context.schema_version === "loopx_turn_envelope_v1" && + context.effective_action === EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED && replan?.schema_version === "replan_action_packet_v0" && + replan.decision === "replan_required" && + replan.settlement_only !== true) { + // The complete contract already carries the review facts: use its own + // obligations and the indexed outcome paths below, without another writer. + for (const field of ["planning_guidance", "required_outcome", "allowed_terminal"]) + include("replan_action_packet", field); + // An invalidated successor can reopen review under the same receipt. Show + // the current cause and basis before the agent chooses a writeback route. + const obligation = object(context.autonomous_replan_obligation); + const review = object(obligation?.replan_context); + if (obligation?.schema_version === "autonomous_replan_obligation_v0" && + typeof replan.obligation_id === "string" && obligation.obligation_id === replan.obligation_id && + review?.schema_version === "replan_context_v0" && review.obligation_id === replan.obligation_id) { + include("autonomous_replan_obligation", "obligation_id"); + include("autonomous_replan_obligation", "stop_condition"); + include("autonomous_replan_obligation", "replan_context", "context_id"); + if (Array.isArray(obligation.triggers)) obligation.triggers.forEach((_trigger, index) => + include("autonomous_replan_obligation", "triggers", index, "kind")); + } } include("agent_lane_next_action", "next_step"); include("agent_lane_next_action", "next_action_basis"); @@ -516,7 +561,7 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: } const planPath = ["interaction_contract", "cli_channel", "settlement_plan"]; const plan = object(object(interaction.cli_channel)?.settlement_plan); - if (plan?.schema_version === "quota_settlement_plan_v1" && Array.isArray(plan.ordered_steps)) { + if ((plan?.schema_version === "quota_settlement_plan_v1" || plan?.schema_version === "quota_settlement_plan_v2") && Array.isArray(plan.ordered_steps)) { for (const field of Object.keys(plan)) if (field !== "ordered_steps") include(...planPath, field); plan.ordered_steps.forEach((value, stepIndex) => { const step = object(value), stepPath = [...planPath, "ordered_steps", stepIndex]; @@ -561,6 +606,8 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: include("context_details", "unread_context_index", field); include("context_details", "instruction"); include("context_details", "command_template"); + } else if (details && known(details, ["command_template", "path", "sha256"])) { + include("context_details"); } else include("context_details", "unread_context_index"); return pointers; } diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 77f22ab38f..bdd77e3d3e 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -2,9 +2,7 @@ from __future__ import annotations -import json from collections.abc import Mapping -from dataclasses import dataclass from typing import Any from ..effect_runtime import effect_runtime_projection_result, source_number_tokens @@ -28,27 +26,6 @@ PLANNING_HORIZON_DETAIL_REFS_REF = "$.detail_ref" -@dataclass(frozen=True, init=False, repr=False, slots=True) -class TurnControllerInput: - """Local producer result; never an agent wire or a retained authority cache. - - The envelope and its typed route originate in one projection. Store the - envelope as immutable bytes so a caller cannot mutate it after routing; - each consumer receives its own decoded object. Ordinary wire dictionaries - cannot supply this qualification through an extra JSON field. - """ - - envelope_json: str - route: str - - def __init__(self) -> None: - raise TypeError("Use build_turn_controller_input with the original quota source") - - def envelope(self) -> dict[str, Any]: - envelope: dict[str, Any] = json.loads(self.envelope_json) - return envelope - - def _turn_envelope_result( operation: str, **params: object, @@ -56,14 +33,13 @@ def _turn_envelope_result( """Call the coarse TS owner and fail closed on transport shape drift.""" request = {"operation": operation, **params} - if operation == "reference_context" and ( - params.get("goal_acceptance") or params.get("vision_audit") or params.get("replan_authoring") - ): - tokens = source_number_tokens(params["payload"]) - if tokens: - request["source_number_tokens"] = tokens - elif operation in {"context_delivery", "context_recovery", "progressive_context_delivery", "context_read_sections"}: - tokens = source_number_tokens(params) + payload = params.get("payload") + complete = isinstance(payload, Mapping) and payload.get("schema_version") == "loopx_turn_envelope_v1" + token_source = payload if isinstance(payload, Mapping) else params + if complete or operation in {"reference_context", + "compose_complete", "context_delivery", "context_recovery", + "progressive_context_delivery", "context_read_sections"}: + tokens = source_number_tokens(token_source) if tokens: request["source_number_tokens"] = tokens result = effect_runtime_projection_result("quota.turn_envelope.evaluate", request) @@ -72,6 +48,120 @@ def _turn_envelope_result( return dict(result) +LOOPX_TURN_PLAN_SCHEMA_VERSION = "loopx_turn_plan_v0" +COMPLETE_TURN_PLAN_SCHEMA_VERSION = "loopx_turn_plan_v1" +LOOPX_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v0" +COMPLETE_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v1" + + +def _wire_mapping(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def turn_plan_host_source(request: Mapping[str, Any]) -> Mapping[str, Any]: + # Result validation and completion also read the private plan. Its schema + # is not a model wire version; adapt only its existing source carriers. + if request.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION: + if "work_contract" not in request or "turn_envelope" in request or "source_decision_hash" not in request: + raise ValueError("complete Turn plan requires one source without an executor adapter") + return {"schema_version": "loopx_turn_host_request_v1", + "turn_key": _wire_mapping(request.get("transaction")).get("turn_key"), + **{key: request[key] for key in ("work_contract", "source_decision_hash", "detail_ref") + if key in request}} + return ({key: request[key] for key in ("work_contract", "turn_envelope") if key in request} + if request.get("schema_version") == LOOPX_TURN_PLAN_SCHEMA_VERSION else request) + + +def captured_turn_host_input(request: Mapping[str, Any]) -> dict[str, Any]: + """Transport original-reader metadata after complete-source validation.""" + envelope = _wire_mapping(request.get("turn_envelope")) + model_input = {**{key: value for key, value in request.items() if key != "turn_envelope"}, + "schema_version": COMPLETE_TURN_HOST_REQUEST_SCHEMA} + detail = _wire_mapping(envelope.get("detail_ref")) + captured = _wire_mapping(detail.get("captured_decision")) + if captured: + model_input["source_decision_hash"] = _wire_mapping(envelope.get("action_signature"))["source_decision_hash"] + model_input["detail_ref"] = { + "full_decision": detail["full_decision"], + "captured_decision": {**captured, "source_hash_ref": "$.source_decision_hash"}, + } + readers = _wire_mapping(detail.get("replan_action_packet")) + if readers.get("review"): + model_input["detail_ref"]["replan_action_packet"] = { + key: value for key, value in readers.items() if key in {"review", "writeback"} + } + return model_input + + +def assert_turn_host_request_version(request: Mapping[str, Any]) -> None: + """Decode the two published Host transports; neither grants authority.""" + version = request.get("schema_version") + if version not in (LOOPX_TURN_HOST_REQUEST_SCHEMA, COMPLETE_TURN_HOST_REQUEST_SCHEMA): + raise ValueError("unsupported LoopX Turn host request schema") + if version == COMPLETE_TURN_HOST_REQUEST_SCHEMA and ( + "work_contract" not in request or "turn_envelope" in request + ): + raise ValueError("complete Host request requires work_contract without an executor adapter") + + +def complete_turn_host_source( + request: Mapping[str, Any], *, operation: str = "host_context", + include_session_lineage: bool = False, +) -> dict[str, Any] | None: + if "schema_version" in request: + assert_turn_host_request_version(request) + if "work_contract" not in request: + return None + header = ({"complete_host_request": {key: request[key] for key in ( + "schema_version", "turn_key", "source_decision_hash", "detail_ref", + ) if key in request}} if request.get("schema_version") == COMPLETE_TURN_HOST_REQUEST_SCHEMA else {}) + return _turn_envelope_result(operation, payload=request["work_contract"], + envelope=_wire_mapping(request.get("turn_envelope")), **header, + **({"include_session_lineage": True} if include_session_lineage else {})) + + +def selected_turn_todo(envelope: Mapping[str, Any]) -> dict[str, Any]: + """Transport historical selection to the shared work/settlement owner. + + Only Python's original scalar codec remains here. Pure source edits retain + unknown fields and exact numbers without another JSON value decoder. + """ + from ..quota.cli_projection import _apply_context_projection_changes + + orchestration = _wire_mapping(envelope.get("task_orchestration_contract")) + source = {"selected_todo": _wire_mapping(_wire_mapping(envelope.get("action")).get("selected_todo")), + "task_orchestration_contract": orchestration} + edits = _turn_envelope_result("selected_work_todo", payload=source, + historical_primary_text=str(orchestration.get("primary_todo_id") or "").strip()) + selected: dict[str, Any] = _apply_context_projection_changes(source, edits)["selected_todo"] + return selected + + +def read_turn_plan_work(plan: Mapping[str, Any]) -> dict[str, Any]: + """Read checked execution facts; current plans never regenerate a v0 view.""" + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION or "work_contract" in plan: + result = complete_turn_host_source(turn_plan_host_source(plan), operation="plan_work") + assert result is not None + return result + if plan.get("schema_version") not in (None, LOOPX_TURN_PLAN_SCHEMA_VERSION): + raise ValueError("unsupported Turn plan version") + return _wire_mapping(plan.get("turn_envelope")) + + +def turn_host_lineage(request: Mapping[str, Any]) -> dict[str, str]: + """Read identity from the complete owner, with historical request decoding.""" + checked = complete_turn_host_source(turn_plan_host_source(request), operation="complete_host_lineage") + if checked is not None: + return checked + envelope = _wire_mapping(request.get("turn_envelope")) + todo = selected_turn_todo(envelope) + return { + "goal_id": str(envelope.get("goal_id") or "").strip(), + "agent_id": str(envelope.get("agent_id") or "").strip(), + "todo_id": str(todo.get("todo_id") or "").strip(), + } + + def assert_turn_envelope_consumer_version(envelope: Mapping[str, Any]) -> None: """Use the typed wire-version gate before any Host consumes this packet.""" _turn_envelope_result("consumer_version", envelope=dict(envelope)) @@ -106,6 +196,27 @@ def turn_envelope_replan_settlement_identity( return dict(identity) +def compose_turn_decision( + payload: Mapping[str, Any], *, scheduler_execution_context: ( + Mapping[str, Any] | SchedulerExecutionContextResolution | None + ) = None, +) -> dict[str, Any]: + """Transport complete source facts to the existing typed composition owner.""" + resolution = resolve_scheduler_execution_context(scheduler_execution_context) + args = render_scheduler_execution_args(scheduler_execution_context=resolution) if resolution.ok and resolution.context is not None else "" + from .cli_projection import _apply_context_projection_changes + + source = dict(payload) + result = _turn_envelope_result("compose_complete", payload=source, + scheduler_execution_args=args, source_edits=True) + return _apply_context_projection_changes(source, result) + + +def assert_complete_turn_decision(payload: Mapping[str, Any]) -> None: + """Validate the original contract; keep its host binding and caller-owned bytes.""" + _turn_envelope_result("complete_consumer", payload=dict(payload)) + + def turn_envelope_scoped_settlement_identity( envelope: Mapping[str, Any], *, turn_instance_id: str | None, ) -> dict[str, Any] | None: @@ -126,39 +237,32 @@ def build_turn_envelope( ) -> dict[str, Any]: """Project one full quota decision through the canonical TS transaction.""" - return _build_turn_projection(payload, scheduler_execution_context=scheduler_execution_context, - captured_decision_path=captured_decision_path) - - -def build_turn_controller_input( - payload: Mapping[str, Any], *, scheduler_execution_context: object = None, -) -> TurnControllerInput: - """Build the managed controller's envelope and route in one existing RPC.""" - result = _build_turn_projection(payload, + return _build_turn_projection(payload, scheduler_execution_context=scheduler_execution_context, - operation="build_controller_input") - envelope, route = result.get("envelope"), result.get("route") - if not isinstance(envelope, Mapping) or not isinstance(route, str): - raise RuntimeError("TypeScript controller input must include an envelope and route") - projected = object.__new__(TurnControllerInput) - object.__setattr__(projected, "envelope_json", json.dumps(dict(envelope), ensure_ascii=False, - separators=(",", ":"))) - object.__setattr__(projected, "route", route) - return projected + captured_decision_path=captured_decision_path) def _build_turn_projection( - payload: Mapping[str, Any], *, scheduler_execution_context: Any = None, - captured_decision_path: str | None = None, operation: str = "build", + payload: Mapping[str, Any], *, scheduler_execution_context: ( + Mapping[str, Any] | SchedulerExecutionContextResolution | None + ) = None, + captured_decision_path: str | None = None, ) -> dict[str, Any]: + resolution = resolve_scheduler_execution_context(scheduler_execution_context) - scheduler_execution_args = "" + scheduler_execution_args: object = "" if resolution.ok and resolution.context is not None: scheduler_execution_args = render_scheduler_execution_args( scheduler_execution_context=resolution, ) + # A captured complete contract already records the original host binding. + # Only transport that value when no caller override was supplied; TS checks + # its integrity and rejects an explicit conflicting binding. + if scheduler_execution_context is None and payload.get("schema_version") == "loopx_turn_envelope_v1": + binding = payload.get("contract_binding") + scheduler_execution_args = binding.get("scheduler_execution_args") if isinstance(binding, Mapping) else None return _turn_envelope_result( - operation, + "build", payload=dict(payload), scheduler_execution_args=scheduler_execution_args, **({"captured_decision_path": captured_decision_path} @@ -167,13 +271,16 @@ def _build_turn_projection( def bind_turn_decision_capture( - payload: Mapping[str, Any], envelope: Mapping[str, Any], *, + payload: Mapping[str, Any], envelope: Mapping[str, Any] | None = None, *, captured_decision_path: str, turn_instance_id: str, turn_key: str, reader_argv: list[str] | None = None, + complete_host_request: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Validate and bind original native Turn evidence through the existing TS owner.""" return _turn_envelope_result( - "bind_capture", payload=dict(payload), envelope=dict(envelope), + "bind_capture", payload=dict(payload), + **({"complete_host_request": dict(complete_host_request)} if complete_host_request is not None + else {"envelope": dict(envelope or {})}), captured_decision_path=captured_decision_path, turn_instance_id=turn_instance_id, turn_key=turn_key, **({"reader_argv": reader_argv} if reader_argv is not None else {}), diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 52ec2676d1..28d267e559 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,13 +1,16 @@ import {capturedContextSelection} from "./context_selection.ts"; -import {contextReadSectionDigests, initialContextReadPointers, prepareProgressiveContext, prepareProgressiveContextDelivery} from "./progressive_context.ts"; -import {contextProjectionChanges, contextSourceIdentity, projectQuotaDiagnostics} from "./diagnostic_projection.ts"; +import {contextReadSectionDigests, initialContextReadPointers, prepareProgressiveContext, prepareProgressiveContextDelivery, + progressiveContextPlan} from "./progressive_context.ts"; import type { LoopXTurnRouteValue } from "../turn_driver/turn_contract_generated.ts"; -import { EffectiveAction, type QuotaEffectiveActionValue } from "./effective_action.generated.ts"; +import { EFFECTIVE_ACTIONS, EffectiveAction, type QuotaEffectiveActionValue } from "./effective_action.generated.ts"; +import { AGENT_SCOPE_FRONTIER_ACTIONS } from "../agents/agent_scope_frontier.generated.ts"; import { createHash } from "node:crypto"; import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; import {omitUnboundExploreResultWriteback, referenceExploreResultWriteback} from "../capabilities/explore_turn_context.ts"; import {prepareContextDelivery, restoreContextDelivery} from "./context_delivery.ts"; import {updateNativeContextLifecycle} from "./native_context_lifecycle.ts"; +import {contextProjectionChanges, contextSourceIdentity, projectQuotaDiagnostics, referenceQuotaSummaryLanes, + resolveQuotaSummaryLanes} from "./diagnostic_projection.ts"; import {referenceExploreResultDelivery} from "../capabilities/explore_result_writeback.ts"; import { @@ -19,9 +22,10 @@ import { } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { projectTurnStartUnavailableContext } from "../capability_hooks.ts"; -import { jsonObject, requireBoolean, requireJsonObject, requireNonEmptyString, requireStringLiteral } from "../runtime_decode.ts"; +import { jsonObject, requireBoolean, requireJsonObject, requireNonEmptyString, requireStringLiteral, + sourceNumberTokens } from "../runtime_decode.ts"; import { projectPendingCapabilityIntent } from "../work_items/pending_capability_intent.ts"; -import { projectInteractionRequiredReads, selectedWorkTodo, selectedWorkTodoId, USER_CHANNEL_NOTIFICATION_POLICIES } from "../work_items/interaction_contract.ts"; +import { assertGoalContextDelivered, assertInteractionRequiredReads, capturedTodoTextSha256, decodeInteractionContract, projectInteractionRequiredReads, selectedWorkTodo, selectedWorkTodoId, USER_CHANNEL_NOTIFICATION_POLICIES } from "../work_items/interaction_contract.ts"; import { measureTurnEnvelope, TURN_ENVELOPE_SECTION_TARGETS } from "./turn_envelope_budget.ts"; export { TURN_ENVELOPE_BUDGET_BYTES } from "./turn_envelope_budget.ts"; @@ -228,70 +232,13 @@ function canonicalHash(value: unknown): string { return `sha256:${createHash("sha256").update(JSON.stringify(canonicalValue(value)), "utf8").digest("hex")}`; } -function replanAuthoringContract(payload: JsonObject): JsonObject { - const contract = object(object(payload.replan_action_packet).writeback_contract); - if (!Object.hasOwn(contract, "vision_authoring_ref")) return contract; - const reference = contract.vision_authoring_ref; - const match = typeof reference === "string" - ? /^\$\.interaction_contract\.cli_channel\.settlement_plan\.ordered_steps\[(0|[1-9]\d*)\]\.vision_authoring$/.exec(reference) : null; - const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; - const step = object(match && Array.isArray(steps) ? steps[Number(match[1])] : null); - if (Object.hasOwn(contract, "vision_authoring") || step.kind !== "durable_writeback" || - Object.hasOwn(step, "vision_authoring_ref") || !Object.keys(object(step.vision_authoring)).length) { - throw new EffectRuntimeRequestError("Replan authoring reference must resolve to one complete inline durable-writeback owner"); - } - delete contract.vision_authoring_ref; - return {...contract, vision_authoring: step.vision_authoring}; -} - -function referenceReplanAuthoring(payload: JsonObject, - identity = contextSourceIdentity(payload)): JsonObject { - const packet = object(payload.replan_action_packet); - const contract = object(packet.writeback_contract); - if (Object.hasOwn(contract, "vision_authoring_ref")) { - replanAuthoringContract(payload); - return payload; - } - const authoring = object(contract.vision_authoring); - const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; - if (!Object.keys(authoring).length || !Array.isArray(steps)) return payload; - const encoded = JSON.stringify(canonicalValue(authoring)); - const source = identity(authoring, ["replan_action_packet", "writeback_contract", "vision_authoring"]); - const index = steps.findIndex((value, index) => { - const step = object(value); - return step.kind === "durable_writeback" && !Object.hasOwn(step, "vision_authoring_ref") && - jsonObject(step.vision_authoring) !== null && - identity(step.vision_authoring, ["interaction_contract", "cli_channel", "settlement_plan", - "ordered_steps", index, "vision_authoring"]) === source; - }); - if (index < 0) return payload; - const reference = `$.interaction_contract.cli_channel.settlement_plan.ordered_steps[${index}].vision_authoring`; - if (Buffer.byteLength(encoded, "utf8") <= Buffer.byteLength(reference, "utf8") + 8) return payload; - delete contract.vision_authoring; - return {...payload, replan_action_packet: {...packet, writeback_contract: { - ...contract, vision_authoring_ref: reference}}}; -} - -function resolveExploreGuidance(payload: JsonObject, context: JsonObject): JsonObject { - if (!Array.isArray(context.sources)) return context; - const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; - return {...context, sources: context.sources.map(value => { - const source = object(value), content = object(source.content), graph = object(content.graph); - if (source.kind !== "explore_turn_context" || !Object.hasOwn(graph, "result_writeback_guidance_ref")) return value; - const reference = graph.result_writeback_guidance_ref; - const match = typeof reference === "string" - ? /^\$\.interaction_contract\.cli_channel\.settlement_plan\.ordered_steps\[(0|[1-9]\d*)\]\.optional_attachments\[(0|[1-9]\d*)\]\.guidance$/.exec(reference) : null; - const step = object(match && Array.isArray(steps) ? steps[Number(match[1])] : null); - const attachment = object(match && Array.isArray(step.optional_attachments) - ? step.optional_attachments[Number(match[2])] : null); - if (Object.hasOwn(graph, "result_writeback_guidance") || step.kind !== "durable_writeback" || - attachment.capability_id !== "explore" || Object.hasOwn(attachment, "guidance_ref") || - typeof attachment.guidance !== "string" || !attachment.guidance) { - throw new EffectRuntimeRequestError("Explore guidance reference must resolve to one inline durable-writeback Explore attachment"); - } - delete graph.result_writeback_guidance_ref; - return {...source, content: {...content, graph: {...graph, result_writeback_guidance: attachment.guidance}}}; - })}; +/** Complete references reuse the checked content binding. Historical captures + * retain their old JSON hash; never rewrite a stored action or Turn key. */ +function decisionSourceHash(payload: JsonObject): string { + if (payload.schema_version !== COMPLETE_TURN_DECISION_VERSION) return canonicalHash(payload); + const digest = object(payload.contract_binding).content_hash; + if (typeof digest !== "string") throw new EffectRuntimeRequestError("complete Turn decision content binding is missing"); + return digest; } /** Existing acceptance carriers only: this is not a general JSON-path codec. @@ -399,6 +346,141 @@ function referenceGoalAcceptance(payload: JsonObject, return result; } +/** One registry for ordinary captured reads and staged replan. Only registered + * acceptance carriers participate; unrelated objects named `outcomes` are not + * sources. Equal content shares a reference, while differing revisions of the + * same outcome retain their complete bodies, including unknown fields. + */ +function capturedAcceptanceOutcomes(payload: JsonObject): Map | null { + const outcomes = new Map(); + const carriers = acceptanceCarriers(payload); + // Sharing prefers the first source, but a diagnostic index must cover every + // loaded registered carrier, including different same-id revisions. + const sources = object(object(object(payload.interaction_contract).agent_channel).work_context).sources; + if (Array.isArray(sources)) for (const [index, value] of sources.entries()) { + const source = object(value), content = object(source.content); + if (source.source === "goal_acceptance" && (content.status == null || content.status === "loaded")) { + carriers.push({parent: content, field: "goal_acceptance_contract", path: "", + parentPath: ["interaction_contract", "agent_channel", "work_context", "sources", index, "content"]}); + } + } + let hasSource = false; + for (const {parent, field} of carriers) { + const acceptance = Object.hasOwn(parent, `${field}_ref`) + ? inlineAcceptance(payload, parent[`${field}_ref`]) : object(parent[field]); + if (!Object.keys(acceptance).length) continue; + hasSource = true; + const requirements = object(acceptance.outcome_requirements); + if (!Object.hasOwn(acceptance, "outcome_requirements")) continue; + if (!Array.isArray(requirements.outcomes)) throw new EffectRuntimeRequestError("captured outcome source is unsupported"); + for (const value of requirements.outcomes) { + const outcome = requireJsonObject(value, "captured acceptance outcome"); + outcomes.set(canonicalHash(outcome), outcome); + } + } + return hasSource ? outcomes : null; +} + +/** Expose existing facts, not another acceptance verdict. Missing values stay + * absent; an Agent's `met` review is not a current independent check. + */ +function capturedOutcomeIndex(outcomes: ReadonlyMap): JsonObject[] { + return [...outcomes].map(([ref, outcome]) => { + const states = new Map(); + if (Array.isArray(outcome.requirements)) for (const value of outcome.requirements) { + const requirement = requireJsonObject(value, "captured outcome requirement"); + const review = jsonObject(requirement.agent_review); + const state = { + ...Object.fromEntries(["status", "planning_satisfied"].filter(key => Object.hasOwn(requirement, key)) + .map(key => [key, requirement[key]])), + ...(Object.hasOwn(requirement, "agent_review") ? {agent_review: review === null + ? requirement.agent_review : Object.fromEntries( + ["conclusion", "basis_current", "independently_verified"].filter(key => Object.hasOwn(review, key)) + .map(key => [key, review[key]]))} : {}), + }; + const key = JSON.stringify(canonicalValue(state)); + const group = states.get(key); + if (group) (group.requirement_ids as unknown[]).push(requirement.id); + else states.set(key, {requirement_ids: [requirement.id], ...state}); + } + return {outcome_ref: ref, id: outcome.id, objective: outcome.objective, revision: outcome.revision, + ...Object.fromEntries(["owner_agent_id", "work", ...(Object.hasOwn(outcome, "work") ? [] : ["todo_ids"])].filter(key => Object.hasOwn(outcome, key)) + .map(key => [key, outcome[key]])), + requirement_count: Array.isArray(outcome.requirements) ? outcome.requirements.length : null, + requirement_states: Array.isArray(outcome.requirements) ? [...states.values()] : null}; + }); +} + +/** Reuse the existing settlement authoring owner, never a summary or a chain. */ +function replanAuthoringContract(payload: JsonObject): JsonObject { + const contract = object(object(payload.replan_action_packet).writeback_contract); + if (!Object.hasOwn(contract, "vision_authoring_ref")) return contract; + const reference = contract.vision_authoring_ref; + const match = typeof reference === "string" + ? /^\$\.interaction_contract\.cli_channel\.settlement_plan\.ordered_steps\[(0|[1-9]\d*)\]\.vision_authoring$/.exec(reference) : null; + const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; + const step = object(match && Array.isArray(steps) ? steps[Number(match[1])] : null); + if (Object.hasOwn(contract, "vision_authoring") || step.kind !== "durable_writeback" || + Object.hasOwn(step, "vision_authoring_ref") || !Object.keys(object(step.vision_authoring)).length) { + throw new EffectRuntimeRequestError("Replan authoring reference must resolve to one complete inline durable-writeback owner"); + } + delete contract.vision_authoring_ref; + return {...contract, vision_authoring: step.vision_authoring}; +} + +function referenceReplanAuthoring(payload: JsonObject, + identity = contextSourceIdentity(payload)): JsonObject { + const packet = object(payload.replan_action_packet); + const contract = object(packet.writeback_contract); + if (Object.hasOwn(contract, "vision_authoring_ref")) { + replanAuthoringContract(payload); + return payload; + } + const authoring = object(contract.vision_authoring); + const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; + if (!Object.keys(authoring).length || !Array.isArray(steps)) return payload; + const encoded = JSON.stringify(canonicalValue(authoring)); + const source = identity(authoring, ["replan_action_packet", "writeback_contract", "vision_authoring"]); + const index = steps.findIndex((value, index) => { + const step = object(value); + return step.kind === "durable_writeback" && !Object.hasOwn(step, "vision_authoring_ref") && + jsonObject(step.vision_authoring) !== null && + identity(step.vision_authoring, ["interaction_contract", "cli_channel", "settlement_plan", + "ordered_steps", index, "vision_authoring"]) === source; + }); + if (index < 0) return payload; + const reference = `$.interaction_contract.cli_channel.settlement_plan.ordered_steps[${index}].vision_authoring`; + if (Buffer.byteLength(encoded, "utf8") <= Buffer.byteLength(reference, "utf8") + 8) return payload; + delete contract.vision_authoring; + return {...payload, replan_action_packet: {...packet, writeback_contract: { + ...contract, vision_authoring_ref: reference}}}; +} + +/** Same-capture references have one typed settlement owner. Historical readers + * expand before moving work context, so a full-packet path never dangles in a + * staged envelope. Explore's independent read and domain guidance stay intact. */ +function resolveExploreGuidance(payload: JsonObject, context: JsonObject): JsonObject { + if (!Array.isArray(context.sources)) return context; + const steps = object(object(object(payload.interaction_contract).cli_channel).settlement_plan).ordered_steps; + return {...context, sources: context.sources.map(value => { + const source = object(value), content = object(source.content), graph = object(content.graph); + if (source.kind !== "explore_turn_context" || !Object.hasOwn(graph, "result_writeback_guidance_ref")) return value; + const reference = graph.result_writeback_guidance_ref; + const match = typeof reference === "string" + ? /^\$\.interaction_contract\.cli_channel\.settlement_plan\.ordered_steps\[(0|[1-9]\d*)\]\.optional_attachments\[(0|[1-9]\d*)\]\.guidance$/.exec(reference) : null; + const step = object(match && Array.isArray(steps) ? steps[Number(match[1])] : null); + const attachment = object(match && Array.isArray(step.optional_attachments) + ? step.optional_attachments[Number(match[2])] : null); + if (Object.hasOwn(graph, "result_writeback_guidance") || step.kind !== "durable_writeback" || + attachment.capability_id !== "explore" || Object.hasOwn(attachment, "guidance_ref") || + typeof attachment.guidance !== "string" || !attachment.guidance) { + throw new EffectRuntimeRequestError("Explore guidance reference must resolve to one inline durable-writeback Explore attachment"); + } + delete graph.result_writeback_guidance_ref; + return {...source, content: {...content, graph: {...graph, result_writeback_guidance: attachment.guidance}}}; + })}; +} + function referenceExploreGuidance(payload: JsonObject): JsonObject { const interaction = object(payload.interaction_contract), agent = object(interaction.agent_channel); const context = object(agent.work_context); @@ -494,6 +576,9 @@ function referenceVisionContinuation(payload: JsonObject, identity(audit, path) === source ? structuredClone(reference) : audit); } +/** The verified current Task is the only owner of a shared lane title. Keep + * the original declaration inline and require exact identity, never a label or + * prefix comparison. The legacy display and complete writer use this owner. */ function currentTaskTitleOwner(payload: JsonObject): {index: number; todo: JsonObject} | null { const lane = object(payload.agent_lane_next_action), selected = object(payload.selected_todo); const context = object(object(object(payload.interaction_contract).agent_channel).work_context); @@ -510,6 +595,20 @@ function currentTaskTitleOwner(payload: JsonObject): {index: number; todo: JsonO return {index, todo}; } +function requireCurrentTaskTitleReference(payload: JsonObject): void { + const lane = object(payload.agent_lane_next_action); + if (!Object.hasOwn(lane, "title_ref")) return; + const reference = lane.title_ref; + const match = typeof reference === "string" + ? /^\$\.interaction_contract\.agent_channel\.work_context\.sources\[(0|[1-9]\d*)\]\.content\.todo\.(text|title)$/.exec(reference) : null; + const owner = currentTaskTitleOwner(payload); + if (Object.hasOwn(lane, "title") || !match || !owner || owner.index !== Number(match[1]) || + typeof owner.todo[match[2]] !== "string" || !owner.todo[match[2]] || + Object.hasOwn(owner.todo, `${match[2]}_ref`)) { + throw new EffectRuntimeRequestError("Current Task title reference must resolve to the complete verified selected Task"); + } +} + function referenceCurrentTaskTitle(payload: JsonObject): JsonObject { const lane = object(payload.agent_lane_next_action), title = lane.title; // Preserve historical caller-owned references; complete readers validate them @@ -578,7 +677,7 @@ function selectedTodo(payload: JsonObject, recommendedAction: string | null): Js /** These reads stay on one captured observation; they never re-enter the guard. */ function replanReadCommands(payload: JsonObject, path = payload.replan_read_path, - argv = payload.replan_read_argv ?? ["loopx"]): JsonObject | null { + argv = payload.replan_read_argv ?? ["loopx"], verifiedSourceHash?: string): JsonObject | null { const packet = object(payload.replan_action_packet); if (typeof path !== "string" || !path.trim() || path.includes("\0") || payload.ok !== true || payload.effective_action === EffectiveAction.GOVERNED_CAPABILITY_INTENT || @@ -589,7 +688,7 @@ function replanReadCommands(payload: JsonObject, path = payload.replan_read_path throw new EffectRuntimeRequestError("captured replan CLI route is malformed"); } const command = `${argv.map(arg => shellQuote(arg as string)).join(" ")} --format json quota replan-view --decision-file ${shellQuote(path)}` + - ` --source-hash ${canonicalHash(payload)}`; + ` --source-hash ${verifiedSourceHash ?? decisionSourceHash(payload)}`; return {review: `${command} --replan-stage review`, writeback: `${command} --replan-stage writeback`, acceptance: `${command} --replan-stage acceptance`, explore: `${command} --replan-stage explore`}; } @@ -610,7 +709,10 @@ function deferredReplanWriteback(writeback: JsonObject): JsonObject { */ function replanGoalContext(payload: JsonObject, reads: JsonObject | null, coreRef = "$.core_goal") { const context = object(object(payload.autonomous_replan_obligation).replan_context); - const outcomes = new Map(); + const complete = payload.schema_version === COMPLETE_TURN_DECISION_VERSION; + // Recorded quota/v0 reads retain their bounded original index. The complete + // source indexes every registered carrier and its current requirement facts. + const outcomes = (reads && complete ? capturedAcceptanceOutcomes(payload) : null) ?? new Map(); function indexedGoal(value: unknown): JsonObject { const core = {...object(value)}; if (!("acceptance_contract" in core) && "acceptance_contract_ref" in core) { @@ -643,19 +745,31 @@ function replanGoalContext(payload: JsonObject, reads: JsonObject | null, coreRe } const acceptanceIndex = outcomes.size ? { read_action_template: `${reads!.acceptance} --outcome-ref {outcome_ref}`, - outcomes: [...outcomes].map(([ref, outcome]) => ({outcome_ref: ref, id: outcome.id, - objective: outcome.objective, revision: outcome.revision, + outcomes: complete ? capturedOutcomeIndex(outcomes) : [...outcomes].map(([ref, outcome]) => ({ + outcome_ref: ref, id: outcome.id, objective: outcome.objective, revision: outcome.revision, requirement_count: Array.isArray(outcome.requirements) ? outcome.requirements.length : null})), } : null; return {core, horizon, outcomes, acceptanceIndex}; } +/** Applicability of Explore's existing attachment owner, shared by complete + * composition and the historical staged projection. No authority is granted. */ +function unboundExploreReplan(payload: JsonObject, plan: JsonObject): boolean { + const steps = Array.isArray(plan.ordered_steps) ? plan.ordered_steps.map(object) : []; + const attachments = object(steps.find(step => step.kind === "durable_writeback")).optional_attachments; + const explore = Array.isArray(attachments) ? attachments.map(object).filter(row => row.capability_id === "explore") : []; + const identity = object(plan.identity); + return payload.effective_action === EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED && + selectedWorkTodoId(payload) === null && identity.binding_kind === "autonomous_replan" && + identity.replan_obligation_id === object(payload.replan_action_packet).obligation_id && + !identity.todo_id && explore.length === 0; +} + /** Move only an identical acceptance copy behind the existing captured read. * Different revisions, other source fields and unavailable context stay intact. */ -function replanWorkContext(payload: JsonObject, context: JsonObject): JsonObject { +function replanWorkContext(payload: JsonObject, context: JsonObject, reads = replanReadCommands(payload)): JsonObject { context = resolveExploreGuidance(payload, context); - const reads = replanReadCommands(payload); const core = object(object(object(payload.autonomous_replan_obligation).replan_context).core_goal); const acceptance = object(core.acceptance_contract); if (!reads || !Array.isArray(context.sources)) return context; @@ -663,11 +777,7 @@ function replanWorkContext(payload: JsonObject, context: JsonObject): JsonObject const steps = Array.isArray(plan.ordered_steps) ? plan.ordered_steps.map(object) : []; const attachments = object(steps.find(step => step.kind === "durable_writeback")).optional_attachments; const explore = Array.isArray(attachments) ? attachments.map(object).filter(row => row.capability_id === "explore") : []; - const identity = object(plan.identity); - const unboundReplan = payload.effective_action === EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED && - selectedWorkTodoId(payload) === null && identity.binding_kind === "autonomous_replan" && - identity.replan_obligation_id === object(payload.replan_action_packet).obligation_id && - !identity.todo_id && explore.length === 0; + const unboundReplan = unboundExploreReplan(payload, plan); return {...context, sources: context.sources.map(value => { const source = object(value), content = object(source.content); if (source.source === "turn_start_capability_hook" && source.hook_id === "explore.turn_context" && @@ -685,12 +795,39 @@ function replanWorkContext(payload: JsonObject, context: JsonObject): JsonObject })}; } +/** Bind successor authoring to the already-decided Turn. This formatting + * owner is shared by complete composition and historical staged reads; it does + * not select work, create a successor or change the settlement plan. + */ +function bindReplanSuccessorContract(value: JsonObject, plan: JsonObject): JsonObject { + const contract = {...value}; + const identity = object(plan.identity); + if (typeof identity.turn_instance_id !== "string") return contract; + const goalRef = object(plan.goal_ref); + contract.successor_binding_args = `--turn-instance-id ${shellQuote(identity.turn_instance_id)}` + + (typeof goalRef.goal_instance_id === "string" ? ` --goal-instance-id ${shellQuote(goalRef.goal_instance_id)}` : ""); + contract.successor_settlement = "If creating a replan successor, include successor_binding_args with its obligation id, then follow the creation receipt's original Turn settlement plan before ending; do not run the successor."; + delete contract.successor_host_action; + if (typeof contract.successor_command === "string") { + contract.successor_command = `${contract.successor_command} ${contract.successor_binding_args}`; + } + return contract; +} + function replanView(request: JsonObject): JsonObject { const payload = requireJsonObject(request.payload, "captured replan decision"); - if (request.source_hash !== canonicalHash(payload) || payload.ok !== true) { + const sourceHash = decisionSourceHash(payload); + if (request.source_hash !== sourceHash || payload.ok !== true) { throw new EffectRuntimeRequestError("Replan read requires the original successful decision and matching source hash"); } - requireVisionAuditReferences(payload); + // A matching file hash proves which bytes were read, not whether a modern + // contract's version and inner binding are supported. Historical unversioned + // captures keep their decoder; modern captures must pass their shared owner. + if (Object.hasOwn(payload, "schema_version") || Object.hasOwn(payload, "contract_binding")) { + requireCompleteTurnDecision(payload, request.source_number_tokens); + } else { + requireVisionAuditReferences(payload); + } replanAuthoringContract(payload); const packet = object(payload.replan_action_packet); const obligation = object(payload.autonomous_replan_obligation); @@ -704,7 +841,7 @@ function replanView(request: JsonObject): JsonObject { // A native journal binds its path after planning. The CLI supplies that same // physical source route without modifying the captured observation or hash. const path = request.captured_decision_path ?? payload.replan_read_path; - const reads = replanReadCommands(payload, path, payload.replan_read_argv ?? request.reader_argv ?? ["loopx"]); + const reads = replanReadCommands(payload, path, payload.replan_read_argv ?? request.reader_argv ?? ["loopx"], sourceHash); const sourceRead = reads ? {source_read: `cat -- ${shellQuote(path as string)}`} : {}; if (request.stage === "acceptance") { const {outcomes} = replanGoalContext(payload, reads); @@ -726,25 +863,18 @@ function replanView(request: JsonObject): JsonObject { delete authoring.minimal_example; contract.vision_authoring = authoring; } - const writeback = actionProjection(payload, false).writeback; - const plan = object(object(writeback).settlement_plan); - const identity = object(plan.identity); - if (typeof identity.turn_instance_id === "string") { - const goalRef = object(plan.goal_ref); - contract.successor_binding_args = `--turn-instance-id ${shellQuote(identity.turn_instance_id)}` + - (typeof goalRef.goal_instance_id === "string" ? ` --goal-instance-id ${shellQuote(goalRef.goal_instance_id)}` : ""); - contract.successor_settlement = "If creating a replan successor, include successor_binding_args with its obligation id, then follow the creation receipt's original Turn settlement plan before ending; do not run the successor."; - delete contract.successor_host_action; - if (typeof contract.successor_command === "string") { - contract.successor_command = `${contract.successor_command} ${contract.successor_binding_args}`; - // Creation returns the original settlement plan directly. A guard - // reentry here would repeat admission/context and can select new work. - object(writeback).next_cli_actions = [contract.successor_command]; - } + const writeback = object(actionProjection(payload, false, sourceHash).writeback); + const plan = object(writeback.settlement_plan); + const boundContract = payload.schema_version === COMPLETE_TURN_DECISION_VERSION + ? contract : bindReplanSuccessorContract(contract, plan); + if (typeof boundContract.successor_command === "string" && typeof object(plan.identity).turn_instance_id === "string") { + // Creation returns the original settlement plan directly. A guard + // reentry would repeat admission/context and can select new work. + writeback.next_cli_actions = [boundContract.successor_command]; } const steps = Array.isArray(plan.ordered_steps) ? plan.ordered_steps.map(object) : []; const refresh = steps.find(step => step.kind === "durable_writeback"); - return {...common, ...sourceRead, writeback_contract: contract, + return {...common, ...sourceRead, writeback_contract: boundContract, required_outcome: packet.required_outcome, allowed_terminal: packet.allowed_terminal, ...(packet.stride_control ? {stride_control: stride} : {}), writeback, @@ -1094,7 +1224,12 @@ function contractCapsule(payload: JsonObject): JsonObject { return capsule; } -function actionProjection(payload: JsonObject, deferSettlement = true): JsonObject { +function actionProjection(payload: JsonObject, deferSettlement = true, verifiedSourceHash?: string): JsonObject { + // Reader identity belongs to the original captured source. Expanding shared + // diagnostic lanes for the historical executor must not hash that local view. + const reads = payload.effective_action === EffectiveAction.GOVERNED_CAPABILITY_INTENT + ? null : replanReadCommands(payload, payload.replan_read_path, payload.replan_read_argv ?? ["loopx"], verifiedSourceHash); + if (payload.schema_version === COMPLETE_TURN_DECISION_VERSION) payload = resolveQuotaSummaryLanes(payload); const agentIdentity = object(payload.agent_identity); const semanticAgentId = scalarString( payload.agent_id || agentIdentity.agent_id, @@ -1111,12 +1246,12 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje ? projectPendingCapabilityIntent(payload.pending_capability_intent) : null; // A governed capability action has already won the live decision. Stale // replan/host-reentry projections must not replace its exact command. - const replanPacket = capabilityIntent ? null : replanActionPacket(payload); + const replanPacket = capabilityIntent ? null : replanActionPacket(payload, reads); const replanSettlementOnly = object(payload.replan_action_packet).settlement_only === true; const recommendedAction = replanPacket ? replanSettlementOnly ? scalarString(object(object(payload.replan_action_packet).writeback_contract).rule, "replan settlement rule") - : replanReadCommands(payload) ? "read replan_action_packet.review, decide from state, then follow review.next" + : reads ? "read replan_action_packet.review, decide from state, then follow review.next" : "apply replan_action_packet and emit one required semantic outcome" : text(turn.observation.recommended_action || payload.recommended_action, 480); // The signed host action is executable authority, not a display summary. @@ -1125,7 +1260,7 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje const action: JsonObject = { recommended_action: recommendedAction, primary_action: replanPacket && !replanSettlementOnly - ? replanReadCommands(payload)?.review ?? "produce one required semantic outcome" + ? reads?.review ?? "produce one required semantic outcome" : primaryAction || null, must_attempt: Boolean(agentChannel.must_attempt), delivery_allowed: Boolean(agentChannel.delivery_allowed), @@ -1136,7 +1271,6 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje if (turn.observation.action_portfolio !== null) { action.action_portfolio = { ...turn.observation.action_portfolio }; } - const reads = capabilityIntent ? null : replanReadCommands(payload); if (turn.observation.planning_horizon !== null) action.planning_horizon = {...turn.observation.planning_horizon}; if (reads && deferSettlement) { const goal = replanGoalContext(payload, reads, "$.action.core_goal"); @@ -1232,7 +1366,7 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje && (typeof object(action.selected_todo).text === "string" || object(action.selected_todo).text_ref === "action.recommended_action"); if (Object.keys(workContext).length > 0 && !selectedTodoContextIsAlreadyInAction) { - projection.work_context = replanWorkContext(payload, workContext); + projection.work_context = replanWorkContext(payload, workContext, reads); } const orchestration = object(payload.task_orchestration_contract); if (Object.keys(orchestration).length > 0) projection.task_orchestration_contract = orchestration; @@ -1450,6 +1584,313 @@ function coldPath( }; } +/** Complete decision composition precedes capture. Domain owners have already + * decided admission, work and settlement; this boundary does not decide again. + * The v0 envelope remains an internal executor adapter during reader migration. + */ +export const COMPLETE_TURN_DECISION_VERSION = "loopx_turn_envelope_v1"; +// Sparse producer metadata is a different identity from the earlier +// experimental all-float binding; its saved contracts need their pinned reader. +const COMPLETE_TURN_HASH_CODEC = "context_source_numbers_v2"; + +function requireCompleteDecisionSource(source: JsonObject): void { + assertSupportedQuotaPacket(source); + // Validate executable semantics with their owner, retaining the original + // object (including optional facts) rather than its normalized decode. + const interaction = decodeInteractionContract(source.interaction_contract); + assertInteractionRequiredReads(interaction.agent_channel.required_reads); + replanAuthoringContract(source); + requireVisionAuditReferences(source); + requireCurrentTaskTitleReference(source); + resolveQuotaSummaryLanes(source); + resolveExploreGuidance(source, object(object(object(source.interaction_contract).agent_channel).work_context)); + // This root field selects execution, unlike optional evidence carried intact + // in the source. Reuse both registered owners; do not inherit the historical + // adapter's unknown-action fallback at the new version boundary. + requireStringLiteral(source.effective_action, + [...EFFECTIVE_ACTIONS, ...AGENT_SCOPE_FRONTIER_ACTIONS], "Turn effective_action"); + for (const {parent, field} of acceptanceCarriers(source)) { + if (!Object.hasOwn(parent, field) && Object.hasOwn(parent, `${field}_ref`)) { + inlineAcceptance(source, parent[`${field}_ref`]); + } + } +} + +function completeSourceHash(value: JsonObject, identity = contextSourceIdentity(value, [])): string { + return `sha256:${createHash("sha256").update(identity(value, []), "utf8").digest("hex")}`; +} + +export function requireCompleteTurnDecision(value: unknown, numberTokens?: unknown): JsonObject { + const decision = requireJsonObject(value, "complete Turn decision"); + if (decision.schema_version !== COMPLETE_TURN_DECISION_VERSION) { + throw new EffectRuntimeRequestError("unsupported complete Turn decision version"); + } + const binding = requireJsonObject(decision.contract_binding, "Turn contract binding"); + if (binding.hash_codec !== COMPLETE_TURN_HASH_CODEC) { + throw new EffectRuntimeRequestError("unsupported complete Turn decision hash codec"); + } + const identity = contextSourceIdentity(decision, numberTokens ?? []); + if (typeof binding.scheduler_execution_args !== "string") { + throw new EffectRuntimeRequestError("Turn scheduler execution binding must be a string"); + } + const unsignedBinding = {...binding}; + delete unsignedBinding.content_hash; + const content = {...decision, contract_binding: unsignedBinding}; + if (binding.content_hash !== completeSourceHash(content, identity)) { + throw new EffectRuntimeRequestError("complete Turn decision content integrity mismatch"); + } + const source = {...decision}; + delete source.schema_version; + delete source.contract_binding; + if (binding.source_decision_hash !== completeSourceHash(source, identity)) { + throw new EffectRuntimeRequestError("complete Turn decision source integrity mismatch"); + } + requireCompleteDecisionSource(source); + return decision; +} + +/** One checked source for model input and transport session identity. The + * historical effect adapter may remain in the request during migration, but it + * cannot redirect the complete contract to another Goal, Agent or work item. */ +export function completeHostSource(request: JsonObject): {decision: JsonObject; projection: JsonObject} { + const decision = requireCompleteTurnDecision(request.payload, request.source_number_tokens); + // The versioned model wire has one complete source. The outer executor has + // already checked its private effect adapter; a standalone reader validates + // the source and original capture header without recreating that adapter. + if (Object.hasOwn(request, "complete_host_request")) { + const header = requireJsonObject(request.complete_host_request, "complete Host request"); + if (header.schema_version !== "loopx_turn_host_request_v1" || + Object.keys(object(request.envelope)).length !== 0 || + typeof header.turn_key !== "string" || !ENVELOPED_SHA256_PATTERN.test(header.turn_key)) { + throw new EffectRuntimeRequestError("unsupported or invalid complete Host request"); + } + if (Object.hasOwn(header, "source_decision_hash") && header.source_decision_hash !== decisionSourceHash(decision)) { + throw new EffectRuntimeRequestError("complete Host request differs from captured source"); + } + const detail = Object.hasOwn(header, "detail_ref") ? requireJsonObject(header.detail_ref, "complete Host detail reference") : {}; + const hasCapture = Object.hasOwn(detail, "captured_decision"); + const captured = hasCapture ? requireJsonObject(detail.captured_decision, "complete Host captured decision") : {}; + // A direct quota capture predates the Host journal and has no Turn key. + // Preserve its source observation rather than inventing a journal binding. + // A provided journal key keeps the existing exact-key rule. + const captureTurnMatches = Object.hasOwn(captured, "turn_key") + ? captured.turn_key === header.turn_key + : captured.turn_instance_id === (object(decision.heartbeat_receipt).turn_instance_id ?? null); + if (hasCapture && (typeof captured.path !== "string" || !captured.path.trim() || captured.path.includes("\0") || + !captureTurnMatches || + captured.goal_id !== decision.goal_id || captured.agent_id !== object(decision.agent_identity).agent_id || + captured.source_hash_ref !== "$.source_decision_hash" || !Object.hasOwn(header, "source_decision_hash"))) { + throw new EffectRuntimeRequestError("complete Host request capture binding differs"); + } + return {decision, projection: turnActionProjection(decision)}; + } + const envelope = requireJsonObject(request.envelope, "complete Turn executor envelope"); + assertTurnEnvelopeConsumerVersion(envelope); + const signature = object(envelope.action_signature); + if (signature.source_decision_hash !== decisionSourceHash(decision) + || signature.envelope_hash !== canonicalHash(turnEnvelopeActionSignatureDocument(envelope)) + || signature.source_hash !== signature.envelope_hash || signature.matches !== true) { + throw new EffectRuntimeRequestError("complete Turn model input differs from captured executor source"); + } + if (decision.goal_id !== envelope.goal_id + || object(decision.agent_identity).agent_id !== envelope.agent_id + || selectedWorkTodoId(decision) !== selectedWorkTodoId({ + selected_todo: object(envelope.action).selected_todo, + task_orchestration_contract: envelope.task_orchestration_contract, + })) { + throw new EffectRuntimeRequestError("complete Turn session lineage differs from executor source"); + } + // An adapter's self-consistent hash does not prove that it carries the + // admitted decision. Reuse the producer's projection, including writeback + // and execution conditions, rather than maintaining a second field list. + const projection = turnActionProjection(decision); + if (signature.source_hash !== canonicalHash(turnEnvelopeActionSignatureDocument(projection))) { + throw new EffectRuntimeRequestError("complete Turn executor projection differs from admitted source"); + } + return {decision, projection}; +} + +/** A current private plan stores its one admitted source and transport header. + * The old plan codec is read only for recorded journals or explicit old input. */ +export function turnPlanWorkSource(plan: JsonObject, numberTokens?: unknown): { + work: JsonObject; decision: JsonObject | null; scopedIdentity: JsonObject | null | undefined; +} { + if (plan.schema_version === "loopx_turn_plan_v1") { + if (!Object.hasOwn(plan, "work_contract") || Object.hasOwn(plan, "turn_envelope") + || !Object.hasOwn(plan, "source_decision_hash")) { + throw new EffectRuntimeRequestError("complete Turn plan requires one source without an executor adapter"); + } + const checked = completeHostSource({payload: plan.work_contract, source_number_tokens: numberTokens, + complete_host_request: {schema_version: "loopx_turn_host_request_v1", + turn_key: object(plan.transaction).turn_key, source_decision_hash: plan.source_decision_hash, + ...(Object.hasOwn(plan, "detail_ref") ? {detail_ref: plan.detail_ref} : {})}}); + const work = {goal_id: checked.decision.goal_id ?? null, ...checked.projection}; + return {work, decision: checked.decision, scopedIdentity: scopedTurnSettlementIdentity(work, + object(plan.transaction).turn_instance_id)}; + } + if (Object.hasOwn(plan, "schema_version") && plan.schema_version !== "loopx_turn_plan_v0") { + throw new EffectRuntimeRequestError("unsupported Turn plan version"); + } + const envelope = object(plan.turn_envelope); + if (Object.hasOwn(plan, "work_contract")) { + const checked = completeHostSource({payload: plan.work_contract, envelope, + source_number_tokens: numberTokens}); + return {work: {...envelope, ...checked.projection}, decision: checked.decision, scopedIdentity: undefined}; + } + return {work: envelope, decision: null, scopedIdentity: undefined}; +} + +function admittedDriverLineage(work: JsonObject, decision: JsonObject | null, + complete: boolean, historical?: JsonObject): JsonObject { + const selected = selectedWorkTodo({selected_todo: object(work.action).selected_todo, + task_orchestration_contract: work.task_orchestration_contract}, + historical && Object.hasOwn(historical, "primary_todo_id") + ? scalarString(historical.primary_todo_id, "historical primary Todo") : undefined); + const signature = turnEnvelopeActionSignatureDocument(work); + signature.action = {...object(signature.action), selected_todo: selected}; + return { + ...(decision ? {goal_id: decision.goal_id ?? null, + agent_id: object(decision.agent_identity).agent_id ?? null, + todo_id: selectedWorkTodoId(decision) ?? ""} : {}), + route: complete ? admittedTurnRoute(work, () => scopedTurnSettlementIdentity(work)) + : turnEnvelopeRoute(work, historical), + action_signature: signature, + ...(complete ? {scoped_settlement_identity: scopedTurnSettlementIdentity(work)} : {}), + }; +} + +export function completePlanningFacts(request: JsonObject): JsonObject { + // An explicitly supplied old carrier retains its consistency proof. New + // producers start from the complete source and never construct that view. + const checked = Object.hasOwn(request, "envelope") ? completeHostSource(request) : null; + const decision = checked?.decision ?? requireCompleteTurnDecision(request.payload, request.source_number_tokens); + const work = {goal_id: decision.goal_id ?? null, ...(checked?.projection ?? turnActionProjection(decision))}; + const lineage = admittedDriverLineage(work, decision, true); + let scopedIdentity: JsonObject | null = null, identityError: string | null = null; + if (lineage.route === "replan_required" || lineage.route === "ready_for_host" && !lineage.todo_id) { + try { scopedIdentity = scopedTurnSettlementIdentity(work, request.turn_instance_id); } + catch (error) { + if (!(error instanceof EffectRuntimeRequestError)) throw error; + identityError = error.message; + } + } + return {...lineage, work, source_decision_hash: decisionSourceHash(decision), + action_source_hash: canonicalHash(turnEnvelopeActionSignatureDocument(work)), + scoped_settlement_identity: scopedIdentity, scoped_identity_error: identityError}; +} + +function completeHostDecision(request: JsonObject): JsonObject { + return completeHostSource(request).decision; +} + +/** Reuse the existing presentation plan only after its complete source passes + * the versioned decision owner. The unsigned root is used for path selection; + * edits and recovery still address the unchanged, signed original capture. */ +function checkedContextPresentationPlan(request: JsonObject): JsonObject { + const context = requireJsonObject(request.context, "current projection"); + if (request.surface !== "complete_turn") + return progressiveContextPlan({payload: context, surface: request.surface}); + const numbers = sourceNumberTokens(request, request.source_number_tokens) + .filter(({path}) => path[0] === "context").map(({path, token}) => [path.slice(1), token]); + requireCompleteTurnDecision(context, numbers); + const source = {...context}; + delete source.schema_version; + delete source.contract_binding; + return progressiveContextPlan({payload: source, surface: "quota_packet"}); +} + +function completeWorkIdentity(decision: JsonObject): JsonObject { + return {goal_id: decision.goal_id ?? null, + agent_id: object(decision.agent_identity).agent_id ?? null, + todo_id: selectedWorkTodoId(decision) ?? ""}; +} + +function completeSessionLineage(decision: JsonObject): JsonObject { + const identity = completeWorkIdentity(decision); + return {goal_id: scalarString(identity.goal_id, "Turn Goal identity").trim(), + agent_id: scalarString(identity.agent_id, "Turn Agent identity").trim(), + todo_id: scalarString(identity.todo_id, "Turn Todo identity").trim()}; +} + +function completeTaskTextSha256(decision: JsonObject, todoId: unknown): string | null { + const context = object(object(object(decision.interaction_contract).agent_channel).work_context); + const declared = {...object(decision.selected_todo), + ...(Object.hasOwn(context, "selected_todo_text_verified") + ? {text_source_verified: context.selected_todo_text_verified} : {})}; + return capturedTodoTextSha256(declared, context, decision.recommended_action, todoId); +} + +export function composeCompleteTurnDecision(value: unknown): JsonObject { + const request = requireJsonObject(value, "complete Turn decision request"); + const payload = requireJsonObject(request.payload, "complete Turn decision source"); + if (typeof request.scheduler_execution_args !== "string") { + throw new EffectRuntimeRequestError("Turn scheduler execution binding must be a string"); + } + if (payload.schema_version === COMPLETE_TURN_DECISION_VERSION) { + const prior = requireCompleteTurnDecision(payload, request.source_number_tokens); + if (object(prior.contract_binding).scheduler_execution_args !== request.scheduler_execution_args) { + throw new EffectRuntimeRequestError("complete Turn decision cannot change its host binding"); + } + return prior; + } + if (Object.hasOwn(payload, "schema_version") || Object.hasOwn(payload, "contract_binding")) { + throw new EffectRuntimeRequestError("complete Turn decision source contains reserved version/binding fields"); + } + requireCompleteDecisionSource(payload); + const sourceIdentity = contextSourceIdentity(payload, request.source_number_tokens ?? []); + // A complete input must carry the original-Turn successor instructions that + // used to exist only in the staged writer. Compose them before hashing and + // capture; a later material read must not add a competing execution contract. + const packet = object(payload.replan_action_packet); + const interaction = object(payload.interaction_contract); + const cli = object(interaction.cli_channel); + const plan = object(cli.settlement_plan); + let source = payload; + if (packet.obligation_id && packet.settlement_only !== true && + payload.effective_action !== EffectiveAction.GOVERNED_CAPABILITY_INTENT && + packet.obligation_id === object(object(payload.autonomous_replan_obligation).replan_context).obligation_id && + typeof object(plan.identity).turn_instance_id === "string") { + const contract = bindReplanSuccessorContract(object(packet.writeback_contract), plan); + source = {...payload, replan_action_packet: {...packet, writeback_contract: contract}, + ...(typeof contract.successor_command === "string" ? { + interaction_contract: {...interaction, cli_channel: {...cli, + next_cli_actions: [contract.successor_command]}}, + } : {})}; + } + // Retain the existing Explore owner's pure-replan applicability correction + // before capture too. A complete contract must not advertise an attachment + // that requires a Todo when this admission has only an obligation. Reuse the + // exact known-guidance check; custom instructions and evidence stay intact. + // Do not run the historical compactor: complete authoring remains in this capture. + if (unboundExploreReplan(source, plan)) { + const currentInteraction = object(source.interaction_contract); + const agent = object(currentInteraction.agent_channel); + const context = object(agent.work_context); + if (Array.isArray(context.sources)) { + source = {...source, interaction_contract: {...currentInteraction, agent_channel: {...agent, + work_context: {...context, sources: context.sources.map(value => { + const item = object(value); + return item.source === "turn_start_capability_hook" && item.hook_id === "explore.turn_context" && + item.capability_id === "explore" + ? {...item, content: omitUnboundExploreResultWriteback(object(item.content))} : value; + })}}}}; + } + } + source = referenceGoalAcceptance(source, sourceIdentity); + source = referenceReplanAuthoring(source, sourceIdentity); + source = referenceExploreGuidance(source); + source = referenceCurrentTaskTitle(source); + source = referenceVisionContinuation(source, sourceIdentity); + source = referenceQuotaSummaryLanes(source, sourceIdentity); + requireCompleteDecisionSource(source); + const result: JsonObject = {...source, schema_version: COMPLETE_TURN_DECISION_VERSION, + contract_binding: {scheduler_execution_args: request.scheduler_execution_args, + hash_codec: COMPLETE_TURN_HASH_CODEC, + source_decision_hash: completeSourceHash(source, sourceIdentity)}}; + result.contract_binding = {...object(result.contract_binding), content_hash: completeSourceHash(result, sourceIdentity)}; + return result; +} + export function buildTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const payload = requireJsonObject(request.payload, "turn envelope payload"); @@ -1458,6 +1899,12 @@ export function buildTurnEnvelope(value: unknown): JsonObject { "turn envelope scheduler_execution_args must be a string", ); } + if (payload.schema_version === COMPLETE_TURN_DECISION_VERSION) { + requireCompleteTurnDecision(payload, request.source_number_tokens); + if (object(payload.contract_binding).scheduler_execution_args !== request.scheduler_execution_args) { + throw new EffectRuntimeRequestError("Turn adapter host binding differs from captured contract"); + } + } const schedulerExecutionArgs = request.scheduler_execution_args; const capturedDecisionPath = request.captured_decision_path; if (capturedDecisionPath !== undefined && ( @@ -1491,7 +1938,7 @@ export function buildTurnEnvelope(value: unknown): JsonObject { source_hash: canonicalHash(sourceSignature), envelope_hash: canonicalHash(envelopeSignature), matches: JSON.stringify(sourceSignature) === JSON.stringify(envelopeSignature), - source_decision_hash: canonicalHash(payload), + source_decision_hash: decisionSourceHash(payload), }; measureTurnEnvelope(envelope, payload); return envelope; @@ -1502,8 +1949,22 @@ export function buildTurnEnvelope(value: unknown): JsonObject { function bindTurnDecisionCapture(request: JsonObject): JsonObject { const payload = requireJsonObject(request.payload, "captured decision"); assertSupportedQuotaPacket(payload); - const envelope = requireJsonObject(request.envelope, "captured decision envelope"); - assertSupportedQuotaPacket(envelope); + if (payload.schema_version === COMPLETE_TURN_DECISION_VERSION) { + try { + requireCompleteTurnDecision(payload, request.source_number_tokens); + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError)) throw error; + throw new EffectRuntimeRequestError( + `Turn decision capture does not match the original observation: ${error.message}`, error.code); + } + } + const sourceHash = decisionSourceHash(payload); + const completeHeader = Object.hasOwn(request, "complete_host_request") + ? requireJsonObject(request.complete_host_request, "complete capture header") : null; + const checked = completeHeader ? completeHostSource(request) : null; + const envelope = checked ? {goal_id: checked.decision.goal_id ?? null, ...checked.projection} + : requireJsonObject(request.envelope, "captured decision envelope"); + if (!checked) assertSupportedQuotaPacket(envelope); const path = scalarString(request.captured_decision_path, "capture path"); const turnId = scalarString(request.turn_instance_id, "capture Turn identity"); const turnKey = scalarString(request.turn_key, "capture Turn key"); @@ -1512,10 +1973,11 @@ function bindTurnDecisionCapture(request: JsonObject): JsonObject { !ENVELOPED_SHA256_PATTERN.test(turnKey) || payload.ok !== true || payload.goal_id !== envelope.goal_id || object(payload.agent_identity).agent_id !== envelope.agent_id || - signature.source_decision_hash !== canonicalHash(payload)) { + (completeHeader ? completeHeader.source_decision_hash !== sourceHash || completeHeader.turn_key !== turnKey + : signature.source_decision_hash !== sourceHash)) { throw new EffectRuntimeRequestError("Turn decision capture does not match the original observation"); } - const detail = object(envelope.detail_ref); + const detail = object(completeHeader ? completeHeader.detail_ref : envelope.detail_ref); const original = object(detail.captured_decision); if (Object.keys(original).length && (original.path !== path || original.turn_instance_id !== turnId || original.turn_key !== turnKey || @@ -1524,20 +1986,30 @@ function bindTurnDecisionCapture(request: JsonObject): JsonObject { } const capture = object(coldPath(payload, String(envelope.agent_id), "", path).captured_decision); const readerArgv = detail.replan_read_argv ?? request.reader_argv; - const reads = readerArgv ? replanReadCommands(payload, path, readerArgv) : null; + const reads = readerArgv ? replanReadCommands(payload, path, readerArgv, sourceHash) : null; const goal = reads ? replanGoalContext(payload, reads) : null; - const bound = {...envelope, detail_ref: {...detail, + const boundDetail = {...detail, full_decision: `cat -- ${shellQuote(path)}`, ...(reads ? {replan_action_packet: replanActionPacket(payload, reads), replan_read_argv: readerArgv, core_goal: goal!.core, planning_horizon: payload.planning_horizon == null ? null : goal!.horizon, } : {}), captured_decision: {...capture, turn_instance_id: turnId, turn_key: turnKey, + ...(completeHeader ? {source_hash_ref: "$.source_decision_hash"} : {}), instruction: "Read this original observation for omitted context. Validate its Goal/Agent and source hash; " + "Turn identity comes from this journal-bound reference, not a fabricated quota receipt. " + "It grants no fresh authority: later actions require current admission and mutation checks. " + "Missing or invalid evidence requires recovery, never blind guard replay.", }, - }}; + }; + if (completeHeader) { + const materialReaders = object(boundDetail.replan_action_packet); + return {...completeHeader, detail_ref: { + full_decision: boundDetail.full_decision, captured_decision: boundDetail.captured_decision, + ...(materialReaders.review ? {replan_action_packet: Object.fromEntries( + Object.entries(materialReaders).filter(([field]) => ["review", "writeback"].includes(field)))} : {}), + }}; + } + const bound = {...envelope, detail_ref: boundDetail}; measureTurnEnvelope(bound, payload); return bound; } @@ -1592,15 +2064,23 @@ export function turnEnvelopeRoute(envelope: JsonObject, historicalText?: JsonObj const sourceHash = scalarString(historicalText ? historicalText.source_hash : signature.source_hash || "", "route source hash"); const envelopeHash = scalarString(historicalText ? historicalText.envelope_hash : signature.envelope_hash || "", "route envelope hash"); if (signature.matches !== true || !sourceHash || sourceHash !== envelopeHash) return "contract_error"; + return admittedTurnRoute(envelope, () => turnEnvelopeReplanSettlementIdentity(envelope), historicalText); +} + +/** Share action rules after the caller's versioned source check. A complete + * source and a historical envelope retain their own proof and scalar codec. */ +function admittedTurnRoute( + work: JsonObject, replanIdentity: () => JsonObject | null, historicalText?: JsonObject, +): LoopXTurnRouteValue { // Size diagnostics grant no authority and do not veto a valid contract. - const action = object(envelope.action); - if (envelope.should_run === true) { + const action = object(work.action); + if (work.should_run === true) { if (action.must_attempt !== true) return "blocked"; - const name = scalarString(historicalText ? historicalText.effective_action : envelope.effective_action || "", "route effective action"); + const name = scalarString(historicalText ? historicalText.effective_action : work.effective_action || "", "route effective action"); const route = Object.hasOwn(TURN_ACTION_ROUTES, name) ? TURN_ACTION_ROUTES[name as keyof typeof TURN_ACTION_ROUTES] : "ready_for_host"; if (route === "replan_required") { - try { turnEnvelopeReplanSettlementIdentity(envelope); } + try { replanIdentity(); } catch (error) { if (!(error instanceof EffectRuntimeRequestError)) throw error; return "contract_error"; @@ -1614,19 +2094,17 @@ export function turnEnvelopeRoute(envelope: JsonObject, historicalText?: JsonObj const hasCommand = Array.isArray(command) ? command.length > 0 : command !== null && typeof command === "object" ? Object.keys(command).length > 0 : Boolean(command); if (intent.schema_version !== "pending_capability_intent_projection_v0" - || (intent.goal_id ?? null) !== (envelope.goal_id ?? null) - || (intent.agent_id ?? null) !== (envelope.agent_id ?? null) || !hasCommand) return "contract_error"; + || (intent.goal_id ?? null) !== (work.goal_id ?? null) + || (intent.agent_id ?? null) !== (work.agent_id ?? null) || !hasCommand) return "contract_error"; return "capability_action_required"; } return route === "repair_required" ? "repair_required" : "ready_for_host"; } - if (object(envelope.user).action_required === true) return "user_action_required"; + if (object(work.user).action_required === true) return "user_action_required"; return action.quiet_noop_allowed === true ? "wait" : "blocked"; } -/** Navigation over captured facts, never a summary or a source-read receipt. */ -// Quota field vocabulary stays with its decision owner; the RFC 6901 reader -// receives addresses and remains independent of Goal/permission policy. +// Local navigation vocabulary remains with the complete decision owner. const INITIAL_CONTEXT_DECISION_FIELDS = ["ok", "schema_version", "goal_id", "agent_id", "should_run", "effective_action", "normal_delivery_allowed", "recovery_delivery_allowed", "self_repair_allowed", "capability_repair_allowed", "workspace_repair_allowed", "safe_bypass_allowed", "safe_bypass_kind", "blocked_action_scope", "reason", @@ -1636,12 +2114,18 @@ const INITIAL_CONTEXT_DECISION_FIELDS = ["ok", "schema_version", "goal_id", "age function capturedDecisionSelection(request: JsonObject): JsonObject { const initial = request.initial_read === true || request.list_fields === true && Array.isArray(request.select) && request.select.length === 1 && request.select[0] === ""; - return capturedContextSelection(request, initial ? initialContextReadPointers(requireJsonObject(request.context, "captured context"), INITIAL_CONTEXT_DECISION_FIELDS) : []); + return capturedContextSelection(request, initial ? initialContextReadPointers( + requireJsonObject(request.context, "captured context"), INITIAL_CONTEXT_DECISION_FIELDS) : []); } export function evaluateTurnEnvelope(value: unknown): JsonObject { const request = requireJsonObject(value, "turn envelope request"); const operation = request.operation; + if (operation === "planning_facts") return completePlanningFacts(request); + if (operation === "plan_work") { + const {decision, projection} = completeHostSource(request); + return {goal_id: decision.goal_id ?? null, ...projection}; + } if (operation === "replan_view") return replanView(request); if (operation === "context_select") return capturedDecisionSelection(request); if (operation === "replan_cli_view") { @@ -1676,6 +2160,41 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { autonomous_replan_obligation: {...obligation, context_read: reads.review}, replan_action_packet: replanActionPacket(payload)}; } + if (operation === "compose_complete") { + const result = composeCompleteTurnDecision(request); + return request.source_edits === true + ? contextProjectionChanges(requireJsonObject(request.payload, "complete Turn source"), result) : result; + } + if (operation === "complete_consumer") { + requireCompleteTurnDecision(request.payload, request.source_number_tokens); + return {ok: true}; + } + if (operation === "complete_host_lineage") { + // Session-index reads need only the original checked source. A supplied + // adapter or wire header still selects full Host validation, even if bad. + // This read returns identity facts and grants no execution authority. + const decision = Object.hasOwn(request, "envelope") || Object.hasOwn(request, "complete_host_request") + ? completeHostDecision(request) : requireCompleteTurnDecision(request.payload, request.source_number_tokens); + return completeSessionLineage(decision); + } + if (operation === "host_context" || operation === "host_dispatch_context") { + if (Object.hasOwn(request, "include_session_lineage") && + typeof request.include_session_lineage !== "boolean") { + throw new EffectRuntimeRequestError("Host session-lineage projection option must be a boolean"); + } + const {decision, projection} = completeHostSource(request); + if (operation === "host_dispatch_context" && Object.keys(object(projection.replan_action_packet)).length === 0) { + assertGoalContextDelivered(object(object(decision.interaction_contract).agent_channel)); + } + // The caller already holds this exact checked source. Return only the + // resolved facts it needs, not another full copy over the transport. + return {primary_action: object(projection.action).primary_action ?? null, + replan_admitted: Object.keys(object(projection.replan_action_packet)).length > 0, + checkpointed_write_approval: Object.hasOwn(object(projection.boundary), "checkpointed_boundary_authority"), + execution_profile_present: Object.keys(object(decision.execution_profile)).length > 0, + ...(operation === "host_dispatch_context" ? {work_identity: completeWorkIdentity(decision), + ...(request.include_session_lineage === true ? {session_lineage: completeSessionLineage(decision)} : {})} : {})}; + } if (operation === "selected_work_todo") { const source = requireJsonObject(request.payload, "Turn work selection source"); const historical = scalarString(request.historical_primary_text, "historical primary Todo"); @@ -1684,14 +2203,42 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { if (operation === "driver_route") return {route: turnEnvelopeRoute(requireJsonObject(request.envelope, "Turn route envelope"), request.historical_route_text === undefined ? undefined : requireJsonObject(request.historical_route_text, "historical route text"))}; if (operation === "driver_lineage") { - const envelope = requireJsonObject(request.envelope, "Turn driver envelope"); - return {route: turnEnvelopeRoute(envelope, request.historical_route_text === undefined - ? undefined : requireJsonObject(request.historical_route_text, "historical route text")), - action_signature: turnEnvelopeActionSignatureDocument(envelope)}; - } - if (operation === "build_controller_input") { - const envelope = buildTurnEnvelope(request); - return {envelope, route: turnEnvelopeRoute(envelope)}; + const completeWire = Object.hasOwn(request, "complete_host_request"); + const checked = Object.hasOwn(request, "payload") || completeWire ? completeHostSource(request) : null; + const decision = checked?.decision ?? null; + // Recovery consumes the already checked action facts. It does not rebuild + // a v0 adapter, cold reader commands or size diagnostics from that source. + // Historical carriers keep their signed envelope and version-pinned proof. + const work = completeWire && checked ? {goal_id: checked.decision.goal_id ?? null, ...checked.projection} + : requireJsonObject(request.envelope, "Turn driver envelope"); + const historical = !decision && request.historical_route_text !== undefined + ? requireJsonObject(request.historical_route_text, "historical route text") : undefined; + // Both planning and recovery use these action/selection/hash facts. Python + // retains only its persisted number codec; historical readers keep proof. + return admittedDriverLineage(work, decision, completeWire, historical); + } + if (operation === "execution_context") { + // The CLI consumes one checked source for execution, not the historical + // adapter's layout. Reuse the projection already checked against that + // source for effect conditions; this is not another model-facing contract. + const {decision, projection} = completeHostSource(request); + const work = {selected_todo: object(projection.action).selected_todo, + task_orchestration_contract: projection.task_orchestration_contract}; + return {selected_todo: selectedWorkTodo(work), + sha256: completeTaskTextSha256(decision, selectedWorkTodoId(work) ?? ""), + effective_action: decision.effective_action ?? null, + writeback: object(projection.writeback), + replan_guard_scoped: Object.hasOwn(projection, "replan_action_packet"), + replan_action_packet: projection.replan_action_packet ?? null}; + } + if (operation === "captured_todo_text") { + const envelope = requireJsonObject(request.envelope, "Turn completion envelope"); + if (Object.hasOwn(request, "payload")) { + return {sha256: completeTaskTextSha256(completeHostDecision(request), request.todo_id)}; + } + const action = object(envelope.action); + return {sha256: capturedTodoTextSha256(object(action.selected_todo), envelope.work_context, + action.recommended_action, request.todo_id)}; } if (operation === "consumer_version") { assertTurnEnvelopeConsumerVersion(requireJsonObject(request.envelope, "turn_envelope")); @@ -1729,10 +2276,12 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { return {sections: contextReadSectionDigests(request, (selected.selections as {path: (string | number)[]}[]).map(row => row.path))}; } - if (operation === "progressive_context_delivery") return prepareProgressiveContext(request); + if (operation === "progressive_context_delivery") + return prepareProgressiveContext(request, checkedContextPresentationPlan(request)); if (operation === "context_delivery") return request.native_projection === undefined ? prepareContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS) - : prepareProgressiveContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS, INITIAL_CONTEXT_DECISION_FIELDS); + : prepareProgressiveContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS, INITIAL_CONTEXT_DECISION_FIELDS, + checkedContextPresentationPlan(request)); if (operation === "native_context_lifecycle") return updateNativeContextLifecycle(request); if (operation === "context_recovery") return restoreContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS); if (operation === "bind_capture") return bindTurnDecisionCapture(request); @@ -1753,9 +2302,9 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { /** A scoped planning identity comes from the signed quota plan, never a Host * session or a missing Todo. Journal validation reuses this exact binding rule. */ -export function turnEnvelopeScopedSettlementIdentity(envelope: JsonObject, turnInstanceId?: unknown): JsonObject | null { - assertSupportedQuotaPacket(envelope); - const writeback = object(envelope.writeback); +function scopedTurnSettlementIdentity(work: JsonObject, turnInstanceId?: unknown): JsonObject | null { + assertSupportedQuotaPacket(work); + const writeback = object(work.writeback); const plan = Object.keys(object(writeback.settlement_plan)).length ? object(writeback.settlement_plan) : {identity: writeback.settlement_identity}; const raw = object(plan.identity); @@ -1763,21 +2312,31 @@ export function turnEnvelopeScopedSettlementIdentity(envelope: JsonObject, turnI const parsed = settlementIdentityFromPlan({settlement_plan: plan}); if (parsed.failure !== null) throw new EffectRuntimeRequestError(parsed.failure.reason); const identity = parsed.value; - const signature = object(envelope.action_signature); - if (envelope.schema_version !== TURN_ENVELOPE_SCHEMA_VERSION - || (identity.binding_kind === "request" ? envelope.effective_action !== EffectiveAction.NORMAL_RUN - : envelope.effective_action !== EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED) - || selectedWorkTodoId({selected_todo: object(envelope.action).selected_todo, - task_orchestration_contract: envelope.task_orchestration_contract}) !== null + if ((identity.binding_kind === "request" ? work.effective_action !== EffectiveAction.NORMAL_RUN + : work.effective_action !== EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED) + || selectedWorkTodoId({selected_todo: object(work.action).selected_todo, + task_orchestration_contract: work.task_orchestration_contract}) !== null || !["autonomous_replan", "request"].includes(identity.binding_kind) - || identity.goal_id !== envelope.goal_id || identity.agent_id !== envelope.agent_id + || identity.goal_id !== work.goal_id || identity.agent_id !== work.agent_id || turnInstanceId != null && identity.turn_instance_id !== turnInstanceId - || identity.binding_kind === "autonomous_replan" && identity.replan_obligation_id !== object(envelope.replan_action_packet).obligation_id + || identity.binding_kind === "autonomous_replan" && identity.replan_obligation_id !== object(work.replan_action_packet).obligation_id) { + throw new EffectRuntimeRequestError("Turn replan settlement identity does not match its signed quota admission"); + } + return settlementIdentityPayload(identity); +} + +/** Historical envelopes keep their version/signature proof around the same + * scoped-identity rules used after complete-source validation. */ +export function turnEnvelopeScopedSettlementIdentity(envelope: JsonObject, turnInstanceId?: unknown): JsonObject | null { + const identity = scopedTurnSettlementIdentity(envelope, turnInstanceId); + if (identity === null) return null; + const signature = object(envelope.action_signature); + if (envelope.schema_version !== TURN_ENVELOPE_SCHEMA_VERSION || signature.matches !== true || signature.source_hash !== signature.envelope_hash || signature.envelope_hash !== canonicalHash(turnEnvelopeActionSignatureDocument(envelope))) { throw new EffectRuntimeRequestError("Turn replan settlement identity does not match its signed quota admission"); } - return settlementIdentityPayload(identity); + return identity; } /** Persisted planning callers retain their narrow compatibility entrypoint. */ diff --git a/loopx/control_plane/reward_memory.py b/loopx/control_plane/reward_memory.py index b7b5d4c25e..45eb52f0af 100644 --- a/loopx/control_plane/reward_memory.py +++ b/loopx/control_plane/reward_memory.py @@ -32,7 +32,16 @@ def reward_memory_automation_enabled( or experiment.get(operation) is not True ): return False - if isinstance(envelope, Mapping): + source = None + if "work_contract" in payload: + from .capability_hooks import turn_context_source + + try: + source = turn_context_source(payload) + except (ValueError, RuntimeError): + return False + boundary = source.get("boundary") + elif isinstance(envelope, Mapping): boundary = envelope.get("boundary") elif "turn_envelope" not in payload: # App guards carry the same verified capability projection directly; @@ -59,7 +68,8 @@ def reward_memory_automation_enabled( try: from .capability_hooks import turn_context_source - source = turn_context_source(payload) + if source is None: + source = turn_context_source(payload) except ValueError: return False capabilities = ( diff --git a/loopx/control_plane/runtime_decode.ts b/loopx/control_plane/runtime_decode.ts index 805070f999..9a42e3b4a8 100644 --- a/loopx/control_plane/runtime_decode.ts +++ b/loopx/control_plane/runtime_decode.ts @@ -2,6 +2,43 @@ import { EffectRuntimeRequestError } from "./effect_runtime_errors.ts"; export type JsonObject = Record; +export type SourceNumbers = WeakMap>; + +/** Native JSON IO retains the same sparse number spellings as Python IO. + * Shared by journal publication and readback; this changes no source values. */ +export function parseJsonSource(encoded: string): {value: unknown; numbers: SourceNumbers} { + const numbers: SourceNumbers = new WeakMap(); + const value: unknown = JSON.parse(encoded, function (key, item: unknown, context?: {source?: string}) { + if (typeof item === "number" && Number.isInteger(item) && + context?.source !== undefined && context.source !== JSON.stringify(item)) { + const fields = numbers.get(this) ?? new Map(); + fields.set(key, context.source); + numbers.set(this, fields); + } + return item; + }); + return {value, numbers}; +} + +/** Transport original spellings from the parsed object, never from rounded numbers. */ +export function parsedSourceNumberTokens(value: unknown, numbers?: SourceNumbers): + [(string | number)[], string][] { + const tokens: [(string | number)[], string][] = []; + if (numbers === undefined) return tokens; + const visit = (item: unknown, path: (string | number)[]): void => { + if (item === null || typeof item !== "object") return; + const child = (key: string | number, value: unknown): void => { + const token = numbers.get(item)?.get(String(key)); + if (token !== undefined) tokens.push([[...path, key], token]); + else visit(value, [...path, key]); + }; + if (Array.isArray(item)) item.forEach((value, index) => child(index, value)); + else Object.entries(item).forEach(([key, value]) => child(key, value)); + }; + visit(value, []); + return tokens; +} + export function jsonObject(value: unknown): JsonObject | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index b6026d6425..5038e344f0 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -36,13 +36,13 @@ HOST_PROGRESS_OBSERVATION_JSON_MAX_CHARS, HOST_RESULT_EVIDENCE_LIMITS, HOST_RESULT_TEXT_LIMITS, - LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION, MATERIAL_HOST_RESULT_KINDS, STOP_HOST_RESULT_KINDS, ) from ..capability_hooks import capability_result_fields from .execution_profile import require_supported_reasoning_effort from .host_failure import BuiltInHostError +from .host_candidate import assert_turn_host_request_version, execution_profile_present from .host_process_transport import HostOutputLines, run_host_process from .transaction import LOOPX_TURN_RESULT_SCHEMA_VERSION, TRANSACTION_PHASES @@ -196,6 +196,12 @@ def _has_subagent_topology(request: Mapping[str, Any] | None) -> bool: def codex_cli_result_schema( request: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + return _result_schema(request) + + +def _result_schema( + request: Mapping[str, Any] | None, *, checked: Mapping[str, Any] | None = None, ) -> dict[str, Any]: text_limits = dict(HOST_RESULT_TEXT_LIMITS) properties: dict[str, Any] = { @@ -279,6 +285,8 @@ def codex_cli_result_schema( "enum": ["codex-cli"], } properties["child_execution_receipts"] = child_receipts + if checked is None: + execution_profile_present(request or {}) # Codex strict output requires every property and forbids conditional schemas. return { "type": "object", @@ -301,9 +309,7 @@ def normalize_codex_result( if normalized.get(field) is None: normalized.pop(field, None) elif kind in MATERIAL_HOST_RESULT_KINDS: - envelope = _mapping(request.get("turn_envelope")) - profile = _mapping(envelope.get("contract_capsule")).get("execution_profile") - if isinstance(profile, Mapping) and profile and any( + if execution_profile_present(request) and any( normalized.get(field) is None for field in HOST_RESULT_EVIDENCE_LIMITS ): raise BuiltInHostError("codex_material_result_missing_evidence") @@ -313,13 +319,58 @@ def normalize_codex_result( def _prompt(request: Mapping[str, Any]) -> str: + return _render_prompt(request, checked=_checked_prompt_context(request)) + + +def _checked_prompt_context( + request: Mapping[str, Any], *, include_session_lineage: bool = False, +) -> dict[str, Any] | None: from ..quota.turn_envelope import assert_turn_envelope_consumer_version envelope = _mapping(request.get("turn_envelope")) - assert_turn_envelope_consumer_version(envelope) - staged = _mapping(_mapping(envelope.get("detail_ref")).get("replan_action_packet")).get("review") - model_request = request - if staged: + from ..quota.turn_envelope import complete_turn_host_source + + # The complete owner checks the adapter version, source and lineage in one + # call. Keep the standalone version gate only for historical requests. + try: + checked = complete_turn_host_source(request, operation="host_dispatch_context", + include_session_lineage=include_session_lineage) + except RuntimeError: + # Preserve the historical diagnostic precedence for doubly malformed + # input without imposing a second bridge call on valid requests. + if "turn_envelope" in request: + assert_turn_envelope_consumer_version(envelope) + raise + return checked + + +def prepare_codex_cli_input(request: Mapping[str, Any]) -> tuple[str, dict[str, Any]]: + """Prepare one launch's prompt and schema from one complete-source check. + + This value never crosses an attempt/result boundary or enters a cache. + Standalone consumers still validate independently; historical requests keep + their existing prompt/schema readers. Launch and result authority stay with + the original Host and outer executor. + """ + checked = _checked_prompt_context(request) + if checked is None: + return _prompt(request), codex_cli_result_schema(request) + return _render_prompt(request, checked=checked), _result_schema(request, checked=checked) + + +def _render_prompt(request: Mapping[str, Any], *, checked: Mapping[str, Any] | None) -> str: + from ..quota.turn_envelope import assert_turn_envelope_consumer_version + from ..quota.turn_envelope import captured_turn_host_input + + envelope = _mapping(request.get("turn_envelope")) + complete_input = captured_turn_host_input(request) if checked is not None else None + complete = complete_input is not None + if not complete: + assert_turn_envelope_consumer_version(envelope) + model_request: Mapping[str, Any] = complete_input if complete_input is not None else request + detail = _mapping((complete_input if complete else envelope).get("detail_ref")) + staged = _mapping(detail.get("replan_action_packet")).get("review") + if staged and not complete: from .host_candidate import extract_turn_authority, replan_prompt_instruction authority = extract_turn_authority(request) @@ -332,7 +383,7 @@ def _prompt(request: Mapping[str, Any]) -> str: ) instructions = [ "Execute exactly one bounded LoopX Turn in the current workspace.", - ("Use turn_authority as the verified authority for this Turn. " if staged else "Use the TurnEnvelope as the source of truth. ") + ("Use work_contract as the complete source of truth. Its interaction_contract.agent_channel carries work_context, required_reads and primary_action; its cli_channel describes Core effects owned by the outer adapter. " if complete else ("Use turn_authority as the verified authority for this Turn. " if staged else "Use the TurnEnvelope as the source of truth. ")) + "Read work_context and remaining required_reads before primary_action; do not repeat fulfilled reads; perform work only when its contract allows it. The selected Todo summary is not its full requirements, and completing that work does not prove Goal completion.", "Do not write LoopX state, spend quota, or apply scheduler changes; the adapter owns those effects.", "Return only the schema-constrained result. For validated_progress, repair_required, or replan_required, fill every material field with public-safe evidence.", @@ -343,11 +394,17 @@ def _prompt(request: Mapping[str, Any]) -> str: "For user_action_required, wait, or iteration_failed, leave material-only fields empty and explain the stop in summary. iteration_failed ends only this iteration and never requests a retry or successor.", 'completed_phases must be exactly ["host_execute","typed_result"], and turn_key must match the request.', ] - if staged: + if staged and not complete: instructions.append(replan_prompt_instruction(authority)) + elif staged: + from .host_candidate import replan_prompt_instruction + + instructions.append(replan_prompt_instruction( + _mapping(model_request.get("detail_ref")), packet_path="detail_ref.replan_action_packet")) instructions.extend(request.get("capability_instructions") or ()) - boundary = _mapping(_mapping(request.get("turn_envelope")).get("boundary")) - if boundary.get("checkpointed_boundary_authority"): + checkpointed_approval = (checked["checkpointed_write_approval"] if checked is not None + else _mapping(envelope.get("boundary")).get("checkpointed_boundary_authority")) + if checkpointed_approval: instructions.append( "The boundary's checkpointed_boundary_authority records existing write approval " "only within its active_write_scope. It satisfies the write approval requirement " @@ -746,8 +803,7 @@ def run_codex_cli_host( goal_admission: FirstPartyHostGoalAdmission | None = None, registry_path: Path | None = None, ) -> dict[str, Any]: - if request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION: - raise ValueError("unsupported LoopX Turn host request schema") + assert_turn_host_request_version(request) if sandbox not in CODEX_CLI_SANDBOXES: raise ValueError(f"Codex CLI sandbox must be one of {CODEX_CLI_SANDBOXES}") if reasoning_effort is not None: @@ -850,9 +906,10 @@ def commit() -> None: temporary = Path(directory) schema_path = temporary / "result-schema.json" output_path = temporary / "last-message.json" + input_text, result_schema = prepare_codex_cli_input(request) schema_path.write_text( json.dumps( - codex_cli_result_schema(request), + result_schema, ensure_ascii=False, separators=(",", ":"), ), @@ -898,7 +955,6 @@ def observe_stderr(line: str) -> None: events = HostOutputLines(observe_event) diagnostics = HostOutputLines(observe_stderr) - input_text = _prompt(request) with capture_codex_input_source(project, input_text, lineage["goal_id"], observed_session): observed = run_host_process(command, project=project, input_text=input_text, timeout_seconds=timeout_seconds, drain_timeout_seconds=OUTPUT_DRAIN_TIMEOUT_SECONDS, diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index a8f0b5ebb4..7715adb6fc 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -7,8 +7,8 @@ from __future__ import annotations from contextlib import contextmanager -from dataclasses import dataclass from collections.abc import Iterator, Mapping +from dataclasses import dataclass from typing import Any import json import os @@ -620,13 +620,18 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap continuity_generation: str | None = None, include_initial_read_plan: bool = True, progressive_enabled: bool | None = None, + selection_command_args_template: str | None = None, + captured_recovery: dict[str, Any] | None = None, hook_session: str | None = None) -> NativeContextPreparation: """Commit current recovery before optional projection or candidate updates. Serialization prevents invalidation from moving the epoch during preparation. Full fallback must recover this capture or refuse lookup, never an older one. Saved-file comparison is independent of any complete model-read observation. + A trusted caller may reuse the identical source it just published; this + reference must never come from the external continuity request. """ + from ...command_invocation import current_loopx_cli_argv from ..quota.error_codes import QuotaDecisionTransportError from ..quota.turn_envelope import prepare_context_delivery from ..effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError @@ -647,7 +652,7 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap native_session = hook_session or os.environ.get("CODEX_THREAD_ID") if (not isinstance(session, str) or not session or (native_session and session != native_session)): - return NativeContextPreparation(payload) # Do not replace a different native session's recovery. + return NativeContextPreparation(payload) # Do not replace another session's recovery. # Native lifecycle events own workspace identity and epoch. # A shell tool may run in another checkout without changing # the host session or losing its previously delivered context. @@ -666,15 +671,15 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap state.pop(key, None) state["read_scope"] = read_scope request = _native_context_request(state, scope, capture_id) - recovery = capture_context_projection(payload, directory=capture_directory, - read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE) + recovery = dict(captured_recovery) if captured_recovery is not None else capture_context_projection( + payload, directory=capture_directory, read_guidance=CONTEXT_CAPTURE_PAGE_GUIDANCE) state["source_recovery"] = {key: recovery[key] for key in ("path", "sha256")} state.pop("section_reads", None) # partial reads of an older source are not current coverage page_bytes, output_bytes = native_context_page_budget() def publish_recovery(reference: dict[str, Any]) -> None: reference["command"] = context_capture_read_command(Path(reference["path"]), reference["sha256"], - page_bytes=page_bytes, max_page_output_bytes=output_bytes) + page_bytes=page_bytes, max_page_output_bytes=output_bytes, reader_argv=current_loopx_cli_argv()) preparation = compact_execution_preparation_request(payload) state["recovery"] = {**{key: reference[key] for key in ("path", "sha256", "command")}, "goal_id": goal_id, "agent_id": agent_id, @@ -694,10 +699,11 @@ def publish_recovery(reference: dict[str, Any]) -> None: transaction = prepare_context_delivery(payload, scope=scope, capture_id=capture_id, surface=surface, baseline=request.get("baseline"), retained=request.get("retained"), continuity=request.get("continuity"), - include_initial_read_plan=include_initial_read_plan if progressive else False, - native_projection={ + include_initial_read_plan=include_initial_read_plan and progressive, native_projection={ "path": recovery["path"], "sha256": recovery["sha256"], "source_path": recovery["path"], + **({"selection_command_args_template": selection_command_args_template} + if selection_command_args_template is not None else {}), "command_template": context_capture_read_command(Path(recovery["path"]), recovery["sha256"], page_bytes=page_bytes, max_page_output_bytes=output_bytes) + " --select '/sources'", diff --git a/loopx/control_plane/turn_driver/codex_operation_host.py b/loopx/control_plane/turn_driver/codex_operation_host.py index 6e6a82f01e..d3e798099b 100644 --- a/loopx/control_plane/turn_driver/codex_operation_host.py +++ b/loopx/control_plane/turn_driver/codex_operation_host.py @@ -20,13 +20,13 @@ from ..goals.first_party_host_admission import FirstPartyHostGoalAdmission from . import managed_operation_host as operations from .codex_cli import ( - _prompt, + _checked_prompt_context, normalize_codex_stdio_mcp_server, - codex_cli_result_schema, + prepare_codex_cli_input, normalize_codex_result, ) from .codex_sessions import _lineage, select_managed_operation_session -from .executor import LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION +from .host_candidate import assert_turn_host_request_version from .host_failure import BuiltInHostError from .operation_requests import ( operation_request_source, request_reference_instruction, @@ -53,15 +53,18 @@ def run_codex_operation_host( operation_request_file: Path | None = None, operation_request_sha256: str | None = None, ) -> dict[str, Any]: - if request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION: - raise ValueError("unsupported LoopX Turn host request schema") + assert_turn_host_request_version(request) if sandbox not in {"read-only", "workspace-write"}: raise ValueError("operation tools require a restricted Codex execution sandbox") if not model or not reasoning_effort: raise ValueError( "managed operation tools require an explicit model and reasoning effort" ) - lineage = _lineage(request) + # Complete work/Goal preparation must precede process or session effects. + # Keep the later prompt preparation as a fresh check after native startup. + checked = (_checked_prompt_context(request, include_session_lineage=True) + if "work_contract" in request else None) + lineage = _lineage(request, checked_lineage=checked["session_lineage"] if checked is not None else None) if not all(lineage.values()): raise ValueError("managed operation tools require a Todo-bound Turn") mcp_server = normalize_codex_stdio_mcp_server(mcp_server) @@ -199,8 +202,9 @@ def record_child() -> None: else: goal_admission.accept_result(record_child) + input_text, result_schema = prepare_codex_cli_input(request) result = session.send( - _prompt(request) + input_text + "\nUse loopx_operation for context/pending/prepare/inspect/consume/report. " "Source conversations are not executor identity. context/pending/inspect do not require consumption. " "When the admitted task authorizes proposal preparation and supplies its terms, prepare may run before human confirmation or consume; " @@ -214,7 +218,7 @@ def record_child() -> None: + json.dumps(continuations, sort_keys=True) + ". Inspect these locators through loopx_operation and consume once before any effect." if continuations else ""), - output_schema=codex_cli_result_schema(request), + output_schema=result_schema, on_event=on_event, turn_start_guard=lambda: launch_scope("codex_operation_turn_start"), on_native_item=observe_child if child_observer is not None else None, diff --git a/loopx/control_plane/turn_driver/codex_sessions.py b/loopx/control_plane/turn_driver/codex_sessions.py index c845e96966..b28fc727ad 100644 --- a/loopx/control_plane/turn_driver/codex_sessions.py +++ b/loopx/control_plane/turn_driver/codex_sessions.py @@ -9,7 +9,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, cast from ...boundary_authority import normalize_checkpointed_boundary_authority_entries from ...file_lock import exclusive_file_lock @@ -21,12 +21,14 @@ require_runtime_compatible_project_registry, ) from ..goals.first_party_host_admission import FirstPartyHostGoalAdmission -from .driver import selected_turn_todo, session_identity_fields +from .driver import session_identity_fields, turn_host_lineage CODEX_CLI_SESSION_SCHEMA_VERSION = "loopx_codex_cli_session_v1" MANAGED_CLI_SESSION_SCHEMA_VERSION = "loopx_managed_cli_session_v1" SESSION_ID_MAX_CHARS = 256 _UNCHECKED_SESSION_DOCUMENT = object() +_ABSENT_WORK_CONTRACT = object() +_ABSENT_EXECUTOR_ADAPTER = object() def _host_session_schema(host: str) -> str: @@ -37,18 +39,22 @@ def _host_session_schema(host: str) -> str: raise ValueError("unsupported managed session host") -def _lineage(request: Mapping[str, Any], *, session_scope: str | None = None) -> dict[str, str]: - envelope = request.get("turn_envelope") or {} - todo = selected_turn_todo(envelope) - lineage = { - "goal_id": str(envelope.get("goal_id") or "").strip(), - "agent_id": str(envelope.get("agent_id") or "").strip(), - "todo_id": str(todo.get("todo_id") or "").strip(), - } +def _lineage( + request: Mapping[str, Any], *, session_scope: str | None = None, + checked_lineage: Mapping[str, str] | None = None, +) -> dict[str, str]: + # Operation preflight may already hold this exact invocation's normalized + # source identity. Scope/path checks stay here; standalone and historical + # readers retain their own source check. Never retain it across attempts. + lineage = turn_host_lineage(request) if checked_lineage is None else dict(checked_lineage) if session_scope is None: session = request.get("session") or {} policy = session.get("context_policy") or {} session_scope = str(policy.get("binding_scope") or "todo") + return _checked_session_lineage(lineage, session_scope) + + +def _checked_session_lineage(lineage: dict[str, str], session_scope: str) -> dict[str, str]: if not all(lineage[field] for field in session_identity_fields(session_scope)): raise ValueError("Codex CLI host request has incomplete turn lineage") lineage["goal_id"] = validate_goal_id_path_segment(lineage["goal_id"]) @@ -168,13 +174,32 @@ def _read_codex_cli_session_document( def codex_cli_session_binding( runtime_root: Path, - turn_envelope: Mapping[str, Any], + turn_envelope: Mapping[str, Any] | object = _ABSENT_EXECUTOR_ADAPTER, *, + work_contract: object = _ABSENT_WORK_CONTRACT, goal_admission: FirstPartyHostGoalAdmission | None = None, session_scope: str = "todo", host: str = "codex-cli", ) -> dict[str, str] | None: - lineage = _lineage({"turn_envelope": dict(turn_envelope)}, session_scope=session_scope) + """Read the original native session from one complete source. + + Explicit historical adapters keep their existing decoder and, when a + complete source accompanies one, its consistency checks. Neither path + authorizes a Host launch or changes the session's owner or namespace. + """ + if turn_envelope is _ABSENT_EXECUTOR_ADAPTER: + if work_contract is _ABSENT_WORK_CONTRACT: + raise ValueError("session binding requires the complete source or historical executor input") + from ..quota.turn_envelope import _turn_envelope_result + + lineage = _checked_session_lineage(_turn_envelope_result( + "complete_host_lineage", payload=work_contract, + ), session_scope) + else: + request: dict[str, Any] = {"turn_envelope": dict(cast(Mapping[str, Any], turn_envelope))} + if work_contract is not _ABSENT_WORK_CONTRACT: + request["work_contract"] = work_contract + lineage = _lineage(request, session_scope=session_scope) session = select_codex_cli_session( runtime_root, lineage=lineage, diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index 42db430ed4..dc531129ea 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -131,6 +131,39 @@ def publish(self) -> dict[str, Any]: write_decision_capture(self.path, self.observation) return self.plan + def publish_for_host( + self, request: Mapping[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Install the published original input without changing recovery bindings.""" + plan = self.publish() + from ..quota.turn_envelope import COMPLETE_TURN_PLAN_SCHEMA_VERSION, turn_plan_host_source + + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION: + bound_request = {**request, **turn_plan_host_source(plan)} + bound_request.pop("turn_envelope", None) + return plan, bound_request + bound_request = {**request, "turn_envelope": plan["turn_envelope"]} + if "work_contract" in plan: + contract = plan["work_contract"] + bound_request["work_contract"] = dict(contract) if isinstance(contract, Mapping) else contract + else: + # Only an original historical absence can clear the current input. + bound_request.pop("work_contract", None) + from ..quota.turn_envelope import COMPLETE_TURN_HOST_REQUEST_SCHEMA, captured_turn_host_input + + if request.get("schema_version") == COMPLETE_TURN_HOST_REQUEST_SCHEMA: + if "work_contract" in plan: + bound_request = captured_turn_host_input(bound_request) + else: + # The immutable original observation, not a bad current source, + # owns restoration of a historical request version. + from ..quota.turn_envelope import LOOPX_TURN_HOST_REQUEST_SCHEMA + + bound_request["schema_version"] = LOOPX_TURN_HOST_REQUEST_SCHEMA + bound_request.pop("source_decision_hash", None) + bound_request.pop("detail_ref", None) + return plan, bound_request + def stage_turn_decision_capture( plan: Mapping[str, Any], *, journal_path: Path, @@ -142,7 +175,10 @@ def stage_turn_decision_capture( observation cannot be reconstructed from today's guard. Already committed journals remain readable through ordinary no-effect replay. """ - envelope = plan["turn_envelope"] + from ..quota.turn_envelope import COMPLETE_TURN_PLAN_SCHEMA_VERSION, turn_plan_host_source + + current = plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION + envelope = turn_plan_host_source(plan) if current else plan["turn_envelope"] transaction = plan["transaction"] path = journal_path.parent / "decisions" / journal_path.name capture = envelope.get("detail_ref", {}).get("captured_decision") @@ -164,14 +200,24 @@ def stage_turn_decision_capture( from ...command_invocation import current_loopx_cli_argv bound = bind_turn_decision_capture( - observation, envelope, captured_decision_path=str(path), + observation, None if current else envelope, captured_decision_path=str(path), turn_instance_id=str(transaction.get("turn_instance_id") or transaction["turn_key"]), turn_key=transaction["turn_key"], # New journals have a real immutable source and executable reader. # Legacy journals retain their recorded delivery/recovery route. reader_argv=current_loopx_cli_argv() if not existing_journal else None, + complete_host_request={key: value for key, value in envelope.items() if key != "work_contract"} + if current else None, ) - return StagedTurnDecisionCapture({**plan, "turn_envelope": bound}, path, observation, existed) + restored = ({**plan, **{key: bound[key] for key in ("source_decision_hash", "detail_ref")}} + if current else {**plan, "turn_envelope": bound}) + if observation.get("schema_version") == "loopx_turn_envelope_v1": + restored["work_contract"] = observation + else: + # Historical observations retain their original input contract. Never + # pair an old capture with a freshly observed complete decision. + restored.pop("work_contract", None) + return StagedTurnDecisionCapture(restored, path, observation, existed) def prepare_turn_decision_capture( @@ -411,6 +457,8 @@ def capture_context_projection(payload: dict[str, Any], *, directory: Path, read_guidance: str = CONTEXT_CAPTURE_READ_GUIDANCE, filename: str = "projection.json") -> dict[str, Any]: """Save the full projection independently of the optional presentation codec.""" + from ...command_invocation import current_loopx_cli_argv + if filename not in ("projection.json", "required-context.json"): raise ValueError("unsupported captured context filename") projection = directory / filename @@ -420,12 +468,13 @@ def capture_context_projection(payload: dict[str, Any], *, directory: Path, "instruction": "Complete JSON for this captured view. Use it when required content is missing or uncertain; " "compaction alone does not require replay within an existing work Turn. " + read_guidance, - "command": context_capture_read_command(projection, digest), + "command": context_capture_read_command(projection, digest, reader_argv=current_loopx_cli_argv()), } def capture_progressive_context(payload: dict[str, Any], *, directory: Path, - surface: str = "quota_packet") -> tuple[dict[str, Any], dict[str, Any]]: + surface: str = "quota_packet", + selection_command_args_template: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]: """Publish the complete current source before optional stateless presentation. The typed owner applies the shrink guard. This neither creates a native @@ -436,6 +485,8 @@ def capture_progressive_context(payload: dict[str, Any], *, directory: Path, source = capture_context_projection(payload, directory=directory) transaction = prepare_progressive_context(payload, surface=surface, source_reference={ **{key: source[key] for key in ("path", "sha256")}, + **({"selection_command_args_template": selection_command_args_template} + if selection_command_args_template is not None else {}), "source_path": source["path"], "command_template": source["command"] + " --select ''", }) diff --git a/loopx/control_plane/turn_driver/driver.py b/loopx/control_plane/turn_driver/driver.py index 9186d5d0cb..03ec08a491 100644 --- a/loopx/control_plane/turn_driver/driver.py +++ b/loopx/control_plane/turn_driver/driver.py @@ -9,6 +9,7 @@ from ..agent_context import envelope_agent_context from ..effect_program import SettlementIdentity +from ..effect_runtime import EffectRuntimeRejected from .subagent_host_adapter import ( project_child_context_adapter, supported_child_context_modes, @@ -18,6 +19,12 @@ build_subagent_execution_topology, ) from ..quota.turn_envelope import ( + COMPLETE_TURN_PLAN_SCHEMA_VERSION as COMPLETE_TURN_PLAN_SCHEMA_VERSION, + selected_turn_todo as selected_turn_todo, + read_turn_plan_work as read_turn_plan_work, + turn_host_lineage as turn_host_lineage, + LOOPX_TURN_PLAN_SCHEMA_VERSION as LOOPX_TURN_PLAN_SCHEMA_VERSION, + captured_turn_host_input, turn_plan_host_source, assert_turn_envelope_consumer_version, _turn_envelope_result, turn_envelope_scoped_settlement_identity, @@ -28,12 +35,13 @@ from .transaction import build_loopx_turn_transaction_plan -LOOPX_TURN_PLAN_SCHEMA_VERSION = "loopx_turn_plan_v0" LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION = "loopx_turn_session_binding_v0" LOOPX_ITERATION_CONTEXT_POLICY_SCHEMA_VERSION = ( "loopx_iteration_context_policy_v0" ) LOOPX_CHILD_HOST_OPERATION_SCHEMA_VERSION = "loopx_child_host_operation_v0" +_ABSENT_WORK_CONTRACT = object() + SUPPORTED_HOSTS = {"codex-cli", "codex-app-server", "claude-code", "dsh", "generic-cli"} SUPPORTED_EXECUTION_MODES = {"interactive-visible", "isolated-headless"} SUPPORTED_ITERATION_CONTEXT_POLICIES = {"fresh", "resume"} @@ -71,9 +79,12 @@ def _historical_route_text(envelope: Mapping[str, Any]) -> dict[str, str]: Complete contracts do not accept these historical decoding hints. """ signature = _mapping(envelope.get("action_signature")) + orchestration = _mapping(envelope.get("task_orchestration_contract")) return {"source_hash": str(signature.get("source_hash") or ""), "envelope_hash": str(signature.get("envelope_hash") or ""), - "effective_action": str(envelope.get("effective_action") or "")} + "effective_action": str(envelope.get("effective_action") or ""), + **({"primary_todo_id": str(orchestration.get("primary_todo_id") or "").strip()} + if "primary_todo_id" in orchestration else {})} def _typed_route(envelope: Mapping[str, Any]) -> LoopXTurnRoute: @@ -83,21 +94,49 @@ def _typed_route(envelope: Mapping[str, Any]) -> LoopXTurnRoute: )["route"]) -def selected_turn_todo(envelope: Mapping[str, Any]) -> dict[str, Any]: - """Transport historical selection to the shared work/settlement owner. +def captured_todo_text_sha256( + envelope: Mapping[str, Any], *, todo_id: str, + work_contract: object = _ABSENT_WORK_CONTRACT, +) -> str | None: + """Use the shared task-source owner; omitted contracts decode old journals.""" + try: + result = _turn_envelope_result( + "captured_todo_text", envelope=dict(envelope), todo_id=todo_id, + **({"payload": work_contract} if work_contract is not _ABSENT_WORK_CONTRACT else {}), + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + digest = result.get("sha256") + if digest is not None and not isinstance(digest, str): + raise RuntimeError("Turn captured task source hash must be a string or null") + return digest - Only Python's original scalar codec remains here. Pure source edits retain - unknown fields and exact numbers without another JSON value decoder. - """ - from ..quota.cli_projection import _apply_context_projection_changes - orchestration = _mapping(envelope.get("task_orchestration_contract")) - source = {"selected_todo": _mapping(_mapping(envelope.get("action")).get("selected_todo")), - "task_orchestration_contract": orchestration} - edits = _turn_envelope_result("selected_work_todo", payload=source, - historical_primary_text=str(orchestration.get("primary_todo_id") or "").strip()) - selected: dict[str, Any] = _apply_context_projection_changes(source, edits)["selected_todo"] - return selected +def read_turn_execution_context( + plan: Mapping[str, Any], envelope: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Read complete execution facts in one call; decode old journals as before.""" + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION or "work_contract" in plan: + try: + from ..quota.turn_envelope import complete_turn_host_source + + result = complete_turn_host_source(turn_plan_host_source(plan), operation="execution_context") + assert result is not None + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + if result.get("sha256") is not None and not isinstance(result["sha256"], str): + raise RuntimeError("Turn captured task source hash must be a string or null") + return result + envelope = _mapping(plan.get("turn_envelope")) if envelope is None else envelope + selected = selected_turn_todo(envelope) + return { + "selected_todo": selected, + "sha256": captured_todo_text_sha256(envelope, todo_id=str(selected.get("todo_id") or "")), + "effective_action": envelope.get("effective_action"), + "writeback": envelope.get("writeback") if isinstance(envelope.get("writeback"), dict) else {}, + "replan_guard_scoped": "replan_action_packet" in envelope, + "replan_action_packet": envelope.get("replan_action_packet"), + } def _receipt_bound_scoped_identity( @@ -118,32 +157,30 @@ def _receipt_bound_scoped_identity( def _turn_lineage( envelope: Mapping[str, Any], *, - selected_todo: Mapping[str, Any], - settlement_identity: SettlementIdentity | None = None, + work_contract: object = _ABSENT_WORK_CONTRACT, projection: Mapping[str, Any] | None = None, + settlement_identity: SettlementIdentity | None = None, ) -> dict[str, str]: resolved = projection if projection is not None else _turn_envelope_result( - "driver_lineage", envelope=dict(envelope), historical_route_text=_historical_route_text(envelope), + "driver_lineage", envelope=dict(envelope), + **({"payload": work_contract} if work_contract is not _ABSENT_WORK_CONTRACT else + {"historical_route_text": _historical_route_text(envelope)}), ) - signature_document = resolved["action_signature"] - action = _mapping(signature_document.get("action")) - signature_document["action"] = { - **action, - "selected_todo": dict(selected_todo), - } - action_hash = "sha256:" + sha256( - json.dumps( - signature_document, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - ).hexdigest() + signature = resolved["action_signature"] + selected_todo = _mapping(_mapping(signature.get("action")).get("selected_todo")) + if work_contract is _ABSENT_WORK_CONTRACT: + # Historical journals retain the exact decoder and JSON preimage. + identity: Mapping[str, Any] = {"goal_id": envelope.get("goal_id"), "agent_id": envelope.get("agent_id"), + "todo_id": selected_todo.get("todo_id")} + else: + identity = resolved + # Persisted Turn keys depend on Python's JSON number encoding. Keep this + # transport codec while the TS owner selects and validates identity facts. lineage = { - "goal_id": str(envelope.get("goal_id") or ""), - "agent_id": str(envelope.get("agent_id") or ""), - "todo_id": str(selected_todo.get("todo_id") or ""), - "action_hash": action_hash, + **{field: str(identity.get(field) or "") for field in ("goal_id", "agent_id", "todo_id")}, + "action_hash": "sha256:" + sha256(json.dumps( + signature, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + ).encode("utf-8")).hexdigest(), } if settlement_identity is not None: if settlement_identity.request_subject is not None: @@ -238,15 +275,30 @@ def reconcile_failed_turn_session_request( """Re-authorize a journaled host request against one current binding.""" envelope = _mapping(request.get("turn_envelope")) - selected_todo = selected_turn_todo(envelope) try: - settlement_identity = (None if selected_todo else _receipt_bound_scoped_identity( - envelope, turn_instance_id=None, - )) + from ..quota.turn_envelope import complete_turn_host_source + + projection = complete_turn_host_source(turn_plan_host_source(request), operation="driver_lineage") + if projection is None: + projection = _turn_envelope_result( + "driver_lineage", envelope=envelope, + historical_route_text=_historical_route_text(envelope), + ) + selected_todo = _mapping(_mapping(projection["action_signature"].get("action")).get("selected_todo")) + settlement_identity = None + if not selected_todo: + if "work_contract" in request: + raw = projection["scoped_settlement_identity"] + settlement_identity = None if raw is None else SettlementIdentity.from_runtime_payload(raw) + else: + settlement_identity = _receipt_bound_scoped_identity(envelope, turn_instance_id=None) + lineage = _turn_lineage( + envelope, projection=projection, + work_contract=request.get("work_contract", _ABSENT_WORK_CONTRACT), + settlement_identity=settlement_identity, + ) except (ValueError, RuntimeError) as exc: raise FailedTurnSessionRecoveryError("turn_lineage_missing", str(exc)) from exc - lineage = _turn_lineage(envelope, selected_todo=selected_todo, - settlement_identity=settlement_identity) planned_session = _mapping(request.get("session")) session, session_error = _session_plan( route=LoopXTurnRoute.READY_FOR_HOST, @@ -377,8 +429,14 @@ def build_loopx_turn_plan( iteration_context_policy: str = "resume", session_scope: str | None = None, goal_ref: Mapping[str, object] | None = None, + work_contract: object = _ABSENT_WORK_CONTRACT, ) -> dict[str, Any]: - """Project a TurnEnvelope into a typed, side-effect-free host decision.""" + """Plan from one complete source; retain the recorded v0 input codec. + + The positional argument also accepts a historical envelope. An explicitly + paired source/adapter is checked before producing the current single-source + plan; normal producers pass the complete source directly. + """ if host not in SUPPORTED_HOSTS: raise ValueError(f"unsupported LoopX Turn host: {host}") @@ -399,36 +457,71 @@ def build_loopx_turn_plan( execution_mode=execution_mode, scheduler_owner=scheduler_owner, ) - envelope = dict(turn_envelope) + supplied = dict(turn_envelope) + # Preserve an explicitly supplied complete carrier even when its version + # is unsupported. The typed reader must reject it, not try a legacy route. + source_only_input = supplied.get("schema_version") == "loopx_turn_envelope_v1" or "contract_binding" in supplied + if source_only_input and work_contract is not _ABSENT_WORK_CONTRACT: + raise ValueError("complete planning input must supply its source once") + if source_only_input: + work_contract = supplied + current = work_contract is not _ABSENT_WORK_CONTRACT + envelope = supplied contract_error = None - try: - assert_turn_envelope_consumer_version(envelope) - except (ValueError, RuntimeError) as exc: - contract_error = str(exc) - projection = _turn_envelope_result("driver_lineage", envelope=envelope, - historical_route_text=_historical_route_text(envelope)) + if not current: + # Historical inputs retain their version-gate diagnostics. Complete + # inputs reach that same gate inside driver_lineage's checked source; + # sending the envelope separately would repeat the pure RPC. + try: + assert_turn_envelope_consumer_version(envelope) + except (ValueError, RuntimeError) as exc: + contract_error = str(exc) + # Check the complete source before route/session/transaction planning. Reuse + # the action-hash boundary rather than adding a separate identity call. + if current: + projection = _turn_envelope_result("planning_facts", payload=work_contract, + turn_instance_id=turn_instance_id, + **({} if source_only_input else {"envelope": supplied})) + envelope = _mapping(projection["work"]) + else: + projection = _turn_envelope_result("driver_lineage", envelope=envelope, + historical_route_text=_historical_route_text(envelope)) + selected_todo = _mapping(_mapping(projection["action_signature"].get("action")).get("selected_todo")) route = ( LoopXTurnRoute(projection["route"]) if execution_context.ok and contract_error is None else LoopXTurnRoute.CONTRACT_ERROR ) - selected_todo = selected_turn_todo(envelope) settlement_identity = None identity_error = None if route is LoopXTurnRoute.REPLAN_REQUIRED or (route is LoopXTurnRoute.READY_FOR_HOST and not selected_todo): try: - settlement_identity = _receipt_bound_scoped_identity( - envelope, turn_instance_id=turn_instance_id, - ) + if current: + if projection.get("scoped_identity_error"): + raise ValueError(projection["scoped_identity_error"]) + raw = projection.get("scoped_settlement_identity") + settlement_identity = SettlementIdentity.from_runtime_payload(raw) if raw is not None else None + else: + settlement_identity = _receipt_bound_scoped_identity( + envelope, turn_instance_id=turn_instance_id, + ) except (ValueError, RuntimeError) as exc: identity_error = str(exc) route = LoopXTurnRoute.CONTRACT_ERROR - if (not selected_todo and identity_error is None - and (settlement_identity is None or (session_scope != "agent" - and not (settlement_identity.request_subject is not None and iteration_context_policy == "fresh")))): - route = LoopXTurnRoute.BLOCKED - lineage = _turn_lineage(envelope, selected_todo=selected_todo, - settlement_identity=settlement_identity, projection=projection) + if not selected_todo and identity_error is None: + if settlement_identity is None: + # Historical inputs without a work binding safely waited. + # Preserve that decoder without weakening complete ordinary + # work: its missing binding still fails session validation. + if (route is LoopXTurnRoute.REPLAN_REQUIRED + or work_contract is _ABSENT_WORK_CONTRACT): + route = LoopXTurnRoute.BLOCKED + elif (session_scope != "agent" and not ( + settlement_identity.request_subject is not None and iteration_context_policy == "fresh" + )): + route = LoopXTurnRoute.BLOCKED + lineage = _turn_lineage(envelope, work_contract=work_contract, projection=projection, + settlement_identity=settlement_identity) session, session_error = _session_plan( route=route, lineage=lineage, @@ -465,6 +558,7 @@ def build_loopx_turn_plan( child_operations=raw_child_operations, turn_key=str(transaction.get("turn_key") or ""), source_state_ref=str( + projection.get("action_source_hash") if current else _mapping(envelope.get("action_signature")).get("source_hash") or "" ), ) @@ -475,7 +569,7 @@ def build_loopx_turn_plan( child_operations = _bind_host_child_operations(child_operations) payload = { "ok": route is not LoopXTurnRoute.CONTRACT_ERROR, - "schema_version": LOOPX_TURN_PLAN_SCHEMA_VERSION, + "schema_version": COMPLETE_TURN_PLAN_SCHEMA_VERSION if current else LOOPX_TURN_PLAN_SCHEMA_VERSION, "mode": "plan", "host": { "kind": host, @@ -493,7 +587,6 @@ def build_loopx_turn_plan( "host_invocation_allowed": False, "selected_todo": selected_todo or None, }, - "turn_envelope": envelope, "transaction": transaction, "effects": { "host_invoked": False, @@ -504,7 +597,7 @@ def build_loopx_turn_plan( "boundary": { "read_only": True, "requires_explicit_execute_surface": True, - "preserves_turn_envelope": True, + **({} if current else {"preserves_turn_envelope": True}), "opaque_session_handle_omitted": True, }, **( @@ -513,6 +606,17 @@ def build_loopx_turn_plan( else {} ), } + if current: + payload["work_contract"] = work_contract + payload["source_decision_hash"] = projection["source_decision_hash"] + if not source_only_input: + # Older callers may already carry an original quota capture. Keep + # its exact header without retaining that caller's second source. + header = captured_turn_host_input({"turn_envelope": supplied}) + if "detail_ref" in header: + payload["detail_ref"] = header["detail_ref"] + else: + payload["turn_envelope"] = envelope if child_operations: payload["child_operations"] = child_operations context = envelope_agent_context( diff --git a/loopx/control_plane/turn_driver/dsh_operation_host.py b/loopx/control_plane/turn_driver/dsh_operation_host.py index f840aca462..ab7e715729 100644 --- a/loopx/control_plane/turn_driver/dsh_operation_host.py +++ b/loopx/control_plane/turn_driver/dsh_operation_host.py @@ -33,7 +33,7 @@ from .operation_requests import operation_request_source, request_reference_instruction from .codex_sessions import _lineage, select_managed_operation_session from .dsh_operation_connection import DshOperationConnection -from .executor import LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION +from .host_candidate import assert_turn_host_request_version from .host_failure import BuiltInHostError DSH_OPERATION_TRANSPORT = "dsh-native-operation-tools-v0" @@ -159,9 +159,8 @@ def _run_dsh_operation_host( owner: OwnedDshOperationHost, ) -> dict[str, Any]: """One owned SDK run; final prose cannot create an operation outcome.""" - if request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA_VERSION: - raise ValueError("unsupported LoopX Turn host request") - authority = extract_turn_authority(request) + assert_turn_host_request_version(request) + authority = extract_turn_authority(request, include_session_lineage=True) if not all( isinstance(value, str) and value for value in (provider, model, reasoning_effort) @@ -175,7 +174,8 @@ def _run_dsh_operation_host( raise ValueError("DSH operation run requires bounded token and time budgets") from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig # type: ignore - lineage = _lineage(request) + lineage = _lineage(request, checked_lineage=(authority["_session_lineage"] + if "work_contract" in request else None)) module = operation_module.resolve(strict=True) patch = cordis.resolve(strict=True) if cordis else None request_source = operation_request_source(operation_request_file, operation_request_sha256) diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index ab6c0375c4..89f1b5c9ec 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -37,7 +37,11 @@ normalize_reward_memory_reflection_validation, reward_memory_reflection_digest, ) -from .driver import selected_turn_todo +from ..quota.turn_envelope import turn_plan_host_source +from .driver import ( + COMPLETE_TURN_PLAN_SCHEMA_VERSION, + selected_turn_todo, turn_host_lineage, +) from .decision_capture import stage_turn_decision_capture from .execution_readback import execution_payload from .host_process_transport import run_host_process @@ -150,6 +154,10 @@ def build_loopx_turn_host_request(plan: Mapping[str, Any]) -> dict[str, Any]: + """Deliver the plan's original source; decode saved v0 plans as recorded.""" + boundary = plan.get("boundary") + if isinstance(boundary, Mapping) and boundary.get("inspection_only") is True: + raise ValueError("LoopX Turn CLI inspection requires the explicit run-once execution surface") transaction = ( plan.get("transaction") if isinstance(plan.get("transaction"), dict) else {} ) @@ -171,6 +179,14 @@ def build_loopx_turn_host_request(plan: Mapping[str, Any]) -> dict[str, Any]: "stdout": "one public-safe JSON object", }, } + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION: + request.pop("turn_envelope") + request.update(turn_plan_host_source(plan)) + if "work_contract" in plan: + # Presence selects the current reader, including malformed values. + # The checked source below must refuse them, never select legacy input. + contract = plan["work_contract"] + request["work_contract"] = dict(contract) if isinstance(contract, Mapping) else contract goal_ref = plan.get("goal_ref") if isinstance(goal_ref, Mapping): request["goal_ref"] = dict(goal_ref) @@ -180,6 +196,10 @@ def build_loopx_turn_host_request(plan: Mapping[str, Any]) -> dict[str, Any]: if configured_native_child_limit(request) is not None: request["turn_instance_id"] = transaction.get("turn_instance_id") or turn_key + if "work_contract" in request: + from .host_candidate import complete_host_input + + return complete_host_input(request) return request @@ -305,13 +325,11 @@ def _normalize_host_path_delta( packet = result["agent_vision"] if not isinstance(packet, dict): raise TypeError("cached agent_vision must be a JSON object") - envelope = plan.get("turn_envelope") or {} + lineage = turn_host_lineage(plan) agent_vision = normalize_goal_vision_packet( - packet, - goal_id=str(envelope.get("goal_id") or ""), - agent_id=str(envelope.get("agent_id") or "") or None, + packet, goal_id=lineage["goal_id"], agent_id=lineage["agent_id"] or None, ) - except (TypeError, ValueError) as exc: + except (TypeError, ValueError, RuntimeError) as exc: errors.append(f"invalid cached agent_vision: {exc}") elif agent_vision_json: if len(agent_vision_json) > HOST_AGENT_VISION_JSON_MAX_CHARS: @@ -324,17 +342,11 @@ def _normalize_host_path_delta( packet = json.loads(agent_vision_json) if not isinstance(packet, dict): raise TypeError("agent_vision_json must decode to a JSON object") - envelope = ( - plan.get("turn_envelope") - if isinstance(plan.get("turn_envelope"), dict) - else {} - ) + lineage = turn_host_lineage(plan) agent_vision = normalize_goal_vision_packet( - packet, - goal_id=str(envelope.get("goal_id") or ""), - agent_id=str(envelope.get("agent_id") or "") or None, + packet, goal_id=lineage["goal_id"], agent_id=lineage["agent_id"] or None, ) - except (json.JSONDecodeError, TypeError, ValueError) as exc: + except (json.JSONDecodeError, TypeError, ValueError, RuntimeError) as exc: errors.append(f"invalid agent_vision_json: {exc}") if path_delta_mode == "material_replan": @@ -815,6 +827,12 @@ def _run_host( project: Path, timeout_seconds: float | None, ) -> dict[str, Any]: + # Dispatch has checked the source and its internal effect adapter before + # journal/cadence admission. External stdin gets the same single complete + # input as native model prompts, not another executable authority carrier. + from ..quota.turn_envelope import captured_turn_host_input + + host_input = captured_turn_host_input(request) if "work_contract" in request else request stdout: list[str] = [] stderr_chars = 0 @@ -826,7 +844,7 @@ def count_stderr(text: str) -> None: observed = run_host_process( argv, project=project, - input_text=json.dumps(request, ensure_ascii=False, separators=(",", ":")), + input_text=json.dumps(host_input, ensure_ascii=False, separators=(",", ":")), timeout_seconds=timeout_seconds, stdout_limit_bytes=HOST_RESULT_MAX_BYTES, on_stdout=stdout.append, @@ -1161,20 +1179,26 @@ def _task_validation_stage( def _ensure_turn_settlement_plan( plan: Mapping[str, Any], transaction_plan: dict[str, Any], -) -> None: +) -> dict[str, str] | None: + # Check the complete source once before either new execution or journal + # recovery. Reuse this call-local identity below; never cache it across a + # host attempt, source replacement or another invocation. + lineage = turn_host_lineage(plan) if "work_contract" in plan or plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION else None if isinstance(transaction_plan.get("settlement_plan"), Mapping): - return - envelope = plan.get("turn_envelope") - if not isinstance(envelope, Mapping): - return - selected_todo = selected_turn_todo(envelope) - lineage = { - "goal_id": str(envelope.get("goal_id") or ""), - "agent_id": str(envelope.get("agent_id") or ""), - "todo_id": str(selected_todo.get("todo_id") or ""), - } + return lineage + if lineage is None: + # Preserve the historical decoder, including its exact string handling. + envelope = plan.get("turn_envelope") + if not isinstance(envelope, Mapping): + return None + selected_todo = selected_turn_todo(envelope) + lineage = { + "goal_id": str(envelope.get("goal_id") or ""), + "agent_id": str(envelope.get("agent_id") or ""), + "todo_id": str(selected_todo.get("todo_id") or ""), + } if not all(lineage.values()): - return + return lineage host = plan.get("host") host_fields = host if isinstance(host, Mapping) else {} built = build_loopx_turn_transaction_plan( @@ -1195,6 +1219,7 @@ def _ensure_turn_settlement_plan( settlement_plan = built.get("settlement_plan") if isinstance(settlement_plan, Mapping): transaction_plan["settlement_plan"] = settlement_plan + return lineage def _typed_settlement_stage( @@ -1433,6 +1458,20 @@ def persist_tail_checkpoint(*, release: bool) -> None: ) +def _prepare_host_invocation( + plan: Mapping[str, Any], *, host_argv: Sequence[str] | None, + host_runner: HostRunner | None, +) -> tuple[list[str] | None, dict[str, str | int]]: + """Normalize the existing external or built-in transport before admission.""" + if host_runner is not None and host_argv is not None: + raise ValueError("run-once accepts either host_argv or host_runner, not both") + if host_runner is None: + argv = normalize_host_argv(host_argv or []) + return argv, {"executable": Path(argv[0]).name, "argv_count": len(argv)} + planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} + return None, {"executable": "built-in", "kind": str(planned_host.get("kind") or "codex-cli")} + + @single_executor_per_turn_lane(execution_payload) def run_loopx_turn_once( plan: Mapping[str, Any], @@ -1464,22 +1503,11 @@ def run_loopx_turn_once( confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, ) -> dict[str, Any]: - if host_runner is not None and host_argv is not None: - raise ValueError("run-once accepts either host_argv or host_runner, not both") - if host_runner is None: - argv = normalize_host_argv(host_argv or []) - host_projection = {"executable": Path(argv[0]).name, "argv_count": len(argv)} - else: - argv = None - planned_host = plan.get("host") if isinstance(plan.get("host"), dict) else {} - host_projection = { - "executable": "built-in", - "kind": str(planned_host.get("kind") or "codex-cli"), - } + argv, host_projection = _prepare_host_invocation(plan, host_argv=host_argv, host_runner=host_runner) transaction_plan = ( plan.get("transaction") if isinstance(plan.get("transaction"), dict) else {} ) - _ensure_turn_settlement_plan(plan, transaction_plan) + settlement_lineage = _ensure_turn_settlement_plan(plan, transaction_plan) request = build_loopx_turn_host_request(plan) empty_effects = { "host_invoked": False, @@ -1562,12 +1590,15 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: operation="turn_recovery_lifetime", ): pass - envelope = ( - plan.get("turn_envelope") - if isinstance(plan.get("turn_envelope"), Mapping) - else {} - ) - agent_id = str(envelope.get("agent_id") or "") + if settlement_lineage is not None: + agent_id = settlement_lineage["agent_id"] + else: + envelope = ( + plan.get("turn_envelope") + if isinstance(plan.get("turn_envelope"), Mapping) + else {} + ) + agent_id = str(envelope.get("agent_id") or "") assessment = assess_existing_turn_recovery( journal, request, @@ -1621,6 +1652,17 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: journal["plan"] if journal is not None else plan, journal_path=journal_path, decision=decision_capture_payload, existing_journal=journal is not None, ) if require_decision_capture and needs_host else None + if needs_host: + from ..quota.turn_envelope import complete_turn_host_source + + # Check the exact input selected for publication before reserving + # an automatic start or writing a new journal/capture. A refusal + # must not put otherwise eligible work into an interval wait. + # Saved typed results retain their original finalization path. + complete_turn_host_source( + build_loopx_turn_host_request(capture.plan) if capture is not None else request, + operation="host_dispatch_context", + ) admission = None if needs_host and admit_start is not None: admission = admit_start( @@ -1645,8 +1687,7 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: plan, waiting, execute=True, replayed=False, effects=empty_effects ) if capture is not None: - plan = capture.publish() - request = {**request, "turn_envelope": plan["turn_envelope"]} + plan, request = capture.publish_for_host(request) if journal is not None and recovery_decision is not None: journal["recovery_audit"] = build_turn_recovery_audit( recovery_decision, diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index efdfbd7de4..6e8956a61c 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -7,11 +7,18 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy from hashlib import sha256 import json -from typing import Any - -from ..quota.turn_envelope import assert_turn_envelope_consumer_version, turn_envelope_action_signature_document +from typing import Any, cast + +from ..quota.turn_envelope import ( + COMPLETE_TURN_HOST_REQUEST_SCHEMA as COMPLETE_TURN_HOST_REQUEST_SCHEMA, + LOOPX_TURN_HOST_REQUEST_SCHEMA as LOOPX_TURN_HOST_REQUEST_SCHEMA, + assert_turn_envelope_consumer_version, turn_envelope_action_signature_document, + captured_turn_host_input, complete_turn_host_source, + assert_turn_host_request_version as assert_turn_host_request_version, +) from ..work_items.delivery_batch_scale import DELIVERY_BATCH_SCALE_CHOICES from ..work_items.delivery_outcome import DELIVERY_OUTCOME_CHOICES from .driver import selected_turn_todo @@ -20,7 +27,6 @@ HOST_RESULT_EVIDENCE_LIMITS, ) -LOOPX_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v0" LOOPX_TURN_RESULT_SCHEMA = "loopx_turn_result_v0" COMPLETED_PHASES = ["host_execute", "typed_result"] @@ -63,19 +69,84 @@ def _canonical_hash(value: Any) -> str: return "sha256:" + sha256(encoded).hexdigest() -def _execution_profile_present(request: Mapping[str, Any]) -> bool: +def execution_profile_present(request: Mapping[str, Any]) -> bool: + """Read result evidence applicability from the checked admitted source. + + An omitted complete contract retains historical capsule decoding; an invalid + supplied contract never falls back. Schema and candidate adapters share this + read, while evidence content validation stays with the result executor. + """ + checked = complete_turn_host_source(request, operation="host_context") + if checked is not None: + return cast(bool, checked["execution_profile_present"]) envelope = _mapping(request.get("turn_envelope")) - capsule = _mapping(envelope.get("contract_capsule")) - profile = capsule.get("execution_profile") + decision = _mapping(envelope.get("contract_capsule")) + profile = decision.get("execution_profile") return isinstance(profile, Mapping) and bool(profile) -def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: - """Return the signed action and safety boundary exactly as projected.""" +def complete_host_input(request: Mapping[str, Any]) -> dict[str, Any] | None: + """Transport the complete contract plus original material-reader metadata. + + The TypeScript owner validates identity and integrity. This adapter never + compacts decision facts or derives a second model-facing authority view. + Historical requests without a complete contract retain their old decoder. + """ + if complete_turn_host_source(request, operation="host_context") is None: + return None + return captured_turn_host_input(request) + + +def extract_turn_work_identity(request: Mapping[str, Any]) -> dict[str, Any]: + """Read tool binding from the same dispatch owner without copying authority. + + Complete input uses the selected-work owner. Historical requests keep raw + identity values so provider string/NUL checks retain their original meaning. + This read grants no tool or work permission. + """ + try: + checked = complete_turn_host_source(request, operation="host_dispatch_context") + except RuntimeError as exc: + raise ValueError("complete Turn contract rejected") from exc + if checked is not None: + identity = checked["work_identity"] + if not isinstance(identity, dict): + raise RuntimeError("complete Turn work identity must be an object") + return dict(identity) + extract_turn_authority(request) + envelope = _mapping(request.get("turn_envelope")) + selected = _mapping(_mapping(envelope.get("action")).get("selected_todo")) + return {"goal_id": envelope.get("goal_id"), "agent_id": envelope.get("agent_id"), + "todo_id": selected.get("todo_id")} + + +def extract_turn_authority( + request: Mapping[str, Any], *, include_session_lineage: bool = False, +) -> dict[str, Any]: + """Use the complete source; decode the old layout for historical input only.""" + + try: + checked = complete_turn_host_source(request, operation="host_dispatch_context", + include_session_lineage=include_session_lineage) + except RuntimeError as exc: + raise ValueError("complete Turn contract rejected") from exc + if checked is not None: + primary_action = checked["primary_action"] + if not isinstance(primary_action, str) or not primary_action.strip(): + raise ValueError("signed TurnEnvelope has no primary_action") + # Keep the old detached authority value without echoing its complete + # bytes through the checked-source RPC. + decision = deepcopy(_mapping(request["work_contract"])) + return {**decision, "primary_action": primary_action, + "execution_profile": _mapping(decision.get("execution_profile")), + "replan_action_packet": decision.get("replan_action_packet") if checked["replan_admitted"] else None, + "_work_identity": dict(checked["work_identity"]), + **({"_session_lineage": dict(checked["session_lineage"])} if include_session_lineage else {}), + "_model_input": captured_turn_host_input(request)} envelope = _mapping(request.get("turn_envelope")) - assert_turn_envelope_consumer_version(envelope) signature = _mapping(envelope.get("action_signature")) + assert_turn_envelope_consumer_version(envelope) source_hash = str(signature.get("source_hash") or "") envelope_hash = str(signature.get("envelope_hash") or "") computed_envelope_hash = _canonical_hash( @@ -161,9 +232,9 @@ def extract_turn_authority(request: Mapping[str, Any]) -> dict[str, Any]: # priorities. Keep the signed envelope intact for the executor; do not send # a second copy ahead of the mandatory immutable review read. for field in (() if staged else ("core_goal", "planning_horizon", "acceptance_index")): - value = detail.get(field, action.get(field)) - if value is not None: - authority[field] = value + detail_value = detail.get(field, action.get(field)) + if detail_value is not None: + authority[field] = detail_value horizon = _mapping(authority.get("planning_horizon")) if horizon: # Relocate only the envelope's registered aliases after flattening its @@ -195,15 +266,17 @@ def extract_action_text(request: Mapping[str, Any]) -> str: return str(extract_turn_authority(request)["primary_action"]) -def replan_prompt_instruction(authority: Mapping[str, Any]) -> str: +def replan_prompt_instruction( + authority: Mapping[str, Any], *, packet_path: str = "replan_action_packet", +) -> str: """Reader routing shared by native Codex and structured Host transports.""" packet = _mapping(authority.get("replan_action_packet")) if not packet: return "" source = ( - "Read replan_action_packet.review and the applicable acceptance/evidence reads. " + f"Read {packet_path}.review and the applicable acceptance/evidence reads. " "Decide the next work and route from those facts before reading " - "replan_action_packet.writeback for the semantic result format. " + f"{packet_path}.writeback for the semantic result format. " "The readers verify the original source hash; check their Goal/Agent and " "obligation/context identity against this Turn. " if packet.get("review") else @@ -229,71 +302,104 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals adapter reads back as a typed candidate; it stays public-safe and bounded. """ + model_input = authority.get("_model_input") authority_json = json.dumps( - dict(authority), + model_input if model_input is not None else dict(authority), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) - context_instruction = ( - "For unavailable_context, discard cached context from the affected hooks; " - "do not interpret failure as empty context. Hold actions that require that " - "missing context until it is read successfully. Independent work may " - "continue under its existing authority; this grants no new permissions.\n" - if authority.get("unavailable_context") else "" - ) - planning_context_instruction = ( - "Read agent_context before planning; its guidance and facts grant no permissions. " - "Only an agent_context.detail_ref requires capability detail: use detail_ref.full_decision " - "only for the saved original observation identified by detail_ref.captured_decision, " - "checking its Goal/Agent/Turn and source hash. Never re-enter quota admission for a detail read. " - "Missing or invalid original evidence holds dependent planning until recovery; " - "independent work retains its existing authority.\n" - if authority.get("agent_context") else "" - ) - replan_instruction = replan_prompt_instruction(authority) - acceptance_instruction = ( - "Read vision_continuation_audit before judging Goal acceptance or proposing " - "closeout; preserve its ordered required_before_closeout, evidence requirements " - "and current vision_gap_judge. Selected Todo completion alone cannot close the Goal. " - "For additional acceptance or coverage evidence, use detail_ref.full_decision " - "only for the saved original observation identified by detail_ref.captured_decision; " - "check its Goal/Agent/Turn and action_signature.source_decision_hash. " - "Never re-enter quota admission for a detail read. Missing or invalid evidence " - "holds dependent closure; independent work retains its existing authority. " - "Do not execute its state or scheduler commands; report the semantic result " - "for the outer Turn executor. The audit grants no new authority.\n" - if authority.get("vision_continuation_audit") else "" - ) - work_instruction = ( - "Use execution_profile to size and qualify the work, including its required " - "artifact, validation, outcome and degradation rules. Return the work and " - "validation evidence; state writeback and quota effects belong to the outer " - "Turn executor. The profile grants no new permissions.\n" - if authority.get("execution_profile") else "" - ) + if model_input is not None: + detail = _mapping(model_input.get("detail_ref")) + input_prompt = ( + "You are executing one bounded LoopX-governed work segment.\n" + "Use work_contract as the complete source of truth. Read its " + "interaction_contract.agent_channel.work_context and remaining required_reads " + "before primary_action; do not repeat fulfilled reads. Perform work only " + "when its contract allows it. Obey every supplied write scope, workspace " + "restriction, approval boundary and stop condition. Checkpointed approval " + "covers only active_write_scope; other approval gates remain.\n" + "Read agent_context before planning; unavailable_context is a failed read, " + "not empty context: discard affected cached context and hold dependent work " + "until recovery. Independent work retains its existing authority.\n" + "Before Goal closeout, preserve vision_continuation_audit's ordered " + "required_before_closeout, evidence requirements and vision_gap_judge. " + "The selected Todo summary is not its full requirements, and Todo " + "completion cannot close the Goal. Use execution_profile to size and " + "qualify work, including artifact, validation, outcome and degradation rules.\n" + "For required additional detail, use detail_ref.full_decision from the " + "saved original observation identified by detail_ref.captured_decision; " + "verify Goal/Agent/Turn and source_decision_hash. Never re-enter admission " + "for detail reads. Missing or invalid evidence holds dependent planning " + "and closure; independent work retains existing authority. Guidance, " + "evidence and reads grant no permissions. Do not write LoopX state, spend " + "quota or apply scheduler changes; the outer Turn executor owns those effects.\n" + + replan_prompt_instruction(detail, packet_path="detail_ref.replan_action_packet") + + "Turn request:\n" + authority_json + "\n\n" + ) + else: + context_instruction = ( + "For unavailable_context, discard cached context from the affected hooks; " + "do not interpret failure as empty context. Hold actions that require that " + "missing context until it is read successfully. Independent work may " + "continue under its existing authority; this grants no new permissions.\n" + if authority.get("unavailable_context") else "" + ) + planning_context_instruction = ( + "Read agent_context before planning; its guidance and facts grant no permissions. " + "Only an agent_context.detail_ref requires capability detail: use detail_ref.full_decision " + "only for the saved original observation identified by detail_ref.captured_decision, " + "checking its Goal/Agent/Turn and source hash. Never re-enter quota admission for a detail read. " + "Missing or invalid original evidence holds dependent planning until recovery; " + "independent work retains its existing authority.\n" + if authority.get("agent_context") else "" + ) + replan_instruction = replan_prompt_instruction(authority) + acceptance_instruction = ( + "Read vision_continuation_audit before judging Goal acceptance or proposing " + "closeout; preserve its ordered required_before_closeout, evidence requirements " + "and current vision_gap_judge. Selected Todo completion alone cannot close the Goal. " + "For additional acceptance or coverage evidence, use detail_ref.full_decision " + "only for the saved original observation identified by detail_ref.captured_decision; " + "check its Goal/Agent/Turn and action_signature.source_decision_hash. " + "Never re-enter quota admission for a detail read. Missing or invalid evidence " + "holds dependent closure; independent work retains its existing authority. " + "Do not execute its state or scheduler commands; report the semantic result " + "for the outer Turn executor. The audit grants no new authority.\n" + if authority.get("vision_continuation_audit") else "" + ) + work_instruction = ( + "Use execution_profile to size and qualify the work, including its required " + "artifact, validation, outcome and degradation rules. Return the work and " + "validation evidence; state writeback and quota effects belong to the outer " + "Turn executor. The profile grants no new permissions.\n" + if authority.get("execution_profile") else "" + ) + input_prompt = ( + "You are executing one bounded LoopX-governed work segment.\n" + "The JSON below is the complete host authority for this Turn. Execute " + "primary_action only after reading work_context and any remaining required_reads. " + "Do not repeat reads already fulfilled in work_context. Write only inside write_scope, " + "and obey workspace_guard and all supplied boundary restrictions and stop conditions. " + "Checkpointed approval covers only its active_write_scope; other approval gates remain. " + "Do not infer authority from other prose. " + "User notification policy does not grant or cancel execution authority. " + "Respect action.must_attempt, action.delivery_allowed and action.quiet_noop_allowed " + "when present; alternatives and capability intents do not override admission or scope. " + "Obey execution_policy and task_orchestration_contract when supplied. " + "Scheduler and response plans describe the surrounding Turn: do not execute " + "state writeback, quota or scheduler commands from this work segment. " + "Return the semantic result; the outer Turn executor owns those effects.\n\n" + f"{context_instruction}" + f"{planning_context_instruction}" + f"{replan_instruction}" + f"{acceptance_instruction}" + f"{work_instruction}" + f"Turn authority JSON:\n{authority_json}\n\n" + ) return ( - "You are executing one bounded LoopX-governed work segment.\n" - "The JSON below is the complete host authority for this Turn. Execute " - "primary_action only after reading work_context and any remaining required_reads. " - "Do not repeat reads already fulfilled in work_context. Write only inside write_scope, " - "and obey workspace_guard and all supplied boundary restrictions and stop conditions. " - "Checkpointed approval covers only its active_write_scope; other approval gates remain. " - "Do not infer authority from other prose. " - "User notification policy does not grant or cancel execution authority. " - "Respect action.must_attempt, action.delivery_allowed and action.quiet_noop_allowed " - "when present; alternatives and capability intents do not override admission or scope. " - "Obey execution_policy and task_orchestration_contract when supplied. " - "Scheduler and response plans describe the surrounding Turn: do not execute " - "state writeback, quota or scheduler commands from this work segment. " - "Return the semantic result; the outer Turn executor owns those effects.\n\n" - f"{context_instruction}" - f"{planning_context_instruction}" - f"{replan_instruction}" - f"{acceptance_instruction}" - f"{work_instruction}" - f"Turn authority JSON:\n{authority_json}\n\n" - "When finished, return only one JSON object (no Markdown fence) with " + input_prompt + + "When finished, return only one JSON object (no Markdown fence) with " "these public-safe fields:\n" "- result_kind: one of validated_progress | " + ("validated_completion | " if allow_completion else "") @@ -451,7 +557,7 @@ def build_result( limit=TEXT_LIMITS["vision_unchanged_reason"], ), } - if kind in material_kinds and _execution_profile_present(request): + if kind in material_kinds and execution_profile_present(request): missing_evidence = [ field for field in HOST_RESULT_EVIDENCE_LIMITS if field not in candidate diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index 4bed17348e..efb6b8ddb1 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -114,6 +114,7 @@ def load_loopx_turn_plan_from_journal( *, goal_id: str, turn_key: str, + agent_id: str | None = None, ) -> dict[str, Any]: path = turn_journal_path(runtime_root, goal_id=goal_id, turn_key=turn_key) with exclusive_file_lock(path): @@ -128,11 +129,18 @@ def load_loopx_turn_plan_from_journal( ) if transaction.get("turn_key") != turn_key or journal.get("turn_key") != turn_key: raise ValueError("LoopX Turn resume journal has mismatched turn lineage") - envelope = ( - cast(dict[str, Any], plan.get("turn_envelope")) if isinstance(plan.get("turn_envelope"), dict) else {} - ) - if envelope.get("goal_id") != goal_id or journal.get("goal_id") != goal_id: + from ..quota.turn_envelope import COMPLETE_TURN_PLAN_SCHEMA_VERSION, turn_host_lineage + + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION or "work_contract" in plan: + # Recorded transitional plans must check their supplied complete source, + # just like current plans. Only source-absent history uses raw identity. + identity = turn_host_lineage(plan) + else: + identity = cast(dict[str, Any], plan.get("turn_envelope")) if isinstance(plan.get("turn_envelope"), dict) else {} + if identity.get("goal_id") != goal_id or journal.get("goal_id") != goal_id: raise ValueError("LoopX Turn resume journal belongs to another goal") + if agent_id is not None and identity.get("agent_id") != agent_id: + raise ValueError("LoopX Turn resume journal belongs to another agent") return dict(plan) diff --git a/loopx/control_plane/turn_driver/lane_fence.py b/loopx/control_plane/turn_driver/lane_fence.py index 9f8ef860e6..05500a0e17 100644 --- a/loopx/control_plane/turn_driver/lane_fence.py +++ b/loopx/control_plane/turn_driver/lane_fence.py @@ -66,6 +66,12 @@ def turn_lane_agent_id(plan: Mapping[str, Any]) -> str: are exactly the overlap this module exists to refuse. """ + from ..quota.turn_envelope import COMPLETE_TURN_PLAN_SCHEMA_VERSION, LOOPX_TURN_PLAN_SCHEMA_VERSION, turn_host_lineage + + if plan.get("schema_version") == COMPLETE_TURN_PLAN_SCHEMA_VERSION or "work_contract" in plan: + return turn_host_lineage(plan)["agent_id"] or TURN_LANE_UNATTRIBUTED_AGENT + if plan.get("schema_version") not in (None, LOOPX_TURN_PLAN_SCHEMA_VERSION): + raise ValueError("unsupported Turn plan version") envelope = plan.get("turn_envelope") if isinstance(envelope, Mapping): agent_id = str(envelope.get("agent_id") or "").strip() diff --git a/loopx/control_plane/turn_driver/loop_controller.py b/loopx/control_plane/turn_driver/loop_controller.py index 0e5ad27e49..ddf98e3ec1 100644 --- a/loopx/control_plane/turn_driver/loop_controller.py +++ b/loopx/control_plane/turn_driver/loop_controller.py @@ -10,8 +10,7 @@ from typing import Any from ..effect_program import SettlementIdentity -from ..effect_runtime import effect_runtime_projection_result -from ..quota.turn_envelope import TurnControllerInput +from ..effect_runtime import effect_runtime_projection_result, source_number_tokens from .driver import _historical_route_text from .transaction import LoopXTurnResultKind from .turn_contract_generated import LoopDisposition as LoopDisposition @@ -23,12 +22,12 @@ def _controller_result(operation: str, **facts: Any) -> dict[str, Any]: decision = facts.get("decision") - if isinstance(decision, TurnControllerInput): - decision = decision.envelope() if isinstance(decision, Mapping): facts["decision"] = dict(decision) - # Preserve scalar spelling here; the shared typed owner decides routes. - facts["historical_route_text"] = _historical_route_text(decision) + if "contract_binding" not in decision: + # Recorded v0 decisions retain their original scalar codec. New + # complete sources use their exact number tokens below. + facts["historical_route_text"] = _historical_route_text(decision) # JSON numbers erase Python's int/float distinction and round large ints. # These are exact scalar codec facts, not precomputed admission decisions. source = facts.get({"receipt": "execution", "disposition": "receipt"}.get(operation, "journal")) @@ -42,6 +41,15 @@ def _controller_result(operation: str, **facts: Any) -> dict[str, Any]: key: str(value) if type(value) is int else None for key, value in scalars.items() } request = {"operation": operation, **facts} + if isinstance(decision, Mapping) and "contract_binding" in decision: + tokens = source_number_tokens(decision) + if tokens: + request["decision_source_number_tokens"] = tokens + journal = facts.get("journal") + if isinstance(journal, Mapping): + tokens = source_number_tokens(journal) + if tokens: + request["source_number_tokens"] = tokens payload = effect_runtime_projection_result("turn.controller", request) if not isinstance(payload, dict): raise RuntimeError("TypeScript Turn controller returned an invalid response") @@ -197,7 +205,7 @@ def remaining(self) -> int: def decide_loop_disposition( *, turn_receipt: ValidatedTurnReceipt | None, - quota_decision: Mapping[str, Any] | TurnControllerInput, + quota_decision: Mapping[str, Any], predecessor_turn_key: str | None = None, bounded_turn_budget: BoundedTurnBudget | None = None, ) -> dict[str, Any]: diff --git a/loopx/control_plane/turn_driver/loop_controller.ts b/loopx/control_plane/turn_driver/loop_controller.ts index 0bbe90d0f1..cf491ca94a 100644 --- a/loopx/control_plane/turn_driver/loop_controller.ts +++ b/loopx/control_plane/turn_driver/loop_controller.ts @@ -3,7 +3,8 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { settlementIdentityFromPlan } from "../effect_program.ts"; import { jsonObject, type JsonObject } from "../runtime_decode.ts"; -import { turnEnvelopeRoute as typedTurnRoute } from "../quota/turn_envelope.ts"; +import { COMPLETE_TURN_DECISION_VERSION, completePlanningFacts, + turnEnvelopeRoute as typedTurnRoute } from "../quota/turn_envelope.ts"; import { EffectiveAction } from "../quota/effective_action.generated.ts"; import { stripPythonWhitespace } from "../coordination/todo_agents.ts"; import { pythonFieldText } from "../todos/read_codec.ts"; @@ -165,7 +166,7 @@ function budget(value: unknown): Budget | null { * returns never require a later progress budget or host retry policy. */ export function decideLoopDisposition(params: JsonObject): JsonObject { const receipt = params.receipt == null ? null : object(params.receipt); - const decision = object(params.decision); + let decision = object(params.decision); let route: LoopXTurnRouteValue = "contract_error"; let chosen: Lineage = lineage({}); let effective = {...chosen}; @@ -198,7 +199,12 @@ export function decideLoopDisposition(params: JsonObject): JsonObject { const check = (name: string): void => { switch (name) { case "envelope": - route = typedTurnRoute(decision, jsonObject(params.historical_route_text) ?? undefined); + if (decision.schema_version === COMPLETE_TURN_DECISION_VERSION || has(decision, "contract_binding")) { + const checked = completePlanningFacts({payload: decision, + source_number_tokens: params.decision_source_number_tokens}); + decision = {...object(checked.work), goal_id: checked.goal_id, agent_id: checked.agent_id}; + route = checked.route as LoopXTurnRouteValue; + } else route = typedTurnRoute(decision, jsonObject(params.historical_route_text) ?? undefined); requireThat(route !== "contract_error", "quota decision failed the shared envelope contract (schema or signature hashes)"); if (object(decision.user).action_required === true) route = "user_action_required"; break; @@ -341,13 +347,15 @@ export function decideManagedStep(params: JsonObject): JsonObject { requireThat(identity.goal_id === params.goal_id && identity.agent_id === params.agent_id, "Turn journal lineage does not match the requested goal/agent"); const inspection = interpretTurnJournal({schema_version: "loopx_turn_journal_interpretation_request_v0", - journal, goal_id: text(params.goal_id), agent_id: text(params.agent_id), turn_key: text(params.turn_key)}); + journal, goal_id: text(params.goal_id), agent_id: text(params.agent_id), turn_key: text(params.turn_key), + source_number_tokens: params.source_number_tokens}); requireThat(inspection.journal_consistent, `Turn journal replay is blocked: ${inspection.violations.join(", ")}`); const decision = object(params.decision); for (const key of ["goal_id", "agent_id"]) requireThat(!text(decision[key]) || text(decision[key]) === params[key], `fresh decision ${key} does not match the managed step`); const disposition = decideLoopDisposition({receipt: {...receipt, lineage: identity, host_failure: journal.host_failure}, decision, integer_facts: integers, historical_route_text: params.historical_route_text, + decision_source_number_tokens: params.decision_source_number_tokens, predecessor_turn_key: params.predecessor_turn_key == null ? params.turn_key : params.predecessor_turn_key}); const result: JsonObject = {schema_version: "loopx_turn_managed_step_v0", disposition: disposition.disposition, reason: disposition.reason, turn_key: params.turn_key, attempt}; diff --git a/loopx/control_plane/turn_driver/managed_step.py b/loopx/control_plane/turn_driver/managed_step.py index 88a32dbd95..8064f47fb4 100644 --- a/loopx/control_plane/turn_driver/managed_step.py +++ b/loopx/control_plane/turn_driver/managed_step.py @@ -8,14 +8,13 @@ from collections.abc import Mapping from typing import Any -from ..quota.turn_envelope import TurnControllerInput from .loop_controller import _controller_result LOOPX_TURN_MANAGED_STEP_SCHEMA_VERSION = "loopx_turn_managed_step_v0" def decide_managed_step( - journal: Mapping[str, Any], fresh_decision: Mapping[str, Any] | TurnControllerInput, *, + journal: Mapping[str, Any], fresh_decision: Mapping[str, Any], *, goal_id: str, agent_id: str, turn_key: str, predecessor_turn_key: str | None = None, observed_attempt: int | None = None, observed_max_attempts: int | None = None, diff --git a/loopx/control_plane/turn_driver/settlement.py b/loopx/control_plane/turn_driver/settlement.py index ee68b9cf2d..ee46bd3e06 100644 --- a/loopx/control_plane/turn_driver/settlement.py +++ b/loopx/control_plane/turn_driver/settlement.py @@ -19,7 +19,7 @@ SOURCE_TURN_EFFECT_HOLD_SCHEMA_VERSION, ) from ..settlement_driver import decode_settlement_result -from .driver import selected_turn_todo +from .driver import read_turn_execution_context, selected_turn_todo from .transaction import ( LoopXTurnResultKind, require_loopx_turn_completion_outcome, @@ -396,7 +396,16 @@ def terminal_closeout_requirement( if not isinstance(observed_completion, Mapping): observed_completion = completion_intent(result) envelope = plan.get("turn_envelope") - selected = selected_turn_todo(envelope) if isinstance(envelope, Mapping) else {} + if "work_contract" in plan: + # This is a new execution boundary, including journal recovery. + # Recheck the captured source after execution/intent; a pre-launch + # check must not authorize a later replacement of the adapter. + selected = read_turn_execution_context( + plan, envelope if isinstance(envelope, Mapping) else {}, + )["selected_todo"] + else: + # Outstanding journals retain their exact historical decoder. + selected = selected_turn_todo(envelope) if isinstance(envelope, Mapping) else {} outcome = require_loopx_turn_completion_outcome( observed_completion, expected_todo_id=str(selected.get("todo_id") or ""), diff --git a/loopx/control_plane/turn_driver/subagent_execution_topology.py b/loopx/control_plane/turn_driver/subagent_execution_topology.py index 815e77e75a..99b4d5effa 100644 --- a/loopx/control_plane/turn_driver/subagent_execution_topology.py +++ b/loopx/control_plane/turn_driver/subagent_execution_topology.py @@ -1014,8 +1014,10 @@ def observe_subagent_host_result( reconciliation = reconcile_subagent_execution(topology, receipts) if reconciliation is not None: normalized["subagent_reconciliation"] = reconciliation + from ..quota.turn_envelope import read_turn_plan_work + context = envelope_agent_context( - _mapping(plan.get("turn_envelope")), + read_turn_plan_work(plan), phase="after_delegate_result", observations={"reconciliation_counts": reconciliation["counts"]}, ) diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts index 91e93362a4..35b8bdb097 100644 --- a/loopx/control_plane/turn_driver/turn_journal.ts +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -16,7 +16,8 @@ import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; import { recordedTurnEffects, type RecordedTurnEffects } from "./turn_journal_effect_readback.ts"; import { selectedWorkTodoId } from "../work_items/interaction_contract.ts"; -import { turnEnvelopeScopedSettlementIdentity } from "../quota/turn_envelope.ts"; +import { turnPlanWorkSource, turnEnvelopeScopedSettlementIdentity } from "../quota/turn_envelope.ts"; +import { sourceNumberTokens } from "../runtime_decode.ts"; export const TURN_JOURNAL_INSPECTION_SCHEMA_VERSION = "loopx_turn_journal_inspection_v1"; @@ -47,6 +48,7 @@ export interface TurnJournalInspectionRequest { turn_key: string; retry_failed?: boolean; session_recovery_check?: TurnRecoveryCheck | null; + source_number_tokens?: unknown; } export interface TurnRecoveryCheck { @@ -224,6 +226,7 @@ function typedSettlementIdentityState( transaction: JsonObject, settlement: JsonObject, identity: JsonObject, + scopedIdentity?: JsonObject | null, ): [valid: boolean, turnInstanceMatches: boolean, bindingMatches: boolean] { if ( settlement.schema_version !== SETTLEMENT_PLAN_SCHEMA_VERSION || @@ -249,7 +252,8 @@ function typedSettlementIdentityState( && parsed.value.todo_id === selectedTodoId && parsed.value.binding_id === selectedTodoId; if (selectedTodoId === null && ["autonomous_replan", "request"].includes(parsed.value.binding_kind)) { try { - bindingMatches = turnEnvelopeScopedSettlementIdentity(envelope, expectedTurnInstance)?.effect_id + bindingMatches = (scopedIdentity === undefined + ? turnEnvelopeScopedSettlementIdentity(envelope, expectedTurnInstance) : scopedIdentity)?.effect_id === parsed.value.effect_id; } catch { bindingMatches = false; // Corrupt evidence remains a refusal, never a surrogate task. @@ -564,7 +568,23 @@ export function interpretTurnJournalEffect( } const journal = asObject(request.journal); const plan = asObject(journal.plan); - const envelope = asObject(plan.turn_envelope); + let envelope = asObject(plan.turn_envelope); + let workContractValid = true; + let scopedIdentity: JsonObject | null | undefined; + try { + const tokens = sourceNumberTokens(journal, request.source_number_tokens) + .filter(({path}) => path[0] === "plan" && path[1] === "work_contract") + .map(({path, token}) => [path.slice(2), token]); + // Current journals consume the same checked source as Host dispatch. + // Keep transport/signature metadata, but derive execution fields from + // its owner. Only historical absence retains adapter-only decoding. + const checked = turnPlanWorkSource(plan, tokens); + envelope = checked.work; + scopedIdentity = checked.scopedIdentity; + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError)) throw error; + workContractValid = false; + } const transaction = asObject(plan.transaction); const settlement = asObject(transaction.settlement_plan); const identity = asObject(settlement.identity); @@ -594,9 +614,10 @@ export function interpretTurnJournalEffect( settlementIdentityValid, settlementTurnInstanceMatches, settlementBindingMatches, - ] = typedSettlementIdentityState(envelope, transaction, settlement, identity); + ] = typedSettlementIdentityState(envelope, transaction, settlement, identity, scopedIdentity); const violations: string[] = []; + if (!workContractValid) violations.push("work_contract_invalid"); if (!goalComplete) violations.push("goal_identity_missing"); else if (!goalMatches) violations.push("goal_mismatch"); if (goalBinding.kind === "invalid") { @@ -649,6 +670,7 @@ export function interpretTurnJournalEffect( } const lineageConsistent = + workContractValid && goalMatches && goalBinding.kind !== "invalid" && ( diff --git a/loopx/control_plane/turn_driver/turn_journal_effects.ts b/loopx/control_plane/turn_driver/turn_journal_effects.ts index 0518c3172b..12035dbda0 100644 --- a/loopx/control_plane/turn_driver/turn_journal_effects.ts +++ b/loopx/control_plane/turn_driver/turn_journal_effects.ts @@ -19,7 +19,8 @@ import { } from "../effect_runtime_io.ts"; import { decideFirstPartyHostRuntime } from "../goals/first_party_host_runtime.ts"; import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; -import { requireNonEmptyString as requiredString, sourceNumberTokens } from "../runtime_decode.ts"; +import { parseJsonSource, parsedSourceNumberTokens, requireNonEmptyString as requiredString, + sourceNumberTokens, type SourceNumbers } from "../runtime_decode.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; import { interpretTurnJournalEffect, @@ -64,8 +65,6 @@ function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -type SourceNumbers = WeakMap>; - function rawNumber(token: string): unknown { const rawJSON = (JSON as typeof JSON & {rawJSON?: (text: string) => unknown}).rawJSON; if (!rawJSON) throw new EffectRuntimeRequestError("Turn journal number preservation requires JSON.rawJSON", "journal_snapshot_invalid"); @@ -98,20 +97,6 @@ function journalWithoutRecoveryAudit(journal: JsonObject, numbers?: SourceNumber return projected; } -function parseJournal(encoded: string): {journal: JsonObject; numbers: SourceNumbers} { - const numbers: SourceNumbers = new WeakMap(); - const journal = asObject(JSON.parse(encoded, function (key, value: unknown, context?: {source?: string}) { - if (typeof value === "number" && Number.isInteger(value) && - context?.source !== undefined && context.source !== JSON.stringify(value)) { - const fields = numbers.get(this) ?? new Map(); - fields.set(key, context.source); - numbers.set(this, fields); - } - return value; - })); - return {journal, numbers}; -} - interface JournalState { status: string; completedPhases: string[]; @@ -141,7 +126,7 @@ function samePrefix(left: readonly string[], right: readonly string[]): boolean return left.length <= right.length && left.every((phase, index) => phase === right[index]); } -function requireJournalState(journal: JsonObject): JournalState { +function requireJournalState(journal: JsonObject, numbers?: SourceNumbers): JournalState { if (journal.schema_version !== "loopx_turn_journal_v0") { throw new EffectRuntimeRequestError( "Turn journal has an unsupported schema", @@ -149,7 +134,10 @@ function requireJournalState(journal: JsonObject): JournalState { ); } const plan = asObject(journal.plan); - const envelope = asObject(plan.turn_envelope); + // These are the caller's declared scope values, not evidence of permission; + // interpretation below independently validates the versioned source. + const envelope = plan.schema_version === "loopx_turn_plan_v1" + ? asObject(asObject(plan.work_contract).agent_identity) : asObject(plan.turn_envelope); const goalId = typeof journal.goal_id === "string" ? journal.goal_id : ""; const agentId = typeof envelope.agent_id === "string" ? envelope.agent_id : ""; const turnKey = typeof journal.turn_key === "string" ? journal.turn_key : ""; @@ -159,6 +147,7 @@ function requireJournalState(journal: JsonObject): JournalState { goal_id: goalId, agent_id: agentId, turn_key: turnKey, + source_number_tokens: parsedSourceNumberTokens(journal, numbers), }); const context = effect.request.context; const effectId = journalEffectId(journal); @@ -368,8 +357,9 @@ export async function commitTurnJournal( } // Validate and publish the same isolated snapshot when source spellings are // supplied. Native objects without metadata retain their historical path. - const {journal, numbers} = originalJson === null - ? {journal: source, numbers: undefined} : parseJournal(originalJson); + const parsed = originalJson === null ? null : parseJsonSource(originalJson); + const journal = parsed === null ? source : asObject(parsed.value); + const numbers = parsed?.numbers; const goalBinding = parseTurnJournalGoalBinding(journal); if (goalBinding.kind === "invalid") { throw new EffectRuntimeRequestError( @@ -377,7 +367,7 @@ export async function commitTurnJournal( "journal_snapshot_invalid", ); } - const incomingState = requireJournalState(journal); + const incomingState = requireJournalState(journal, numbers); const expectedEffectId = typeof params.expected_effect_id === "string" ? params.expected_effect_id.trim() : ""; @@ -393,9 +383,9 @@ export async function commitTurnJournal( let existing: JsonObject | null = null; try { const encoded = await readFile(path, "utf8"); - const previous = parseJournal(encoded); - existing = previous.journal; - const existingState = requireJournalState(existing); + const previous = parseJsonSource(encoded); + existing = asObject(previous.value); + const existingState = requireJournalState(existing, previous.numbers); const existingEffectId = existingState.effectId; if ( existingEffectId && diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 6e24de74f3..757c83e00a 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -13,11 +13,13 @@ import { } from "../effect_program.ts"; import { EffectRuntimeConflictError, EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { stripPythonWhitespace } from "../coordination/todo_agents.ts"; +import { turnPlanWorkSource } from "../quota/turn_envelope.ts"; import { parseExactGoalRef, type ExactGoalRef, } from "../goals/goal_instance_identity.ts"; -import { jsonObject, requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; +import { jsonObject, parseJsonSource, parsedSourceNumberTokens, requireJsonObject, sourceNumberTokens, + requireNonEmptyString, type SourceNumbers } from "../runtime_decode.ts"; import { interpretTurnJournal, parseTurnJournalGoalBinding, @@ -78,6 +80,7 @@ function evidenceForJournal( turnKey: string, expected: BoundSettlementIdentity, expectedGoalRef: ExactGoalRef | null, + numbers: SourceNumbers, ): JournalEvidence | null { const journal = jsonObject(value); const plan = jsonObject(journal?.plan); @@ -118,6 +121,7 @@ function evidenceForJournal( const inspection = interpretTurnJournal({ schema_version: "loopx_turn_journal_interpretation_request_v0", journal, goal_id: expected.goal_id, agent_id: expected.agent_id, turn_key: turnKey, + source_number_tokens: parsedSourceNumberTokens(journal, numbers), }); if (!inspection.journal_consistent) { journalConflict(`Turn journal history is inconsistent: ${inspection.violations.join(", ")}`); @@ -125,7 +129,9 @@ function evidenceForJournal( return { turn_key: turnKey, observed_capabilities: inspection.replay_legal - ? observedCapabilities(jsonObject(plan?.turn_envelope) ?? {}) : null, + ? observedCapabilities(turnPlanWorkSource(plan ?? {}, sourceNumberTokens(journal, + parsedSourceNumberTokens(journal, numbers)).filter(({path}) => path[0] === "plan" && path[1] === "work_contract") + .map(({path, token}) => [path.slice(2), token])).work) : null, }; } @@ -153,7 +159,7 @@ async function querySettlementJournal( for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { if (!/^[a-f0-9]{64}\.json$/u.test(entry.name)) continue; if (!entry.isFile()) journalConflict("Turn journal history contains a non-regular snapshot; reconcile it before retrying"); - let value: unknown; + let parsed: ReturnType; // Journals are atomic-replace files. Read one complete version without // creating reader lock files; do not follow a replaced symlink. This is // not a snapshot of the directory or proof of provider-side absence. @@ -161,7 +167,7 @@ async function querySettlementJournal( try { if (!(await handle.stat()).isFile()) journalConflict("Turn journal snapshot is not a regular file"); try { - value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile())); + parsed = parseJsonSource(new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile())); } catch (error) { if (error instanceof SyntaxError || error instanceof TypeError) journalConflict("Turn journal history is unreadable; reconcile it before retrying"); throw error; @@ -170,10 +176,11 @@ async function querySettlementJournal( await handle.close(); } const evidence = evidenceForJournal( - value, + parsed.value, `sha256:${entry.name.slice(0, -5)}`, identity, goalRef, + parsed.numbers, ); if (evidence === null) continue; if (matched !== null) journalConflict("LoopX Turn settlement identity matched multiple journals"); diff --git a/loopx/control_plane/turn_driver/turn_journal_runtime.py b/loopx/control_plane/turn_driver/turn_journal_runtime.py index 7fadc4f8a0..0126337684 100644 --- a/loopx/control_plane/turn_driver/turn_journal_runtime.py +++ b/loopx/control_plane/turn_driver/turn_journal_runtime.py @@ -124,6 +124,9 @@ def interpret_turn_journal_projection( else None ), } + tokens = source_number_tokens(journal) + if tokens: + request["source_number_tokens"] = tokens payload = effect_runtime_result("turn_journal.inspect", request) if not isinstance(payload, dict) or set(payload) != _PROJECTION_KEYS: raise RuntimeError( @@ -172,6 +175,7 @@ def write_turn_journal( request: dict[str, Any] = { "path": path, + # TS owns validation, transitions and publication over the native object. "journal": dict(journal), "expected_effect_id": expected_effect_id, } diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index 8e79eb3016..a6fb44f193 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -739,6 +739,28 @@ function readArgument(value: unknown, label: string): string | null { return text; } +/** Current executable obligations must not disappear during projection. + * Historical readers keep their original permissive codec; this validates + * the complete-source boundary without treating a pending read as fulfilled. + */ +export function assertInteractionRequiredReads(value: unknown): void { + if (value === undefined) return; + if (!Array.isArray(value)) throw new EffectRuntimeRequestError("required reads must be an array"); + for (const raw of value) { + const read = requireJsonObject(raw, "required read"); + // Complete sources also carry prose-only obligations. Keep their original + // instructions; only explicit executable coordinates require a command. + if (!Object.hasOwn(read, "command") && !Object.hasOwn(read, "ordering")) continue; + requireNonEmptyString(read.command, "required read command"); + readArgument(read.command, "required read command"); + // Reuse the existing pre-work ordering. An omitted historical coordinate + // remains compatible; a new explicit execution order is not optional data. + if (read.ordering !== undefined) { + requireStringLiteral(read.ordering, ["before_work"], "required read ordering"); + } + } +} + function shellQuote(value: string): string { return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`; } @@ -839,6 +861,7 @@ export function planInteractionWorkContext(request: JsonObject): JsonObject { && packet.effective_action !== EffectiveAction.HEARTBEAT_SETTLED_SKIP}; } + export function assertGoalContextDelivered(channel: JsonObject): void { const reads = Array.isArray(channel.required_reads) ? channel.required_reads : []; if (reads.some(read => jsonObject(read)?.source === "goal_state")) { @@ -857,8 +880,6 @@ export function assertGoalContextDelivered(channel: JsonObject): void { } } -/** Deliver full registered sources once. Ordinary mixed Goals retain progressive - * reads; native delivery can require the complete original. */ async function readSelectedTodoContext(source: JsonObject, todoId: unknown): Promise { if (typeof todoId !== "string" || !todoId) return {error_code: "context_source_unavailable"}; const {readCanonicalSnapshot, CANONICAL_SNAPSHOT_PAGE_REQUEST} = @@ -908,6 +929,38 @@ export async function readAndProjectInteractionWorkContext(request: JsonObject): ]}); } +/** Completion binds to the admitted body, including whitespace, never a live + * reread or display prefix. Both complete contracts and historical journals + * use this resolver; absence of the old witness retains receipt recovery. */ +export function capturedTodoTextSha256( + selected: JsonObject, contextValue: unknown, recommendedAction: unknown, todoId: unknown, +): string | null { + if (!Object.hasOwn(selected, "text_source_verified")) return null; + if (selected.text_source_verified !== true || selected.todo_id !== todoId) { + throw new EffectRuntimeRequestError("Turn task source does not match its selected Todo"); + } + const context = contextValue == null ? {} : jsonObject(contextValue); + if (context === null || (context.sources !== undefined && !Array.isArray(context.sources))) { + throw new EffectRuntimeRequestError("Turn completion requires its admitted full task source"); + } + const records = (Array.isArray(context.sources) ? context.sources : []) + .map(value => jsonObject(value)).filter(source => source?.source === "selected_todo") + .map(source => jsonObject(jsonObject(source!.content)?.todo)) + .filter(record => record?.todo_id === todoId && typeof record?.text === "string"); + if (records.length > 1) throw new EffectRuntimeRequestError("Turn task source is ambiguous"); + let text = records.length ? records[0]!.text : undefined; + if (text === undefined && (!Object.keys(context).length || context.selected_todo_ref === "selected_todo")) { + text = selected.text; + if (text == null && selected.text_ref === "action.recommended_action") text = recommendedAction; + } + if (typeof text !== "string" || !text) { + throw new EffectRuntimeRequestError("Turn completion requires its admitted full task source"); + } + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +/** Deliver full registered sources once. Unowned mixed Goal documents remain + * progressive reads; failed/changed sources forbid dependent work. */ export function projectInteractionWorkContext(request: JsonObject): JsonObject { if (request.require_full_goal_source !== undefined && typeof request.require_full_goal_source !== "boolean") { throw new EffectRuntimeRequestError("Goal source delivery requirement must be boolean"); diff --git a/loopx/dsh_goal_mode/turn_host_adapter.py b/loopx/dsh_goal_mode/turn_host_adapter.py index 24dc19619d..333895a053 100644 --- a/loopx/dsh_goal_mode/turn_host_adapter.py +++ b/loopx/dsh_goal_mode/turn_host_adapter.py @@ -39,9 +39,11 @@ TEXT_LIMITS as TEXT_LIMITS, _canonical_hash, _mapping, + assert_turn_host_request_version, build_result as _build_host_result, extract_action_text as extract_action_text, extract_turn_authority as extract_turn_authority, + extract_turn_work_identity, parse_model_json as parse_model_json, render_prompt as render_prompt, ) @@ -395,7 +397,28 @@ def resolved_profile(self) -> dict[str, Any]: ) -def _derive_session_id(request: Mapping[str, Any], turn_key: str) -> str: +def _work_identity( + request: Mapping[str, Any], *, authority: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + if "work_contract" in request: + if authority is None: + return extract_turn_work_identity(request) + # Reuse the dispatch check from this invocation, never an ambient or + # cross-attempt cache. The complete source must not use the old decoder. + identity = authority.get("_work_identity") + if not isinstance(identity, Mapping): + raise ValueError("complete Turn work identity is unavailable") + return dict(identity) + # Historical requests retain raw values and their original hash/coercion. + envelope = _mapping(request.get("turn_envelope")) + selected = _mapping(_mapping(envelope.get("action")).get("selected_todo")) + return {"goal_id": envelope.get("goal_id"), "agent_id": envelope.get("agent_id"), + "todo_id": selected.get("todo_id")} + + +def _derive_session_id( + request: Mapping[str, Any], turn_key: str, *, work_identity: Mapping[str, Any] | None = None, +) -> str: planned_session = _mapping(request.get("session")) context_policy = _mapping(planned_session.get("context_policy")) if context_policy.get("mode") == "fresh": @@ -411,14 +434,8 @@ def _derive_session_id(request: Mapping[str, Any], turn_key: str) -> str: # adapter concern and must not enter public LoopX state. Encode every # component position before hashing: delimiter-joined ids can collide when # an id itself contains the delimiter. - envelope = _mapping(request.get("turn_envelope")) - action = _mapping(envelope.get("action")) - selected_todo = _mapping(action.get("selected_todo")) - lineage = [ - envelope.get("goal_id"), - envelope.get("agent_id"), - selected_todo.get("todo_id"), - ] + identity = _work_identity(request) if work_identity is None else work_identity + lineage = [identity.get(key) for key in ("goal_id", "agent_id", "todo_id")] # Keep the legacy truthiness boundary: empty or non-string false-like # values are not lineage identities, while their positions stay encoded. lineage = [value if value else None for value in lineage] @@ -444,7 +461,9 @@ def _execute_turn_host_request( workspace = config.workspace.expanduser().resolve() dsh_home = resolve_dsh_home(workspace, config.dsh_home) dsh_home.mkdir(parents=True, exist_ok=True) - session_id = _derive_session_id(request, str(request.get("turn_key") or "")) + work_identity = _work_identity(request, authority=authority) + session_id = _derive_session_id(request, str(request.get("turn_key") or ""), + work_identity=work_identity) profile = config.resolved_profile() profile_reason = managed_profile_unavailable_reason(profile) if profile_reason is not None: @@ -487,13 +506,11 @@ def _execute_turn_host_request( # Bind invocation-scoped tools to the verified Turn, never stale # ambient process identity. This is an SDK override map, not an # environment allowlist: the SDK still inherits the parent env. - envelope = _mapping(request.get("turn_envelope")) - selected = _mapping(_mapping(envelope.get("action")).get("selected_todo")) runner_arguments["env"] = { **dict(config.env or {}), - "LOOPX_TURN_GOAL_ID": str(envelope.get("goal_id") or ""), - "LOOPX_TURN_AGENT_ID": str(envelope.get("agent_id") or ""), - "LOOPX_TURN_TODO_ID": str(selected.get("todo_id") or ""), + "LOOPX_TURN_GOAL_ID": str(work_identity.get("goal_id") or ""), + "LOOPX_TURN_AGENT_ID": str(work_identity.get("agent_id") or ""), + "LOOPX_TURN_TODO_ID": str(work_identity.get("todo_id") or ""), "LOOPX_TURN_WORKSPACE": str(workspace), } outcome = normalize_runner_outcome(runner(**runner_arguments)) @@ -575,14 +592,15 @@ def run_dsh_host( the Turn journal records retryability instead of a bare ``unknown``. """ - if ( - not isinstance(request, Mapping) - or request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA - ): + try: + if not isinstance(request, Mapping): + raise ValueError("Host request must be an object") + assert_turn_host_request_version(request) + except ValueError as exc: raise BuiltInHostError( "dsh_request_schema_mismatch", failure_kind="contract_rejected", - ) + ) from exc try: authority = extract_turn_authority(request) except ValueError as exc: @@ -663,8 +681,12 @@ def main(argv: Sequence[str] | None = None) -> int: except (json.JSONDecodeError, ValueError) as exc: print(f"adapter: invalid request JSON on stdin: {exc}", file=sys.stderr) return 2 - if not isinstance(request, dict) or request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA: - print("adapter: stdin is not a loopx_turn_host_request_v0 object", file=sys.stderr) + try: + if not isinstance(request, dict): + raise ValueError("Host request must be an object") + assert_turn_host_request_version(request) + except ValueError as exc: + print(f"adapter: invalid LoopX Turn host request: {exc}", file=sys.stderr) return 2 try: diff --git a/loopx/extensions/codex_native_child.py b/loopx/extensions/codex_native_child.py index 1dff414114..e0fdc9b889 100644 --- a/loopx/extensions/codex_native_child.py +++ b/loopx/extensions/codex_native_child.py @@ -17,8 +17,16 @@ def configured_native_child_limit(request: Mapping[str, Any]) -> int | None: - envelope = request.get("turn_envelope") - context = envelope.get("agent_context") if isinstance(envelope, Mapping) else None + # Invocation metadata only: the Host still verifies the complete source + # before launch, and the existing recorder owns receipt admission. Live + # requests read their original context; only old journals use the v0 view. + if "work_contract" in request: + contract = request["work_contract"] + interaction = contract.get("interaction_contract") if isinstance(contract, Mapping) else None + context = interaction.get("agent_context") if isinstance(interaction, Mapping) else None + else: + envelope = request.get("turn_envelope") + context = envelope.get("agent_context") if isinstance(envelope, Mapping) else None contributions = context.get("contributions") if isinstance(context, Mapping) else None if not isinstance(contributions, list): return None diff --git a/loopx/extensions/turn_host.py b/loopx/extensions/turn_host.py index 2b191c5c19..a9657128ec 100644 --- a/loopx/extensions/turn_host.py +++ b/loopx/extensions/turn_host.py @@ -13,14 +13,16 @@ from ..control_plane.goals.goal_ref_validation import exact_goal_ref from ..control_plane.turn_driver.executor import validate_loopx_turn_host_result from ..control_plane.turn_driver.host_candidate import ( + assert_turn_host_request_version, build_result, extract_turn_authority, + extract_turn_work_identity, parse_model_json, render_prompt, ) __all__ = [ - "build_result", "exact_goal_ref", "extract_turn_authority", "parse_model_json", + "assert_turn_host_request_version", "build_result", "exact_goal_ref", "extract_turn_authority", "extract_turn_work_identity", "parse_model_json", "render_prompt", "supervised_stdio_command", "validate_loopx_turn_host_result", ] diff --git a/packages/loopx-ark-turn/pyproject.toml b/packages/loopx-ark-turn/pyproject.toml index 6892d3ae2b..ac2efe19e8 100644 --- a/packages/loopx-ark-turn/pyproject.toml +++ b/packages/loopx-ark-turn/pyproject.toml @@ -9,7 +9,7 @@ description = "Optional Ark Managed Agent host for independently validated LoopX readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" -dependencies = ["arkruntime[mcp]>=0.8.0,<0.9", "mcp==1.30.0"] +dependencies = ["loopx>=2.2.0", "arkruntime[mcp]>=0.8.0,<0.9", "mcp==1.30.0"] [project.scripts] loopx-ark-turn = "loopx_ark_turn.cli:main" diff --git a/packages/loopx-ark-turn/src/loopx_ark_turn/config.py b/packages/loopx-ark-turn/src/loopx_ark_turn/config.py index 298ccb5c70..91bba64f66 100644 --- a/packages/loopx-ark-turn/src/loopx_ark_turn/config.py +++ b/packages/loopx-ark-turn/src/loopx_ark_turn/config.py @@ -62,22 +62,23 @@ def __post_init__(self) -> None: def require_request(request: Mapping[str, Any]) -> dict[str, str]: - from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + from loopx.extensions.turn_host import assert_turn_host_request_version, extract_turn_work_identity - if request.get("schema_version") != "loopx_turn_host_request_v0": - raise AdapterError("request_schema_mismatch") - extract_turn_authority(request) + try: + assert_turn_host_request_version(request) + except ValueError as exc: + raise AdapterError("request_schema_mismatch") from exc + work = extract_turn_work_identity(request) key = request.get("turn_key") if not isinstance(key, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", key): raise AdapterError("invalid_turn_key") if request.get("session", {}).get("context_policy", {}).get("mode") != "fresh": raise AdapterError("ark_turn_requires_iteration_context_fresh") - envelope = request["turn_envelope"] identity = { "KEY": key, - "GOAL_ID": envelope.get("goal_id"), - "AGENT_ID": envelope.get("agent_id"), - "TODO_ID": envelope.get("action", {}).get("selected_todo", {}).get("todo_id"), + "GOAL_ID": work.get("goal_id"), + "AGENT_ID": work.get("agent_id"), + "TODO_ID": work.get("todo_id"), } if any(not isinstance(v, str) or not v or "\x00" in v for v in identity.values()): raise AdapterError("signed_work_identity_required") diff --git a/packages/loopx-memt-turn/pyproject.toml b/packages/loopx-memt-turn/pyproject.toml index 31010dbda6..b2ec97ac52 100644 --- a/packages/loopx-memt-turn/pyproject.toml +++ b/packages/loopx-memt-turn/pyproject.toml @@ -9,7 +9,7 @@ description = "Opt-in MemT Nano host with scoped native memory for LoopX Turns" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" -dependencies = ["loopx>=2.1.0", "mcp==1.30.0", "jinja2>=3.1.6,<4"] +dependencies = ["loopx>=2.2.0", "mcp==1.30.0", "jinja2>=3.1.6,<4"] [project.scripts] loopx-memt-turn = "loopx_memt_turn.cli:main" diff --git a/packages/loopx-memt-turn/src/loopx_memt_turn/host.py b/packages/loopx-memt-turn/src/loopx_memt_turn/host.py index e30af147fe..d0472514d2 100644 --- a/packages/loopx-memt-turn/src/loopx_memt_turn/host.py +++ b/packages/loopx-memt-turn/src/loopx_memt_turn/host.py @@ -5,7 +5,8 @@ import re from loopx.extensions.turn_host import ( - build_result, exact_goal_ref, extract_turn_authority, parse_model_json, render_prompt, + assert_turn_host_request_version, + build_result, exact_goal_ref, extract_turn_authority, extract_turn_work_identity, parse_model_json, render_prompt, validate_loopx_turn_host_result, ) from loopx.file_lock import exclusive_file_lock, LockAcquisitionPolicy, write_json_atomic @@ -24,15 +25,15 @@ def work_identity(request: dict) -> dict: - if request.get("schema_version") != "loopx_turn_host_request_v0": - raise AdapterError("invalid_request_schema") - extract_turn_authority(request) + try: + assert_turn_host_request_version(request) + except ValueError as exc: + raise AdapterError("invalid_request_schema") from exc + work = extract_turn_work_identity(request) if request.get("session", {}).get("context_policy", {}).get("mode") != "fresh": raise AdapterError("fresh_iteration_context_required") - envelope = request["turn_envelope"] - identity = {"KEY": request.get("turn_key"), "GOAL_ID": envelope.get("goal_id"), - "AGENT_ID": envelope.get("agent_id"), - "TODO_ID": envelope.get("action", {}).get("selected_todo", {}).get("todo_id")} + identity = {"KEY": request.get("turn_key"), "GOAL_ID": work.get("goal_id"), + "AGENT_ID": work.get("agent_id"), "TODO_ID": work.get("todo_id")} if any(not isinstance(text, str) or not text or "\0" in text for text in identity.values()): raise AdapterError("signed_work_identity_required") if not re.fullmatch(r"sha256:[0-9a-f]{64}", identity["KEY"]): @@ -134,7 +135,7 @@ async def generate(session, tools, request: dict, config: Config) -> dict: if candidate is None: raise AdapterError("typed_candidate_required") result = build_result(request, candidate, host_name="MemT Nano") - plan = {"transaction": {"turn_key": request["turn_key"]}, "turn_envelope": request["turn_envelope"]} + plan = {**request, "transaction": {"turn_key": request["turn_key"]}} if not validate_loopx_turn_host_result(plan, result)["ok"]: raise AdapterError("invalid_typed_candidate") return result diff --git a/scripts/qualify-native-goal-release.py b/scripts/qualify-native-goal-release.py index 89e5854022..0840ff5d2f 100644 --- a/scripts/qualify-native-goal-release.py +++ b/scripts/qualify-native-goal-release.py @@ -112,6 +112,7 @@ def setup( ) fixture = importlib.util.module_from_spec(spec) spec.loader.exec_module(fixture) + # Finish the release source before its one canonical cold import. project, runtime, registry = fixture.write_fixture(root, initialize=False) config = json.loads(registry.read_text()) goal = config["goals"][0] From 5b4f6bbbe425df4acba9ab98c76abf14d7031bdd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:34:54 +0800 Subject: [PATCH 02/33] test(turn): qualify complete source and versioned recovery boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- packages/loopx-ark-turn/tests/test_host.py | 96 ++ packages/loopx-memt-turn/tests/conftest.py | 25 +- packages/loopx-memt-turn/tests/test_host.py | 132 ++ .../test_codex_native_child_receipts.py | 88 ++ .../canonical_authority_fixture.py | 13 +- .../control_plane/codex_checkpoint_fixture.py | 27 +- .../test_complete_codex_result.py | 154 +++ .../test_complete_host_request.py | 120 ++ .../test_complete_host_request_cutover.py | 366 ++++++ .../test_complete_numeric_transport.py | 120 ++ .../test_complete_session_binding.py | 131 ++ .../test_complete_turn_decision.py | 903 ++++++++++++++ .../test_complete_turn_journal.py | 240 ++++ .../test_delegation_native_host_context.py | 11 +- .../test_delegation_request_subject.py | 14 +- tests/control_plane/test_dsh_operation_sdk.py | 146 +-- .../test_operation_host_source_preflight.py | 233 ++++ .../test_quota_execution_preparation.py | 17 + .../control_plane/test_replan_staged_read.py | 23 + .../test_reward_memory_host_isolation.py | 18 +- .../test_turn_completion_source.py | 117 +- .../test_turn_envelope_capability_facts.py | 19 + .../test_turn_envelope_settlement.py | 3 +- .../test_turn_host_context_delivery.py | 173 ++- .../test_turn_host_decision_capture.py | 1061 ++++++++++++++++- .../test_turn_replan_writeback.py | 7 +- .../context_source_identity.test.ts | 32 +- .../interaction_contract.test.ts | 1 - tests/control_plane_ts/runtime_decode.test.ts | 22 + tests/control_plane_ts/turn_envelope.test.ts | 527 +++++++- tests/test_ark_managed_agent_host.py | 2 +- tests/test_codex_operation_host.py | 1 + tests/test_delegation_turn_context.py | 7 +- tests/test_dsh_goal_mode.py | 25 + tests/test_local_delegation.py | 13 +- tests/test_loop_turn_loop_controller.py | 26 +- tests/test_loopx_turn_codex_cli.py | 103 +- tests/test_loopx_turn_driver.py | 268 ++++- tests/test_loopx_turn_managed_step.py | 5 +- tests/test_managed_complete_source.py | 96 ++ tests/test_managed_research_team.py | 13 +- tests/test_native_steward_turn_host.py | 75 ++ tests/test_turn_controller_projection.py | 43 +- 43 files changed, 5224 insertions(+), 292 deletions(-) create mode 100644 tests/control_plane/test_complete_codex_result.py create mode 100644 tests/control_plane/test_complete_host_request.py create mode 100644 tests/control_plane/test_complete_host_request_cutover.py create mode 100644 tests/control_plane/test_complete_numeric_transport.py create mode 100644 tests/control_plane/test_complete_session_binding.py create mode 100644 tests/control_plane/test_complete_turn_decision.py create mode 100644 tests/control_plane/test_complete_turn_journal.py create mode 100644 tests/control_plane/test_operation_host_source_preflight.py create mode 100644 tests/test_managed_complete_source.py diff --git a/packages/loopx-ark-turn/tests/test_host.py b/packages/loopx-ark-turn/tests/test_host.py index 5db02b46e1..20f63793be 100644 --- a/packages/loopx-ark-turn/tests/test_host.py +++ b/packages/loopx-ark-turn/tests/test_host.py @@ -12,6 +12,8 @@ import pytest from arkruntime import AsyncArk +from tests.control_plane.test_turn_host_decision_capture import complete_host_request as complete_host_request + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document from loopx_ark_turn.config import AdapterError, Config from loopx_ark_turn.host import run @@ -336,3 +338,97 @@ async def exercise(): def test_unsafe_configuration_is_rejected(tmp_path, override): with pytest.raises(AdapterError): replace(config(tmp_path, tools=False), **override) + + +@pytest.mark.parametrize("checkpoint", ["completed", "running"]) +@pytest.mark.parametrize("wire", [False, True]) +def test_complete_cli_input_reaches_sdk_and_replays_original_receipt(tmp_path, monkeypatch, complete_host_request, checkpoint, wire): + req, contract = complete_host_request + if wire: + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + req = complete_host_input(req) + assert req["schema_version"] == "loopx_turn_host_request_v1" and "turn_envelope" not in req + cfg = config(tmp_path, tools=False) + provider = Provider([]) + checkpoints = [] + save = Receipt.save + + def capture(receipt): + save(receipt) + if receipt.data["stage"] == "running": + checkpoints.append(copy.deepcopy(receipt.data)) + + monkeypatch.setattr(Receipt, "save", capture) + result = asyncio.run(execute(provider, cfg, req)) + sent = [body["events"][0] for method, path, body in provider.calls + if method == "POST" and path.endswith("/events")] + assert len(sent) == 1 and sent[0]["type"] == "user.message" + prompt = sent[0]["content"][0]["text"] + model_input, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + assert model_input["work_contract"] == contract + assert "turn_envelope" not in model_input and "turn_authority" not in model_input + assert result["turn_key"] == req["turn_key"] + if checkpoint == "running": + # Replay a real persisted checkpoint; the provider retains the original + # input and result. Recovery must observe it instead of submitting again. + assert checkpoints + Receipt(cfg.state_dir, req["turn_key"]).path.write_text(json.dumps(checkpoints[0])) + provider.deleted.clear() + calls = len(provider.calls) + assert asyncio.run(execute(provider, cfg, req)) == result + if checkpoint == "completed": + assert len(provider.calls) == calls + else: + assert not [row for row in provider.calls[calls:] if row[0] == "POST"] + + +@pytest.mark.parametrize("mutation", ["source", "version", "binding", "missing"]) +@pytest.mark.parametrize("wire", [False, True]) +def test_complete_cli_input_rejects_corruption_before_provider(tmp_path, complete_host_request, mutation, wire): + original, _ = complete_host_request + if wire: + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + original = complete_host_input(original) + req = copy.deepcopy(original) + if mutation == "source": + req["work_contract"]["goal_id"] = "different-goal" + elif mutation == "version": + req["work_contract"]["schema_version"] = "unsupported-contract" + elif mutation == "binding": + req["work_contract"]["contract_binding"]["scheduler_execution_args"] = "different-host" + else: + req["work_contract"] = None + cfg = config(tmp_path, tools=False) + provider = Provider([]) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + asyncio.run(execute(provider, cfg, req)) + assert provider.calls == [] + assert not cfg.state_dir.exists() + + +def test_complete_tool_identity_uses_the_shared_source_owner(complete_host_request): + from loopx_ark_turn.config import require_request + from tests.control_plane.test_turn_host_decision_capture import _fixture_module + + class ExecutorLayout(dict): + def get(self, key, *default): + if key in {"goal_id", "agent_id", "action"}: + raise AssertionError("provider decoded the historical executor layout") + return super().get(key, *default) + + request, _ = complete_host_request + request = {**request, "turn_envelope": ExecutorLayout(request["turn_envelope"])} + fixture = _fixture_module() + assert require_request(request) == {"KEY": request["turn_key"], + "GOAL_ID": fixture.GOAL_ID, "AGENT_ID": fixture.AGENT_ID, "TODO_ID": fixture.TODO_ID} + + +def test_unknown_host_version_never_reaches_provider(tmp_path, complete_host_request): + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + + request, _ = complete_host_request + cfg, provider = config(tmp_path, tools=False), Provider([]) + for original in (request, complete_host_input(request)): + with pytest.raises(AdapterError, match="request_schema_mismatch"): + asyncio.run(execute(provider, cfg, {**original, "schema_version": "unsupported_host_fixture"})) + assert provider.calls == [] and not cfg.state_dir.exists() diff --git a/packages/loopx-memt-turn/tests/conftest.py b/packages/loopx-memt-turn/tests/conftest.py index e9c5924ca4..0fe9c25882 100644 --- a/packages/loopx-memt-turn/tests/conftest.py +++ b/packages/loopx-memt-turn/tests/conftest.py @@ -5,10 +5,6 @@ import threading import pytest -from tokenizers import Tokenizer, models -from transformers import PreTrainedTokenizerFast -from memt_sglang.models.common.snapshot_identity import snapshot_identity - from loopx_memt_turn.config import Config @@ -92,6 +88,11 @@ def reply(self, body): @pytest.fixture def config(tmp_path, provider): + # Only native-memory tests need the separately installed client/tokenizer. + from tokenizers import Tokenizer, models + from transformers import PreTrainedTokenizerFast + from memt_sglang.models.common.snapshot_identity import snapshot_identity + workspace, tokenizer = tmp_path / "workspace", tmp_path / "tokenizer" workspace.mkdir() tokenizer.mkdir() @@ -110,3 +111,19 @@ def config(tmp_path, provider): provider.snapshot_identity = snapshot_identity(model_config, provider.tokenizer) return Config(workspace=str(workspace), state_dir=str(tmp_path / "private"), tokenizer=str(tokenizer), base_url=provider.url, enabled=True, timeout_seconds=10) + + +@pytest.fixture +def preflight_config(tmp_path, monkeypatch): + """Invalid input must stop before opening the optional native runtime.""" + from loopx_memt_turn import host + + workspace = tmp_path / "workspace" + workspace.mkdir() + + async def unexpected_execution(*_args): + raise AssertionError("preflight refusal reached provider execution") + + monkeypatch.setattr(host, "execute", unexpected_execution) + return Config(workspace=str(workspace), state_dir=str(tmp_path / "private"), + tokenizer=str(tmp_path / "tokenizer"), enabled=True) diff --git a/packages/loopx-memt-turn/tests/test_host.py b/packages/loopx-memt-turn/tests/test_host.py index f70764e7fe..e9ccedb1e0 100644 --- a/packages/loopx-memt-turn/tests/test_host.py +++ b/packages/loopx-memt-turn/tests/test_host.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import copy from dataclasses import replace import json from pathlib import Path @@ -13,6 +14,7 @@ from loopx_memt_turn.host import run from memt_fixtures import turn, with_tools +from tests.control_plane.test_turn_host_decision_capture import complete_host_request as complete_host_request def execute(config, request=None): @@ -194,3 +196,133 @@ def test_receipt_is_not_a_transcript_or_credential_log(config, provider): execute(config) receipts = [json.loads(path.read_text()) for path in Path(config.state_dir).rglob("*.json")] assert not any("input_ids" in receipt or "responses" in receipt for receipt in receipts) + + +@pytest.mark.parametrize("wire", [False, True]) +def test_complete_cli_input_without_exact_lifetime_cannot_open_memory(preflight_config, complete_host_request, wire): + request, _ = complete_host_request + if wire: + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + request = complete_host_input(request) + assert "goal_ref" not in request + with pytest.raises(AdapterError, match="exact_goal_instance_required"): + execute(preflight_config, request) + assert not Path(preflight_config.state_dir).exists() + + +def test_complete_identity_reaches_the_unchanged_lifetime_gate(complete_host_request): + from loopx_memt_turn.host import work_identity + + class ExecutorLayout(dict): + def get(self, key, *default): + if key in {"goal_id", "agent_id", "action"}: + raise AssertionError("provider decoded the historical executor layout") + return super().get(key, *default) + + request, _ = complete_host_request + request = {**request, "turn_envelope": ExecutorLayout(request["turn_envelope"])} + # The actual CLI still has no admitted exact lifetime. Moving its identity + # reader must reach this same refusal, never manufacture a Goal instance. + with pytest.raises(AdapterError, match="exact_goal_instance_required"): + work_identity(request) + + +@pytest.mark.parametrize("invalidate_source", [False, True]) +@pytest.mark.parametrize("wire", [False, True]) +def test_generation_validation_retains_the_complete_source(complete_host_request, monkeypatch, invalidate_source, wire): + from types import SimpleNamespace + from loopx_memt_turn import host + + request, _ = complete_host_request + if wire: + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + request = complete_host_input(request) + request = copy.deepcopy(request) + vision = {"schema_version": "goal_vision_replan_contract_v0", "state": "active", + "vision_patch": {"acceptance_summary": "Validate the preserved work."}, + "path_delta": {"outcome": "replan", "prior_assumption": "Existing work remains useful.", + "observed_reality": "The artifact is independently checked.", + "changed": ["Use the verified artifact for the next work step."], + "evidence_refs": ["fixture:checked-work"]}} + candidate = {"result_kind": "replan_required", "classification": "validated_fixture_replan", + "summary": "Original work remains useful.", "next_action": "Continue the original work.", + "path_delta_mode": "material_replan", "agent_vision_json": json.dumps(vision), + "work_evidence": ["fixture:artifact"], "validation_evidence": ["fixture:checked-work"]} + + class Session: + def chat(self, *_args, **_kwargs): + return {"turn_completed": True, "answer": json.dumps(candidate)} + + real_build = host.build_result + + def build_then_invalidate(*args, **kwargs): + result = real_build(*args, **kwargs) + if invalidate_source: + request["work_contract"]["reason"] = "source changed after candidate conversion" + return result + + monkeypatch.setattr(host, "build_result", build_then_invalidate) + # This exercises the converter only. It does not supply a synthetic lifetime + # to the actual CLI or qualify opening/exporting native memory. + config = SimpleNamespace(max_new_tokens=128, max_tool_calls=4) + call = host.generate(Session(), None, request, config) + if invalidate_source: + with pytest.raises(AdapterError, match="invalid_typed_candidate"): + asyncio.run(call) + else: + assert asyncio.run(call)["result_kind"] == "replan_required" + + +def test_lifecycle_only_profile_cannot_supply_an_executable_turn(tmp_path): + from tests.control_plane.test_turn_host_decision_capture import _fixture_module + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + + fixture = _fixture_module() + _, runtime, _, legacy = fixture._write_fixture(tmp_path, turn_count=2) + registration = json.loads(legacy.read_text()) + registration.update(schema_version="0.2", registry_role="project-local", profile_id="source_session_v1", + projects=[], session_bindings=[], session_receipts=[], lifetime_receipts=[], retired_goal_instances=[]) + registration["goals"][0].update(goal_instance_id="ginst_" + "a" * 32, execution_authority=False) + registry = legacy.with_name("source-registry.json") + with source_session_registry_transaction(registry, operation="optional_host_fixture", create=lambda: registration) as tx: + tx.commit(registration) + before = registry.read_bytes() + code, rejected = fixture._run_cli(["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "turn", "plan", "--goal-id", fixture.GOAL_ID, + "--agent-id", fixture.AGENT_ID, "--host", "generic-cli", "--iteration-context", "fresh"]) + assert code == 1 and rejected["ok"] is False + assert "lifecycle-only profile" in rejected["error"] + assert registry.read_bytes() == before + assert not list(runtime.rglob("decisions/*.json")) + assert fixture._quota_spend_count(runtime) == 0 + + +@pytest.mark.parametrize("mutation", ["source", "version", "binding", "missing"]) +@pytest.mark.parametrize("wire", [False, True]) +def test_complete_cli_input_rejects_corruption_before_memory(preflight_config, complete_host_request, mutation, wire): + original, _ = complete_host_request + if wire: + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + original = complete_host_input(original) + request = copy.deepcopy(original) + if mutation == "source": + request["work_contract"]["goal_id"] = "different-goal" + elif mutation == "version": + request["work_contract"]["schema_version"] = "unsupported-contract" + elif mutation == "binding": + request["work_contract"]["contract_binding"]["scheduler_execution_args"] = "different-host" + else: + request["work_contract"] = None + with pytest.raises(ValueError, match="complete Turn contract rejected"): + execute(preflight_config, request) + assert not Path(preflight_config.state_dir).exists() + + +def test_unknown_host_version_never_opens_memory(preflight_config, complete_host_request): + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + + request, _ = complete_host_request + for original in (request, complete_host_input(request)): + with pytest.raises(AdapterError, match="invalid_request_schema"): + execute(preflight_config, {**original, "schema_version": "unsupported_host_fixture"}) + assert not Path(preflight_config.state_dir).exists() diff --git a/tests/capabilities/test_codex_native_child_receipts.py b/tests/capabilities/test_codex_native_child_receipts.py index 178f866434..ee6d13ffae 100644 --- a/tests/capabilities/test_codex_native_child_receipts.py +++ b/tests/capabilities/test_codex_native_child_receipts.py @@ -110,6 +110,94 @@ def test_feature_off_has_no_observer_and_reports_cannot_attest_reviews(tmp_path: execute=True, _host_observed=True) +@pytest.mark.parametrize("enabled", [False, True]) +def test_complete_host_observes_children_without_reading_legacy_context(tmp_path, monkeypatch, enabled): + import copy + from hashlib import sha256 + import sys + from loopx.control_plane.quota.turn_envelope import compose_turn_decision, build_turn_envelope + from loopx.control_plane.turn_driver import codex_cli + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request + from tests.test_turn_envelope import _full_decision + + source = _full_decision() + source["goal_id"] = GOAL + source["agent_identity"]["agent_id"] = AGENT + source["selected_todo"].update(todo_id="todo_native_1", claimed_by=AGENT) + channel = source["interaction_contract"]["agent_channel"] + goal = tmp_path / "goal.md" + goal.write_text("Observe disposable host events; no external model.\n") + # Fulfill the existing Goal read with its actual file and full revision; + # metadata alone must not get this fixture past Host dispatch. + channel["required_reads"] = [read for read in channel["required_reads"] + if read.get("source") not in {"goal_state", "selected_todo"}] + channel["work_context"] = {"complete": True, "selected_todo_text_verified": True, + "sources": [{"source": "goal_state", "content": {"full_source": True, + "state_file": str(goal), "text": goal.read_text(), + "revision": "sha256:" + sha256(goal.read_bytes()).hexdigest()}}, + {"source": "selected_todo", "content": {"todo": source["selected_todo"]}}]} + if enabled: + source["interaction_contract"]["agent_context"] = {"contributions": [ + {"capability_id": "multi_subagent", "facts": {"max_children": 3}}]} + contract = compose_turn_decision(source) + plan = build_loopx_turn_plan(build_turn_envelope(contract), work_contract=contract, + host="codex-cli", execution_mode="isolated-headless", turn_instance_id=TURN) + request = build_loopx_turn_host_request(plan) + + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in request + request["host_attempt"] = 1 + _admit(tmp_path) + script = tmp_path / "host.py" + script.write_text("import json,sys\nfrom pathlib import Path\n" + "sys.stdin.read()\n" + "print(json.dumps({'type':'thread.started','thread_id':'parent-1'}),flush=True)\n" + f"for item in {_turn()['items']!r}:\n" + " print(json.dumps({'type':'item.completed','item':item}),flush=True)\n" + "Path(sys.argv[1]).write_text(json.dumps({'parent_work':'preserved'}))\n") + launched = [] + + def command(**kwargs): + launched.append(True) + return [sys.executable, str(script), str(kwargs["output_path"])] + + monkeypatch.setattr(codex_cli, "_codex_command", command) + assert codex_cli.run_codex_cli_host(request, runtime_root=tmp_path, project=tmp_path, + codex_bin=sys.executable) == {"parent_work": "preserved"} + activity = load_native_child_activity(tmp_path, goal_id=GOAL, agent_id=AGENT, + turn_instance_id=TURN, configured_limit=3) + assert activity["host_attested"] is enabled + assert activity["launched_count"] == int(enabled) + assert activity["parent_accepted_count"] == 0 + events = load_rollout_events(rollout_event_log_path(tmp_path, GOAL)) + assert "private child" not in json.dumps(events) + assert not any(event["event_kind"] == "quota_spend" for event in events) + + # The native adapter does not grant authority. Corrupting the supplied + # complete source must still stop before another process or receipt. + tampered = copy.deepcopy(request) + tampered["work_contract"]["reason"] = "different source" + with pytest.raises(RuntimeError, match="integrity mismatch"): + codex_cli.run_codex_cli_host(tampered, runtime_root=tmp_path, project=tmp_path, + codex_bin=sys.executable) + assert launched == [True] + assert load_rollout_events(rollout_event_log_path(tmp_path, GOAL)) == events + + # A historical-context trap cannot be attached to the current producer: + # its second carrier is retired. Challenge the same no-fallback boundary + # with an injected, contradictory capability instead; refuse before effects. + mixed = copy.deepcopy(request) + mixed["turn_envelope"] = {"agent_context": {"contributions": [ + {"capability_id": "multi_subagent", "facts": {"max_children": 0 if enabled else 3}}, + ]}} + with pytest.raises(ValueError, match="without an executor adapter"): + codex_cli.run_codex_cli_host(mixed, runtime_root=tmp_path, project=tmp_path, + codex_bin=sys.executable) + assert launched == [True] + assert load_rollout_events(rollout_event_log_path(tmp_path, GOAL)) == events + + def test_cli_host_collects_native_items_before_returning_parent_result(tmp_path: Path, monkeypatch): import sys from loopx.control_plane.turn_driver import codex_cli diff --git a/tests/control_plane/canonical_authority_fixture.py b/tests/control_plane/canonical_authority_fixture.py index a64e360a7e..a1f6eeb915 100644 --- a/tests/control_plane/canonical_authority_fixture.py +++ b/tests/control_plane/canonical_authority_fixture.py @@ -92,14 +92,15 @@ def initialize_canonical_authority(runtime_root: Path, goal_id: str, projection: def read_fixture_todos(state_path: Path) -> list[dict]: """Read authored input before initializing a disposable canonical fixture.""" - from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos + from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source + from loopx.control_plane.todos.todo_summary import structured_todo_item - summary = parse_active_state_todos( - state_path.read_text(encoding="utf-8"), state_path=state_path, item_limit=None, - ) + # Attention summaries intentionally shorten text. Seeding their preview as + # canonical authority would erase the tail before the consumer is tested. + items, _, _ = parse_todo_source(state_path.read_text(encoding="utf-8"), state_path=state_path) return [ - *(summary.get("user_todos", {}).get("items") or []), - *(summary.get("agent_todos", {}).get("items") or []), + structured_todo_item(item, role=role, source_section=item["source_section"], text_limit=None) + for role in ("user", "agent") for item in items[role] ] diff --git a/tests/control_plane/codex_checkpoint_fixture.py b/tests/control_plane/codex_checkpoint_fixture.py index a83a984b36..59c7145624 100644 --- a/tests/control_plane/codex_checkpoint_fixture.py +++ b/tests/control_plane/codex_checkpoint_fixture.py @@ -56,7 +56,9 @@ def delivered_context(request, digest): delivered[offset] = value if not delivered: return "" - assert set(delivered) == set(range(len(delivered))) + assert set(delivered) == set(range(len(delivered))), ( + f"Non-contiguous native context: received {len(delivered)} bytes; " + f"first offset {min(delivered, default=None)}, last offset {max(delivered, default=None)}") raw = bytes(delivered[i] for i in range(len(delivered))) assert hashlib.sha256(raw).hexdigest() == digest return raw.decode().rstrip("\n") @@ -261,6 +263,29 @@ def native_context_command(request, *, todo_id=None): argv += ["--todo-id", todo_id] return shlex.join(argv) +def native_capture_wire(descriptor, *, recovery=None): + """Independent saved-byte oracle; full delivery needs no duplicate wire file. + + Native requests must still consume every returned page. This fixture reads + the exact capture separately to verify those actual model-visible bytes. + """ + import hashlib + from pathlib import Path + from loopx.control_plane.quota.turn_envelope import prepare_context_delivery + + raw = Path(descriptor["path"]).read_bytes() + assert hashlib.sha256(raw).hexdigest() == descriptor["sha256"] + if descriptor["mode"] != "full": + return json.loads(raw) + target = descriptor["target"] + wire = prepare_context_delivery(json.loads(raw), + scope={key: target[key] for key in ("goal_id", "agent_id", "session_id", "context_epoch", "continuity_generation") + if key in target}, capture_id=target["capture_id"], surface=target["surface"]) + assert wire["target"] == target + wire["recovery"] = recovery if recovery is not None else descriptor["recovery"] + return wire + + @contextmanager def local_inference_provider(response_builder=None, usage_builder=None): """Static loopback Responses stream, retaining the actual native model input.""" diff --git a/tests/control_plane/test_complete_codex_result.py b/tests/control_plane/test_complete_codex_result.py new file mode 100644 index 0000000000..1c0f35feb9 --- /dev/null +++ b/tests/control_plane/test_complete_codex_result.py @@ -0,0 +1,154 @@ +"""Strict Codex results retain evidence obligations from the complete source.""" +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver.codex_cli import normalize_codex_result, run_codex_cli_host +from loopx.control_plane.turn_driver.codex_operation_host import run_codex_operation_host +from loopx.control_plane.turn_driver.host_candidate import complete_host_input +from loopx.control_plane.turn_driver.host_failure import BuiltInHostError +from tests.control_plane.test_turn_host_decision_capture import ( + complete_host_request as complete_host_request, +) + + +@pytest.fixture(params=["executor", "wire"]) +def codex_request(complete_host_request, request): + internal, source = complete_host_request + assert isinstance(source["execution_profile"], dict) and source["execution_profile"] + value = deepcopy(internal) if request.param == "executor" else complete_host_input(internal) + assert isinstance(value, dict) and value["work_contract"] == source + assert ("turn_envelope" in value) is (request.param == "executor") + return value, source + + +def _result(turn_key, kind="validated_progress"): + material = kind in {"validated_progress", "repair_required", "replan_required"} + return { + "schema_version": "loopx_turn_result_v0", "turn_key": turn_key, + "result_kind": kind, "completed_phases": ["host_execute", "typed_result"], + "classification": "synthetic_complete_result", "recommended_action": "Continue the fixture", + "next_action": "Inspect the fixture result", "summary": "One public fixture result", + "delivery_batch_scale": "implementation" if material else "", + "delivery_outcome": "outcome_progress" if material else "", + "vision_unchanged_reason": "The fixture objective remains unchanged." if material else "", + "path_delta_mode": "", "agent_vision_json": "", "progress_observation_json": "", + "work_evidence": ["artifact:fixture"] if material else None, + "validation_evidence": ["check:fixture"] if material else None, + } + + +@pytest.mark.parametrize("kind", ["validated_progress", "repair_required", "replan_required"]) +@pytest.mark.parametrize("evidence", ["null", "omitted"]) +def test_complete_material_result_cannot_lose_its_evidence_obligation(codex_request, kind, evidence): + request, _source = codex_request + original = deepcopy(request) + result = _result(request["turn_key"], kind) + for field in ("work_evidence", "validation_evidence"): + if evidence == "null": + result[field] = None + else: + result.pop(field) + with pytest.raises(BuiltInHostError, match="codex_material_result_missing_evidence"): + normalize_codex_result(request, result) + assert request == original + + +@pytest.mark.parametrize("fault", ["null", "changed", "future"]) +def test_material_result_refuses_a_bad_supplied_complete_source(codex_request, fault): + request, _source = codex_request + damaged = deepcopy(request) + if fault == "null": + damaged["work_contract"] = None + elif fault == "future": + damaged["work_contract"]["schema_version"] = "unsupported_complete_source" + else: + damaged["work_contract"]["reason"] = "changed after launch" + before = deepcopy(damaged) + with pytest.raises((ValueError, RuntimeError)): + normalize_codex_result(damaged, _result(request["turn_key"])) + assert damaged == before + + +@pytest.mark.parametrize("kind", ["validated_progress", "wait", "user_action_required", "iteration_failed"]) +def test_complete_result_preserves_material_evidence_and_stop_absence(codex_request, kind): + request, _source = codex_request + original = deepcopy(request) + result = _result(request["turn_key"], kind) + normalized = normalize_codex_result(request, result) + if kind == "validated_progress": + assert normalized == result + else: + assert normalized == {k: v for k, v in result.items() + if k not in {"work_evidence", "validation_evidence"}} + assert request == original + + +def test_non_null_evidence_content_stays_with_the_executor_validator(codex_request): + request, _source = codex_request + result = _result(request["turn_key"]) + result.update(work_evidence=[], validation_evidence=[]) + assert normalize_codex_result(request, result) == result + + +@pytest.mark.parametrize("profile", [False, True]) +@pytest.mark.parametrize("kind", ["validated_progress", "wait"]) +def test_historical_result_normalization_keeps_its_original_profile_rule(profile, kind): + request = {"schema_version": "loopx_turn_host_request_v0", "turn_envelope": { + "contract_capsule": {"execution_profile": {"minimum_scale": "implementation"}} if profile else {}}} + result = _result("sha256:" + "a" * 64, kind) + result.update(work_evidence=None, validation_evidence=None) + if profile and kind == "validated_progress": + with pytest.raises(BuiltInHostError, match="codex_material_result_missing_evidence"): + normalize_codex_result(request, result) + elif kind == "wait": + assert "work_evidence" not in normalize_codex_result(request, result) + else: + assert normalize_codex_result(request, result) == result + + +@pytest.mark.parametrize("host", ["cli", "operation"]) +@pytest.mark.parametrize("outcome", ["material", "missing_evidence", "stop"]) +def test_actual_codex_boundaries_apply_the_complete_result_rule( + codex_request, tmp_path, monkeypatch, host, outcome, +): + from test_loopx_turn_codex_cli import _fake_codex + from test_codex_operation_host import FAKE_SERVER + + request, source = codex_request + result = _result(request["turn_key"], "wait" if outcome == "stop" else "validated_progress") + if outcome == "missing_evidence": + result.update(work_evidence=None, validation_evidence=None) + if host == "cli": + executable, log = _fake_codex(tmp_path) + monkeypatch.setenv("FAKE_CODEX_LOG", str(log)) + monkeypatch.setenv("FAKE_CODEX_RESULT_FIELDS", json.dumps(result)) + run = run_codex_cli_host + else: + executable = tmp_path / "fake-complete-codex-operation" + executable.write_text(FAKE_SERVER) + executable.chmod(0o700) + monkeypatch.setenv("FAKE_OPERATION_RESULT_FIELDS", json.dumps(result)) + run = run_codex_operation_host + registry = Path(source["registry"]) + goal = next(g for g in json.loads(registry.read_text())["goals"] + if g.get("id", g.get("goal_id")) == source["goal_id"]) + original = deepcopy(request) + options = dict(runtime_root=Path(source["runtime_root"]), registry_path=registry, + project=Path(goal["repo"]), codex_bin=str(executable), + model="test-model", reasoning_effort="xhigh", timeout_seconds=5) + if outcome == "missing_evidence": + with pytest.raises(BuiltInHostError, match="codex_material_result_missing_evidence"): + run(request, **options) + else: + observed = run(request, **options) + assert observed["turn_key"] == request["turn_key"] + assert observed["result_kind"] == result["result_kind"] + if outcome == "stop": + assert "work_evidence" not in observed and "validation_evidence" not in observed + else: + assert observed["work_evidence"] == result["work_evidence"] + assert observed["validation_evidence"] == result["validation_evidence"] + assert request == original diff --git a/tests/control_plane/test_complete_host_request.py b/tests/control_plane/test_complete_host_request.py new file mode 100644 index 0000000000..3306cd7d95 --- /dev/null +++ b/tests/control_plane/test_complete_host_request.py @@ -0,0 +1,120 @@ +"""One complete Host wire; historical adapters stay version-bound and internal.""" +from __future__ import annotations + +from copy import deepcopy +import json + +import pytest + +from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan, turn_host_lineage +from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request +from loopx.control_plane.turn_driver.decision_capture import prepare_turn_decision_capture +from loopx.control_plane.turn_driver.host_candidate import ( + complete_host_input, extract_turn_authority, extract_turn_work_identity, +) +from tests.control_plane.test_turn_host_decision_capture import ( + complete_host_request as complete_host_request, +) + + +def _captured_request(original, tmp_path): + request, source = original + plan = build_loopx_turn_plan(request["turn_envelope"], work_contract=source, + host="generic-cli", execution_mode="isolated-headless", turn_instance_id="complete-wire") + plan = prepare_turn_decision_capture(plan, journal_path=tmp_path / "wire" / "journal.json", + decision=source, existing_journal=False) + return build_loopx_turn_host_request(plan) + + +def test_complete_wire_roundtrip_has_one_source_and_original_readers(complete_host_request, tmp_path): + from loopx.control_plane.turn_driver.codex_cli import _prompt + + internal = _captured_request(complete_host_request, tmp_path) + legacy, _ = complete_host_request + before = deepcopy(internal) + wire = complete_host_input(internal) + assert wire["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in wire and "turn_authority" not in wire + assert wire["work_contract"] == internal["work_contract"] + assert wire["turn_key"] == internal["turn_key"] + assert wire["detail_ref"]["captured_decision"]["turn_key"] == wire["turn_key"] + assert complete_host_input(wire) == wire + assert turn_host_lineage(wire) == turn_host_lineage(legacy) + assert extract_turn_work_identity(wire) == extract_turn_work_identity(legacy) + assert extract_turn_authority(wire)["primary_action"] == extract_turn_authority(legacy)["primary_action"] + rendered = json.loads(_prompt(wire).split("Turn request:\n", 1)[1]) + assert rendered == wire + assert internal == before + + +def test_private_plan_lineage_is_not_a_host_wire(complete_host_request): + request, source = complete_host_request + plan = build_loopx_turn_plan(request["turn_envelope"], work_contract=source, + host="generic-cli", execution_mode="isolated-headless", turn_instance_id="private-plan") + assert turn_host_lineage(plan) == turn_host_lineage(request) + with pytest.raises(ValueError, match="host request schema"): + complete_host_input(plan) + + +def test_complete_wire_rejects_downgrade_mixed_carriers_and_capture_drift(complete_host_request, tmp_path): + original = _captured_request(complete_host_request, tmp_path) + wire = complete_host_input(original) + assert wire["schema_version"] == "loopx_turn_host_request_v1" + malformed = [] + missing = deepcopy(wire) + del missing["work_contract"] + malformed.append(missing) + for replacement in (None, {**wire["work_contract"], "reason": "changed after capture"}, + {**wire["work_contract"], "schema_version": "unsupported_future_version"}): + malformed.append({**wire, "work_contract": replacement}) + for replacement in (None, {}, complete_host_request[0]["turn_envelope"]): + malformed.append({**wire, "turn_envelope": replacement}) + malformed.extend([{**wire, "schema_version": "unsupported_future_host"}, + {**wire, "turn_key": "not-a-turn-key"}, + {**wire, "source_decision_hash": "sha256:" + "0" * 64}]) + malformed.extend({**wire, "detail_ref": value} for value in (None, [], "invalid reference")) + malformed.extend({**wire, "detail_ref": {**wire["detail_ref"], "captured_decision": value}} + for value in (None, {}, [], "invalid capture")) + for key, value in (("turn_key", "sha256:" + "0" * 64), ("goal_id", "other-goal"), + ("agent_id", "other-agent"), ("source_hash_ref", "$.unverified")): + changed = deepcopy(wire) + changed["detail_ref"]["captured_decision"][key] = value + malformed.append(changed) + without_hash = deepcopy(wire) + del without_hash["source_decision_hash"] + malformed.append(without_hash) + for changed in malformed: + before = deepcopy(changed) + for consume in (complete_host_input, extract_turn_authority, extract_turn_work_identity, turn_host_lineage): + with pytest.raises((ValueError, RuntimeError)): + consume(changed) + assert changed == before + + +def test_historical_v0_wire_keeps_its_original_decoder(complete_host_request): + internal, _ = complete_host_request + historical = {key: deepcopy(value) for key, value in internal.items() if key != "work_contract"} + assert historical["schema_version"] == "loopx_turn_host_request_v0" + before = deepcopy(historical) + assert complete_host_input(historical) is None + assert extract_turn_work_identity(historical) == extract_turn_work_identity(internal) + assert extract_turn_authority(historical)["primary_action"] == extract_turn_authority(internal)["primary_action"] + assert historical == before + + +def test_complete_wire_keeps_original_numbers_through_capture_and_readback(complete_host_request, tmp_path): + from loopx.control_plane.quota.turn_envelope import compose_turn_decision, build_turn_envelope + + _, original = complete_host_request + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + source["observations"] = [1.0, -0.0, 1e-7, 2**60 + 1] + contract = compose_turn_decision(source) + request = _captured_request(({"turn_envelope": build_turn_envelope(contract)}, contract), tmp_path) + wire = complete_host_input(request) + assert wire["schema_version"] == "loopx_turn_host_request_v1" + restored = extract_turn_authority(wire) + def encode(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + assert encode(restored["observations"]) == encode(source["observations"]) + assert encode(complete_host_input(wire)) == encode(wire) diff --git a/tests/control_plane/test_complete_host_request_cutover.py b/tests/control_plane/test_complete_host_request_cutover.py new file mode 100644 index 0000000000..2ddcd1d637 --- /dev/null +++ b/tests/control_plane/test_complete_host_request_cutover.py @@ -0,0 +1,366 @@ +"""Current Host producers share one input; recovery retains the original work.""" +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision +from loopx.control_plane.turn_driver.decision_capture import stage_turn_decision_capture +from loopx.control_plane.turn_driver.driver import ( + FailedTurnSessionRecoveryError, + build_loopx_turn_plan, + reconcile_failed_turn_session_request, + turn_host_lineage, +) +from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request +from loopx.control_plane.turn_driver.host_candidate import complete_host_input +from tests.control_plane.test_turn_host_decision_capture import ( + complete_host_request as complete_host_request, +) + + +def _plan(contract, *, scope="todo", turn="complete-request-cutover"): + return build_loopx_turn_plan(build_turn_envelope(contract), work_contract=contract, + host="codex-cli" if scope == "agent" else "generic-cli", + execution_mode="isolated-headless", session_scope=scope, + turn_instance_id=turn) + + +def test_complete_source_can_plan_without_producing_a_legacy_adapter(complete_host_request): + legacy, contract = complete_host_request + before = deepcopy(contract) + plan = build_loopx_turn_plan(contract, host="generic-cli", + execution_mode="isolated-headless", session_scope="todo", + turn_instance_id="source-only-plan") + assert plan["ok"] is True + assert plan["schema_version"] == "loopx_turn_plan_v1" + assert plan["work_contract"] == contract and "turn_envelope" not in plan + historical = _plan(contract, turn="source-only-plan") + assert plan["route"] == historical["route"] + assert plan["transaction"] == historical["transaction"] + assert plan["session"] == historical["session"] + request = build_loopx_turn_host_request(plan) + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert request["work_contract"] == contract and "turn_envelope" not in request + assert turn_host_lineage(request) == turn_host_lineage(legacy) + assert contract == before + + +def test_complete_plan_capture_and_journal_keep_one_original_source( + complete_host_request, tmp_path, +): + from loopx.control_plane.turn_driver.journal_store import ( + find_loopx_turn_key_by_settlement_identity, load_loopx_turn_plan_from_journal, + turn_journal_path, + ) + from loopx.control_plane.turn_driver.turn_journal_runtime import write_turn_journal + + _legacy, contract = complete_host_request + runtime = Path(contract["runtime_root"]) + plan = build_loopx_turn_plan(contract, host="generic-cli", + execution_mode="isolated-headless", session_scope="todo", + turn_instance_id="source-plan-journal") + assert plan["ok"] is True and "turn_envelope" not in plan + key = plan["transaction"]["turn_key"] + identity = plan["transaction"]["settlement_plan"]["identity"] + path = turn_journal_path(runtime, goal_id=contract["goal_id"], turn_key=key) + staged = stage_turn_decision_capture(plan, journal_path=path, + decision=contract, existing_journal=False) + published, wire = staged.publish_for_host(build_loopx_turn_host_request(plan)) + assert published["schema_version"] == "loopx_turn_plan_v1" + assert "turn_envelope" not in published and "turn_envelope" not in wire + assert published["work_contract"] == wire["work_contract"] == contract + assert wire["detail_ref"]["captured_decision"]["turn_key"] == key + journal = {"schema_version": "loopx_turn_journal_v0", "goal_id": contract["goal_id"], + "turn_key": key, "status": "in_progress", "completed_phases": [], + "host_attempt_count": 0, "plan": published} + path.parent.mkdir(parents=True, exist_ok=True) + assert write_turn_journal(str(path), journal, expected_effect_id=identity["effect_id"])["appended"] + original = path.read_bytes() + assert "turn_envelope" not in json.loads(original)["plan"] + assert load_loopx_turn_plan_from_journal(runtime, goal_id=contract["goal_id"], turn_key=key) == published + assert find_loopx_turn_key_by_settlement_identity(runtime, goal_id=identity["goal_id"], + agent_id=identity["agent_id"], todo_id=identity["todo_id"], + turn_instance_id=identity["turn_instance_id"]) == key + assert write_turn_journal(str(path), journal, expected_effect_id=identity["effect_id"])["replayed"] + assert path.read_bytes() == original + assert json.loads(staged.path.read_text()) == contract + + +@pytest.mark.parametrize("mutation", ["missing", "null", "mixed", "future", "source_hash", "capture_turn"]) +def test_current_plan_invalid_carrier_cannot_dispatch_or_recover(complete_host_request, tmp_path, mutation): + from loopx.control_plane.turn_driver.driver import read_turn_execution_context, read_turn_plan_work + from loopx.control_plane.turn_driver.lane_fence import turn_lane_agent_id + + _legacy, contract = complete_host_request + plan = _plan(contract) + if mutation == "missing": + plan.pop("work_contract") + elif mutation == "null": + plan["work_contract"] = None + elif mutation == "mixed": + plan["turn_envelope"] = build_turn_envelope(contract) + elif mutation == "future": + plan["schema_version"] = "future_turn_plan" + elif mutation == "source_hash": + plan["source_decision_hash"] = "sha256:" + "0" * 64 + else: + plan = stage_turn_decision_capture(plan, journal_path=tmp_path / "journal.json", + decision=contract, existing_journal=False).plan + plan["detail_ref"]["captured_decision"]["turn_key"] = "sha256:" + "0" * 64 + for reader in (build_loopx_turn_host_request, turn_host_lineage, read_turn_execution_context, + read_turn_plan_work, turn_lane_agent_id): + with pytest.raises((ValueError, RuntimeError)): + reader(plan) + assert not (tmp_path / "decisions").exists() # staging is read-only + + +def test_current_host_request_producer_delivers_one_complete_source(complete_host_request): + _legacy, contract = complete_host_request + plan = _plan(contract) + before = deepcopy(plan) + request = build_loopx_turn_host_request(plan) + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in request and "turn_authority" not in request + assert request["work_contract"] == contract + assert request["turn_key"] == plan["transaction"]["turn_key"] + assert turn_host_lineage(request) == turn_host_lineage(plan) + assert complete_host_input(request) == request + assert plan == before + + +def test_disabled_memory_does_not_read_or_activate_complete_plan(complete_host_request, monkeypatch): + from loopx.control_plane.reward_memory import reward_memory_automation_enabled + + _legacy, contract = complete_host_request + plan = _plan(contract) + monkeypatch.setattr("loopx.control_plane.capability_hooks.turn_context_source", + lambda *_a, **_kw: pytest.fail("disabled capability must not read its source or provider")) + for recall in ({}, {"experiment": {"enabled": False}}, {"experiment": { + "enabled": True, "available": False, "configured_for_agent": True, "automatic_ingest": True}}): + assert reward_memory_automation_enabled({**plan, "reward_memory_recall": recall}, + operation="automatic_ingest") is False + + +def test_original_capture_publication_keeps_the_complete_request_version( + complete_host_request, tmp_path, +): + _legacy, contract = complete_host_request + plan = _plan(contract) + # Explicit conversion keeps the input to this boundary identical on both + # old/new products, independently of the producer assertion above. + incoming = complete_host_input(build_loopx_turn_host_request(plan)) + original = deepcopy(incoming) + staged = stage_turn_decision_capture(plan, journal_path=tmp_path / "journal.json", + decision=contract, existing_journal=False) + published, request = staged.publish_for_host(incoming) + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in request + assert request == complete_host_input(build_loopx_turn_host_request(published)) + assert request["work_contract"] == contract and incoming == original + detail = request["detail_ref"]["captured_decision"] + assert detail["turn_key"] == request["turn_key"] + assert detail["source_hash_ref"] == "$.source_decision_hash" + assert json.loads(staged.path.read_text()) == contract + + +def test_original_historical_capture_restores_its_version_without_a_current_source( + complete_host_request, tmp_path, +): + _legacy, contract = complete_host_request + incoming = complete_host_input(build_loopx_turn_host_request(_plan(contract, turn="historical-capture"))) + historical = {key: value for key, value in contract.items() + if key not in {"schema_version", "contract_binding"}} + plan = build_loopx_turn_plan(build_turn_envelope(historical), host="generic-cli", + execution_mode="isolated-headless", session_scope="todo", turn_instance_id="historical-capture") + assert incoming["turn_key"] == plan["transaction"]["turn_key"] + staged = stage_turn_decision_capture(plan, journal_path=tmp_path / "journal.json", + decision=historical, existing_journal=False) + _published, restored = staged.publish_for_host(incoming) + assert restored["schema_version"] == "loopx_turn_host_request_v0" + assert "work_contract" not in restored and "source_decision_hash" not in restored + assert "detail_ref" not in restored + assert restored["turn_envelope"] == staged.plan["turn_envelope"] + assert json.loads(staged.path.read_text()) == historical + + +@pytest.mark.parametrize("scope", ["todo", "agent"]) +def test_failed_complete_request_resumes_the_same_native_session( + complete_host_request, tmp_path, scope, +): + from loopx.control_plane.turn_driver import codex_sessions + + _legacy, contract = complete_host_request + request = complete_host_input(build_loopx_turn_host_request(_plan(contract, scope=scope))) + before = deepcopy(request) + lineage = turn_host_lineage(request) + binding = {"schema_version": "loopx_turn_session_binding_v0", **lineage, + "host_session_id": "original-session"} + runtime = tmp_path / "recovery-sessions" + codex_sessions._store_codex_cli_session(runtime, lineage=lineage, session_scope=scope, + session_id="original-session") + stored = {p: p.read_bytes() for p in runtime.rglob("*.json")} + recovered = reconcile_failed_turn_session_request(request, session_binding=binding) + assert recovered["session"]["action"] == "resume" + assert recovered["session"]["binding_status"] == "failed_turn_recovery" + assert codex_sessions.select_codex_cli_session(runtime, lineage=turn_host_lineage(recovered), + session_scope=scope)["session_id"] == "original-session" + assert "turn_envelope" not in recovered and recovered["work_contract"] == contract + assert reconcile_failed_turn_session_request(recovered, session_binding=binding) == recovered + assert request == before + assert {p: p.read_bytes() for p in runtime.rglob("*.json")} == stored + with pytest.raises(FailedTurnSessionRecoveryError): + reconcile_failed_turn_session_request(request, session_binding={**binding, "agent_id": "other-agent"}) + for bad in (None, {**contract, "reason": "source changed before recovery"}): + with pytest.raises(FailedTurnSessionRecoveryError): + reconcile_failed_turn_session_request({**request, "work_contract": bad}, session_binding=binding) + + +@pytest.mark.parametrize("request_scope", [False, True], ids=["replan", "request"]) +def test_complete_todoless_scoped_recovery_preserves_its_original_identity( + complete_host_request, tmp_path, request_scope, +): + from loopx.control_plane.quota.settlement import build_turn_scoped_cli_settlement_plan + from loopx.control_plane.turn_driver import codex_sessions + from loopx.control_plane.quota.turn_envelope import complete_turn_host_source + + _legacy, original = complete_host_request + goal, agent, obligation = original["goal_id"], original["agent_identity"]["agent_id"], "replan-" + "a" * 16 + settlement = build_turn_scoped_cli_settlement_plan(goal_id=goal, agent_id=agent, + replan_obligation_id=None if request_scope else obligation, + request_subject={"request_id": "a" * 64, "request_revision": 1, + "request_digest": "b" * 64, "execution_scope_sha256": "c" * 64} if request_scope else None, + turn_instance_id="fixture.replan", command_prefix="loopx", + scoped_cli_args="", lifecycle_actor_args="").as_dict() + contract = compose_turn_decision({"ok": True, "goal_id": goal, + "agent_identity": {"agent_id": agent}, "decision": "run", "selected_todo": None, + "should_run": True, "effective_action": "normal_run" if request_scope else "autonomous_replan_required", + **({} if request_scope else {"replan_action_packet": {"schema_version": "replan_action_packet_v0", + "decision": "replan_required", "obligation_id": obligation}}), + "interaction_contract": {"schema_version": "loopx_interaction_contract_v0", + "mode": "bounded_delivery" if request_scope else "autonomous_replan", + "user_channel": {"action_required": False, "notify": "DONT_NOTIFY"}, + "agent_channel": {"must_attempt": True, "delivery_allowed": request_scope, "quiet_noop_allowed": False}, + "cli_channel": {"spend_after_validation": True, "settlement_plan": settlement}}}) + plan = _plan(contract, scope="agent", turn="fixture.replan") + assert plan["transaction"]["settlement_plan"]["identity"] == settlement["identity"] + wire = complete_host_input(build_loopx_turn_host_request(plan)) + binding = {"schema_version": "loopx_turn_session_binding_v0", "goal_id": goal, + "agent_id": agent, "host_session_id": "original-replan-session"} + runtime = tmp_path / "replan-sessions" + codex_sessions._store_codex_cli_session(runtime, lineage=turn_host_lineage(wire), + session_scope="agent", session_id="original-replan-session") + stored = {p: p.read_bytes() for p in runtime.rglob("*.json")} + assert complete_turn_host_source(wire, operation="driver_lineage")["scoped_settlement_identity"] == settlement["identity"] + recovered = reconcile_failed_turn_session_request(wire, session_binding=binding) + assert recovered["session"]["action"] == "resume" + assert codex_sessions.select_codex_cli_session(runtime, lineage=turn_host_lineage(recovered), + session_scope="agent")["session_id"] == "original-replan-session" + assert recovered["work_contract"] == contract and "turn_envelope" not in recovered + assert reconcile_failed_turn_session_request(recovered, session_binding=binding) == recovered + assert {p: p.read_bytes() for p in runtime.rglob("*.json")} == stored + + +def test_complete_recovery_retains_the_original_number_codec_and_action_hash(complete_host_request): + from loopx.control_plane.turn_driver.driver import _turn_lineage + from loopx.control_plane.quota.turn_envelope import complete_turn_host_source + + _legacy, original = complete_host_request + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + source["selected_todo"]["future_numeric_context"] = [1.0, -0.0, 1e-7, 2**60 + 1] + contract = compose_turn_decision(source) + plan = _plan(contract) + request = build_loopx_turn_host_request(plan) + # The retained v0 codec is an independent numeric/hash oracle; the current + # private plan intentionally has no such carrier. + expected = _turn_lineage(build_turn_envelope(contract), work_contract=contract) + checked = complete_turn_host_source(request, operation="driver_lineage") + assert _turn_lineage({}, work_contract=contract, projection=checked) == expected + + +def test_original_source_capture_keeps_its_read_reference_without_a_fabricated_journal_key( + complete_host_request, tmp_path, +): + from loopx.control_plane.turn_driver.decision_capture import write_decision_capture + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + + _legacy, contract = complete_host_request + path = tmp_path / "original-source.json" + write_decision_capture(path, contract) + original = path.read_bytes() + envelope = build_turn_envelope(contract, captured_decision_path=str(path)) + plan = build_loopx_turn_plan(envelope, work_contract=contract, host="generic-cli", + execution_mode="isolated-headless", session_scope="todo", turn_instance_id="source-reader") + request = build_loopx_turn_host_request(plan) + wire = complete_host_input(request) + capture = wire["detail_ref"]["captured_decision"] + assert "turn_key" not in capture + assert capture["turn_instance_id"] == contract.get("heartbeat_receipt", {}).get("turn_instance_id") + assert capture["path"] == str(path) and wire["work_contract"] == contract + assert extract_turn_authority(wire)["_model_input"] == wire + for field, value in (("goal_id", "other-goal"), ("agent_id", "other-agent"), + ("turn_instance_id", "other-turn"), ("path", "bad\0path"), + ("source_hash_ref", "$.unverified")): + changed = deepcopy(wire) + changed["detail_ref"]["captured_decision"][field] = value + with pytest.raises((RuntimeError, ValueError)): + extract_turn_authority(changed) + assert path.read_bytes() == original + + +def test_real_callback_receives_published_complete_input_and_replay_has_no_effects( + complete_host_request, +): + from loopx.control_plane.turn_driver.executor import run_loopx_turn_once + from loopx.control_plane.turn_driver.host_candidate import build_result + + _legacy, contract = complete_host_request + plan = _plan(contract) + project = Path(contract["registry"]).parent.parent + artifact = project / "docs/turn-e2e-marker.txt" + effects = [] + + def host(request): + effects.append("host") + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in request and request["work_contract"] == contract + original = Path(request["detail_ref"]["captured_decision"]["path"]) + assert json.loads(original.read_text()) == contract + artifact.parent.mkdir(exist_ok=True) + artifact.write_text("loopx-turn-real-e2e-step-1\n") + return build_result(request, {"result_kind": "validated_progress", + "classification": "complete_callback_source", "delivery_batch_scale": "implementation", + "delivery_outcome": "outcome_progress", "next_action": "Continue the original synthetic task.", + "work_evidence": ["artifact:synthetic-step-1"], + "validation_evidence": ["validation:independent-marker-check"]}) + + def validate(_plan, _result): + effects.append("validation") + assert artifact.read_text().strip() == "loopx-turn-real-e2e-step-1" + return {"status": "progress", "validator_kind": "fixture", "summary": "Marker step is valid.", "exit_code": 10} + + def writeback(_result): + effects.append("writeback") + return {"ok": True, "appended": True} + + def spend(): + effects.append("spend") + return {"ok": True, "appended": True, "slots": 1} + + def scheduler(_spend): + effects.append("scheduler") + return {"disposition": "outer_controller_owned", "completed": True, + "acknowledged": False, "apply_needed": False} + + options = dict(host_runner=host, project=project, runtime_root=Path(contract["runtime_root"]), + goal_id=contract["goal_id"], timeout_seconds=10, execute=True, task_validator=validate, + writeback=writeback, spend=spend, scheduler=scheduler, + require_decision_capture=True, decision_capture_payload=contract) + result = run_loopx_turn_once(plan, **options) + assert result["status"] == "committed" and result["quota_slot_spend_count"] == 1 + assert effects == ["host", "validation", "writeback", "spend", "scheduler"] + replay = run_loopx_turn_once(plan, **options) + assert replay["replayed"] and replay["status"] == "committed" + assert effects == ["host", "validation", "writeback", "spend", "scheduler"] diff --git a/tests/control_plane/test_complete_numeric_transport.py b/tests/control_plane/test_complete_numeric_transport.py new file mode 100644 index 0000000000..e0b6048643 --- /dev/null +++ b/tests/control_plane/test_complete_numeric_transport.py @@ -0,0 +1,120 @@ +"""Complete consumer costs and the explicit experimental hash transition.""" +from copy import deepcopy +import json + +import pytest + +from loopx.control_plane.quota import turn_envelope as turn +from loopx.control_plane.turn_driver.host_candidate import complete_host_input +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.test_complete_turn_decision import _complete_digest +from tests.control_plane.test_replan_host_context_projection import _quota_payload + + +def _json(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + + +@pytest.mark.parametrize("native", [False, True]) +def test_checked_complete_progressive_projection_keeps_sparse_numeric_source(tmp_path, native): + from tests.control_plane.test_progressive_native_context import payload, outcome + + source = _quota_payload() + source["planning_horizon"] = payload()["planning_horizon"] + source["observations"] = [0.5, 1.0, -0.0, 2**60 + 1, 1e-7] + contract = turn.compose_turn_decision(source) + before = _json(contract) + reference = {"path": str(tmp_path / "original.json"), "sha256": "a" * 64} + if native: + result = turn.prepare_context_delivery(contract, scope={"goal_id": "fixture", "agent_id": "reader", + "session_id": "native", "context_epoch": "epoch"}, capture_id="original", + surface="complete_turn", native_projection=reference, include_initial_read_plan=True) + assert result["delivery"]["context"] == result["context"] + assert "initial_read_plan" in result and "read_sections" in result + else: + result = turn.prepare_progressive_context(contract, surface="complete_turn", source_reference=reference) + assert result["projected"] is True + required = result["context"] + assert "sources" not in outcome(required) + assert outcome(required)["requirements"][0]["description"] == outcome(source)["requirements"][0]["description"] + assert _json(required["observations"]) == _json(source["observations"]) + assert _json(contract) == before + turn.assert_complete_turn_decision(contract) + + +def test_prior_complete_numeric_binding_needs_its_pinned_reader(): + source = _quota_payload() + source["observations"] = [0.5, 1.0, -0.0, 2**60 + 1, 1e-7] + contract = turn.compose_turn_decision(source) + assert contract["contract_binding"]["hash_codec"] == "context_source_numbers_v2" + legacy = deepcopy(contract) + binding = legacy["contract_binding"] + binding["hash_codec"] = "context_source_numbers_v1" + binding.pop("content_hash") + original_source = {key: value for key, value in legacy.items() + if key not in {"schema_version", "contract_binding"}} + binding["source_decision_hash"] = _complete_digest(original_source, include_fractional_tokens=True) + binding["content_hash"] = _complete_digest(legacy, include_fractional_tokens=True) + before = _json(legacy) + envelope = turn.build_turn_envelope(contract) + # This is a correctly signed earlier experimental binding, not corruption + # that happens to reject after reinterpretation under the new hash rules. + for read in ( + lambda: turn.assert_complete_turn_decision(legacy), + lambda: turn.compose_turn_decision(legacy), + lambda: turn.build_turn_envelope(legacy), + lambda: complete_host_input({"work_contract": legacy, "turn_envelope": envelope}), + lambda: turn._turn_envelope_result("driver_lineage", payload=legacy, envelope=envelope), + lambda: turn._turn_envelope_result("replan_view", payload=legacy, + source_hash=binding["content_hash"], stage="review"), + lambda: turn.bind_turn_decision_capture(legacy, envelope, + captured_decision_path="/capture/original.json", turn_instance_id="original-turn", + turn_key="sha256:" + "a" * 64), + ): + with pytest.raises(RuntimeError, match="unsupported complete Turn decision hash codec"): + read() + assert _json(legacy) == before + turn.assert_complete_turn_decision(contract) + + +def test_complete_numeric_observations_keep_full_transport_cost_proportional(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + original_rpc, costs = turn.effect_runtime_projection_result, [] + + def measured_rpc(method, params, **kwargs): + result = original_rpc(method, params, **kwargs) + costs.append(len(_json(params).encode()) + len(_json(result).encode())) + return result + + monkeypatch.setattr(turn, "effect_runtime_projection_result", measured_rpc) + totals, source_sizes = [], [] + template = _quota_payload() + for fraction in (False, True): + costs.clear() + before = deepcopy(template) + before["observations"] = [i + 0.5 if fraction else i for i in range(1000)] + after = deepcopy(before) + after["observations"][-1] += 1 + first, current = turn.compose_turn_decision(before), turn.compose_turn_decision(after) + envelope = turn.build_turn_envelope(current) + turn.assert_complete_turn_decision(current) + model_input = complete_host_input({"work_contract": current, "turn_envelope": envelope}) + assert model_input is not None and _json(model_input["work_contract"]) == _json(current) + assert _json(current["observations"]) == _json(after["observations"]) + scope = dict(goal_id="fixture", agent_id="reader", session_id="native", context_epoch="epoch") + cold = turn.prepare_context_delivery(first, scope=scope, capture_id="before") + full = turn.prepare_context_delivery(current, scope=scope, capture_id="after") + delta = turn.prepare_context_delivery(current, scope=scope, capture_id="after", baseline=cold, + continuity={"basis": "captured_file", "capture_bytes_verified": True, "target": cold["target"]}) + assert delta["mode"] == "delta" + for baseline in (cold, None): + restored = turn.restore_context_delivery(delta, baseline=baseline, current=full) + assert _json(restored["context"]) == _json(current) + turn.assert_complete_turn_decision(restored["context"]) + assert len(costs) == 12 + totals.append(sum(costs)) + source_sizes.append(len(_json(before).encode()) + len(_json(after).encode())) + # The matched source differs only in numeric representation. Count both + # RPC directions through composition, Host input, cold/delta and recovery; + # larger fractional spellings do not justify per-value provenance growth. + assert totals[1] / totals[0] <= 1.25 * source_sizes[1] / source_sizes[0] diff --git a/tests/control_plane/test_complete_session_binding.py b/tests/control_plane/test_complete_session_binding.py new file mode 100644 index 0000000000..dce0395e30 --- /dev/null +++ b/tests/control_plane/test_complete_session_binding.py @@ -0,0 +1,131 @@ +"""Complete-source session lookup uses the existing identity and session owners.""" +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from loopx.control_plane.turn_driver import codex_sessions +from loopx.control_plane.turn_driver.driver import turn_host_lineage +from tests.control_plane.test_turn_host_decision_capture import ( + complete_host_request as complete_host_request, +) + + +@pytest.mark.parametrize("host", ["codex-cli", "dsh"]) +@pytest.mark.parametrize("scope", ["todo", "agent"]) +def test_source_only_lookup_reuses_the_original_native_session( + complete_host_request, tmp_path, host, scope, monkeypatch, +): + request, contract = complete_host_request + runtime = tmp_path / "session-runtime" + lineage = turn_host_lineage(request) + codex_sessions._store_codex_cli_session(runtime, lineage=lineage, session_scope=scope, + host=host, session_id="original-session") + expected = {"schema_version": "loopx_turn_session_binding_v0", **lineage} + assert codex_sessions.codex_cli_session_binding(runtime, request["turn_envelope"], + work_contract=contract, session_scope=scope, host=host) == expected + before = {p: p.read_bytes() for p in runtime.rglob("*.json")} + from loopx.control_plane.quota import turn_envelope + invoke = turn_envelope._turn_envelope_result + observed = [] + + def record(operation, **kwargs): + observed.append((operation, kwargs)) + return invoke(operation, **kwargs) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", record) + for _ in range(2): + assert codex_sessions.codex_cli_session_binding(runtime, work_contract=contract, + session_scope=scope, host=host) == expected + assert len(observed) == 2 + assert all(operation == "complete_host_lineage" and set(options) == {"payload"} + for operation, options in observed) + assert codex_sessions.select_codex_cli_session(runtime, lineage=lineage, + session_scope=scope, host=host)["session_id"] == "original-session" + assert {p: p.read_bytes() for p in before} == before + + +@pytest.mark.parametrize("replacement", [None, False, {}, "invalid", "corrupted"]) +def test_source_only_lookup_refuses_bad_source_without_a_legacy_fallback( + complete_host_request, tmp_path, replacement, +): + request, contract = complete_host_request + runtime = tmp_path / "session-runtime" + lineage = turn_host_lineage(request) + codex_sessions._store_codex_cli_session(runtime, lineage=lineage, + session_id="original-session") + damaged = deepcopy(contract) + damaged["reason"] = "changed after admission" + value = damaged if replacement == "corrupted" else replacement + before = {p: p.read_bytes() for p in runtime.rglob("*.json")} + with pytest.raises((ValueError, RuntimeError, TypeError)): + codex_sessions.codex_cli_session_binding(runtime, work_contract=value) + assert {p: p.read_bytes() for p in before} == before + # The existing historical API and its exact session namespace still work. + assert codex_sessions.codex_cli_session_binding(runtime, + request["turn_envelope"])["todo_id"] == lineage["todo_id"] + + +@pytest.mark.parametrize("adapter", [None, {}, False, "wrong-goal"]) +def test_explicit_adapter_still_has_to_match_the_complete_source(complete_host_request, tmp_path, adapter): + request, contract = complete_host_request + invalid = deepcopy(request["turn_envelope"]) + invalid["goal_id"] = "other-goal" + if adapter != "wrong-goal": + invalid = adapter + before = json.dumps(request, ensure_ascii=False, sort_keys=True) + with pytest.raises((ValueError, RuntimeError, TypeError)): + codex_sessions.codex_cli_session_binding(tmp_path, invalid, work_contract=contract) + assert json.dumps(request, ensure_ascii=False, sort_keys=True) == before + + +def test_real_cli_session_lookup_uses_only_current_complete_source( + complete_host_request, monkeypatch, capsys, +): + from loopx.cli import main + from loopx.control_plane import turn_driver + from loopx.control_plane.turn_driver import codex_cli + + request, contract = complete_host_request + runtime = Path(contract["runtime_root"]) + lineage = turn_host_lineage(request) + codex_sessions._store_codex_cli_session(runtime, lineage=lineage, + session_id="original-session", goal_ref=request.get("goal_ref")) + before = {} + for path in runtime.rglob("*.json"): + value = json.loads(path.read_text()) + if isinstance(value, dict) and value.get("schema_version") == "loopx_codex_cli_session_v1": + before[path] = path.read_bytes() + assert len(before) == 1 + registry = json.loads(Path(contract["registry"]).read_text()) + goal = next(row for row in registry["goals"] if row.get("id", row.get("goal_id")) == lineage["goal_id"]) + # Resolve the lazy export before patching its owner, so later recovery + # consumers cannot retain this test's transport probe on first import. + original = turn_driver.codex_cli_session_binding + assert original is codex_cli.codex_cli_session_binding + observed = [] + bindings = [] + + def lookup(*args, **kwargs): + observed.append((args, kwargs)) + assert len(args) == 1 and "turn_envelope" not in kwargs + binding = original(*args, **kwargs) + bindings.append(binding) + return binding + + monkeypatch.setattr(codex_cli, "codex_cli_session_binding", lookup) + code = main(["--registry", contract["registry"], "--runtime-root", str(runtime), + "--format", "json", "turn", "run-once", "--goal-id", lineage["goal_id"], + "--agent-id", lineage["agent_id"], "--host", "codex-cli", + "--project", goal["repo"], + "--session-scope", "todo", + "--execution-mode", "isolated-headless", "--iteration-context", "resume"]) + result = json.loads(capsys.readouterr().out) + assert code == 0 and result["ok"], result + assert turn_driver.codex_cli_session_binding is original + assert len(observed) == 1 + assert observed[0][1]["work_contract"]["goal_id"] == lineage["goal_id"] + assert bindings == [{"schema_version": "loopx_turn_session_binding_v0", **lineage}] + assert result["dry_run"] and result["status"] == "preview" + assert {p: p.read_bytes() for p in before} == before diff --git a/tests/control_plane/test_complete_turn_decision.py b/tests/control_plane/test_complete_turn_decision.py new file mode 100644 index 0000000000..28af5dc618 --- /dev/null +++ b/tests/control_plane/test_complete_turn_decision.py @@ -0,0 +1,903 @@ +"""Candidate complete source through actual producers; no model is invoked.""" +import copy +from hashlib import sha256 +import json +import subprocess +import sys + +import pytest + +from loopx.control_plane.quota.turn_envelope import ( + _turn_envelope_result, compose_turn_decision, +) +from loopx.control_plane.turn_driver.codex_cli import _prompt +from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request +from tests.control_plane.test_quota_settlement_cli import ( + _run_cli, _spend_run_count, GOAL_ID, AGENT_ID, +) +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + +def _valid_interaction(): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + return _quota_payload()["interaction_contract"] + + +def _canonical_digest(value): + # Negative fixtures need the existing JS hash codec, independently of the + # production validator. Source-preserving IO now legitimately retains 1.0; + # hashing Python's spelling would reject the fixture before its semantic + # fault is reached. Native JSON.stringify supplies the number codec only. + canonical = subprocess.check_output(["node", "-e", + "let s=''; process.stdin.setEncoding('utf8'); process.stdin.on('data',x=>s+=x); " + "process.stdin.on('end',()=>process.stdout.write(JSON.stringify(JSON.parse(s))));"], + input=json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()) + return "sha256:" + sha256(canonical).hexdigest() + + +def _resign_contract(contract): + source = {key: value for key, value in contract.items() + if key not in {"schema_version", "contract_binding"}} + contract["contract_binding"].pop("content_hash") + contract["contract_binding"]["source_decision_hash"] = _complete_digest(source) + contract["contract_binding"]["content_hash"] = _complete_digest(contract) + return contract, source + + +def _complete_digest(value, *, include_fractional_tokens=False): + # Independent negative-fixture oracle: canonical JS values plus the + # caller's sparse original numeric tokens, sorted by their encoded paths. + tokens = [] + + def visit(item, path): + if (isinstance(item, float) and (include_fractional_tokens or item.is_integer()) + or type(item) is int and abs(item) > 2**53 - 1): + tokens.append([path, json.dumps(item, allow_nan=False)]) + elif isinstance(item, dict): + for key, child in item.items(): + visit(child, [*path, key]) + elif isinstance(item, list): + for index, child in enumerate(item): + visit(child, [*path, index]) + + visit(value, []) + tokens.sort(key=lambda entry: json.dumps(entry[0], ensure_ascii=False, separators=(",", ":"))) + return _canonical_digest([value, tokens]) + + +def _source_digest(value): + binding = value.get("contract_binding", {}) + if value.get("schema_version") == "loopx_turn_envelope_v1" and isinstance(binding.get("content_hash"), str): + return binding["content_hash"] + return _canonical_digest(value) + + +@pytest.mark.parametrize("original,changed", [(1, 1.0), (1.0, 1), + (2**60, 2**60 + 1), (-0.0, 0), (0, -0.0)]) +def test_complete_numeric_integrity_rejects_changes_across_consumers(original, changed): + from loopx.control_plane.quota.turn_envelope import ( + assert_complete_turn_decision, build_turn_envelope, bind_turn_decision_capture, + ) + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + source["future_evidence"] = {"measurement": original} + contract = compose_turn_decision(source) + before = copy.deepcopy(contract) + assert_complete_turn_decision(contract) + envelope = build_turn_envelope(contract) + assert compose_turn_decision(contract) == contract + tampered = copy.deepcopy(contract) + tampered["future_evidence"]["measurement"] = changed + for read in ( + lambda: assert_complete_turn_decision(tampered), + lambda: compose_turn_decision(tampered), + lambda: build_turn_envelope(tampered), + lambda: _turn_envelope_result("host_context", payload=tampered, envelope=envelope), + lambda: _turn_envelope_result("driver_lineage", payload=tampered, envelope=envelope), + lambda: _turn_envelope_result("replan_view", payload=tampered, + source_hash=_source_digest(tampered), stage="review"), + lambda: bind_turn_decision_capture(tampered, envelope, captured_decision_path="/capture/original.json", + turn_instance_id="original-turn", turn_key="sha256:" + "a" * 64), + ): + with pytest.raises(RuntimeError, match="integrity mismatch"): + read() + assert contract == before + + +@pytest.mark.parametrize("codec", [None, "future_source_codec"]) +def test_complete_readers_refuse_an_unsupported_numeric_codec_without_fallback(codec): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + contract = compose_turn_decision(_quota_payload()) + contract["contract_binding"]["hash_codec"] = codec + contract, _ = _resign_contract(contract) + with pytest.raises(RuntimeError, match="unsupported complete Turn decision hash codec"): + _turn_envelope_result("complete_consumer", payload=contract) + + +def test_complete_reader_requires_original_numeric_transport_before_validation(): + from loopx.control_plane.effect_runtime import effect_runtime_result + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + source["future_measurement"] = {"value": 1.0} + contract = compose_turn_decision(source) + assert _turn_envelope_result("complete_consumer", payload=contract) == {"ok": True} + # A transport that parsed away the original token cannot certify this + # contract. Reject it; do not reinterpret the source or admit more work. + with pytest.raises(RuntimeError, match="integrity mismatch"): + effect_runtime_result("quota.turn_envelope.evaluate", + {"operation": "complete_consumer", "payload": contract}) + + +def test_retired_complete_echo_cannot_recreate_a_second_source_view(): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + contract = compose_turn_decision(_quota_payload()) + with pytest.raises(RuntimeError, match="turn envelope operation is unsupported"): + _turn_envelope_result("validate_complete", payload=contract, envelope=build_turn_envelope(contract)) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_replan_reader_rejects_numeric_mutation_and_restores_original_source(tmp_path, monkeypatch, provider): + from tests.control_plane.test_replan_successor_guard_reentry import _fixture, GOAL, AGENT, TURN + + call, runtime, _ = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) + guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL, + "--agent-id", AGENT, "--turn-instance-id", TURN) + source = {key: value for key, value in guard.items() if key not in {"schema_version", "contract_binding"}} + source["future_measurement"] = {"value": 1.0} + contract = compose_turn_decision(source) + capture = tmp_path / "numeric-source.json" + original = json.dumps(contract, ensure_ascii=False, allow_nan=False) + capture.write_text(original) + args = ("quota", "replan-view", "--decision-file", str(capture), + "--source-hash", contract["contract_binding"]["content_hash"], "--replan-stage", "review") + before = call(*args) + contract["future_measurement"]["value"] = 1 + capture.write_text(json.dumps(contract, ensure_ascii=False, allow_nan=False)) + refused = call(*args, expected_code=1) + assert "integrity mismatch" in json.dumps(refused) + capture.write_text(original) + assert call(*args) == before + assert _spend_run_count(runtime) == 0 + + +def _future_producer_contract(contract): + contract = copy.deepcopy(contract) + contract["effective_action"] = "future_execution" + return _resign_contract(contract) + + +@pytest.mark.parametrize("mutation, error", [ + ("version", "schema_version"), + ("boolean", "must_attempt"), + ("notification", "notify"), + ("contradiction", "quiet"), + ("read_command", "required read command"), + ("read_order", "required read ordering"), +]) +def test_complete_readers_reject_invalid_interaction_with_valid_integrity(mutation, error): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + interaction = source["interaction_contract"] + if mutation == "version": + interaction["schema_version"] = "future_interaction_version" + elif mutation == "boolean": + interaction["agent_channel"]["must_attempt"] = "true" + elif mutation == "notification": + interaction["user_channel"]["notify"] = "FUTURE_NOTIFY" + elif mutation == "contradiction": + interaction["agent_channel"]["quiet_noop_allowed"] = True + else: + read = {"command": "cat -- 'required material.md'", "ordering": "before_work"} + if mutation == "read_command": + read.pop("command") + else: + read["ordering"] = "after_spend" + interaction["agent_channel"]["required_reads"] = [read] + # Obtain a valid current contract, then emulate a producer with different + # required semantics and correct integrity hashes. Every reader must refuse. + contract = compose_turn_decision(_quota_payload()) + contract["interaction_contract"] = interaction + contract, _ = _resign_contract(contract) + before = copy.deepcopy(source) + for read in ( + lambda: compose_turn_decision(source), + lambda: compose_turn_decision(contract), + lambda: build_turn_envelope(contract), + lambda: _turn_envelope_result("replan_view", payload=contract, + source_hash=_source_digest(contract), stage="review"), + ): + with pytest.raises(RuntimeError, match=error): + read() + assert source == before + + +def test_current_required_reads_preserve_pending_commands_and_historical_decoding(): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + command = "cat -- 'original material / 原文.md'" + agent = source["interaction_contract"]["agent_channel"] + # No fabricated read receipt: a valid unread obligation remains pending, + # including a historical read that omitted its ordering coordinate. + agent["required_reads"] = [{"command": command, "reason": "Read before dependent work."}] + before = copy.deepcopy(source) + contract = compose_turn_decision(source) + assert contract["interaction_contract"]["agent_channel"]["required_reads"] == agent["required_reads"] + assert source == before + # Explicit old-version decoding is retained. It cannot make the same + # unsupported instruction a valid new complete source by fallback. + agent["required_reads"][0]["ordering"] = "after_spend" + historical = build_turn_envelope(source) + assert historical["required_reads"][0]["ordering"] == "after_spend" + with pytest.raises(RuntimeError, match="required read ordering"): + compose_turn_decision(source) + + +def test_complete_source_rejects_tampering_and_binding_substitution(): + source = {"ok": True, "goal_id": "fixture", "agent_identity": {"agent_id": "worker"}, + "effective_action": "normal_run", + "interaction_contract": {**_valid_interaction(), "future_optional": {"value": None}}, "future_evidence": {"original": "unchanged"}} + before = copy.deepcopy(source) + result = compose_turn_decision(source) + assert {k: v for k, v in result.items() if k not in {"schema_version", "contract_binding"}} == before + assert source == before + assert compose_turn_decision(result) == result + tampered = copy.deepcopy(result) + tampered["future_evidence"]["original"] = "changed" + with pytest.raises(RuntimeError, match="integrity mismatch"): + compose_turn_decision(tampered) + with pytest.raises(RuntimeError, match="host binding"): + _turn_envelope_result("compose_complete", payload=result, scheduler_execution_args="different") + with pytest.raises(RuntimeError, match="reserved"): + compose_turn_decision({**source, "schema_version": "unknown"}) + + +@pytest.mark.parametrize("reference", [None, False, [], "$.user_todo_summary.first_executable_items", + "$.agent_todo_summary.missing", "$.agent_todo_summary.backlog_items"]) +def test_complete_readers_refuse_invalid_diagnostic_references_with_valid_hashes(reference): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + lane = [{"todo_id": "task", "text": "Preserve the complete requirement. " * 80}] + source["agent_todo_summary"] = {"schema_version": "todo_summary_v0", + "first_executable_items": lane, "backlog_items": copy.deepcopy(lane)} + contract = compose_turn_decision(source) + assert contract["agent_todo_summary"]["backlog_items_ref"] == "$.agent_todo_summary.first_executable_items" + contract["agent_todo_summary"]["backlog_items_ref"] = reference + contract, _ = _resign_contract(contract) + for read in (lambda: compose_turn_decision(contract), lambda: build_turn_envelope(contract), + lambda: _turn_envelope_result("replan_view", payload=contract, + source_hash=_source_digest(contract), stage="review")): + with pytest.raises(RuntimeError, match="summary lane reference"): + read() + + +@pytest.mark.parametrize("case", ["historical", "complete", "future_adapter", + "tampered_source", "forged_adapter", "null_contract"]) +def test_plan_uses_one_complete_source_check_without_weakening_refusal(monkeypatch, case): + from loopx.control_plane.quota import turn_envelope as transport + from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, turn_envelope_action_signature_document, + ) + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from tests.test_turn_envelope import _full_decision + + source = _full_decision() + contract = compose_turn_decision(source) + envelope = build_turn_envelope(source if case == "historical" else contract) + error = None + if case == "future_adapter": + envelope["schema_version"] = "future_envelope" + error = "unsupported TurnEnvelope schema_version" + elif case == "tampered_source": + contract["reason"] = "different original facts" + error = "integrity mismatch" + elif case == "forged_adapter": + envelope["action"]["primary_action"] = "Substitute a different action." + digest = _canonical_digest(turn_envelope_action_signature_document(envelope)) + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + error = "executor projection differs" + elif case == "null_contract": + contract = None + error = "complete Turn decision" + original = copy.deepcopy((envelope, contract)) + calls = [] + invoke = transport.effect_runtime_projection_result + + def observed(method, params, **kwargs): + if method == "quota.turn_envelope.evaluate": + calls.append(params["operation"]) + return invoke(method, params, **kwargs) + + monkeypatch.setattr(transport, "effect_runtime_projection_result", observed) + + def plan(): + return build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", + turn_instance_id="original-turn", + **({} if case == "historical" else {"work_contract": contract})) + + if error: + with pytest.raises(RuntimeError, match=error): + plan() + else: + result = plan() + assert result["route"]["would_invoke_host"] is True + assert result["transaction"]["turn_instance_id"] == "original-turn" + assert calls == (["consumer_version", "driver_lineage"] if case == "historical" + else ["planning_facts"]) + assert (envelope, contract) == original + + +@pytest.mark.parametrize("transfer", ["host_request", "published_capture"]) +@pytest.mark.parametrize("case", ["historical", "complete", "null", "false", "zero", + "list", "text", "empty_object", "invalid_hash"]) +def test_host_transports_preserve_complete_contract_presence(tmp_path, transfer, case): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.turn_driver.codex_cli import prepare_codex_cli_input + from loopx.control_plane.turn_driver.decision_capture import stage_turn_decision_capture + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + from loopx.control_plane.turn_driver.host_failure import BuiltInHostError + from loopx.dsh_goal_mode.turn_host_adapter import DshHostConfig, run_dsh_host + from tests.test_turn_envelope import _full_decision + + source = _full_decision() + goal = tmp_path / "goal.md" + goal.write_text("# Goal\nPreserve the requested artifact, evidence and original stop.\n") + goal_text = goal.read_text() + agent = source["interaction_contract"]["agent_channel"] + goal_read = next(read for read in agent["required_reads"] if read.get("source") == "goal_state") + agent["work_context"] = {"sources": [{**goal_read, "command": f"cat -- {goal}", + "content": {"full_source": True, "state_file": str(goal), "text": goal_text, + "revision": "sha256:" + sha256(goal_text.encode()).hexdigest()}}]} + agent["required_reads"] = [read for read in agent["required_reads"] if read is not goal_read] + contract = compose_turn_decision(source) + historical = case == "historical" + observation = source if historical else contract + plan = build_loopx_turn_plan(build_turn_envelope(observation), host="codex-cli", + execution_mode="isolated-headless", turn_instance_id="original-turn", + **({} if historical else {"work_contract": contract})) + malformed = {"null": None, "false": False, "zero": 0, "list": [], + "text": "unreadable-contract", "empty_object": {}, + "invalid_hash": {**contract, "reason": "different original facts"}} + original = copy.deepcopy(plan) + if transfer == "published_capture": + # The real publication seam may receive a mutable staged plan. It must + # transport a supplied bad value to the same reader, not reinterpret it + # as an old journal; the immutable original capture remains unchanged. + request = build_loopx_turn_host_request(plan) + staged = stage_turn_decision_capture(plan, journal_path=tmp_path / "turn" / "journal.json", + decision=observation, existing_journal=False) + if case in malformed: + staged.plan["work_contract"] = malformed[case] + _, request = staged.publish_for_host(request) + assert json.loads(staged.path.read_text()) == observation + else: + request = build_loopx_turn_host_request(plan) + if case in malformed: + # The current sender checks its source too. Also exercise receiver + # rejection when a valid transport is modified after construction. + with pytest.raises(RuntimeError, match="complete Turn decision"): + build_loopx_turn_host_request({**plan, "work_contract": malformed[case]}) + request["work_contract"] = malformed[case] + + if case in malformed: + with pytest.raises(RuntimeError, match="complete Turn decision"): + prepare_codex_cli_input(request) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_authority(request) + with pytest.raises(BuiltInHostError, match="dsh_turn_authority_rejected"): + run_dsh_host(request, config=DshHostConfig(workspace=tmp_path, + dsh_runner=tmp_path / "must-not-load.py")) + assert request["work_contract"] == malformed[case] + assert not (tmp_path / ".dsh").exists() + else: + prompt, schema = prepare_codex_cli_input(request) + model_input = json.loads(prompt.split("Turn request:\n", 1)[1]) + assert schema and extract_turn_authority(request) + assert ("work_contract" in request) is not historical + assert ("work_contract" in model_input) is not historical + if not historical: + assert model_input["work_contract"] == contract + assert plan == original + + +@pytest.mark.parametrize("historical_adapter", [False, True]) +def test_pending_goal_capture_remains_readable_but_cannot_dispatch_business_work(historical_adapter): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.turn_driver.codex_cli import prepare_codex_cli_input + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, execution_profile_present + from tests.test_turn_envelope import _full_decision + + source = _full_decision() + source["interaction_contract"]["agent_channel"]["required_reads"] = [ + {"source": "goal_state", "command": "cat -- original-goal.md", "ordering": "before_work"}] + contract = compose_turn_decision(source) + before = copy.deepcopy(contract) + plan = build_loopx_turn_plan( + build_turn_envelope(contract) if historical_adapter else contract, + **({"work_contract": contract} if historical_adapter else {}), + host="dsh", execution_mode="isolated-headless", turn_instance_id="original-turn") + request = build_loopx_turn_host_request(plan) + assert isinstance(execution_profile_present(request), bool) + with pytest.raises(RuntimeError, match="Goal source"): + prepare_codex_cli_input(request) + with pytest.raises(ValueError, match="complete Turn contract rejected") as rejected: + extract_turn_authority(request) + assert "Goal source" in str(rejected.value.__cause__) + assert contract == before + + +@pytest.mark.parametrize("action", ["future_execution", "", None, 0]) +def test_complete_source_refuses_unknown_required_action_before_capture(action): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + source["effective_action"] = action + original = copy.deepcopy(source) + with pytest.raises(RuntimeError, match="effective_action"): + compose_turn_decision(source) + assert source == original + + +def test_complete_readers_refuse_unknown_action_even_with_valid_integrity(): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + contract, _ = _future_producer_contract(compose_turn_decision(_quota_payload())) + # A future producer can supply perfectly valid hashes for a semantic value + # this consumer cannot execute. Integrity alone must not admit that action. + with pytest.raises(RuntimeError, match="effective_action"): + compose_turn_decision(contract) + with pytest.raises(RuntimeError, match="effective_action"): + build_turn_envelope(contract) + with pytest.raises(RuntimeError, match="effective_action"): + _turn_envelope_result("replan_view", payload=contract, + source_hash=_source_digest(contract), stage="review") + + +def test_complete_action_validation_preserves_registered_union_and_optional_facts(): + from loopx.control_plane.quota.effective_action import EffectiveAction + from loopx.control_plane.agents.agent_scope_frontier import AgentScopeFrontierAction + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + for action in {*EffectiveAction, *AgentScopeFrontierAction}: + source = _quota_payload() + source["effective_action"] = action.value + source["future_evidence"] = {"schema_version": "future_optional_evidence", + "effective_action": "uninterpreted evidence", "result": None} + contract = compose_turn_decision(source) + assert contract["effective_action"] == action.value + assert contract["future_evidence"] == source["future_evidence"] + assert compose_turn_decision(contract) == contract + + +@pytest.mark.parametrize("mutation", ["version", "content", "binding"]) +def test_replan_reader_refuses_invalid_complete_contract_even_with_matching_file_hash(mutation): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + contract = compose_turn_decision(_quota_payload()) + if mutation == "version": + contract["schema_version"] = "unsupported-future-version" + elif mutation == "content": + contract["replan_action_packet"]["planning_guidance"] = ["Altered obligation"] + else: + contract.pop("contract_binding") + # A caller can truthfully hash the bytes it read without proving that their + # version or inner source binding is valid. Both checks are required. + digest = _source_digest(contract) + with pytest.raises(RuntimeError, match="version|integrity|binding"): + _turn_envelope_result("replan_view", payload=contract, source_hash=digest, stage="review") + + +@pytest.mark.parametrize("successor", [False, True]) +def test_complete_replan_carries_original_turn_successor_binding_before_capture(successor): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + plan = {"identity": {"turn_instance_id": "original-turn"}, + "goal_ref": {"goal_instance_id": "original-goal-instance"}} + source["interaction_contract"]["cli_channel"]["settlement_plan"] = plan + if successor: + source["replan_action_packet"]["writeback_contract"] = { + "successor_command": "loopx todo add --goal-id fixture", + "successor_host_action": "end_current_heartbeat"} + before = copy.deepcopy(source) + contract = compose_turn_decision(source) + writer = contract["replan_action_packet"]["writeback_contract"] + assert writer["successor_binding_args"] == ( + "--turn-instance-id original-turn --goal-instance-id original-goal-instance") + assert "original Turn settlement plan before ending" in writer["successor_settlement"] + assert "do not run the successor" in writer["successor_settlement"] + assert "successor_host_action" not in writer + assert contract["interaction_contract"]["cli_channel"]["settlement_plan"] == plan + if successor: + assert writer["successor_command"].endswith(writer["successor_binding_args"]) + assert contract["interaction_contract"]["cli_channel"]["next_cli_actions"] == [writer["successor_command"]] + assert source == before + assert compose_turn_decision(contract) == contract + + +@pytest.mark.parametrize("inactive", ["settlement_only", "capability_intent", "different_context", "unbound"]) +def test_complete_composition_preserves_inactive_replan_authoring(inactive): + from tests.control_plane.test_replan_host_context_projection import _quota_payload + + source = _quota_payload() + source["interaction_contract"]["cli_channel"]["settlement_plan"] = { + "identity": {"turn_instance_id": "original-turn"}} + source["replan_action_packet"]["writeback_contract"] = { + "successor_command": "loopx todo add --goal-id fixture", + "successor_host_action": "end_current_heartbeat"} + if inactive == "settlement_only": + source["replan_action_packet"]["settlement_only"] = True + elif inactive == "capability_intent": + source["effective_action"] = "governed_capability_intent" + elif inactive == "different_context": + source["autonomous_replan_obligation"]["replan_context"]["obligation_id"] = "different" + else: + source["interaction_contract"]["cli_channel"].pop("settlement_plan") + contract = compose_turn_decision(source) + assert {k: v for k, v in contract.items() if k not in {"schema_version", "contract_binding"}} == source + + + + + + + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("first_attempt_fails", [False, True]) +def test_real_turn_preview_reaches_complete_codex_input(tmp_path, monkeypatch, provider, first_attempt_fails): + from tests.control_plane.test_turn_host_decision_capture import _fixture_module + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from loopx.control_plane.todos.active_state_todo_parser import parse_active_state_todos + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + isolate_sqlite_runtime(tmp_path, monkeypatch) + from loopx.cli_commands import turn_run_once + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + + prepare_request = turn_run_once.prepare_turn_host_request + + class SignatureOnlyEnvelope(dict): + def get(self, key, default=None): + if key in {"action", "task_orchestration_contract", "writeback", + "effective_action", "replan_action_packet"}: + raise AssertionError("complete execution read the historical adapter: " + key) + return super().get(key, default) + + def require_complete_execution(*args, **kwargs): + project, argv, validator, envelope = prepare_request(*args, **kwargs) + # Keep the real adapter for source/signature verification, but forbid + # CLI execution policy from reading its obsolete field layout. The + # persisted plan, native host, provider and settlement remain real. + if "work_contract" in args[1]: + envelope = SignatureOnlyEnvelope(envelope) + return project, argv, validator, envelope + + monkeypatch.setattr(turn_run_once, "prepare_turn_host_request", require_complete_execution) + f = _fixture_module() + project, runtime, workspace, registry = f._write_fixture(tmp_path, turn_count=2) + state = project / ".codex/goals" / f.GOAL_ID / "ACTIVE_GOAL_STATE.md" + summary = parse_active_state_todos(state.read_text(), state_path=state, item_limit=None) + initialize_canonical_authority(runtime, f.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=f.GOAL_ID, handoff_mode="soft_claim", + todos=summary["agent_todos"]["items"]), state_path=state, provider=provider) + # Add acceptance through the real authority API, so native execution and + # failed-turn recovery exercise sharing rather than a hand-built contract. + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + document = tmp_path / "acceptance.json" + document.write_text(json.dumps({"outcome_id": "complete-input", "owner_agent_id": f.AGENT_ID, + "objective": "Deliver the complete requested artifact", "todo_ids": [f.TODO_ID], + "requirements": [{"id": "complete", "description": "Preserve the original content and tail constraint."}], + "source": {"ref": "message:owner", "summary": "Original artifact requirement"}})) + code, inspected = f._run_cli([*prefix, "goal-acceptance", "inspect", "--goal-id", f.GOAL_ID]) + assert code == 0, inspected + code, refined = f._run_cli([*prefix, "goal-acceptance", "refine", "--goal-id", f.GOAL_ID, + "--agent-id", f.AGENT_ID, "--document", str(document), "--expected-provider-revision", + inspected["provider_revision"], "--operation-id", "original-acceptance", "--execute"]) + assert code == 0, refined + code, inspected = f._run_cli([*prefix, "goal-acceptance", "inspect", "--goal-id", f.GOAL_ID]) + assert code == 0, inspected + expected_acceptance = inspected["goal_acceptance_contract"] + args = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "turn", "plan", "--goal-id", f.GOAL_ID, "--agent-id", f.AGENT_ID, + "--turn-instance-id", "complete-source", "--host", "codex-cli", + "--execution-mode", "isolated-headless", "--include-transaction-detail"] + code, plan = f._run_cli(args) + assert code == 0, plan + assert "work_contract" in plan, json.dumps(plan) + contract = plan["work_contract"] + assert contract["schema_version"] == "loopx_turn_envelope_v1" + request = build_loopx_turn_host_request(plan) + prompt = _prompt(request) + actual = json.loads(prompt.split("Turn request:\n", 1)[1]) + assert actual["work_contract"] == contract + assert "turn_envelope" not in actual and "turn_authority" not in actual + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, render_prompt + structured = render_prompt(extract_turn_authority(request)) + structured_input, _ = json.JSONDecoder().raw_decode(structured.split("Turn request:\n", 1)[1]) + assert structured_input == actual + assert "_model_input" not in structured_input + tampered = copy.deepcopy(request) + tampered["work_contract"]["reason"] = "different facts" + with pytest.raises(RuntimeError, match="integrity mismatch"): + _prompt(tampered) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_authority(tampered) + from loopx.dsh_goal_mode.turn_host_adapter import DshHostConfig, run_dsh_host + from loopx.control_plane.turn_driver.host_failure import BuiltInHostError + with pytest.raises(BuiltInHostError, match="dsh_turn_authority_rejected"): + run_dsh_host(tampered, config=DshHostConfig(workspace=workspace, + dsh_runner=tmp_path / "must-not-load.py")) + + assert not (workspace / ".dsh").exists() + assert not list(runtime.rglob("decisions/*.json")) + assert f._quota_spend_count(runtime) == 0 + + # An old journal must not inherit a new admission's complete source. + from loopx.control_plane.turn_driver.decision_capture import prepare_turn_decision_capture + historical = {key: value for key, value in contract.items() + if key not in {"schema_version", "contract_binding"}} + historical_envelope = _turn_envelope_result("build", payload=historical, + scheduler_execution_args=contract["contract_binding"]["scheduler_execution_args"]) + historical_plan = build_loopx_turn_plan(historical_envelope, host="codex-cli", + execution_mode="isolated-headless", turn_instance_id="historical-turn") + historical_journal = tmp_path / "historical" / "journal.json" + prepared = prepare_turn_decision_capture(historical_plan, journal_path=historical_journal, + decision=historical, existing_journal=False) + restored = prepare_turn_decision_capture({**prepared, "work_contract": contract}, + journal_path=historical_journal, decision=None, existing_journal=True) + assert "work_contract" not in restored + assert "work_contract" not in build_loopx_turn_host_request(restored) + + executable = f._write_fake_codex(tmp_path) + script = executable.read_text().replace("#!/usr/bin/env python3", "#!" + sys.executable) + script = script.replace("prompt = sys.stdin.read()", 'prompt = sys.stdin.read()\npathlib.Path("received.txt").write_text(prompt)') + if first_attempt_fails: + script = script.replace("marker = pathlib.Path(", + 'failed = pathlib.Path("failed-once")\n' + 'if not failed.exists():\n' + ' failed.touch()\n' + ' print(json.dumps({"type":"thread.started","thread_id":"session-fixture-0001"}), flush=True)\n' + ' print("Selected model is at capacity. Please try a different model.", file=sys.stderr)\n' + ' raise SystemExit(9)\n' + 'marker = pathlib.Path(') + executable.write_text(script) + run_args = f._base_argv(registry=registry, runtime=runtime, workspace=workspace, + codex_bin=executable, model=None, timeout_seconds=20, + expected_marker=f._marker_value(1), turn_instance_id="complete-execution") + from loopx.cli_commands import turn as native_turn + real_compose = native_turn.compose_turn_decision + def future_action(payload, **kwargs): + return real_compose({**payload, "effective_action": "future_execution"}, **kwargs) + with monkeypatch.context() as patch: + # Fault only the producer's semantic value. Admission, provider, + # composition, CLI refusal and capture/host boundaries remain real. + patch.setattr(native_turn, "compose_turn_decision", future_action) + code, refused = f._run_cli([*run_args, "--execute"]) + assert code == 1 and "effective_action" in json.dumps(refused), refused + assert not (workspace / "received.txt").exists() + assert not list(runtime.rglob("decisions/*.json")) + assert f._quota_spend_count(runtime) == 0 + # Preserve the historical decoder's existing route, but refuse the same + # unknown execution semantics in a validly hashed complete contract before + # either native host can load a runner or produce a captured execution. + future_contract, future_source = _future_producer_contract(contract) + legacy_envelope = _turn_envelope_result("build", payload=future_source, + scheduler_execution_args=contract["contract_binding"]["scheduler_execution_args"]) + legacy_plan = build_loopx_turn_plan(legacy_envelope, host="codex-cli", + execution_mode="isolated-headless", turn_instance_id="future-source") + assert legacy_plan["route"]["would_invoke_host"] is True + with pytest.raises(RuntimeError, match="effective_action"): + build_loopx_turn_host_request({**legacy_plan, "work_contract": future_contract}) + future_request = {**request, "work_contract": future_contract, + "source_decision_hash": future_contract["contract_binding"]["source_decision_hash"]} + with pytest.raises(RuntimeError, match="effective_action"): + _prompt(future_request) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_authority(future_request) + with pytest.raises(BuiltInHostError, match="dsh_turn_authority_rejected"): + run_dsh_host(future_request, config=DshHostConfig(workspace=workspace, + dsh_runner=tmp_path / "must-not-load.py")) + + invalid_interaction = copy.deepcopy(request) + invalid_interaction["work_contract"]["interaction_contract"]["schema_version"] = "future_interaction_version" + invalid_interaction["work_contract"], _ = _resign_contract(invalid_interaction["work_contract"]) + with pytest.raises(RuntimeError, match="interaction_contract.schema_version"): + _prompt(invalid_interaction) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_authority(invalid_interaction) + with pytest.raises(BuiltInHostError, match="dsh_turn_authority_rejected"): + run_dsh_host(invalid_interaction, config=DshHostConfig(workspace=workspace, + dsh_runner=tmp_path / "must-not-load.py")) + assert not (workspace / "received.txt").exists() + assert not (workspace / ".dsh").exists() + assert f._quota_spend_count(runtime) == 0 + + code, executed = f._run_cli([*run_args, "--execute"]) + if first_attempt_fails: + assert code == 1 and executed["status"] == "failed", executed + assert f._quota_spend_count(runtime) == 0 + else: + assert code == 0 and executed["status"] == "committed", executed + observed = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) + assert observed["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" + assert "turn_envelope" not in observed + acceptance_sources = observed["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["sources"] + acceptance_index = next(i for i, row in enumerate(acceptance_sources) if row["source"] == "goal_acceptance") + assert acceptance_sources[acceptance_index]["content"]["goal_acceptance_contract"] == expected_acceptance + assert observed["work_contract"]["agent_todo_summary"]["goal_acceptance_contract_ref"] == ( + f"$.interaction_contract.agent_channel.work_context.sources[{acceptance_index}].content.goal_acceptance_contract") + saved = list(runtime.rglob("decisions/*.json")) + assert len(saved) == 1 + saved_bytes = saved[0].read_bytes() + assert json.loads(saved_bytes) == observed["work_contract"] + journal = json.loads((saved[0].parent.parent / saved[0].name).read_text()) + # Native journal persistence must preserve the captured source's number + # types and precision, not merely Python's permissive 1 == 1.0 equality. + assert json.dumps(journal["plan"]["work_contract"], sort_keys=True) == json.dumps( + observed["work_contract"], sort_keys=True) + replay_args = list(run_args) + index = replay_args.index("--turn-instance-id") + del replay_args[index:index + 2] + if first_attempt_fails: + retry_args = [*replay_args, "--resume-turn-key", observed["turn_key"], "--retry-failed-turn", "--execute"] + received = (workspace / "received.txt").read_bytes() + for corrupt in (True, False): + if corrupt: + saved[0].write_text(json.dumps({**observed["work_contract"], "reason": "tampered source"})) + else: + saved[0].unlink() + code, rejected = f._run_cli(retry_args) + assert code == 1, rejected + assert (workspace / "received.txt").read_bytes() == received + assert f._quota_spend_count(runtime) == 0 + saved[0].write_bytes(saved_bytes) + code, recovered = f._run_cli(retry_args) + assert code == 0 and recovered["status"] == "committed", recovered + restored = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) + assert restored["work_contract"] == observed["work_contract"] + code, replay = f._run_cli([*replay_args, "--resume-turn-key", observed["turn_key"], "--execute"]) + assert code == 0 and replay["replayed"] is True, replay + assert saved[0].read_bytes() == saved_bytes + assert f._quota_spend_count(runtime) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("prior_todo_binding", [False, True]) +def test_native_replan_complete_input_retains_verified_material_readers(tmp_path, monkeypatch, provider, prior_todo_binding): + from tests.control_plane.test_turn_replan_writeback import _fixture + from tests.control_plane.test_quota_settlement_cli import _classification_count, SELECTED_REPLAN_TODO_ID + project, runtime, registry, _state, _prior_turn = _fixture(tmp_path, monkeypatch, provider) + configuration = json.loads(registry.read_text()) + configuration["goals"][0]["explore_graph"] = {"enabled": True} + registry.write_text(json.dumps(configuration)) + turn = _prior_turn if prior_todo_binding else "complete-replan-readers" + executable = tmp_path / "replan-host" + executable.write_text("#!" + sys.executable + ''' +import json, pathlib, shlex, subprocess, sys +print(json.dumps({"type":"thread.started", "thread_id":"session-fixture-replan"}), flush=True) +prompt = sys.stdin.read() +root = pathlib.Path.cwd() +(root / "received.txt").write_text(prompt) +request = json.loads(prompt.split("Turn request:\\n", 1)[1]) +packet = request["detail_ref"]["replan_action_packet"] +for stage in ("review", "writeback"): + read = subprocess.run(shlex.split(packet[stage]), text=True, capture_output=True, check=True) + (root / (stage + ".json")).write_text(read.stdout) +output = pathlib.Path(sys.argv[sys.argv.index("--output-last-message") + 1]) +output.write_text(json.dumps({"schema_version":"loopx_turn_result_v0", "turn_key":request["turn_key"], + "result_kind":"wait", "completed_phases":["host_execute","typed_result"], + "summary":"Original material readers verified; no task outcome claimed."})) +''') + executable.chmod(0o700) + code, result = _run_cli(registry, runtime, "turn", "run-once", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", turn, "--host", "codex-cli", + "--execution-mode", "isolated-headless", "--project", str(project), + "--codex-bin", str(executable), "--timeout-seconds", "20", "--no-global-sync", "--execute", cwd=project) + assert code == 0, json.dumps(result, indent=2) + prompt = (project / "received.txt").read_text() + request = json.loads(prompt.split("Turn request:\n", 1)[1]) + source = request["work_contract"] + assert source["schema_version"] == "loopx_turn_envelope_v1" + assert "turn_envelope" not in request and "turn_authority" not in request + identity = result["receipt"]["lineage"] + if prior_todo_binding: + assert identity["todo_id"] == SELECTED_REPLAN_TODO_ID + assert not identity.get("replan_obligation_id") + else: + assert not identity["todo_id"] + assert identity["replan_obligation_id"] == source["autonomous_replan_obligation"]["obligation_id"] + graph = next(row["content"]["graph"] for row in + source["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row.get("hook_id") == "explore.turn_context") + assert ("result_attachment_template" in graph) is prior_todo_binding + assert ("unavailable for this pure replan" in graph["result_writeback_guidance"]) is not prior_todo_binding + assert "Read measurement_feedback before repeating a measured route" in graph["result_writeback_guidance"] + assert graph["record_finding_template"] and graph["record_node_template"] + context = source["autonomous_replan_obligation"]["replan_context"] + review = json.loads((project / "review.json").read_text()) + writeback = json.loads((project / "writeback.json").read_text()) + assert review["context_id"] == writeback["context_id"] == context["context_id"] + # The native outer controller owns settlement; its model input has no + # inline settlement owner to reference. Preserve all authoring here. + contract = source["replan_action_packet"]["writeback_contract"] + steps = source["interaction_contract"]["cli_channel"].get("settlement_plan", {}).get("ordered_steps", []) + assert not any("vision_authoring" in step for step in steps) + assert "vision_authoring_ref" not in contract + if "vision_authoring" in contract: + assert writeback["writeback_contract"]["vision_authoring"] == { + key: value for key, value in contract["vision_authoring"].items() if key != "minimal_example"} + assert review["source_hash"] == writeback["source_hash"] == request["source_decision_hash"] + assert "Read detail_ref.replan_action_packet.review" in prompt + assert "before reading detail_ref.replan_action_packet.writeback" in prompt + assert "Never re-enter quota admission" in prompt and "Do not execute writeback commands" in prompt + from pathlib import Path + import shlex + import subprocess + captured = Path(request["detail_ref"]["captured_decision"]["path"]) + original = captured.read_bytes() + assert json.loads(original) == source + captured.write_text(json.dumps({**source, "reason": "Changed original facts"})) + rejected = subprocess.run(shlex.split(request["detail_ref"]["replan_action_packet"]["review"]), + capture_output=True, text=True) + assert rejected.returncode != 0 + captured.write_bytes(original) + assert _classification_count(runtime, "quota_slot_spent") == 0 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("prior_todo_binding", [False, True]) +def test_real_structured_host_reads_same_complete_replan_source(tmp_path, monkeypatch, provider, prior_todo_binding): + from tests.control_plane.test_turn_replan_writeback import _fixture, _runner, _run_turn + project, runtime, registry, _state, prior_turn = _fixture(tmp_path, monkeypatch, provider) + runner = _runner(tmp_path, {"result_kind": "wait", "summary": "Original evidence read; no outcome claimed."}) + script = runner.read_text().replace("import json\n", "import json, shlex, subprocess\n") + script = script.replace(" return json.dumps(candidate)", + " candidate.pop('work_evidence', None)\n candidate.pop('validation_evidence', None)\n return json.dumps(candidate)") + script = script.replace(" counter = root / 'host-count.txt'", """ (root / "received.txt").write_text(prompt) + request, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + for stage in ("review", "writeback"): + result = subprocess.run(shlex.split(request["detail_ref"]["replan_action_packet"][stage]), + text=True, capture_output=True, check=True) + (root / (stage + ".json")).write_text(result.stdout) + counter = root / 'host-count.txt'""") + # The runner is a separate Python module; preserve its literal newline escape. + script = script.replace('split("Turn request:\n",', 'split("Turn request:\\n",') + runner.write_text(script) + turn = prior_turn if prior_todo_binding else "structured-complete-replan" + rc, result = _run_turn(project, runtime, registry, runner, turn=turn) + if not prior_todo_binding: + # Structured DSH still has Todo-scoped sessions: an obligation-only + # replan remains blocked rather than inventing a Todo or widening scope. + assert rc == 1 and result["error"] == "LoopX Turn route is not host executable" + assert not (project / "received.txt").exists() + assert _spend_run_count(runtime) == 0 + return + assert rc == 0, json.dumps(result, indent=2) + prompt = (project / "received.txt").read_text() + request, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + source = request["work_contract"] + assert source["schema_version"] == "loopx_turn_envelope_v1" + assert "turn_envelope" not in request and "turn_authority" not in request + saved = list(runtime.rglob("decisions/*.json")) + assert len(saved) == 1 and json.loads(saved[0].read_text()) == source + for stage in ("review", "writeback"): + material = json.loads((project / (stage + ".json")).read_text()) + assert material["ok"] is True + assert material["source_hash"] == request["source_decision_hash"] + assert "before reading detail_ref.replan_action_packet.writeback" in prompt + assert "outer Turn executor owns those effects" in prompt + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane/test_complete_turn_journal.py b/tests/control_plane/test_complete_turn_journal.py new file mode 100644 index 0000000000..bce1041340 --- /dev/null +++ b/tests/control_plane/test_complete_turn_journal.py @@ -0,0 +1,240 @@ +"""Complete-source integrity must survive every journal read/write boundary.""" +from copy import deepcopy +import json + +import pytest + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.turn_driver.journal_store import ( + find_loopx_turn_key_by_settlement_identity, load_loopx_turn_plan_from_journal, turn_journal_path, +) +from loopx.control_plane.turn_driver.turn_journal_runtime import write_turn_journal +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos, +) +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, _configure_read_only_todo, _run_cli, + _spend_run_count, _write_fixture, +) + + +def _journal_plan(contract, *, legacy, goal_ref): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + + plan = build_loopx_turn_plan(build_turn_envelope(contract) if legacy else contract, + host="generic-cli", execution_mode="isolated-headless", turn_instance_id="journal-source", + goal_ref=goal_ref) + if legacy: + # Explicit recorded transitional format, independent of today's writer. + assert plan["schema_version"] == "loopx_turn_plan_v0" + plan["work_contract"] = contract + else: + assert plan["schema_version"] == "loopx_turn_plan_v1" and "turn_envelope" not in plan + return plan + + +@pytest.fixture(params=[(provider, legacy) for provider in ("file", "sqlite") for legacy in (False, True)]) +def complete_journal(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + state = _configure_read_only_todo(project) + initialize_canonical_authority(runtime, GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=GOAL_ID, handoff_mode="soft_claim", + todos=read_fixture_todos(state)), state_path=state, provider=request.param[0]) + code, plan = _run_cli(registry, runtime, "turn", "plan", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "journal-source", + "--host", "generic-cli", "--execution-mode", "isolated-headless", + "--include-transaction-detail", "--available-capability", "shell", + "--available-capability", "filesystem_read", cwd=project) + assert code == 0, plan + assert plan["route"]["would_invoke_host"] is True + assert plan["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" + # CLI inspection intentionally withholds executable settlement steps. + # Use the ordinary executor planner to prepare the actual journal carrier. + plan = _journal_plan(plan["work_contract"], legacy=request.param[1], goal_ref=plan.get("goal_ref")) + key = plan["transaction"]["turn_key"] + journal = {"schema_version": "loopx_turn_journal_v0", "goal_id": GOAL_ID, + "turn_key": key, "status": "in_progress", "completed_phases": [], + "host_attempt_count": 0, "plan": plan} + before = state.read_bytes() + yield project, runtime, registry, journal + assert state.read_bytes() == before + assert _spend_run_count(runtime) == 0 + + +def _inspect(project, runtime, registry, key): + code, result = _run_cli(registry, runtime, "turn", "inspect-journal", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-key", key, cwd=project) + assert code == 0, result # Invalid evidence is still a successful observation. + return result + + +def test_complete_journal_readers_and_writer_refuse_source_drift(complete_journal): + project, runtime, registry, original = complete_journal + key = original["turn_key"] + identity = original["plan"]["transaction"]["settlement_plan"]["identity"] + path = turn_journal_path(runtime, goal_id=GOAL_ID, turn_key=key) + path.parent.mkdir(parents=True, exist_ok=True) + for mutation in ("content", "version", "null", "binding", "adapter", "interaction", "action", "reference"): + journal = deepcopy(original) + plan = journal["plan"] + if mutation == "content": + plan["work_contract"]["reason"] = "Changed complete source after admission" + elif mutation == "version": + plan["work_contract"]["schema_version"] = "future_turn_contract" + elif mutation == "null": + plan["work_contract"] = None + elif mutation == "binding": + plan["work_contract"].pop("contract_binding") + elif mutation in {"interaction", "action", "reference"}: + from tests.control_plane.test_complete_turn_decision import _resign_contract + source = plan["work_contract"] + if mutation == "interaction": + source["interaction_contract"]["schema_version"] = "future_interaction" + elif mutation == "action": + source["effective_action"] = "future_action" + else: + source.setdefault("agent_lane_next_action", {}).pop("title", None) + source["agent_lane_next_action"]["title_ref"] = "$.unavailable_original_requirement" + _resign_contract(source) + elif mutation == "adapter": + # The adapter can be internally self-consistent yet differ from + # the admitted source. Its own signature alone cannot qualify it. + from tests.control_plane.test_complete_turn_decision import _canonical_digest + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + envelope = plan.setdefault("turn_envelope", build_turn_envelope(plan["work_contract"])) + envelope["boundary"]["write_scope"] = ["different-artifact"] + digest = _canonical_digest(turn_envelope_action_signature_document(envelope)) + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + with pytest.raises(RuntimeError, match="work_contract_invalid"): + write_turn_journal(str(path), journal, expected_effect_id=identity["effect_id"]) + assert not path.exists(), mutation + # Corrupt or older files can bypass today's writer. Inspection and + # settlement lookup must agree, with no repair or fresh admission. + path.write_text(json.dumps(journal, ensure_ascii=False)) + before = path.read_bytes() + result = _inspect(project, runtime, registry, key) + assert result["journal_consistent"] is False, mutation + assert "work_contract_invalid" in result["violations"], mutation + assert result["recovery_decision"]["action"] == "blocked", mutation + assert result["effects"] == [] + with pytest.raises((RuntimeError, ValueError)): + load_loopx_turn_plan_from_journal(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, turn_key=key) + with pytest.raises(ValueError, match="work_contract_invalid"): + find_loopx_turn_key_by_settlement_identity(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id="journal-source") + assert path.read_bytes() == before + path.unlink() + + +def test_complete_journal_keeps_numeric_source_and_historical_decoder(complete_journal): + from loopx.control_plane.quota.turn_envelope import compose_turn_decision + + project, runtime, registry, journal = complete_journal + plan = journal["plan"] + prior = plan["work_contract"] + source = {key: value for key, value in prior.items() if key not in {"schema_version", "contract_binding"}} + source["future_measurements"] = [1.0, -0.0, 2**60 + 1] + contract = compose_turn_decision(source) + journal["plan"] = plan = _journal_plan(contract, legacy=plan["schema_version"] == "loopx_turn_plan_v0", + goal_ref=plan.get("goal_ref")) + key = journal["turn_key"] + identity = plan["transaction"]["settlement_plan"]["identity"] + path = turn_journal_path(runtime, goal_id=GOAL_ID, turn_key=key) + path.parent.mkdir(parents=True, exist_ok=True) + written = write_turn_journal(str(path), journal, expected_effect_id=identity["effect_id"]) + assert written["appended"] is True + assert json.loads(path.read_text())["plan"]["work_contract"]["future_measurements"] == [1.0, -0.0, 2**60 + 1] + assert load_loopx_turn_plan_from_journal(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, turn_key=key) == plan + assert _inspect(project, runtime, registry, key)["journal_consistent"] is True + assert find_loopx_turn_key_by_settlement_identity(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, todo_id=TODO_ID, turn_instance_id="journal-source") == key + replay = write_turn_journal(str(path), journal, expected_effect_id=identity["effect_id"]) + assert replay["replayed"] is True and replay["appended"] is False + before = path.read_bytes() + corrupted = json.loads(before) + corrupted["plan"]["work_contract"]["future_measurements"][2] += 1 + path.write_text(json.dumps(corrupted)) + assert _inspect(project, runtime, registry, key)["journal_consistent"] is False + path.write_bytes(before) + assert _inspect(project, runtime, registry, key)["journal_consistent"] is True + # Absence is the existing historical format, not a way for a supplied + # null/unknown complete source to select that decoder. + historical = deepcopy(journal) + if plan["schema_version"] == "loopx_turn_plan_v1": + # Missing source in a current plan is invalid, never a downgrade. + historical["plan"].pop("work_contract") + path.write_text(json.dumps(historical)) + assert _inspect(project, runtime, registry, key)["journal_consistent"] is False + with pytest.raises(ValueError, match="complete Turn plan requires one source"): + load_loopx_turn_plan_from_journal(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, turn_key=key) + historical["plan"] = _journal_plan(contract, legacy=True, goal_ref=plan.get("goal_ref")) + historical["plan"].pop("work_contract") + path.write_text(json.dumps(historical)) + original_bytes = path.read_bytes() + assert load_loopx_turn_plan_from_journal(runtime, goal_id=GOAL_ID, + agent_id=AGENT_ID, turn_key=key) == historical["plan"] + assert _inspect(project, runtime, registry, key)["journal_consistent"] is True + assert path.read_bytes() == original_bytes + + +def test_cli_resume_refuses_changed_complete_source_without_rewriting_history(complete_journal): + project, runtime, registry, journal = complete_journal + journal["plan"]["work_contract"]["reason"] = "Changed original requirement after capture" + key = journal["turn_key"] + path = turn_journal_path(runtime, goal_id=GOAL_ID, turn_key=key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(journal)) + original_bytes = path.read_bytes() + for command in ( + ("run-once", "--resume-turn-key", key, "--host", "generic-cli", + "--execution-mode", "isolated-headless", "--project", str(project)), + ("inspect-journal", "--turn-key", key, "--retry-failed-turn"), + ): + code, result = _run_cli(registry, runtime, "turn", *command, + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, cwd=project) + assert code == 1 and result["ok"] is False, result + assert path.read_bytes() == original_bytes + + +def test_managed_retry_reads_complete_source_with_original_numbers(complete_journal): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.control_plane.turn_driver.host_failure import build_host_failure_record + from loopx.control_plane.turn_driver.managed_step import decide_managed_step + from loopx.control_plane.turn_driver.transaction import validate_loopx_turn_receipt + + _, _, _, journal = complete_journal + plan = journal["plan"] + source = {key: value for key, value in plan["work_contract"].items() + if key not in {"schema_version", "contract_binding"}} + source["future_measurements"] = [1.0, -0.0, 2**60 + 1] + journal["plan"] = plan = _journal_plan(compose_turn_decision(source), + legacy=plan["schema_version"] == "loopx_turn_plan_v0", goal_ref=plan.get("goal_ref")) + # The managed controller's current quota preview remains a distinct input; + # never recreate a second carrier inside the current journal to supply it. + quota = build_turn_envelope(plan["work_contract"]) + result = {"schema_version": "loopx_turn_result_v0", "turn_key": journal["turn_key"], + "result_kind": "host_failure", "completed_phases": [], "failed_phase": "host_execute"} + journal.update(status="failed", result_kind="host_failure", host_attempt_count=1, + host_failure=build_host_failure_record("provider_capacity", attempt=1), + receipt=validate_loopx_turn_receipt(plan["transaction"], result)) + + def decide(): + return decide_managed_step(journal, quota, goal_id=GOAL_ID, + agent_id=AGENT_ID, turn_key=journal["turn_key"]) + + original = deepcopy(journal) + continuation = decide() + assert continuation["disposition"] == "wait" + assert continuation["retry_continuation"]["same_turn"] is True + assert continuation["retry_continuation"]["retry_after_seconds"] == 30 + assert journal == original + plan["work_contract"]["future_measurements"][2] += 1 + with pytest.raises(ValueError, match="work_contract_invalid"): + decide() diff --git a/tests/control_plane/test_delegation_native_host_context.py b/tests/control_plane/test_delegation_native_host_context.py index 3dbebed8f6..d5d484b1a7 100644 --- a/tests/control_plane/test_delegation_native_host_context.py +++ b/tests/control_plane/test_delegation_native_host_context.py @@ -32,8 +32,9 @@ def preview(worker, binding, operation, *, include_context=True, **override): *worker._execution_arguments(binding, operation), *context) -def original_context(envelope): - return next(source["content"] for source in envelope["work_context"]["sources"] +def original_context(contract): + context = contract["interaction_contract"]["agent_channel"]["work_context"] + return next(source["content"] for source in context["sources"] if source.get("source") == "delegation_work") @@ -52,7 +53,9 @@ def test_original_material_reaches_capture_and_actual_codex_stdin(saved_handoff, worker.execute(operation) assert worker.read(operation)["status"] == "accepted" host_request = json.loads((root / "received-request.json").read_text()) - content = original_context(host_request["turn_envelope"]) + assert host_request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in host_request + content = original_context(host_request["work_contract"]) assert content["source_request"]["message"] == ORIGINAL assert content["source_request"]["request_id"] == receipt["request_id"] assert content["brief"] == content["source_request"]["brief"] == request["brief"] @@ -97,7 +100,7 @@ def test_worker_consumes_captured_work_without_reading_compatibility_bootstrap(s host = root / "fixture-host.py" host.write_text(host.read_text().replace( "delegation = json.loads((workspace / 'DELEGATION.json').read_text())", - "delegation = next(s['content'] for s in envelope['work_context']['sources'] if s.get('source') == 'delegation_work')\n" + "delegation = next(s['content'] for s in request['work_contract']['interaction_contract']['agent_channel']['work_context']['sources'] if s.get('source') == 'delegation_work')\n" "(root / 'received-work.json').write_text(json.dumps(delegation))")) worker.execute(operation) assert worker.read(operation)["status"] == "accepted" diff --git a/tests/control_plane/test_delegation_request_subject.py b/tests/control_plane/test_delegation_request_subject.py index 0480f47040..c5b5e99a55 100644 --- a/tests/control_plane/test_delegation_request_subject.py +++ b/tests/control_plane/test_delegation_request_subject.py @@ -38,7 +38,7 @@ def _ordinary_binding(delegation_service): } runner.config.write_text(json.dumps(config)) source = HOST.replace("delegation = json.loads((workspace / 'DELEGATION.json').read_text())", - "delegation = next(s['content'] for s in envelope['work_context']['sources'] if s.get('source') == 'delegation_work')") + "delegation = next(s['content'] for s in request['work_contract']['interaction_contract']['agent_channel']['work_context']['sources'] if s.get('source') == 'delegation_work')") source = source.replace("print(json.dumps(build_result", """ (workspace / 'output.json').write_text(json.dumps({'answer': 17 + 25})) (workspace / 'received-request.json').write_text(json.dumps(request)) @@ -77,7 +77,9 @@ def test_original_request_executes_and_settles_without_changing_todos(delegation workspace = Path(binding["workspace"]) assert (workspace / "host-invocations").read_text() == "1" received = json.loads((workspace / "received-request.json").read_text()) - identity = received["turn_envelope"]["writeback"]["settlement_plan"]["identity"] + assert received["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in received + identity = received["work_contract"]["interaction_contract"]["cli_channel"]["settlement_plan"]["identity"] assert identity["binding_kind"] == "request" assert identity["request_subject"]["request_id"] == initial["request_id"] assert "todo_id" not in identity @@ -200,8 +202,8 @@ def ordinary_source(delegation_service, monkeypatch, request): host.write_text(host.read_text().replace("print(json.dumps(build_result", """ from loopx.capabilities.manager_context.roundtrip import report original = delegation['source_request'] -acknowledge(root / 'runtime', envelope['goal_id'], actor, original['request_id'], 'adopt', 'Read the original requested arithmetic.') -report(root / 'runtime', envelope['goal_id'], actor, original['request_id'], 'conclusion', 'The independently checked result is 42.') +acknowledge(root / 'runtime', identity['goal_id'], actor, original['request_id'], 'adopt', 'Read the original requested arithmetic.') +report(root / 'runtime', identity['goal_id'], actor, original['request_id'], 'conclusion', 'The independently checked result is 42.') print(json.dumps(build_result""")) monkeypatch.setattr(execution.Delegations, "_spawn", lambda *_: None) submitted = execution.dispatch(runner.root, runner.registry, session=session, turn=turn, @@ -233,7 +235,9 @@ def test_original_chat_request_returns_without_creating_a_task(ordinary_source): assert result["todo_id"] is None assert result["request_subject"]["request_id"] == receipt["request_id"] received = json.loads((Path(binding["workspace"]) / "received-request.json").read_text()) - content = next(s["content"] for s in received["turn_envelope"]["work_context"]["sources"] + assert received["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in received + content = next(s["content"] for s in received["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["sources"] if s.get("source") == "delegation_work") assert content["source_request"]["message"] == ORIGINAL_MESSAGE assert content["source_request"]["brief"] == request["brief"] diff --git a/tests/control_plane/test_dsh_operation_sdk.py b/tests/control_plane/test_dsh_operation_sdk.py index af27a3058a..0558a7f614 100644 --- a/tests/control_plane/test_dsh_operation_sdk.py +++ b/tests/control_plane/test_dsh_operation_sdk.py @@ -103,6 +103,81 @@ def values(value): return next(values(messages)) +def _sdk_response_stream(index, delta, finish, arguments, *, path): + """Shared scripted stream for the two qualified SDK provider transports.""" + chunks = [ + { + "id": f"fixture-{index}", + "object": "chat.completion.chunk", + "created": 1, + "model": "fixture-model", + "choices": [ + {"index": 0, "delta": delta, "finish_reason": None} + ], + }, + { + "id": f"fixture-{index}", + "object": "chat.completion.chunk", + "created": 1, + "model": "fixture-model", + "choices": [{"index": 0, "delta": {}, "finish_reason": finish}], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + }, + ] + data = ( + "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + + "data: [DONE]\n\n" + ).encode() + if path.endswith("/messages"): + # 0.2.1's official adapter uses Messages SSE; 0.1.5 uses + # Chat Completions. Both are actual loopback wire paths. + block = ( + {"type": "text", "text": delta["content"]} + if finish == "stop" + else { + "type": "tool_use", + "id": f"sdk-native-call-{index}", + "name": "loopx_operation", + "input": arguments(index), + } + ) + events = [ + { + "type": "message_start", + "message": { + "id": f"fixture-{index}", + "model": "fixture-model", + "usage": {"input_tokens": 1, "output_tokens": 0}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": block, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": { + "stop_reason": "end_turn" + if finish == "stop" + else "tool_use" + }, + "usage": {"output_tokens": 1}, + }, + {"type": "message_stop"}, + ] + data = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" + for event in events + ).encode() + return data + + @pytest.mark.parametrize("request_transport", ["inline", "frozen"]) @pytest.mark.parametrize("authority_provider", ["file", "sqlite"]) @pytest.mark.parametrize( @@ -228,76 +303,7 @@ def do_POST(self): ], } finish = "tool_calls" - chunks = [ - { - "id": f"fixture-{index}", - "object": "chat.completion.chunk", - "created": 1, - "model": "fixture-model", - "choices": [ - {"index": 0, "delta": delta, "finish_reason": None} - ], - }, - { - "id": f"fixture-{index}", - "object": "chat.completion.chunk", - "created": 1, - "model": "fixture-model", - "choices": [{"index": 0, "delta": {}, "finish_reason": finish}], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2, - }, - }, - ] - data = ( - "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) - + "data: [DONE]\n\n" - ).encode() - if self.path.endswith("/messages"): - # 0.2.1's official adapter uses Messages SSE; 0.1.5 uses - # Chat Completions. Both are actual loopback wire paths. - block = ( - {"type": "text", "text": delta["content"]} - if finish == "stop" - else { - "type": "tool_use", - "id": f"sdk-native-call-{index}", - "name": "loopx_operation", - "input": arguments(index), - } - ) - events = [ - { - "type": "message_start", - "message": { - "id": f"fixture-{index}", - "model": "fixture-model", - "usage": {"input_tokens": 1, "output_tokens": 0}, - }, - }, - { - "type": "content_block_start", - "index": 0, - "content_block": block, - }, - {"type": "content_block_stop", "index": 0}, - { - "type": "message_delta", - "delta": { - "stop_reason": "end_turn" - if finish == "stop" - else "tool_use" - }, - "usage": {"output_tokens": 1}, - }, - {"type": "message_stop"}, - ] - data = "".join( - f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" - for event in events - ).encode() + data = _sdk_response_stream(index, delta, finish, arguments, path=self.path) self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Content-Length", str(len(data))) diff --git a/tests/control_plane/test_operation_host_source_preflight.py b/tests/control_plane/test_operation_host_source_preflight.py new file mode 100644 index 0000000000..517ca7a1c9 --- /dev/null +++ b/tests/control_plane/test_operation_host_source_preflight.py @@ -0,0 +1,233 @@ +"""Operation Hosts keep complete work preparation ahead of native initialization.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from loopx.control_plane.turn_driver import codex_operation_host, dsh_operation_host +from loopx.control_plane.turn_driver.host_candidate import complete_host_input +from tests.control_plane.test_turn_host_decision_capture import ( + _pending_goal_contract, + complete_host_request as complete_host_request, +) + + +class PreflightObserved(Exception): + """Stop at the native boundary, before this test starts any provider.""" + + +@pytest.mark.parametrize('wire', [False, True], ids=['executor', 'complete-wire']) +def test_optional_session_metadata_preserves_prompt_and_standalone_scope_checks(complete_host_request, wire): + from loopx.control_plane.turn_driver.codex_sessions import _lineage + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, render_prompt + + internal, _source = complete_host_request + request = complete_host_input(internal) if wire else deepcopy(internal) + original = extract_turn_authority(request) + prepared = extract_turn_authority(request, include_session_lineage=True) + assert render_prompt(prepared) == render_prompt(original) + lineage = prepared.pop('_session_lineage') + assert prepared == original + for scope in ['todo', 'agent']: + assert _lineage(request, session_scope=scope, checked_lineage=lineage) == _lineage(request, session_scope=scope) + assert lineage == prepared['_work_identity'] + + +def _options(tmp_path, source): + registry = Path(source['registry']) + goal = next(g for g in json.loads(registry.read_text())['goals'] + if g.get('id', g.get('goal_id')) == source['goal_id']) + executable = tmp_path / 'operation-preflight-executable' + executable.write_text('synthetic file pin; this executable is never started') + executable.chmod(0o700) + return dict(runtime_root=Path(source['runtime_root']), registry_path=registry, + project=Path(goal['repo']), model='test-model', reasoning_effort='xhigh'), executable + + +@pytest.mark.parametrize('host', ['codex', 'dsh']) +@pytest.mark.parametrize('wire', [False, True], ids=['executor', 'complete-wire']) +@pytest.mark.parametrize('state', ['valid', 'pending_goal', 'damaged_source']) +def test_operation_preflight_uses_one_checked_source_before_native_boundary( + complete_host_request, tmp_path, monkeypatch, host, wire, state, +): + from loopx.control_plane.quota import turn_envelope + + original, source = complete_host_request + request = deepcopy(original) + if state == 'pending_goal': + request['work_contract'], request['turn_envelope'] = _pending_goal_contract(source) + if wire: + request = complete_host_input(request) if state != 'pending_goal' else { + **{k: v for k, v in request.items() if k != 'turn_envelope'}, + 'schema_version': 'loopx_turn_host_request_v1', + } + if state == 'damaged_source': + request['work_contract']['reason'] = 'changed source before operation preflight' + before = deepcopy(request) + options, executable = _options(tmp_path, source) + calls, native = [], [] + projection = turn_envelope.effect_runtime_projection_result + + def observe(method, arguments, **kwargs): + if arguments.get('operation') in {'host_dispatch_context', 'complete_host_lineage'}: + calls.append((arguments['operation'], deepcopy(arguments))) + return projection(method, arguments, **kwargs) + + def native_boundary(*_args, **_kwargs): + native.append(True) + raise PreflightObserved + + monkeypatch.setattr(turn_envelope, 'effect_runtime_projection_result', observe) + if host == 'codex': + monkeypatch.setattr(codex_operation_host.CodexChatAgentSession, 'start', native_boundary) + + def run(): + return codex_operation_host.run_codex_operation_host( + request, **options, codex_bin=str(executable), timeout_seconds=5) + else: + # Only make the preflight import available. No SDK class is invoked; + # the real SDK/runtime qualification is separate from this boundary oracle. + monkeypatch.setitem(sys.modules, 'deepseek_harness', SimpleNamespace( + DeepSeekHarness=object, DeepSeekHarnessConfig=object)) + monkeypatch.setattr(dsh_operation_host, '_sdk_runtime_pin', native_boundary) + + def run(): + return dsh_operation_host._run_dsh_operation_host( + request, **options, dsh_home=tmp_path / 'dsh-home', + operation_module=executable, provider='fixture-provider', max_tokens=128, + owner=SimpleNamespace(session_id=None)) + + if state == 'valid': + with pytest.raises(PreflightObserved): + run() + assert native == [True] + assert [operation for operation, _ in calls] == ['host_dispatch_context'] + assert calls[0][1]['include_session_lineage'] is True + else: + with pytest.raises((RuntimeError, ValueError)) as rejected: + run() + if state == 'pending_goal': + assert 'Goal' in str(rejected.value) + str(rejected.value.__cause__) + else: + assert any(word in str(rejected.value) for word in ['complete', 'integrity', 'source']) + assert native == [] + assert request == before + assert not list(Path(source['runtime_root']).glob('goals/*/turn-sessions/*.json')) + + +@pytest.mark.parametrize('wire', [False, True], ids=['executor', 'complete-wire']) +def test_codex_operation_rechecks_source_after_native_initialization( + complete_host_request, tmp_path, monkeypatch, wire, +): + from tests.test_codex_operation_host import FAKE_SERVER + + internal, source = complete_host_request + request = complete_host_input(internal) if wire else deepcopy(internal) + options, executable = _options(tmp_path, source) + executable.write_text(FAKE_SERVER) + prompt = tmp_path / 'sent-prompt.txt' + monkeypatch.setenv('FAKE_OPERATION_PROMPT_FILE', str(prompt)) + start = codex_operation_host.CodexChatAgentSession.start + sessions = [] + + def changed_after_start(**kwargs): + session = start(**kwargs) + sessions.append(session.thread_id) + request['work_contract']['reason'] = 'changed during native initialization' + return session + + monkeypatch.setattr(codex_operation_host.CodexChatAgentSession, 'start', changed_after_start) + with pytest.raises((RuntimeError, ValueError), match='complete|integrity|source'): + codex_operation_host.run_codex_operation_host( + request, **options, codex_bin=str(executable), timeout_seconds=5) + assert len(sessions) == 1 + assert not prompt.exists() # The fresh post-start check precedes turn/start. + + +@pytest.mark.parametrize('wire', [False, True], ids=['executor', 'complete-wire']) +def test_complete_dsh_operation_preflight_reaches_real_sdk_context_and_result( + complete_host_request, tmp_path, monkeypatch, wire, +): + import os + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from loopx.control_plane.quota import turn_envelope + from loopx.control_plane.turn_driver.codex_sessions import _lineage, load_codex_cli_session + from tests.control_plane.test_dsh_operation_sdk import _sdk_response_stream, tool_results + + pytest.importorskip('deepseek_harness') + module = os.environ.get('DSH_NATIVE_OPERATION_MODULE') + if not module: + pytest.skip('explicit compiled native operation module required') + internal, source = complete_host_request + request = complete_host_input(internal) if wire else deepcopy(internal) + before = deepcopy(request) + lineage = _lineage(request) + messages, errors, notifications, projections = [], [], [], [] + + class Provider(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + try: + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + assert body['model'] == 'fixture-model' + assert self.path.endswith(('/messages', '/chat/completions')) + index = len(messages) + assert index < 2, 'bounded loopback provider request count exceeded' + messages.append(body['messages']) + delta = ({'role': 'assistant', 'reasoning_content': '', 'tool_calls': [{ + 'index': 0, 'id': 'preflight-context', 'type': 'function', + 'function': {'name': 'loopx_operation', 'arguments': '{"action":"context"}'}, + }]} if index == 0 else {'role': 'assistant', 'content': json.dumps({ + 'result_kind': 'wait', 'classification': 'complete_source_context_observed', + 'summary': 'Synthetic context read; no domain effect'})}) + data = _sdk_response_stream(index, delta, 'tool_calls' if index == 0 else 'stop', + lambda _index: {'action': 'context'}, path=self.path) + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + except Exception as exc: + errors.append(repr(exc)) + self.send_error(500) + + server = ThreadingHTTPServer(('127.0.0.1', 0), Provider) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setenv('DEEPSEEK_API_KEY', 'loopback-fixture-placeholder') + monkeypatch.setenv('DEEPSEEK_BASE_URL', f'http://127.0.0.1:{server.server_port}/anthropic') + projection = turn_envelope.effect_runtime_projection_result + + def observe(method, arguments, **kwargs): + if arguments.get('operation') in {'host_dispatch_context', 'complete_host_lineage'}: + projections.append(arguments['operation']) + return projection(method, arguments, **kwargs) + + monkeypatch.setattr(turn_envelope, 'effect_runtime_projection_result', observe) + options, _executable = _options(tmp_path, source) + options.update(model='fixture-model', reasoning_effort='high') + try: + result = dsh_operation_host.run_dsh_operation_host( + request, **options, dsh_home=tmp_path / 'sdk-home', operation_module=Path(module), + provider='deepseek-official', max_tokens=1024, timeout_seconds=60, + on_notification=notifications.append) + assert result['result_kind'] == 'wait' and result['turn_key'] == request['turn_key'] + assert len(messages) == 2 and not errors + contexts = [row for row in tool_results(notifications) if isinstance(row.get('executor'), dict)] + assert len(contexts) == 1 + assert all(contexts[0][field] == value for field, value in lineage.items()) + assert contexts[0]['executor']['todo_id'] == lineage['todo_id'] + binding = load_codex_cli_session(options['runtime_root'], lineage=lineage, host='dsh') + assert binding['session_id'] == contexts[0]['executor']['session_id'] + assert projections == ['host_dispatch_context'] + assert request == before + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index 2473c022d9..bee331b979 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -25,6 +25,23 @@ AGENT = "agent-a" +@pytest.mark.parametrize("status", ["open", "deferred"]) +def test_explicit_non_delivery_obligation_does_not_prepare_selected_execution(monkeypatch, status): + from loopx.control_plane.quota import execution_preparation + + def no_execution_read(**_): + pytest.fail("A planning operation must not consult the selected delivery Todo's lease") + + monkeypatch.setattr(execution_preparation, "project_selected_todo_execution_preparation", no_execution_read) + obligation = {"must_attempt_work": True, "delivery_allowed": False} + payload = {"should_run": True, "selected_todo": {"todo_id": TODO, "status": status}, + "execution_obligation": obligation} + apply_selected_todo_execution_preparation(payload, runtime_root=None, goal_id=GOAL, + agent_id=AGENT, obligation=obligation) + assert payload == {"should_run": True, "selected_todo": {"todo_id": TODO, "status": status}, + "execution_obligation": {"must_attempt_work": True, "delivery_allowed": False}} + + def _todo() -> dict[str, object]: return { "schema_version": "todo_item_v0", diff --git a/tests/control_plane/test_replan_staged_read.py b/tests/control_plane/test_replan_staged_read.py index 05df95313d..869753d859 100644 --- a/tests/control_plane/test_replan_staged_read.py +++ b/tests/control_plane/test_replan_staged_read.py @@ -494,6 +494,29 @@ def _views(source): return envelope, _turn_envelope_result("replan_view", **params, stage="review"), params +def test_complete_review_indexes_current_requirements_without_changing_historical_index(tmp_path): + from replan_packet_fixture import populated_replan_source + from loopx.control_plane.quota.turn_envelope import compose_turn_decision + + source = populated_replan_source(tmp_path, acceptance=True) + _, historical, _ = _views(source) + complete = compose_turn_decision(source) + _, current, params = _views(complete) + originals = source["autonomous_replan_obligation"]["replan_context"]["core_goal"][ + "acceptance_contract"]["outcome_requirements"]["outcomes"] + assert len(current["acceptance_index"]["outcomes"]) == len(originals) + assert all("requirement_states" not in row for row in historical["acceptance_index"]["outcomes"]) + for row, original in zip(current["acceptance_index"]["outcomes"], originals, strict=True): + assert row["requirement_count"] == len(original["requirements"]) + assert sorted(key for group in row["requirement_states"] for key in group["requirement_ids"]) == sorted( + requirement["id"] for requirement in original["requirements"]) + for field in ("owner_agent_id", "work"): + if field in original: + assert row[field] == original[field] + detail = _turn_envelope_result("replan_view", **params, stage="acceptance", outcome_ref=row["outcome_ref"]) + assert detail["outcome"] == original + + def test_populated_history_and_cumulative_acceptance_have_separate_budgets(tmp_path): from replan_packet_fixture import populated_replan_source diff --git a/tests/control_plane/test_reward_memory_host_isolation.py b/tests/control_plane/test_reward_memory_host_isolation.py index cc0fd0c5c4..e65e37cad4 100644 --- a/tests/control_plane/test_reward_memory_host_isolation.py +++ b/tests/control_plane/test_reward_memory_host_isolation.py @@ -396,8 +396,8 @@ def test_real_turn_cli_memory_projection_and_settlement(tmp_path: Path, monkeypa runner = tmp_path / "fixture_dsh_runner.py" runner.write_text('''import json, os, pathlib def run_dsh_turn(*, prompt, **kwargs): - authority = json.loads(prompt.split('Turn authority JSON:\\n', 1)[1].split('\\n\\nWhen finished', 1)[0]) - pathlib.Path(os.environ['FIXTURE_HOST_CAPTURE']).write_text(json.dumps({'prompt': prompt, 'authority': authority})) + request, _ = json.JSONDecoder().raw_decode(prompt.split('Turn request:\\n', 1)[1]) + pathlib.Path(os.environ['FIXTURE_HOST_CAPTURE']).write_text(json.dumps({'prompt': prompt, 'request': request})) return json.dumps({'result_kind': 'validated_progress', 'classification': 'fixture_progress', 'next_action': 'Run the next bounded fixture', 'path_delta_mode': 'unchanged', 'vision_unchanged_reason': 'The original fixture remains open.', @@ -437,17 +437,23 @@ def run_dsh_turn(*, prompt, **kwargs): assert ("Set reward_memory_reflection_json" in observed["prompt"]) is ingest assert ("When reward_memory_recall contains guidance" in observed["prompt"]) is (mode == "recall_context") if mode in {"recall_context", "oversized_recall"}: - memory = observed["authority" if host_kind == "dsh" else "request"]["reward_memory_recall"] + memory = observed["request"]["reward_memory_recall"] assert memory["status"] == "applied" if mode == "recall_context": assert "integration branch" in memory["context"]["guidance"][0]["content_summary"] else: assert memory["context"] is None if mode in {"unconfigured", "disabled", "stale", "both_off"}: - if host_kind == "generic-cli": - assert "reward_memory_recall" not in observed["request"] - else: + request = observed["request"] + assert "reward_memory_recall" not in request + assert "reward_memory_reflection_json" not in request.get("capability_result_fields", {}) + assert not any("reward_memory" in instruction + for instruction in request.get("capability_instructions", [])) + if host_kind != "generic-cli" and "work_contract" not in request: assert "reward_memory" not in observed["prompt"] + # A complete captured source retains inactive configuration/diagnostics. + # Those facts must not activate recall instructions or reflection output; + # deleting them merely to pass a substring check would lose source facts. assert result["effects"]["quota_spent"] is True assert result.get("post_settlement", {}).get("external_writes_performed", False) is False if ingest: diff --git a/tests/control_plane/test_turn_completion_source.py b/tests/control_plane/test_turn_completion_source.py index 69732e7d34..6d5d60f6b6 100644 --- a/tests/control_plane/test_turn_completion_source.py +++ b/tests/control_plane/test_turn_completion_source.py @@ -13,9 +13,8 @@ from loopx.cli import main from loopx.control_plane.turn_driver import executor -from loopx.cli_commands.turn_todo_writeback import captured_todo_text_sha256 +from loopx.control_plane.turn_driver.driver import captured_todo_text_sha256 from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime -from tests.control_plane import canonical_authority_fixture from tests.test_loopx_turn_driver import ( _completion_host_and_validation_scripts, _turn_journal, @@ -117,7 +116,7 @@ def test_context_witness_preserves_legacy_requests_and_markdown_obligations() -> @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("accepted_before_upgrade", [False, True]) -def test_native_legacy_turn_recovers_history_but_cannot_complete_without_source( +def test_native_pre_witness_turn_recovers_history_but_cannot_complete_without_source( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, @@ -195,9 +194,13 @@ def recover_old_writer(**kwargs): return assert result["error"] == "Synthetic old-writer response loss", result journal = _turn_journal(runtime) - assert ( - "text_source_verified" - not in journal["plan"]["turn_envelope"]["action"]["selected_todo"] + # A pre-witness source is independent of the private plan wire version. + # Today's producer records v1; explicit v0 journal recovery has its own + # File/SQLite matrix in test_complete_turn_journal.py. + assert journal["plan"]["schema_version"] == "loopx_turn_plan_v1" + assert "turn_envelope" not in journal["plan"] + assert "selected_todo_text_verified" not in ( + journal["plan"]["work_contract"]["interaction_contract"]["agent_channel"]["work_context"] ) resumed_code, resumed = _cli( [], *argv[:-1], "--resume-turn-key", str(journal["turn_key"]), "--execute" @@ -220,9 +223,25 @@ def recover_old_writer(**kwargs): assert not any(replay["effects"].values()) assert len(results) == 1 assert _turn_journal(runtime) == recovered + _assert_recovered_completion(runtime, registry, journal) assert _todo(prefix)["todo"]["status"] == "done" +def _assert_recovered_completion(runtime: Path, registry: Path, original_journal: dict) -> None: + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + + turn_id = original_journal["turn_key"] # This CLI fixture supplies no explicit Turn id. + readback = read_heartbeat_settlement(runtime, registry_path=registry, + goal_id="loopx-turn-fixture", agent_id="codex-fixture", + todo_id="todo_fixture0001", turn_instance_id=turn_id) + assert readback is not None + assert readback.completion_event is not None and readback.spend_event is not None + recovered = _turn_journal(runtime) + assert recovered["turn_key"] == original_journal["turn_key"] + assert recovered["host_attempt_count"] == 1 + assert recovered["writeback"]["completion"]["todo_id"] == "todo_fixture0001" + + def _cli(prefix: list[str], *args: str) -> tuple[int, dict]: output = io.StringIO() with contextlib.redirect_stdout(output): @@ -277,6 +296,12 @@ def test_native_turn_completion_checks_full_source_and_preserves_unrelated_chang change: str, no_followup: bool, ) -> None: + from loopx.control_plane.turn_driver import settlement + + def legacy_selection(*_args, **_kwargs): + raise AssertionError("native completion reinterpreted historical selection") + + monkeypatch.setattr(settlement, "selected_turn_todo", legacy_selection) isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_live_fixture( tmp_path, @@ -338,9 +363,12 @@ def change_after_real_validation(*args, **kwargs): monkeypatch.setattr(executor, "_run_task_validator", change_after_real_validation) code, result = _cli([], *argv) request = json.loads((project / "request.json").read_text()) - captured_todo = request["turn_envelope"]["action"]["selected_todo"] + assert request["schema_version"] == "loopx_turn_host_request_v1" + assert "turn_envelope" not in request + captured_todo = request["work_contract"]["selected_todo"] assert captured_todo["todo_id"] == "todo_fixture0001" - assert captured_todo["text_source_verified"] is True + captured_context = request["work_contract"]["interaction_contract"]["agent_channel"]["work_context"] + assert captured_context["selected_todo_text_verified"] is True current = _todo(prefix)["todo"] if change != "requirements": assert code == 0 and result["status"] == "committed", result @@ -446,10 +474,9 @@ def lose_response_after_commit(**kwargs): # The public authoring path intentionally forbids replacing a done # task. Inject a later synthetic provider head in this disposable # store to challenge the recovery adapter, retaining its receipt. - module = ( - Path(canonical_authority_fixture.__file__).resolve().parents[2] - / "loopx/control_plane/coordination" - ) + import loopx + + module = Path(loopx.__file__).resolve().parent / "control_plane/coordination" script = ( f"import {{openLocalAuthorityStore}} from {json.dumps((module / 'local_authority_provider.ts').as_uri())};" f"import {{prepareCoordinationProjectionCommit}} from {json.dumps((module / 'coordination_projection.ts').as_uri())};" @@ -515,4 +542,70 @@ def lose_response_after_commit(**kwargs): assert not any(replay["effects"].values()) assert len(completions) == 1 assert _turn_journal(runtime) == recovered + _assert_recovered_completion(runtime, registry, journal) assert _todo(prefix)["todo"]["text"] == "[P0] A later requirement after acceptance." + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("no_followup", [False, True]) +@pytest.mark.parametrize("replacement", ["source", "mixed_adapter"]) +def test_native_completion_refuses_replaced_source_before_effects_then_recovers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + provider: str, no_followup: bool, replacement: str, +) -> None: + from copy import deepcopy + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_live_fixture(tmp_path, provider=provider, + todo_metadata_extra="no_followup=true" if no_followup else "") + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + host, validator = _completion_host_and_validation_scripts() + host = host.replace("request = json.load(sys.stdin)", + 'request = json.load(sys.stdin)\ncalls = pathlib.Path("host-calls.txt")\n' + 'calls.write_text(calls.read_text() + "x" if calls.exists() else "x")') + argv = _turn_run_once_completion_argv(project, runtime, registry, host, validator) + actual_stage = executor._typed_settlement_stage + + def replace_after_execution(plan, *args, **kwargs): + # Challenge the real post-execution boundary, after initial source and + # task validation. The durable journal retains its original capture. + changed = deepcopy(plan) + if replacement == "source": + changed["work_contract"]["reason"] = "Replaced after independent validation." + else: + # The v1 writer has already retired this second carrier. Inject + # one deliberately so the actual settlement boundary must refuse + # mixed authority, rather than failing inside the fixture. + assert plan["schema_version"] == "loopx_turn_plan_v1" + assert "turn_envelope" not in plan + changed["turn_envelope"] = {"writeback": {"next_cli_actions": ["foreign effect"]}} + return actual_stage(changed, *args, **kwargs) + + monkeypatch.setattr(executor, "_typed_settlement_stage", replace_after_execution) + code, refused = _cli([], *argv) + assert code == 1 and refused["result_kind"] == "writeback_failed", refused + assert refused["effects"] == {"host_invoked": True, "state_written": False, + "quota_spent": False, "scheduler_acknowledged": False} + assert _todo(prefix)["todo"]["status"] == "open" + journal = _turn_journal(runtime) + identity = journal["plan"]["transaction"]["settlement_plan"]["identity"] + original = deepcopy(journal["plan"]["work_contract"]) + assert (identity["goal_id"], identity["agent_id"], identity["todo_id"]) == ( + "loopx-turn-fixture", "codex-fixture", "todo_fixture0001") + assert (project / "completion-artifact.txt").read_text() == "completed" + + monkeypatch.setattr(executor, "_typed_settlement_stage", actual_stage) + retry = [*argv[:-1], "--resume-turn-key", str(journal["turn_key"]), + "--retry-failed-turn", "--execute"] + code, recovered = _cli([], *retry) + assert code == 0 and recovered["status"] == "committed", recovered + assert recovered["effects"]["host_invoked"] is False + assert recovered["effects"]["quota_spent"] is True + assert _todo(prefix)["todo"]["status"] == "done" + assert _turn_journal(runtime)["plan"]["work_contract"] == original + assert (project / "host-calls.txt").read_text() == "x" + code, replay = _cli([], *argv[:-1], "--resume-turn-key", str(journal["turn_key"]), "--execute") + assert code == 0 and replay["replayed"] is True, replay + assert not any(replay["effects"].values()) + rows = (runtime / "goals/loopx-turn-fixture/runs/index.jsonl").read_text().splitlines() + assert sum(json.loads(row).get("classification") == "quota_slot_spent" for row in rows) == 1 diff --git a/tests/control_plane/test_turn_envelope_capability_facts.py b/tests/control_plane/test_turn_envelope_capability_facts.py index daec89b1e2..576ac760e0 100644 --- a/tests/control_plane/test_turn_envelope_capability_facts.py +++ b/tests/control_plane/test_turn_envelope_capability_facts.py @@ -34,6 +34,10 @@ def test_real_captured_guard_preserves_required_and_missing_capabilities( state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state.write_text(state.read_text().replace("action_kind=validate ", "action_kind=validate continuation_policy=same_agent_non_delivery ")) + # Initialize only this disposable fixture; ordinary reads must refuse + # unregistered projections instead of restoring canonical work from them. + code, refused = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert code == 1 and refused["error_code"] == "canonical_authority_required" projection = build_todo_runtime_shadow_projection( goal_id=GOAL_ID, handoff_mode="soft_claim", todos=read_fixture_todos(state), ) @@ -52,6 +56,11 @@ def test_real_captured_guard_preserves_required_and_missing_capabilities( "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) assert code == (0 if network_available else 1), envelope full = json.loads((capture / "decision.json").read_text()) + # Both admission and refusal carry the same complete source. Keep the + # historical decoder checks below without choosing a second producer path. + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + assert envelope == full and full["schema_version"] == "loopx_turn_envelope_v1" + envelope = build_turn_envelope(full, captured_decision_path=str(capture / "decision.json")) gate = full["capability_gate"] assert "network" in gate["required"] assert ("network" in gate["missing"]) is (not network_available) @@ -86,6 +95,10 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state.write_text(state.read_text().replace("action_kind=validate ", "action_kind=validate continuation_policy=same_agent_non_delivery ")) + # Initialize only this disposable fixture; ordinary reads must refuse + # unregistered projections instead of restoring canonical work from them. + code, refused = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert code == 1 and refused["error_code"] == "canonical_authority_required" initialize_canonical_authority(runtime, GOAL_ID, build_todo_runtime_shadow_projection( goal_id=GOAL_ID, handoff_mode="soft_claim", todos=read_fixture_todos(state), ), state_path=state, provider=provider) @@ -122,6 +135,11 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( "--decision-output-dir", str(capture), cwd=project) assert code == 0, envelope full = json.loads((capture / "decision.json").read_text()) + assert envelope == full and full["schema_version"] == "loopx_turn_envelope_v1" + # The real CLI now emits the complete contract. Retain the historical + # decoder and its independent signature/refusal checks below. + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + envelope = build_turn_envelope(full, captured_decision_path=str(capture / "decision.json")) audit = full["vision_continuation_audit"] assert audit["required"] and audit["required_before_closeout"] assert audit["vision_gap_judge"]["done"] is False @@ -181,6 +199,7 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", turn_instance_id="vision-contract-turn") assert plan["route"]["would_invoke_host"] is True + # A replan route permits planning, not ordinary delivery. assert envelope["action"]["delivery_allowed"] is (not replan) request = {"schema_version": "loopx_turn_host_request_v0", "turn_key": plan["transaction"]["turn_key"], "route": "replan_required" if replan else "primary_delivery", "turn_envelope": envelope} diff --git a/tests/control_plane/test_turn_envelope_settlement.py b/tests/control_plane/test_turn_envelope_settlement.py index 904293e690..351b8b44b9 100644 --- a/tests/control_plane/test_turn_envelope_settlement.py +++ b/tests/control_plane/test_turn_envelope_settlement.py @@ -102,7 +102,8 @@ def test_generated_json_commands_settle_original_turn_once(tmp_path, profile_arg assert rc == 0, full rc, envelope = _run_cli(registry, runtime, *args, "--turn-envelope") assert rc == 0, envelope - plan = envelope["writeback"]["settlement_plan"] + assert envelope["schema_version"] == full["schema_version"] == "loopx_turn_envelope_v1" + plan = envelope["interaction_contract"]["cli_channel"]["settlement_plan"] assert plan == full["interaction_contract"]["cli_channel"]["settlement_plan"] assert plan["identity"] == full["heartbeat_receipt"]["settlement_identity"] assert [step["kind"] for step in plan["ordered_steps"]] == [ diff --git a/tests/control_plane/test_turn_host_context_delivery.py b/tests/control_plane/test_turn_host_context_delivery.py index 973d1b8c6f..dfa3334ef5 100644 --- a/tests/control_plane/test_turn_host_context_delivery.py +++ b/tests/control_plane/test_turn_host_context_delivery.py @@ -3,6 +3,8 @@ from copy import deepcopy import json +import os +from pathlib import Path import shlex import subprocess import sys @@ -10,8 +12,11 @@ import pytest from loopx.control_plane.quota.turn_envelope import build_turn_envelope, turn_envelope_action_signature_document +from loopx.control_plane.turn_driver.decision_capture import prepare_turn_decision_capture from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan +from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request from loopx.control_plane.turn_driver.host_candidate import _canonical_hash +from loopx.control_plane.turn_driver import codex_context from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime from tests.control_plane.test_agent_context import POLICY from tests.control_plane.test_quota_settlement_cli import ( @@ -26,6 +31,14 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monkeypatch, provider, mode): isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_fixture(tmp_path) + # Child cwd is the disposable Goal, so the interpreter's installed Core + # may differ from this checkout. Bind and independently check test-only IO. + checkout = Path(__file__).resolve().parents[2] + monkeypatch.setenv("PYTHONPATH", os.pathsep.join(filter(None, + [str(checkout), os.environ.get("PYTHONPATH")]))) + imported = subprocess.run([sys.executable, "-c", "import loopx; print(loopx.__file__)"], + cwd=project, text=True, capture_output=True, check=True) + assert Path(imported.stdout.strip()).resolve() == checkout / "loopx" / "__init__.py" state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" state.write_text(state.read_text().replace( "- [ ] [P1] Validate and settle the selected delivery.", @@ -39,14 +52,30 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monk _initialize_fixture_authority(project, runtime, provider=provider) capture = tmp_path / "capture" - code, envelope = _run_cli(registry, runtime, "quota", "should-run", + if mode != "legacy_reference": + native_directory = tmp_path / "context" + native_directory.mkdir(mode=0o700) + codex_context.codex_context_hook(str(native_directory), { + "session_id": "host-context-fixture", "cwd": str(project), + "hook_event_name": "SessionStart", "source": "startup", + }) + delivery = (["--context-delivery", "on", "--codex-context-dir", str(tmp_path / "context")] + if mode != "legacy_reference" else ["--context-delivery", "off"]) + binary = Path(sys.executable).with_name("loopx.exe" if sys.platform == "win32" else "loopx") + guard = [str(binary), "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", "host-context-turn", "--codex-app", "--available-capability", "filesystem_read", "--available-capability", "subagent_spawn", - "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) - assert code == 0, envelope + "--turn-envelope", "--decision-output-dir", str(capture), *delivery] + produced = subprocess.run(guard, cwd=project, text=True, capture_output=True, timeout=30) + assert produced.returncode == 0, produced.stdout + produced.stderr original = (capture / "decision.json").read_bytes() source = json.loads(original) + if mode == "legacy_reference": + assert json.loads(produced.stdout) == source + assert source["schema_version"] == "loopx_turn_envelope_v1" + envelope = build_turn_envelope(source, captured_decision_path=str(capture / "decision.json")) context = source["interaction_contract"].get("agent_context") assert bool(context) is (mode != "off") if not context: @@ -70,59 +99,74 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monk ) command = [sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(project), "--dsh-home", str(tmp_path / "host-home"), "--dsh-runner", str(runner)] - request = {"schema_version": "loopx_turn_host_request_v0", "turn_key": "sha256:" + "0" * 64, - "route": "primary_delivery", "turn_envelope": envelope} + plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", + turn_instance_id="host-context-turn") + if mode != "legacy_reference": + plan["work_contract"] = source + request = build_loopx_turn_host_request(plan) before = {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} result = subprocess.run(command, input=json.dumps(request), text=True, capture_output=True, timeout=30) assert result.returncode == 0, result.stdout + result.stderr assert json.loads(result.stdout)["result_kind"] == "wait" prompt_path = project / "received.txt" prompt = prompt_path.read_text() - authority = json.loads(prompt.split("Turn authority JSON:\n", 1)[1].split("\n\nWhen finished", 1)[0]) - # Capability context can be off or complete inline while the admitted Turn - # still needs its original evidence route and signed required reads. - assert authority["action_signature"] == envelope["action_signature"] - assert authority["required_reads"] == envelope["required_reads"] - detail = authority["detail_ref"] - assert detail == envelope["detail_ref"] - captured = detail["captured_decision"] - assert (captured["goal_id"], captured["agent_id"], captured["turn_instance_id"]) == (GOAL_ID, AGENT_ID, "host-context-turn") - read = subprocess.run(shlex.split(detail["full_decision"]), capture_output=True, check=True).stdout - assert read == original - # Full decision JSON includes numeric values whose canonical wire is owned - # by TS (e.g. Python 1.0 and JSON.stringify's 1 differ). - assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == authority["action_signature"]["source_decision_hash"] - if context: - # Independent source oracle after the actual subprocess boundary: two - # agreeing reduced projections cannot prove delivery of the full source. + if mode == "legacy_reference": + # Historical decoder remains explicitly qualified until its reader exits. + authority, _ = json.JSONDecoder().raw_decode(prompt.split("Turn authority JSON:\n", 1)[1]) assert authority["agent_context"] == envelope["agent_context"] - if mode == "legacy_reference": - assert _canonical_hash(json.loads(read)["interaction_contract"]["agent_context"]) == authority["agent_context"]["content_hash"] - else: - assert envelope["agent_context"] == context - assert "detail_ref" not in envelope["agent_context"] - assert envelope["compaction"]["within_budget"] is False + assert authority["action_signature"] == envelope["action_signature"] + assert authority["required_reads"] == envelope["required_reads"] + assert authority["required_reads"] + assert authority["detail_ref"] == envelope["detail_ref"] + detail = authority["detail_ref"] + read = subprocess.run(shlex.split(detail["full_decision"]), capture_output=True, check=True).stdout + assert read == original + assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == authority["action_signature"]["source_decision_hash"] + assert _canonical_hash(json.loads(read)["interaction_contract"]["agent_context"]) == authority["agent_context"]["content_hash"] invalid = deepcopy(request) invalid["turn_envelope"]["agent_context"]["scope"]["agent_id"] = "wrong-agent" rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) assert rejected.returncode != 0 and "action signature" in rejected.stderr - assert prompt_path.read_text() == prompt # Refusal precedes the provider seam. + assert prompt_path.read_text() == prompt + invalid = deepcopy(request) + invalid["turn_envelope"]["required_reads"] = [] + rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) + assert rejected.returncode != 0 and "action signature" in rejected.stderr + assert prompt_path.read_text() == prompt else: - assert "agent_context" not in authority - # Even with capability context off, dropping signed reads must be refused - # before the provider seam, without another admission or state mutation. - assert envelope["required_reads"] - invalid = deepcopy(request) - invalid["turn_envelope"]["required_reads"] = [] - rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) - assert rejected.returncode != 0 and "action signature" in rejected.stderr - assert prompt_path.read_text() == prompt + actual, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + # Compare at the real subprocess/provider seam with the independent + # immutable capture, including full acceptance, context and stop facts. + assert actual["work_contract"] == source + assert "turn_envelope" not in actual and "turn_authority" not in actual + delivered_context = actual["work_contract"]["interaction_contract"].get("agent_context") + assert delivered_context == context + if context: + assert "detail_ref" not in delivered_context + for field, value in (("reason", "Changed original facts"), + ("schema_version", "loopx_turn_envelope_v999")): + invalid = deepcopy(request) + invalid["work_contract"][field] = value + rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) + assert rejected.returncode != 0 and "complete Turn contract rejected" in rejected.stderr + assert prompt_path.read_text() == prompt # Refusal precedes the provider seam. + channel = source["interaction_contract"]["agent_channel"] + assert not any(read.get("source") == "goal_state" for read in channel["required_reads"]) + assert any(row.get("source") == "goal_state" + for row in channel["work_context"]["sources"]) + invalid = deepcopy(request) + invalid["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["sources"] = [] + rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) + assert rejected.returncode != 0 and "complete Turn contract rejected" in rejected.stderr + assert prompt_path.read_text() == prompt assert (capture / "decision.json").read_bytes() == original assert {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} == before @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monkeypatch, provider): + from loopx.control_plane.turn_driver.codex_cli import prepare_codex_cli_input + isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_fixture(tmp_path) _configure_selected_todo_replan_fixture(project, registry) @@ -146,14 +190,20 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke "--todo-id", SELECTED_REPLAN_TODO_ID, "--turn-instance-id", turn, "--codex-app", "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) assert code == 0, envelope - assert envelope["action"]["delivery_allowed"] is False - assert envelope["action"]["must_attempt"] is True + original = (capture / "decision.json").read_bytes() + source = json.loads(original) + assert envelope == source + assert source["schema_version"] == "loopx_turn_envelope_v1" + agent_channel = source["interaction_contract"]["agent_channel"] + assert agent_channel["delivery_allowed"] is False + assert agent_channel["must_attempt"] is True + envelope = build_turn_envelope(source) plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", turn_instance_id=turn) + plan = prepare_turn_decision_capture(plan, journal_path=tmp_path / "turn" / "journal.json", + decision=source, existing_journal=False) assert plan["route"]["kind"] == "replan_required" assert plan["route"]["would_invoke_host"] is True - original = (capture / "decision.json").read_bytes() - source = json.loads(original) assert source["heartbeat_receipt"]["settlement_identity"]["todo_id"] == SELECTED_REPLAN_TODO_ID runner = tmp_path / "runner.py" @@ -165,44 +215,43 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke ) command = [sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(project), "--dsh-home", str(tmp_path / "host-home"), "--dsh-runner", str(runner)] - request = {"schema_version": "loopx_turn_host_request_v0", "turn_key": plan["transaction"]["turn_key"], - "route": "replan_required", "turn_envelope": envelope} + request = build_loopx_turn_host_request({**plan, "work_contract": source}) + codex_prompt, _ = prepare_codex_cli_input(request) + assert "Read detail_ref.replan_action_packet.review" in codex_prompt + assert "before reading detail_ref.replan_action_packet.writeback" in codex_prompt before = {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} result = subprocess.run(command, input=json.dumps(request), text=True, capture_output=True, timeout=30) assert result.returncode == 0, result.stdout + result.stderr assert json.loads(result.stdout)["result_kind"] == "wait" prompt_path = project / "received.txt" prompt = prompt_path.read_text() - authority = json.loads(prompt.split("Turn authority JSON:\n", 1)[1].split("\n\nWhen finished", 1)[0]) - # Compare preserved choices with the complete source and execute the reader - # actually delivered to the provider. No unconditional full-source detour. - packet = authority["replan_action_packet"] - for field in ("obligation_id", "required_outcome", "allowed_terminal"): - assert packet[field] == source["replan_action_packet"][field] + actual, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + assert actual["work_contract"] == source + assert "turn_envelope" not in actual and "turn_authority" not in actual + # Execute the original observation readers actually delivered to the provider. + detail = actual["detail_ref"] + packet = detail["replan_action_packet"] review = subprocess.run(shlex.split(packet["review"]), capture_output=True, text=True, check=True) - assert json.loads(review.stdout)["context_id"] == source["autonomous_replan_obligation"]["replan_context"]["context_id"] - assert authority["action_signature"] == envelope["action_signature"] - detail = authority["detail_ref"] - assert detail == envelope["detail_ref"] + context = source["autonomous_replan_obligation"]["replan_context"] + assert json.loads(review.stdout)["context_id"] == context["context_id"] captured = detail["captured_decision"] assert (captured["goal_id"], captured["agent_id"], captured["turn_instance_id"]) == (GOAL_ID, AGENT_ID, turn) read = subprocess.run(shlex.split(detail["full_decision"]), capture_output=True, check=True).stdout assert read == original - assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == authority["action_signature"]["source_decision_hash"] - context = json.loads(read)["autonomous_replan_obligation"]["replan_context"] - assert context["obligation_id"] == authority["replan_action_packet"]["obligation_id"] - goal_read = next(r for r in authority["required_reads"] if r["source"] == "goal_state") + assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == actual["source_decision_hash"] + assert context["obligation_id"] == source["replan_action_packet"]["obligation_id"] + goal_read = next(r for r in agent_channel["required_reads"] if r["source"] == "goal_state") delivered_goal = subprocess.run(shlex.split(goal_read["command"]), capture_output=True, check=True).stdout assert delivered_goal == goal_bytes and stop.encode() in delivered_goal - assert "Read replan_action_packet.review" in prompt - assert "read autonomous_replan_obligation.replan_context" not in prompt + assert "Read detail_ref.replan_action_packet.review" in prompt + assert "before reading detail_ref.replan_action_packet.writeback" in prompt assert "Never re-enter quota admission" in prompt assert "Do not execute writeback commands" in prompt for field in ("obligation_id", "planning_guidance", "uncovered_frontier"): invalid = deepcopy(request) - invalid["turn_envelope"]["replan_action_packet"][field] = "changed after signing" + invalid["work_contract"]["replan_action_packet"][field] = "changed after signing" rejected = subprocess.run(command, input=json.dumps(invalid), text=True, capture_output=True, timeout=30) - assert rejected.returncode != 0 and "action signature" in rejected.stderr + assert rejected.returncode != 0 and "complete Turn contract rejected" in rejected.stderr assert prompt_path.read_text() == prompt assert (capture / "decision.json").read_bytes() == original assert state.read_bytes() == goal_bytes diff --git a/tests/control_plane/test_turn_host_decision_capture.py b/tests/control_plane/test_turn_host_decision_capture.py index a0073bb774..2acbea8a0d 100644 --- a/tests/control_plane/test_turn_host_decision_capture.py +++ b/tests/control_plane/test_turn_host_decision_capture.py @@ -29,12 +29,208 @@ def _fixture_module(): return module +@pytest.fixture(params=["file", "sqlite"]) +def complete_host_request(tmp_path, monkeypatch, request): + """Real source/store with a pinned v0 carrier for decoder compatibility. + + Current producer/capture/recovery qualification lives in the cutover module; + these tests also retain the old private carrier as an independent control. + """ + from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = _fixture_module() + project, runtime, workspace, registry = fixture._write_fixture(tmp_path, turn_count=2) + state = project / ".codex/goals" / fixture.GOAL_ID / "ACTIVE_GOAL_STATE.md" + summary = parse_active_state_todos(state.read_text(), state_path=state, item_limit=None) + initialize_canonical_authority(runtime, fixture.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=fixture.GOAL_ID, handoff_mode="soft_claim", + todos=summary["agent_todos"]["items"]), state_path=state, provider=request.param) + code, plan = fixture._run_cli(["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "turn", "plan", "--goal-id", fixture.GOAL_ID, + "--agent-id", fixture.AGENT_ID, "--turn-instance-id", "optional-host-source", + "--host", "generic-cli", "--iteration-context", "fresh", + "--execution-mode", "isolated-headless", "--include-transaction-detail"]) + assert code == 0, plan + contract = plan["work_contract"] + assert contract["schema_version"] == "loopx_turn_envelope_v1" + original_state = state.read_bytes() + legacy = {**build_loopx_turn_host_request(plan), + "schema_version": "loopx_turn_host_request_v0", "turn_envelope": build_turn_envelope(contract)} + legacy.pop("source_decision_hash", None) + legacy.pop("detail_ref", None) + yield legacy, contract + assert state.read_bytes() == original_state + assert fixture._quota_spend_count(runtime) == 0 # Adapter results are not Core settlement. + + +def _pending_goal_contract(original): + from copy import deepcopy + from loopx.control_plane.quota.turn_envelope import compose_turn_decision, build_turn_envelope + + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + agent = source["interaction_contract"]["agent_channel"] + goal = next(row for row in agent["work_context"]["sources"] if row["source"] == "goal_state") + # Keep source positions: other exact references still belong to this capture. + # A valid source commitment does not fulfill the pending narrative read. + goal["content"] = {key: value for key, value in goal["content"].items() + if key in {"state_file", "revision"}} + agent["required_reads"] = [*agent.get("required_reads", []), + {key: value for key, value in goal.items() if key != "content"}] + contract = compose_turn_decision(source) + return contract, build_turn_envelope(contract) + + +@pytest.mark.parametrize("launch", ["runner", "argv"]) +@pytest.mark.parametrize("mode,capture", [ + ("current", False), ("pending_goal", False), ("historical", False), + ("current", True), ("pending_goal", True), +]) +def test_generic_dispatch_checks_complete_goal_before_host( + complete_host_request, tmp_path, launch, mode, capture, +): + from loopx.cli_commands.turn_cadence import managed_cadence_start + from loopx.control_plane.effect_runtime import effect_runtime_result + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.executor import run_loopx_turn_once + + request, original = complete_host_request + if mode == "pending_goal": + contract, envelope = _pending_goal_contract(original) + else: + contract, envelope = original, request["turn_envelope"] + plan = build_loopx_turn_plan(envelope, host="generic-cli", execution_mode="isolated-headless", + turn_instance_id="generic-goal-interlock", + **({} if mode == "historical" else {"work_contract": contract})) + assert plan["route"]["would_invoke_host"] is True + runtime = tmp_path / "generic-runtime" + scope = {"runtime_root": str(runtime), "goal_id": original["goal_id"], + "agent_id": original["agent_identity"]["agent_id"], "automation_id": None} + configured = effect_runtime_result("quota.automation_cadence.manage", { + **scope, "operation": "configure", "min_interval_minutes": 3, + "expected_revision": 0, "owner_reference": "synthetic-owner-fixture", "execute": True, + }) + assert configured["eligible_now"] is True + cadence = managed_cadence_start(runtime_root=runtime, goal_id=scope["goal_id"], + agent_id=scope["agent_id"], automation_id=None, manual_reason=None) + calls = [] + marker = tmp_path / "host-started" + + def runner(_request): + calls.append(_request) + raise RuntimeError("synthetic stop after dispatch observation") + + def unexpected_settlement(*_args, **_kwargs): + raise AssertionError("a failed Host observation must not settle work") + + host = {"host_runner": runner} if launch == "runner" else {"host_argv": [ + sys.executable, "-c", + "from pathlib import Path; import sys; sys.stdin.read(); " + f"Path({str(marker)!r}).write_text('started'); raise SystemExit(9)", + ]} + + def execute(): + return run_loopx_turn_once(plan, **host, project=tmp_path / "project", + runtime_root=runtime, goal_id=original["goal_id"], + timeout_seconds=5, execute=True, writeback=unexpected_settlement, + spend=unexpected_settlement, scheduler=unexpected_settlement, + admit_start=cadence.admit, confirm_start=cadence.confirm, + decision_capture_payload=contract if capture else None, require_decision_capture=capture) + + if mode == "pending_goal": + with pytest.raises(RuntimeError, match="Goal"): + execute() + assert calls == [] and not marker.exists() + else: + result = execute() + assert result["effects"]["host_invoked"] is True + assert len(calls) == (1 if launch == "runner" else 0) + assert marker.exists() is (launch == "argv") + state = effect_runtime_result("quota.automation_cadence.manage", {**scope, "operation": "read"}) + if mode == "pending_goal": + assert state["eligibility"]["state"] == "eligible" + journal = turn_journal_path(runtime, goal_id=scope["goal_id"], + turn_key=plan["transaction"]["turn_key"]) + assert not journal.exists() + assert not (journal.parent / "decisions" / journal.name).exists() + else: + assert state["eligibility"]["state"] == "waiting" + + +def test_typed_result_recovery_does_not_start_a_pending_goal_host( + complete_host_request, tmp_path, +): + from loopx.control_plane.turn_driver.executor import _host_result_stage, TRANSACTION_PHASES + from loopx.control_plane.turn_driver.host_candidate import build_result + + request, original = complete_host_request + contract, envelope = _pending_goal_contract(original) + request = {**request, "work_contract": contract, "turn_envelope": envelope} + vision = {"schema_version": "goal_vision_replan_contract_v0", "state": "active", + "vision_patch": {"acceptance_summary": "Validate the preserved work."}, + "path_delta": {"outcome": "replan", "prior_assumption": "Existing work remains useful.", + "observed_reality": "The artifact is independently checked.", + "changed": ["Use the verified artifact for the next work step."], + "evidence_refs": ["fixture:checked-work"]}} + candidate = build_result(request, {"result_kind": "replan_required", + "classification": "validated_fixture_replan", "summary": "Original work remains useful.", + "next_action": "Continue the original work.", "path_delta_mode": "material_replan", + "agent_vision_json": json.dumps(vision), "work_evidence": ["fixture:artifact"], + "validation_evidence": ["fixture:checked-work"]}) + plan = {**request, "transaction": {"turn_key": request["turn_key"]}} + journal = {"completed_phases": list(TRANSACTION_PHASES[:2]), "host_result": candidate} + effects = {"host_invoked": False, "state_written": False, + "quota_spent": False, "scheduler_acknowledged": False} + saved = [] + + def no_new_host(_request): + raise AssertionError("an existing typed result must not start another Host") + + result, phases, terminal = _host_result_stage(plan, request, + host_runner=no_new_host, argv=None, completion_lifecycle_configured=False, + project=tmp_path, timeout_seconds=5, journal=journal, + persist_journal=lambda value: saved.append(dict(value)), effects=effects) + assert terminal is None and result["result_kind"] == "replan_required" + assert "typed_result" in phases and not effects["host_invoked"] + assert saved and "host_attempt_count" not in journal + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("first_attempt_fails", [False, True]) @pytest.mark.parametrize("legacy_pretty", [False, True]) def test_real_host_reads_original_capture_after_source_change( tmp_path, monkeypatch, provider, first_attempt_fails, legacy_pretty, ): + from loopx.cli_commands import turn_decision + + launch_plans = [] + original_planner = turn_decision.build_loopx_turn_plan + + def observe_launch_plan(envelope, **kwargs): + # Observe the real live reader and planner; neither is replaced by a + # fixture result. Every launch, including retry, uses the same contract. + assert envelope["schema_version"] == "loopx_turn_envelope_v1" + assert "work_contract" not in kwargs + # The actual native caller must consume the typed owner's selected + # work, not independently decode the historical adapter again. + from loopx.control_plane.turn_driver import driver + + def legacy_selection(*_args, **_kwargs): + raise AssertionError("native planning reinterpreted historical selection") + + with monkeypatch.context() as planning: + planning.setattr(driver, "selected_turn_todo", legacy_selection) + planned = original_planner(envelope, **kwargs) + launch_plans.append(planned) + return planned + + monkeypatch.setattr(turn_decision, "build_loopx_turn_plan", observe_launch_plan) + def legacy_launch_selection(*_args, **_kwargs): + raise AssertionError("native launch reinterpreted historical selection") + + monkeypatch.setattr(turn_decision, "selected_turn_todo", legacy_launch_selection, raising=False) isolate_sqlite_runtime(tmp_path, monkeypatch) f = _fixture_module() project, runtime, workspace, registry = f._write_fixture(tmp_path, turn_count=2) @@ -73,13 +269,16 @@ def test_real_host_reads_original_capture_after_source_change( assert not list(runtime.rglob("decisions/*.json")) assert not (workspace / "received.txt").exists() code, result = f._run_cli([*base, "--execute"]) + assert len(launch_plans) == 1 + assert launch_plans[0]["transaction"]["settlement_plan"]["identity"]["turn_instance_id"] == "original-observation" if first_attempt_fails: assert code == 1 and result["status"] == "failed", result assert f._quota_spend_count(runtime) == 0 else: assert code == 0 and result["status"] == "committed", result request = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) - detail = request["turn_envelope"]["detail_ref"] + assert "turn_envelope" not in request + detail = request["detail_ref"] capture = detail["captured_decision"] assert capture["turn_instance_id"] == "original-observation" assert capture["turn_key"] == request["turn_key"] @@ -94,13 +293,14 @@ def test_real_host_reads_original_capture_after_source_change( else: assert original.endswith(b"\n") and b"\n" not in original[:-1] source_a = json.loads(original) + assert request["work_contract"] == source_a assert source_a["goal_id"] == f.GOAL_ID assert source_a["agent_identity"]["agent_id"] == f.AGENT_ID # Direct `turn` bypasses quota CLI emission. Check its actual subprocess # prompt against the same original contracts, including recovery below. for field in ("work_lane_contract", "execution_obligation", "automation_liveness"): assert source_a[field] - assert request["turn_envelope"]["contract_capsule"][field] == source_a[field] + assert request["work_contract"][field] == source_a[field] # Change the real provider through its native CAS CLI, not the projection. prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] code, current = f._run_cli([*prefix, "todo", "list", "--goal-id", f.GOAL_ID, "--todo-id", f.TODO_ID]) @@ -118,9 +318,33 @@ def test_real_host_reads_original_capture_after_source_change( assert code == 0 and "SOURCE_B" in current["todo"]["text"] plan = load_loopx_turn_plan_from_journal(runtime, goal_id=f.GOAL_ID, turn_key=request["turn_key"]) journal_path = turn_journal_path(runtime, goal_id=f.GOAL_ID, turn_key=request["turn_key"]) + # An existing receipt must not let a malformed complete request bypass the + # source gate on recovery. Reuse the real CLI journal and provider state; + # none of these rejected calls may invoke a host or settlement callback. + from copy import deepcopy + from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request, run_loopx_turn_once + + def unexpected_effect(*_args, **_kwargs): + raise AssertionError("rejected recovery invoked an effect") + + original_journal = journal_path.read_bytes() + spent_before = f._quota_spend_count(runtime) + for bad_contract in (None, {**plan["work_contract"], "reason": "changed after capture"}): + malformed = deepcopy(plan) + malformed["work_contract"] = bad_contract + with pytest.raises(RuntimeError): + run_loopx_turn_once(malformed, project=project, runtime_root=runtime, + goal_id=f.GOAL_ID, timeout_seconds=10, execute=True, + host_runner=unexpected_effect, writeback=unexpected_effect, + spend=unexpected_effect, scheduler=unexpected_effect) + assert journal_path.read_bytes() == original_journal + assert f._quota_spend_count(runtime) == spent_before rebound = prepare_turn_decision_capture(plan, journal_path=journal_path, decision=None, existing_journal=True) - assert rebound["turn_envelope"]["detail_ref"] == detail + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + rebound_input = complete_host_input(build_loopx_turn_host_request(rebound)) + assert rebound_input["detail_ref"] == detail + assert rebound_input["work_contract"] == source_a replay = list(base) index = replay.index("--turn-instance-id") del replay[index:index + 2] @@ -157,7 +381,8 @@ def test_real_host_reads_original_capture_after_source_change( "--retry-failed-turn", "--execute"]) assert code == 0 and recovered["status"] == "committed", json.dumps(recovered) retried = json.loads((workspace / "received.txt").read_text().split("Turn request:\n", 1)[1]) - assert retried["turn_envelope"] == request["turn_envelope"] + assert retried["work_contract"] == request["work_contract"] + assert retried["detail_ref"] == detail assert path.read_bytes() == original code, repeated = f._run_cli([*replay, "--resume-turn-key", request["turn_key"], "--execute"]) assert code == 0, repeated @@ -177,7 +402,7 @@ def test_real_host_reads_original_capture_after_source_change( staged.publish() assert path.read_bytes() == changed path.write_text(json.dumps(dict(reversed(source_a.items())))) - assert staged.publish()["turn_envelope"]["detail_ref"] == detail + assert complete_host_input(build_loopx_turn_host_request(staged.publish()))["detail_ref"] == detail path.write_bytes(original) path.write_text(json.dumps({**source_a, "reason": "changed during admission"})) changed = path.read_bytes() @@ -189,7 +414,7 @@ def test_real_host_reads_original_capture_after_source_change( staged.publish() assert not path.exists() path.write_bytes(original) - assert staged.publish()["turn_envelope"]["detail_ref"] == detail + assert complete_host_input(build_loopx_turn_host_request(staged.publish()))["detail_ref"] == detail assert path.read_bytes() == original # Corrupt/missing evidence cannot be replaced by the caller's new guard. path.write_text(json.dumps({**source_a, "reason": "wrong original"})) @@ -200,7 +425,12 @@ def test_real_host_reads_original_capture_after_source_change( with pytest.raises(ValueError, match="missing"): prepare_turn_decision_capture(plan, journal_path=journal_path, decision=source_a, existing_journal=True) - legacy = {**plan, "turn_envelope": {**plan["turn_envelope"], "detail_ref": {}}} + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + legacy = {**plan, "schema_version": "loopx_turn_plan_v0", + "turn_envelope": {**build_turn_envelope(source_a), "detail_ref": {}}} + for field in ("work_contract", "source_decision_hash", "detail_ref"): + legacy.pop(field, None) with pytest.raises(ValueError, match="no original decision capture"): prepare_turn_decision_capture(legacy, journal_path=journal_path, decision=source_a, existing_journal=True) @@ -239,3 +469,820 @@ def no_readback(*args, **kwargs): assert Path(reference["path"]).read_bytes() == expected assert reference["sha256"] == hashlib.sha256(expected).hexdigest() assert Path(reference["path"]).stat().st_mode & 0o077 == 0 + + +@pytest.mark.parametrize("adaptive", [False, True]) +def test_complete_session_lineage_preserves_saved_host_sessions(complete_host_request, tmp_path, adaptive): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.extensions.turn_host import extract_turn_work_identity + from loopx.control_plane.turn_driver.codex_sessions import ( + _lineage, _store_codex_cli_session, load_codex_cli_session, codex_cli_session_binding, + ) + + request, contract = complete_host_request + if adaptive: + source = {key: value for key, value in contract.items() + if key not in {"schema_version", "contract_binding"}} + source["task_orchestration_contract"] = { + "schema_version": "task_orchestration_contract_v2", "mode": "adaptive", + "primary_todo_id": "todo_adaptive_owner", + } + contract = compose_turn_decision(source) + request = {**request, "work_contract": contract, "turn_envelope": build_turn_envelope(contract)} + fixture = _fixture_module() + expected = {"goal_id": fixture.GOAL_ID, "agent_id": fixture.AGENT_ID, + "todo_id": "todo_adaptive_owner" if adaptive else fixture.TODO_ID} + historical = {key: value for key, value in request.items() if key != "work_contract"} + assert extract_turn_work_identity(request) == expected + # Historical optional hosts read the declared id directly; changing their + # adaptive fallback would be a separate compatibility change. + assert extract_turn_work_identity(historical) == {**expected, "todo_id": fixture.TODO_ID} + for scope in ("todo", "agent"): + assert _lineage(request, session_scope=scope) == expected + assert _lineage(historical, session_scope=scope) == expected + for host in ("codex-cli", "dsh"): + runtime = tmp_path / host / scope + _store_codex_cli_session(runtime, lineage=expected, session_scope=scope, + host=host, session_id="existing-native-session") + saved = next(runtime.rglob("turn-sessions/*.json")) + before = saved.read_bytes() + restored = load_codex_cli_session(runtime, lineage=_lineage(request, session_scope=scope), + session_scope=scope, host=host) + assert restored["session_id"] == "existing-native-session" + for fields in ({}, {"work_contract": contract}): + binding = codex_cli_session_binding(runtime, request["turn_envelope"], + session_scope=scope, host=host, **fields) + assert binding == {"schema_version": "loopx_turn_session_binding_v0", **expected} + with pytest.raises(RuntimeError): + codex_cli_session_binding(runtime, request["turn_envelope"], + session_scope=scope, host=host, work_contract=None) + assert saved.read_bytes() == before + + +@pytest.mark.parametrize("adaptive", [False, True]) +@pytest.mark.parametrize("context_mode", ["fresh", "reuse"]) +def test_complete_dsh_binds_session_and_tools_to_shared_work( + complete_host_request, tmp_path, monkeypatch, adaptive, context_mode, +): + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.dsh_goal_mode import turn_host_adapter + + request, original = complete_host_request + source = {key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}} + expected_todo = original["selected_todo"]["todo_id"] + if adaptive: + expected_todo = "todo_adaptive_owner" + source["task_orchestration_contract"] = { + "schema_version": "task_orchestration_contract_v2", "mode": "adaptive", + "primary_todo_id": expected_todo, + } + contract = compose_turn_decision(source) + request = {**request, "work_contract": contract, "turn_envelope": build_turn_envelope(contract), + "session": {**request["session"], "context_policy": {"mode": context_mode}}} + calls = [] + + def runner(**kwargs): + calls.append(kwargs) + return '{"result_kind":"wait"}' + + monkeypatch.setattr(turn_host_adapter, "run_dsh_turn", runner) + pinned = {"LOOPX_TURN_TODO_ID": "stale-ambient-todo", "DSH_PERMISSION_MODE": "read-only"} + config = turn_host_adapter.DshHostConfig(workspace=tmp_path, env=pinned) + result = turn_host_adapter.run_dsh_host(request, config=config) + assert result["result_kind"] == "wait" + [call] = calls + assert call["env"] == {**pinned, "LOOPX_TURN_GOAL_ID": original["goal_id"], + "LOOPX_TURN_AGENT_ID": original["agent_identity"]["agent_id"], + "LOOPX_TURN_TODO_ID": expected_todo, + "LOOPX_TURN_WORKSPACE": str(tmp_path.resolve())} + if context_mode == "fresh": + prefix, identity = "dsh-iteration-v1-", [request["turn_key"]] + else: + prefix = "dsh-lineage-v1-" + identity = [original["goal_id"], original["agent_identity"]["agent_id"], expected_todo] + instance = request.get("goal_ref", {}).get("goal_instance_id") + if instance: + prefix, identity = "dsh-lineage-v2-", [*identity, instance] + expected_session = prefix + sha256(json.dumps(identity, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + assert call["session_id"] == expected_session + model_input, _ = json.JSONDecoder().raw_decode(call["prompt"].split("Turn request:\n", 1)[1]) + assert model_input["work_contract"] == contract and "turn_envelope" not in model_input + assert pinned["LOOPX_TURN_TODO_ID"] == "stale-ambient-todo" + for invalid in (None, {**contract, "reason": "changed source"}, + {**contract, "schema_version": "unsupported_fixture_version"}): + with pytest.raises(turn_host_adapter.BuiltInHostError) as rejected: + turn_host_adapter.run_dsh_host({**request, "work_contract": invalid}, config=config) + assert rejected.value.failure_kind == "contract_rejected" + assert len(calls) == 1 + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("launch", ["builtin", "generic_stdio"]) +def test_real_dsh_cli_preserves_complete_input_and_once_only_settlement(tmp_path, monkeypatch, provider, launch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = _fixture_module() + project, runtime, workspace, registry = fixture._write_fixture(tmp_path, turn_count=2) + state = project / ".codex/goals" / fixture.GOAL_ID / "ACTIVE_GOAL_STATE.md" + summary = parse_active_state_todos(state.read_text(), state_path=state, item_limit=None) + initialize_canonical_authority(runtime, fixture.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=fixture.GOAL_ID, handoff_mode="soft_claim", + todos=summary["agent_todos"]["items"]), state_path=state, provider=provider) + runner = tmp_path / "dsh_runner.py" + runner.write_text( + "import json\nfrom pathlib import Path\n" + "def run_dsh_turn(**kwargs):\n" + " source, _ = json.JSONDecoder().raw_decode(kwargs['prompt'].split('Turn request:\\n', 1)[1])\n" + " contract = source['work_contract']\n" + " assert source['schema_version'] == 'loopx_turn_host_request_v1'\n" + " assert contract['schema_version'] == 'loopx_turn_envelope_v1'\n" + " assert 'turn_envelope' not in source\n" + f" assert contract['goal_id'] == {fixture.GOAL_ID!r}\n" + f" assert contract['selected_todo']['todo_id'] == {fixture.TODO_ID!r}\n" + " root = Path(kwargs['workspace'])\n" + " calls = root / 'runner-calls.txt'\n" + " assert not calls.exists(), 'settlement replay dispatched the runner again'\n" + " calls.write_text('1')\n" + f" (root / {fixture.MARKER_NAME!r}).write_text({fixture._marker_value(1)!r})\n" + " return json.dumps({'result_kind':'validated_progress','classification':'dsh_complete_work',\n" + f" 'work_evidence':['artifact:{fixture.MARKER_NAME}'],\n" + " 'validation_evidence':['fixture:independent-marker-validator'],\n" + " 'summary':'Completed step 1; step 2 remains open.', 'next_action':'Advance step 2.'})\n", + encoding="utf-8", + ) + validator = [sys.executable, "-c", "import json,pathlib,sys; json.load(sys.stdin); " + f"assert pathlib.Path({fixture.MARKER_NAME!r}).read_text() == {fixture._marker_value(1)!r}"] + host_args = (["--host", "dsh", "--dsh-runner", str(runner)] if launch == "builtin" else [ + "--host", "generic-cli", "--host-adapter-command-json", json.dumps([ + sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(workspace), + "--dsh-runner", str(runner), "--dsh-home", str(tmp_path / "stdio-dsh-home")])]) + argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "turn", "run-once", "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, + *host_args, "--execution-mode", "isolated-headless", "--project", str(workspace), + "--validation-command-json", json.dumps(validator), + "--validation-failure-kind", "repair_required", "--no-global-sync", "--timeout-seconds", "20"] + code, first = fixture._run_cli([*argv, "--turn-instance-id", "dsh-complete-source", "--execute"]) + assert code == 0 and first["status"] == "committed", first + assert first["result_kind"] == "validated_progress" and fixture._quota_spend_count(runtime) == 1 + code, replay = fixture._run_cli([*argv, "--resume-turn-key", first["resume_turn_key"], "--execute"]) + assert code == 0 and replay["status"] == "committed", replay + assert (workspace / "runner-calls.txt").read_text() == "1" + assert fixture._quota_spend_count(runtime) == 1 + code, current = fixture._run_cli(["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "todo", "list", "--goal-id", fixture.GOAL_ID, "--todo-id", fixture.TODO_ID]) + assert code == 0 and current["todo"]["status"] == "open" + assert current["authority_read"]["source_authority"] == provider + "_v0" + + +def test_historical_tool_identity_preserves_raw_values(complete_host_request): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.extensions.turn_host import extract_turn_work_identity + + original, _ = complete_host_request + fixture = _fixture_module() + for field in ("goal_id", "agent_id", "todo_id"): + for value in (None, False, 0, 23, [], " \ufeffraw identity "): + request = deepcopy({key: value for key, value in original.items() if key != "work_contract"}) + envelope = request["turn_envelope"] + target = envelope["action"]["selected_todo"] if field == "todo_id" else envelope + target[field] = value + digest = "sha256:" + sha256(json.dumps(turn_envelope_action_signature_document(envelope), + sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + expected = {"goal_id": fixture.GOAL_ID, "agent_id": fixture.AGENT_ID, "todo_id": fixture.TODO_ID, + field: value} + assert extract_turn_work_identity(request) == expected + + +def test_complete_tool_identity_never_falls_back_to_historical_input(complete_host_request): + from copy import deepcopy + from loopx.extensions.turn_host import extract_turn_work_identity + + request, contract = complete_host_request + before = deepcopy(request) + for invalid in (None, {**contract, "reason": "changed source"}, + {**contract, "schema_version": "unsupported_fixture_version"}): + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_work_identity({**request, "work_contract": invalid}) + assert request == before + + +def test_complete_host_rejects_self_consistent_wrong_effect_identity(complete_host_request): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.control_plane.turn_driver.codex_sessions import _lineage + from loopx.control_plane.turn_driver.host_candidate import complete_host_input, extract_turn_work_identity + + original, _ = complete_host_request + for field in ("goal_id", "agent_id", "todo_id"): + request = deepcopy(original) + envelope = request["turn_envelope"] + if field == "todo_id": + envelope["action"]["selected_todo"][field] = "different-work" + else: + envelope[field] = "different-owner" + # Both digests are individually valid. The original complete input must + # still reject a compatibility adapter redirected to another identity. + digest = "sha256:" + sha256(json.dumps(turn_envelope_action_signature_document(envelope), + sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + for consume in (_lineage, complete_host_input): + with pytest.raises(RuntimeError, match="lineage differs"): + consume(request) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + extract_turn_work_identity(request) + + +@pytest.mark.parametrize("from_journal", [False, True]) +def test_complete_result_vision_uses_original_identity(complete_host_request, from_journal): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.control_plane.turn_driver.executor import validate_loopx_turn_host_result + from loopx.control_plane.turn_driver.host_candidate import build_result + + request, _ = complete_host_request + vision = {"schema_version": "goal_vision_replan_contract_v0", "state": "active", + "vision_patch": {"acceptance_summary": "Validate the preserved work."}, + "path_delta": {"outcome": "replan", "prior_assumption": "Existing work remains useful.", + "observed_reality": "The artifact is independently checked.", + "changed": ["Use the verified artifact for the next work step."], "evidence_refs": ["fixture:checked-work"]}} + candidate = build_result(request, {"result_kind": "replan_required", + "classification": "validated_fixture_replan", "summary": "Original work remains useful.", + "next_action": "Continue the original work.", "path_delta_mode": "material_replan", + "agent_vision_json": json.dumps(vision), "work_evidence": ["fixture:artifact"], + "validation_evidence": ["fixture:checked-work"]}) + plan = {**request, "transaction": {"turn_key": request["turn_key"]}} + accepted = validate_loopx_turn_host_result(plan, candidate) + assert accepted["ok"], accepted + original_vision = accepted["result"]["agent_vision"] + fixture = _fixture_module() + assert (original_vision["goal_id"], original_vision["agent_id"]) == (fixture.GOAL_ID, fixture.AGENT_ID) + if from_journal: + candidate = accepted["result"] + assert validate_loopx_turn_host_result(plan, candidate, from_journal=from_journal)["ok"] + changed = deepcopy(plan) + envelope = changed["turn_envelope"] + envelope["agent_id"] = "another-agent" + digest = "sha256:" + sha256(json.dumps(turn_envelope_action_signature_document(envelope), + sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + rejected = validate_loopx_turn_host_result(changed, candidate, from_journal=from_journal) + assert not rejected["ok"] + assert any("lineage differs" in error for error in rejected["errors"]) + + +@pytest.mark.parametrize("carrier", ["canonical_detail", "deduplicated_text", "missing_source", "ambiguous_source"]) +def test_complete_completion_source_uses_admitted_contract(complete_host_request, carrier): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.turn_driver.driver import captured_todo_text_sha256 + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + + _, contract = complete_host_request + source = deepcopy({key: value for key, value in contract.items() + if key not in {"schema_version", "contract_binding"}}) + todo_id = source["selected_todo"]["todo_id"] + text = "Preserve every requirement. 保留全部要求。\n" * 50 + " final whitespace\n" + source["selected_todo"]["text"] = text if carrier == "deduplicated_text" else "Display prefix" + context = {"complete": True, "selected_todo_text_verified": True} + if carrier == "deduplicated_text": + context["selected_todo_ref"] = "selected_todo" + elif carrier != "missing_source": + record = {"source": "selected_todo", "content": {"todo": {"todo_id": todo_id, "text": text}}} + context["sources"] = [record, record] if carrier == "ambiguous_source" else [record] + source["interaction_contract"]["agent_channel"]["work_context"] = context + # This synthetic source replaces the task record, so do not retain the old + # lane title reference to its previous source index. + source["agent_lane_next_action"].pop("title_ref", None) + source["agent_lane_next_action"]["title"] = "Completion source qualification" + contract = compose_turn_decision(source) + envelope = build_turn_envelope(contract) + if carrier in {"missing_source", "ambiguous_source"}: + with pytest.raises(ValueError, match="full task source|ambiguous"): + captured_todo_text_sha256(envelope, todo_id=todo_id, work_contract=contract) + return + assert captured_todo_text_sha256(envelope, todo_id=todo_id, work_contract=contract) == sha256(text.encode()).hexdigest() + assert captured_todo_text_sha256(envelope, todo_id=todo_id) == sha256(text.encode()).hexdigest() + for bad in (None, {**contract, "reason": "changed after capture"}): + with pytest.raises(ValueError): + captured_todo_text_sha256(envelope, todo_id=todo_id, work_contract=bad) + with pytest.raises(ValueError, match="selected Todo"): + captured_todo_text_sha256(envelope, todo_id="foreign_todo", work_contract=contract) + altered = deepcopy(envelope) + altered["action"]["selected_todo"]["text"] = "changed internal effect body" + with pytest.raises(ValueError, match="captured executor source"): + captured_todo_text_sha256(altered, todo_id=todo_id, work_contract=contract) + + +def test_complete_result_evidence_applicability_uses_admitted_profile(complete_host_request): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, compose_turn_decision, turn_envelope_action_signature_document, + ) + from loopx.control_plane.turn_driver.codex_cli import ( + codex_cli_result_schema, normalize_codex_result, prepare_codex_cli_input, + ) + from loopx.control_plane.turn_driver.host_failure import BuiltInHostError + from loopx.control_plane.turn_driver.host_candidate import build_result + + request, original = complete_host_request + candidate = {"result_kind": "validated_progress", "next_action": "Continue checked work."} + # The complete source and historical projection agree for every applicability + # case. Profile content remains owned by admission, not this result adapter. + for profile in ("omitted", None, {}, "not a profile", {"work_requirements": ["Check the artifact."]}): + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + if profile == "omitted": + source.pop("execution_profile", None) + else: + source["execution_profile"] = profile + contract = compose_turn_decision(source) + envelope = build_turn_envelope(contract) + complete = {**request, "work_contract": contract, "turn_envelope": envelope} + historical = {key: value for key, value in complete.items() if key != "work_contract"} + expected = isinstance(profile, dict) and bool(profile) + for consumer_request in (complete, historical): + schema = codex_cli_result_schema(consumer_request) + assert set(schema["required"]) == set(schema["properties"]) + assert not {"allOf", "if", "then"}.intersection(schema) + assert prepare_codex_cli_input(consumer_request)[1] == schema + null_result = {"result_kind": "validated_progress", + "work_evidence": None, "validation_evidence": None} + if expected: + with pytest.raises(BuiltInHostError, match="codex_material_result_missing_evidence"): + normalize_codex_result(consumer_request, null_result) + else: + assert normalize_codex_result(consumer_request, null_result) == null_result + result = build_result(consumer_request, candidate) + assert (result["classification"] == "missing_host_execution_evidence") is expected + for field in ("work_evidence", "validation_evidence"): + partial = build_result(consumer_request, {**candidate, field: ["fixture:checked"]}) + assert (partial["classification"] == "missing_host_execution_evidence") is expected + completion = build_result(consumer_request, {"result_kind": "validated_completion"}, allow_completion=True) + assert (completion["classification"] == "missing_host_execution_evidence") is expected + assert build_result(consumer_request, {"result_kind": "wait"})["result_kind"] == "wait" + # Supplied evidence is still validated by the executor; conversion + # must not manufacture evidence or silently sanitize invalid values. + supplied = {**candidate, "work_evidence": None, "validation_evidence": []} + result = build_result(consumer_request, supplied) + assert result["result_kind"] == "validated_progress" + assert result["work_evidence"] is None and result["validation_evidence"] == [] + + # A separately signed effect representation cannot become the profile + # authority of a complete request. Historical-only requests still read it. + altered = deepcopy(envelope) + altered["contract_capsule"]["execution_profile"] = {} if expected else {"work_requirements": ["Legacy"]} + digest = "sha256:" + sha256(json.dumps(turn_envelope_action_signature_document(altered), + ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + altered["action_signature"].update(source_hash=digest, envelope_hash=digest, matches=True) + changed = {**complete, "turn_envelope": altered} + with pytest.raises(RuntimeError, match="projection differs"): + codex_cli_result_schema(changed) + with pytest.raises(RuntimeError, match="projection differs"): + build_result(changed, candidate) + with pytest.raises(RuntimeError, match="projection differs"): + normalize_codex_result(changed, null_result) + historical_changed = {key: value for key, value in changed.items() if key != "work_contract"} + assert not codex_cli_result_schema(historical_changed).get("allOf") + if not expected: + with pytest.raises(BuiltInHostError, match="codex_material_result_missing_evidence"): + normalize_codex_result(historical_changed, null_result) + else: + assert normalize_codex_result(historical_changed, null_result) == null_result + assert (build_result(historical_changed, candidate)["classification"] == "missing_host_execution_evidence") is not expected + + for bad_contract in (None, {**original, "reason": "changed after admission"}): + malformed = {**request, "work_contract": bad_contract} + with pytest.raises(RuntimeError): + codex_cli_result_schema(malformed) + with pytest.raises(RuntimeError): + build_result(malformed, candidate) + malformed = deepcopy(request) + malformed["turn_envelope"]["contract_capsule"]["execution_profile"] = {"changed": True} + with pytest.raises(RuntimeError): + codex_cli_result_schema(malformed) + with pytest.raises(RuntimeError): + build_result(malformed, candidate) + + +def test_complete_settlement_fallback_keeps_original_identity(complete_host_request): + from copy import deepcopy + from loopx.control_plane.turn_driver.executor import _ensure_turn_settlement_plan + + request, contract = complete_host_request + plan = {**request, "host": {"kind": "generic-cli", "execution_mode": "isolated-headless"}} + transaction = {"turn_instance_id": "original-settlement", "turn_key": request["turn_key"]} + lineage = _ensure_turn_settlement_plan(plan, transaction) + identity = transaction["settlement_plan"]["identity"] + assert (identity["goal_id"], identity["agent_id"], identity["todo_id"]) == ( + contract["goal_id"], contract["agent_identity"]["agent_id"], contract["selected_todo"]["todo_id"]) + assert identity["turn_instance_id"] == "original-settlement" + before = deepcopy(transaction) + assert _ensure_turn_settlement_plan(plan, transaction) == lineage + assert transaction == before # A recorded settlement is never rebuilt. + historical = {key: value for key, value in plan.items() if key != "work_contract"} + legacy_transaction = {"turn_instance_id": "original-settlement", "turn_key": request["turn_key"]} + _ensure_turn_settlement_plan(historical, legacy_transaction) + assert legacy_transaction == transaction + for existing in (False, True): + for bad_contract in (None, {**contract, "reason": "changed source"}): + target = deepcopy(transaction) if existing else {"turn_instance_id": "original-settlement"} + original = deepcopy(target) + with pytest.raises(RuntimeError): + _ensure_turn_settlement_plan({**plan, "work_contract": bad_contract}, target) + assert target == original + + +def test_complete_terminal_intent_rechecks_original_source(complete_host_request, monkeypatch): + from copy import deepcopy + from loopx.control_plane.turn_driver import settlement + + request, contract = complete_host_request + todo_id = contract["selected_todo"]["todo_id"] + + def legacy_selection(*_args, **_kwargs): + raise AssertionError("complete terminal intent reinterpreted historical selection") + + monkeypatch.setattr(settlement, "selected_turn_todo", legacy_selection) + before = deepcopy(request) + for continuation in ("active_goal", "successor", "no_followup"): + outcome = {"todo_id": todo_id, "continuation": continuation} + if continuation == "successor": + outcome["successor_todo_ids"] = ["todo_checked_successor"] + for phase in (None, "writeback", "terminal_closeout"): + journal = {phase: {"completion": outcome}} if phase else {} + def intent(_result): + assert phase is None, "stored completion must not repeat lifecycle intent" + return outcome + + actual = settlement.terminal_closeout_requirement(plan=request, + result={"result_kind": "validated_completion"}, journal=journal, + completion_intent=intent) + assert actual == (continuation == "no_followup", None) + # Another attempt or journal recovery must recheck its source; + # prior successful consumption cannot become a retained proof. + for malformed in (None, {**contract, "reason": "replaced after execution"}): + rejected = settlement.terminal_closeout_requirement( + plan={**request, "work_contract": malformed}, + result={"result_kind": "validated_completion"}, journal=journal, + completion_intent=intent) + assert rejected[0] is False and rejected[1] + assert request == before + + +def test_codex_prompt_preserves_complete_source_and_historical_version_gate(complete_host_request): + from copy import deepcopy + from loopx.control_plane.turn_driver.codex_cli import _prompt + from loopx.control_plane.turn_driver.host_candidate import complete_host_input + + request, contract = complete_host_request + prompt = _prompt(request) + payload = json.loads(prompt.split("Turn request:\n", 1)[1]) + assert payload == complete_host_input(request) + assert payload["work_contract"] == contract and "turn_envelope" not in payload + historical = {key: value for key, value in request.items() if key != "work_contract"} + assert "TurnEnvelope as the source of truth" in _prompt(historical) + for source in (request, historical): + broken = deepcopy(source) + broken["turn_envelope"]["schema_version"] = "unsupported_fixture_version" + with pytest.raises(RuntimeError): + _prompt(broken) + for invalid in (None, {**contract, "reason": "changed source"}, + {**contract, "schema_version": "unsupported_fixture_version"}): + with pytest.raises(RuntimeError): + _prompt({**request, "work_contract": invalid}) + broken = deepcopy(request) + broken["turn_envelope"]["agent_id"] = "another-agent" + with pytest.raises(RuntimeError): + _prompt(broken) + broken["work_contract"] = None + broken["turn_envelope"]["schema_version"] = "unsupported_fixture_version" + with pytest.raises(RuntimeError, match="unsupported TurnEnvelope schema_version"): + _prompt(broken) + + +def test_complete_structured_authority_keeps_detached_source(complete_host_request): + from copy import deepcopy + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority + + request, contract = complete_host_request + before = deepcopy(contract) + authority = extract_turn_authority(request) + authority["contract_binding"]["source_decision_hash"] = "changed local authority" + authority["selected_todo"]["text"] = "changed local work" + assert contract == before and request["work_contract"] == before + + +@pytest.mark.parametrize("operation_tools", [False, True]) +def test_complete_codex_launch_shares_input_check_only_within_attempt( + complete_host_request, tmp_path, monkeypatch, operation_tools, +): + from copy import deepcopy + from loopx.control_plane.quota import turn_envelope + from loopx.control_plane.turn_driver import codex_cli, codex_operation_host + from loopx.control_plane.turn_driver.codex_cli import _prompt, codex_cli_result_schema, run_codex_cli_host + from loopx.control_plane.turn_driver.codex_operation_host import run_codex_operation_host + from loopx.control_plane.turn_driver.executor import MATERIAL_HOST_RESULT_KINDS + from tests.test_codex_operation_host import FAKE_SERVER + + request, _ = complete_host_request + expected_prompt, expected_schema = _prompt(request), codex_cli_result_schema(request) + prompt_file, schema_file = tmp_path / "prompt.txt", tmp_path / "schema.json" + if operation_tools: + executable = tmp_path / "fake-operation-host" + executable.write_text(FAKE_SERVER.replace( + 'assert row["params"]["outputSchema"]["type"] == "object"', + 'import pathlib\n ' + f'pathlib.Path({str(schema_file)!r}).write_text(json.dumps(row["params"]["outputSchema"]))', + )) + executable.chmod(0o700) + monkeypatch.setenv("FAKE_OPERATION_PROMPT_FILE", str(prompt_file)) + else: + executable = _fixture_module()._write_fake_codex(tmp_path) + executable.write_text(executable.read_text().replace( + "prompt = sys.stdin.read()", "prompt = sys.stdin.read()\n" + f"pathlib.Path({str(prompt_file)!r}).write_text(prompt)\n" + f"pathlib.Path({str(schema_file)!r}).write_text(pathlib.Path(args[args.index('--output-schema') + 1]).read_text())", + )) + observed = [] + original = turn_envelope.effect_runtime_projection_result + phase = "prepare" + normalizer = codex_cli.normalize_codex_result + expected_source = request["work_contract"] + + def normalize_after_provider(request, result): + nonlocal phase + assert phase == "prepare" + phase = "result" + return normalizer(request, result) + + def observe(method, arguments, **kwargs): + if arguments.get("operation") in {"host_context", "host_dispatch_context", "validate_complete"}: + assert arguments["payload"] == expected_source + observed.append((phase, arguments["operation"], arguments.get("include_session_lineage", False))) + return original(method, arguments, **kwargs) + + monkeypatch.setattr(turn_envelope, "effect_runtime_projection_result", observe) + monkeypatch.setattr(codex_cli, "normalize_codex_result", normalize_after_provider) + monkeypatch.setattr(codex_operation_host, "normalize_codex_result", normalize_after_provider) + options = dict(runtime_root=tmp_path / "runtime", project=tmp_path / "project", + codex_bin=str(executable), timeout_seconds=10) + run = run_codex_cli_host + if operation_tools: + run = run_codex_operation_host + options.update(registry_path=tmp_path / "project/.loopx/registry.json", + model="test-model", reasoning_effort="xhigh") + for _ in range(2): + observed.clear() + phase = "prepare" + result = run(request, **options) + assert result["turn_key"] == request["turn_key"] + # Both transports preserve the original complete input. Native operation + # guidance may follow it, but must not fold its whitespace or JSON prose. + assert prompt_file.read_text().startswith(expected_prompt) + assert json.loads(schema_file.read_text()) == expected_schema + # Operation preflight checks Goal delivery before native initialization + # and shares that call's normalized lineage. Its post-start preparation + # still rechecks before send; ordinary CLI preparation remains one call. + # Material results independently check again after the provider. + assert observed == ( + ([("prepare", "host_dispatch_context", True)] if operation_tools else []) + + [("prepare", "host_dispatch_context", False)] + + ([("result", "host_context", False)] if result["result_kind"] in MATERIAL_HOST_RESULT_KINDS else [])) + changed = deepcopy(request) + changed["work_contract"]["reason"] = "source changed after a successful launch" + before = prompt_file.read_bytes(), schema_file.read_bytes() + phase = "prepare" + expected_source = changed["work_contract"] + with pytest.raises((RuntimeError, ValueError)): + run(changed, **options) + assert before == (prompt_file.read_bytes(), schema_file.read_bytes()) + + +@pytest.mark.parametrize("host", ["codex", "structured"]) +def test_complete_host_prompt_uses_source_without_decoding_effect_layout(complete_host_request, host, monkeypatch): + from loopx.control_plane.turn_driver import codex_cli, host_candidate + from loopx.control_plane.turn_driver.codex_cli import _prompt + from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, render_prompt + + request, contract = complete_host_request + + class OpaqueEffectEnvelope(dict): + def get(self, key, default=None): + # The real typed owner still receives the entire envelope for its + # signature/source fence. Only original-capture transport metadata + # may be read here; Host policy must use the complete source. + if key not in {"detail_ref", "action_signature"}: + raise AssertionError("Host decoded the historical effect layout: " + key) + return super().get(key, default) + + original = json.dumps(request, ensure_ascii=False, sort_keys=True) + guarded = {**request, "turn_envelope": OpaqueEffectEnvelope(request["turn_envelope"])} + for adapter in (codex_cli, host_candidate): + original_mapping = adapter._mapping + + def observed_mapping(value, *, convert=original_mapping): + return value if isinstance(value, OpaqueEffectEnvelope) else convert(value) + + # Keep the observation wrapper across the adapters' shallow defensive + # copies; otherwise the assertion would never see their policy reads. + monkeypatch.setattr(adapter, "_mapping", observed_mapping) + if host == "codex": + prompt = _prompt(guarded) + else: + authority = extract_turn_authority(guarded) + assert authority["primary_action"] == request["turn_envelope"]["action"]["primary_action"] + prompt = render_prompt(authority) + model_input, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) + assert model_input["work_contract"] == contract + assert "turn_envelope" not in model_input and "turn_authority" not in model_input + assert json.dumps(request, ensure_ascii=False, sort_keys=True) == original + + +def test_complete_codex_approval_guidance_uses_resolved_boundary(complete_host_request): + from copy import deepcopy + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.control_plane.turn_driver.codex_cli import _prompt + + request, original = complete_host_request + active = {"schema_version": "checkpointed_boundary_authority_v0", "active_count": 1, + "active_write_scope": ["artifacts/**"]} + for approval, expected in ( + (None, False), ({}, False), ({**active, "schema_version": "future"}, False), + ({**active, "active_count": 0}, False), ({**active, "active_write_scope": []}, False), + ({**active, "active_write_scope": [None, ""]}, False), (active, True), + ): + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + source.setdefault("goal_boundary", {})["checkpointed_boundary_authority"] = approval + contract = compose_turn_decision(source) + envelope = build_turn_envelope(contract) + prompt = _prompt({**request, "work_contract": contract, "turn_envelope": envelope}) + assert ("It satisfies the write approval requirement" in prompt) is expected + + +def test_complete_planning_and_failed_recovery_share_lineage(complete_host_request, monkeypatch): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.control_plane.turn_driver.driver import ( + FailedTurnSessionRecoveryError, _turn_lineage, build_loopx_turn_plan, reconcile_failed_turn_session_request, + ) + + request, original = complete_host_request + from loopx.control_plane.turn_driver import driver + + def legacy_selection(*_args, **_kwargs): + raise AssertionError("planning/recovery reinterpreted historical selection") + + monkeypatch.setattr(driver, "selected_turn_todo", legacy_selection) + source = {k: v for k, v in original.items() if k not in {"schema_version", "contract_binding"}} + # Hash parity protects existing journals, including numeric encodings and + # Unicode in retained source facts; a new hash silently forks the Turn. + source["execution_profile"] = {"probe": [1e-7, 1e-6, 1e20, -0.0, "原文", "\U00010000", "\ue000"]} + for adaptive in (False, True): + if adaptive: + source["task_orchestration_contract"] = { + "schema_version": "task_orchestration_contract_v2", "mode": "adaptive", + "primary_todo_id": "todo_adaptive_owner", + } + contract = compose_turn_decision(source) + envelope = build_turn_envelope(contract) + kwargs = dict(host="codex-cli", execution_mode="isolated-headless", turn_instance_id="planning-parity") + historical = build_loopx_turn_plan(envelope, **kwargs) + complete = build_loopx_turn_plan(envelope, work_contract=contract, **kwargs) + expected = {**historical, "schema_version": "loopx_turn_plan_v1", "work_contract": contract, + "source_decision_hash": envelope["action_signature"]["source_decision_hash"], + "boundary": {k: v for k, v in historical["boundary"].items() if k != "preserves_turn_envelope"}} + del expected["turn_envelope"] + assert complete == expected + expected_todo = "todo_adaptive_owner" if adaptive else original["selected_todo"]["todo_id"] + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + signature = turn_envelope_action_signature_document(envelope) + signature["action"]["selected_todo"] = complete["route"]["selected_todo"] + expected_hash = "sha256:" + sha256(json.dumps(signature, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + lineage = _turn_lineage(envelope, work_contract=contract) + assert lineage["action_hash"] == expected_hash + assert lineage["todo_id"] == expected_todo + binding = {"schema_version": "loopx_turn_session_binding_v0", "host_session_id": "fixture-session", + "goal_id": original["goal_id"], "agent_id": original["agent_identity"]["agent_id"], + "todo_id": expected_todo} + recovery = {**request, "turn_envelope": envelope, "work_contract": contract, + "session": complete["session"]} + recovered = reconcile_failed_turn_session_request(recovery, session_binding=binding) + assert recovered["session"]["action"] == "resume" + assert recovered["session"]["binding_status"] == "failed_turn_recovery" + assert recovered["work_contract"] == contract and recovered["turn_envelope"] == envelope + assert reconcile_failed_turn_session_request(recovered, session_binding=binding) == recovered + for bad in (None, {**contract, "reason": "changed source"}, + {**contract, "schema_version": "unsupported_fixture_version"}): + with pytest.raises(RuntimeError): + build_loopx_turn_plan(envelope, work_contract=bad, **kwargs) + for previous in (recovery, recovered): + with pytest.raises(FailedTurnSessionRecoveryError, match="complete Turn"): + reconcile_failed_turn_session_request({**previous, "work_contract": bad}, session_binding=binding) + altered = deepcopy(envelope) + altered["agent_id"] = "wrong-agent" + with pytest.raises(RuntimeError): + build_loopx_turn_plan(altered, work_contract=contract, **kwargs) + assert envelope["agent_id"] == original["agent_identity"]["agent_id"] + + +def test_complete_planning_keeps_receipt_bound_todoless_replan(complete_host_request): + from loopx.control_plane.quota.settlement import build_turn_scoped_cli_settlement_plan + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan, reconcile_failed_turn_session_request + + _, original = complete_host_request + goal, agent, obligation = original["goal_id"], original["agent_identity"]["agent_id"], "replan-" + "a" * 16 + source = {"ok": True, "goal_id": goal, "agent_identity": {"agent_id": agent}, "decision": "run", + "interaction_contract": {"schema_version": "loopx_interaction_contract_v0", "mode": "autonomous_replan", + "user_channel": {"action_required": False, "notify": "DONT_NOTIFY"}, + "agent_channel": {}, "cli_channel": {"spend_after_validation": True}}} + settlement = build_turn_scoped_cli_settlement_plan(goal_id=goal, agent_id=agent, + replan_obligation_id=obligation, turn_instance_id="fixture.replan", command_prefix="loopx", + scoped_cli_args="", lifecycle_actor_args="").as_dict() + source.update(selected_todo=None, should_run=True, effective_action="autonomous_replan_required", + replan_action_packet={"schema_version": "replan_action_packet_v0", "decision": "replan_required", + "obligation_id": obligation}) + source.pop("task_orchestration_contract", None) + interaction = source["interaction_contract"] + interaction["mode"] = "autonomous_replan" + interaction["agent_channel"].update(must_attempt=True, delivery_allowed=False, quiet_noop_allowed=False) + interaction["cli_channel"]["settlement_plan"] = settlement + contract = compose_turn_decision(source) + envelope = build_turn_envelope(contract) + kwargs = dict(host="codex-cli", execution_mode="isolated-headless", session_scope="agent", + turn_instance_id="fixture.replan") + historical = build_loopx_turn_plan(envelope, **kwargs) + plan = build_loopx_turn_plan(envelope, work_contract=contract, **kwargs) + expected = {**historical, "schema_version": "loopx_turn_plan_v1", "work_contract": contract, + "source_decision_hash": envelope["action_signature"]["source_decision_hash"], + "boundary": {k: v for k, v in historical["boundary"].items() if k != "preserves_turn_envelope"}} + del expected["turn_envelope"] + assert plan == expected + assert plan["route"]["kind"] == "replan_required" and plan["route"]["selected_todo"] is None + assert plan["transaction"]["settlement_plan"]["identity"] == settlement["identity"] + binding = {"schema_version": "loopx_turn_session_binding_v0", "goal_id": goal, "agent_id": agent, + "host_session_id": "fixture-session"} + assert reconcile_failed_turn_session_request(plan, session_binding=binding)["session"]["action"] == "resume" + wrong_turn = build_loopx_turn_plan(envelope, work_contract=contract, **{**kwargs, "turn_instance_id": "other"}) + assert wrong_turn["route"]["would_invoke_host"] is False + assert not any(wrong_turn["effects"].values()) + + +def test_complete_plan_rejects_rehashed_adapter_decision_drift(complete_host_request, tmp_path): + from copy import deepcopy + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.executor import run_loopx_turn_once + + request, contract = complete_host_request + envelope = request['turn_envelope'] + options = dict(host='generic-cli', execution_mode='isolated-headless', + turn_instance_id='adapter-drift') + # Retain a real transitional v0 plan as the adapter-drift control. Current + # writers instead reject the presence of any second carrier outright. + plan = {**build_loopx_turn_plan(envelope, **options), "work_contract": contract} + assert plan['route']['would_invoke_host'] is True + + def unexpected_effect(*_args, **_kwargs): + raise AssertionError('divergent adapter invoked an effect') + + # Each payload retains its Goal/Agent/Todo and correctly recomputes its own + # integrity hash. The independent invariant is derivation from the admitted + # decision, not self-consistency of a second decision carrier. + for field, replacement in ( + ('should_run', False), ('effective_action', 'wait'), + ('action', {**envelope['action'], 'delivery_allowed': False}), + ('user', {**envelope['user'], 'action_required': True}), + ('execution_policy', {**envelope['execution_policy'], 'normal_delivery_allowed': False}), + ('writeback', {**envelope['writeback'], 'spend_allowed_now': True}), + ): + changed = deepcopy(envelope) + changed[field] = replacement + assert changed != envelope + digest = 'sha256:' + sha256(json.dumps(turn_envelope_action_signature_document(changed), + sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest() + changed['action_signature'].update(source_hash=digest, envelope_hash=digest, matches=True) + with pytest.raises(RuntimeError, match='projection differs'): + build_loopx_turn_plan(changed, work_contract=contract, **options) + malformed = {**plan, 'turn_envelope': changed} + with pytest.raises(RuntimeError, match='projection differs'): + run_loopx_turn_once(malformed, project=tmp_path, runtime_root=tmp_path / 'rejected', + goal_id=contract['goal_id'], timeout_seconds=10, execute=True, + host_runner=unexpected_effect, writeback=unexpected_effect, + spend=unexpected_effect, scheduler=unexpected_effect) + # The existing lane lock may be allocated before the checked execution + # entry. No Turn journal or settlement receipt may be produced. + assert not list((tmp_path / 'rejected').rglob('*.json')) + assert not list((tmp_path / 'rejected').rglob('*.jsonl')) diff --git a/tests/control_plane/test_turn_replan_writeback.py b/tests/control_plane/test_turn_replan_writeback.py index 23b4b30b86..055637a5d7 100644 --- a/tests/control_plane/test_turn_replan_writeback.py +++ b/tests/control_plane/test_turn_replan_writeback.py @@ -72,10 +72,9 @@ def _runner(root, candidate, *, follow=False, reviewed=False): + ( # Consume the structured authority actually delivered to this Host, # including a required captured read; do not manufacture its refs. - " authority, _ = json.JSONDecoder().raw_decode(prompt.split('Turn authority JSON:\\n', 1)[1])\n" - " packet = authority['replan_action_packet']\n" - " if 'stride_control' not in packet:\n" - " packet = json.loads(subprocess.check_output(shlex.split(packet['writeback']), text=True))\n" + " request, _ = json.JSONDecoder().raw_decode(prompt.split('Turn request:\\n', 1)[1])\n" + " route = request['detail_ref']['replan_action_packet']['writeback']\n" + " packet = json.loads(subprocess.check_output(shlex.split(route), text=True))\n" " control = packet['stride_control']\n" " vision = json.loads(candidate['agent_vision_json'])\n" " decision = {**control['minimal_example'], 'basis': 'uncertain_evidence', 'action': 'verify',\n" diff --git a/tests/control_plane_ts/context_source_identity.test.ts b/tests/control_plane_ts/context_source_identity.test.ts index 9ed05f2c6e..2107152994 100644 --- a/tests/control_plane_ts/context_source_identity.test.ts +++ b/tests/control_plane_ts/context_source_identity.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {contextSourceIdentity, contextProjectionChanges, contextProjectionIdentity} from "../../loopx/control_plane/quota/diagnostic_projection.ts"; +import {contextSourceIdentity, contextProjectionChanges, contextProjectionIdentity, referenceQuotaSummaryLanes} from "../../loopx/control_plane/quota/diagnostic_projection.ts"; test("source identity keeps number spellings separate from strings and retains later exact owners", () => { const source = {first: {observations: [1, 2 ** 60]}, @@ -41,3 +41,33 @@ test("source edit identities follow retained objects and primitive source refere assert.notEqual(contextProjectionIdentity(source, {literal: {value: 0}}, tokens), contextProjectionIdentity(source, {literal: value}, tokens, origins)); }); + +test("already shared numeric summary arrays reuse their original identity without new copies", () => { + const rows = [{todo_id: "task", text: "Keep every original constraint. ".repeat(20), measurement: 1}]; + const source = {agent_todo_summary: {schema_version: "todo_summary_v0", first_executable_items: rows, + backlog_items_ref: "$.agent_todo_summary.first_executable_items"}}; + const key = contextSourceIdentity(source, [ + [["agent_todo_summary", "first_executable_items", 0, "measurement"], "1.0"], + ]); + assert.deepEqual(referenceQuotaSummaryLanes(source, key), source); +}); + +test("projected source identity tracks retained and moved values without removed tokens", () => { + const source = {first: {values: [1, 2 ** 60]}, second: {values: [1, 2 ** 60]}}; + const tokens = [ + [["first", "values", 0], "1.0"], [["first", "values", 1], "1152921504606846977"], + [["second", "values", 0], "1.0"], [["second", "values", 1], "1152921504606846977"], + ]; + const identity = contextSourceIdentity(source, tokens); + const projected = {saved: source.first, second_ref: "$.saved"}; + const actual = JSON.parse(identity(projected, [])); + assert.deepEqual(actual[1], [ + [["saved", "values", 0], "1.0"], [["saved", "values", 1], "1152921504606846977"], + ]); + const restored = contextSourceIdentity(projected, actual[1]); + assert.equal(restored(projected, []), identity(projected, [])); + // The complete codec explicitly selects numeric framing. Existing callers + // without transport metadata retain their original canonical identity. + assert.equal(contextSourceIdentity({n: 1})({n: 1}, []), '{"n":1}'); + assert.equal(contextSourceIdentity({n: 1}, [])({n: 1}, []), '[{"n":1},[]]'); +}); diff --git a/tests/control_plane_ts/interaction_contract.test.ts b/tests/control_plane_ts/interaction_contract.test.ts index d03176a2af..f530d0a7dd 100644 --- a/tests/control_plane_ts/interaction_contract.test.ts +++ b/tests/control_plane_ts/interaction_contract.test.ts @@ -662,7 +662,6 @@ test("declared outcomes remain required reads without enabled checks after a wor assert.ok(!off.some(read => read.source === "goal_acceptance")); }); - test("full original Goal delivery fulfills its read without excluding legacy text", () => { const text = "Original intent\nUnknown section\n停止条件:不得发布。\n"; const read = {source: "goal_state", command: "cat -- goal.md", ordering: "before_work"}; diff --git a/tests/control_plane_ts/runtime_decode.test.ts b/tests/control_plane_ts/runtime_decode.test.ts index 330034e622..be62c94a7e 100644 --- a/tests/control_plane_ts/runtime_decode.test.ts +++ b/tests/control_plane_ts/runtime_decode.test.ts @@ -6,6 +6,8 @@ import { assertNever, jsonObject, optionalNonEmptyString, + parseJsonSource, + parsedSourceNumberTokens, requireBoolean, requireInteger, requireJsonObject, @@ -14,6 +16,26 @@ import { requireStringLiteral, } from "../../loopx/control_plane/runtime_decode.ts"; +test("native source numbers retain original spellings across nested object and array reads", () => { + const parsed = parseJsonSource('{"plan":{"work_contract":{"measurements":[1.0,-0.0,1152921504606846977,0.125,2],"1":2.0}},"ignored":3.0}'); + assert.deepEqual(parsedSourceNumberTokens(parsed.value, parsed.numbers), [ + [["plan", "work_contract", "1"], "2.0"], + [["plan", "work_contract", "measurements", 0], "1.0"], + [["plan", "work_contract", "measurements", 1], "-0.0"], + [["plan", "work_contract", "measurements", 2], "1152921504606846977"], + [["ignored"], "3.0"], + ]); + const contract = requireJsonObject(requireJsonObject(requireJsonObject(parsed.value, "root").plan, "plan").work_contract, "contract"); + assert.deepEqual(parsedSourceNumberTokens(contract, parsed.numbers), [ + [["1"], "2.0"], + [["measurements", 0], "1.0"], [["measurements", 1], "-0.0"], + [["measurements", 2], "1152921504606846977"], + ]); + assert.deepEqual(parsedSourceNumberTokens(contract), []); + assert.deepEqual(parsedSourceNumberTokens(null, parsed.numbers), []); + assert.throws(() => parseJsonSource('{"plan":'), SyntaxError); +}); + test("runtime decoder accepts valid primitive boundary values", () => { const source = { nested: true }; const strings = ["a", "b"]; diff --git a/tests/control_plane_ts/turn_envelope.test.ts b/tests/control_plane_ts/turn_envelope.test.ts index f40b64b7b4..a42b4c7fb0 100644 --- a/tests/control_plane_ts/turn_envelope.test.ts +++ b/tests/control_plane_ts/turn_envelope.test.ts @@ -1,7 +1,12 @@ +import { EFFECTIVE_ACTIONS } from "../../loopx/control_plane/quota/effective_action.generated.ts"; +import { AGENT_SCOPE_FRONTIER_ACTIONS } from "../../loopx/control_plane/agents/agent_scope_frontier.generated.ts"; import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import test from "node:test"; import { + TURN_ACTION_ROUTES, + turnEnvelopeRoute, ACTION_SIGNATURE_COVERAGE_V0, ACTION_SIGNATURE_COVERAGE_V3, buildTurnEnvelope, @@ -10,11 +15,375 @@ import { quotaActionSignatureDocument, turnEnvelopeActionSignatureDocument, turnEnvelopeReplanSettlementIdentity, + turnEnvelopeScopedSettlementIdentity, } from "../../loopx/control_plane/quota/turn_envelope.ts"; import { EffectRuntimeRequestError } from "../../loopx/control_plane/effect_runtime_errors.ts"; -import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; import { TURN_ENVELOPE_SECTION_TARGETS } from "../../loopx/control_plane/quota/turn_envelope_budget.ts"; import { projectPeerOrchestration } from "../../loopx/control_plane/quota/peer_orchestration.ts"; +import {projectQuotaDiagnostics, referenceQuotaSummaryLanes, resolveQuotaSummaryLanes} + from "../../loopx/control_plane/quota/diagnostic_projection.ts"; +import { controllerRequest } from "../../loopx/control_plane/turn_driver/loop_controller.ts"; + +test("complete captures reuse progressive presentation only after source validation", () => { + const source = payload(); + source.planning_horizon = {core_goal: {acceptance_contract: {outcome_requirements: { + schema_version: "loopx_goal_outcomes_v0", revision: 1, current_lifetime: true, + outcomes: [{id: "export", objective: "Deliver the complete result and preserve the final stop.", + requirements: [{id: "tail", description: "Stop before publication.", status: "unverified", + agent_review: {id: "tail", conclusion: "unmet", basis_current: false, independently_verified: false, + note: "The previous negative result remains binding. ".repeat(150), evidence_refs: ["original:failure"]}}], + sources: [{ref: "original", summary: "Supporting history. ".repeat(150)}]}], + }}}}; + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const before = structuredClone(complete); + const request = {operation: "progressive_context_delivery", surface: "complete_turn", context: complete, + native_projection: {path: "source.json", sha256: "a".repeat(64)}}; + const present = (input: JsonObject): JsonObject => { + const result = structuredClone(input); + const at = (root: unknown, path: (string | number)[]) => path.reduce((row, key) => + (row as Record)[key], root); + for (const change of evaluateTurnEnvelope(input).changes as JsonObject[]) { + const path = change.path as (string | number)[], key = path.at(-1)!; + const parent = at(result, path.slice(0, -1)) as Record; + if (change.remove) delete parent[key]; + else parent[key] = structuredClone(change.from ? at(input, change.from as (string | number)[]) : change.value); + } + return result; + }; + const result = present(request); + assert.equal(result.projected, true); + const required = result.context as JsonObject; + const requirements = (((required.planning_horizon as JsonObject).core_goal as JsonObject) + .acceptance_contract as JsonObject).outcome_requirements as JsonObject; + const row = (requirements.outcomes as JsonObject[])[0]; + assert.equal(row.sources, undefined); + const requirement = (row.requirements as JsonObject[])[0]; + assert.equal(requirement.description, "Stop before publication."); + assert.deepEqual(requirement.agent_review, + {id: "tail", conclusion: "unmet", basis_current: false, independently_verified: false}); + assert.deepEqual(required.contract_binding, complete.contract_binding); + assert.deepEqual(complete, before); + for (const context of [{...complete, reason: "changed after capture"}, + {...complete, schema_version: "future_complete_turn"}, {...complete, contract_binding: {}}]) + assert.throws(() => evaluateTurnEnvelope({...request, context})); + // The generic owner still leaves unknown signed shapes untouched; only the + // checked complete-source entry above may plan presentation of that source. + assert.equal(present({...request, surface: "turn_envelope"}).projected, false); +}); + +test("complete diagnostic lanes share exact arrays without losing distinct facts or future fields", () => { + const item = {todo_id: "task", text: "Original requirements and final stop. ".repeat(80), + status: "blocked", future: {may_publish: false, value: null}}; + const lane = [item, {...item, todo_id: "dependency", status: "open"}]; + const source: JsonObject = {agent_todo_summary: {schema_version: "todo_summary_v0", open_count: 2, + first_open_items: structuredClone(lane), first_executable_items: lane, + backlog_items: structuredClone(lane), claimed_open_items: [...lane].reverse(), + deferred_items: [{...item, future: {may_publish: 0, value: null}}, lane[1]], + future_items: structuredClone(lane), future_control: {requires_stop: true}}, + user_todo_summary: {first_open_items: lane, gate_open_items: structuredClone(lane)}, + action_portfolio: {suggested_actions: lane}, future: {items: lane}}; + const before = structuredClone(source), shared = referenceQuotaSummaryLanes(source); + const summary = shared.agent_todo_summary as JsonObject; + for (const key of ["first_open_items", "backlog_items"]) { + assert.equal(summary[key], undefined); + assert.equal(summary[`${key}_ref`], "$.agent_todo_summary.first_executable_items"); + } + for (const key of ["first_executable_items", "claimed_open_items", "deferred_items", "future_items", "future_control", "open_count"]) + assert.deepEqual(summary[key], (source.agent_todo_summary as JsonObject)[key]); + assert.deepEqual(resolveQuotaSummaryLanes(shared), source); + assert.deepEqual(referenceQuotaSummaryLanes(shared), shared); + assert.deepEqual(projectQuotaDiagnostics(resolveQuotaSummaryLanes(shared), {capture_ref: "$.source_capture"}), + projectQuotaDiagnostics(source, {capture_ref: "$.source_capture"})); + assert.deepEqual(source, before); + assert.deepEqual(referenceQuotaSummaryLanes({agent_todo_summary: {...source.agent_todo_summary as JsonObject, + schema_version: "future_summary"}}).agent_todo_summary, + {...source.agent_todo_summary as JsonObject, schema_version: "future_summary"}); + assert.deepEqual(referenceQuotaSummaryLanes({agent_todo_summary: {first_open_items: [1], first_executable_items: [true]}}), + {agent_todo_summary: {first_open_items: [1], first_executable_items: [true]}}); +}); + +test("diagnostic references reject ambiguity, chains, wrong roots and missing literal owners", () => { + const lane = [{todo_id: "task", text: "Complete source. ".repeat(40)}]; + const valid = {agent_todo_summary: {first_executable_items: lane, + backlog_items_ref: "$.agent_todo_summary.first_executable_items"}}; + for (const patch of [{backlog_items: lane}, {backlog_items_ref: null}, {backlog_items_ref: []}, + {backlog_items_ref: "$.user_todo_summary.first_executable_items"}, + {backlog_items_ref: "$.agent_todo_summary.future_items", future_items: lane}, + {backlog_items_ref: "$.agent_todo_summary.backlog_items"}, + {first_executable_items: null}, {first_executable_items_ref: "$.agent_todo_summary.items", items: lane}]) { + const invalid = {agent_todo_summary: {...valid.agent_todo_summary, ...patch}}; + const before = structuredClone(invalid); + assert.throws(() => resolveQuotaSummaryLanes(invalid), /summary lane reference/); + assert.deepEqual(invalid, before); + } +}); + +test("complete producer and Host preserve action semantics when diagnostic lanes share a source", () => { + const source = payload(); + const lane = [{todo_id: "todo-1", text: "Keep complete requirements and stops. ".repeat(80), + status: "open", priority: "P0", claimed_by: "agent-ts"}]; + source.agent_todo_summary = {schema_version: "todo_summary_v0", first_open_items: lane, + first_executable_items: structuredClone(lane), backlog_items: structuredClone(lane), open_count: 1}; + const before = structuredClone(source); + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + assert.equal((complete.agent_todo_summary as JsonObject).backlog_items_ref, "$.agent_todo_summary.first_executable_items"); + const restored = resolveQuotaSummaryLanes(complete); + delete restored.schema_version; delete restored.contract_binding; + assert.deepEqual(restored, source); + const legacy = buildTurnEnvelope({payload: source, scheduler_execution_args: ""}); + const current = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + assert.deepEqual(turnEnvelopeActionSignatureDocument(current), turnEnvelopeActionSignatureDocument(legacy)); + assert.deepEqual(evaluateTurnEnvelope({operation: "compose_complete", payload: complete, scheduler_execution_args: ""}), complete); + const host = evaluateTurnEnvelope({operation: "host_dispatch_context", payload: complete, envelope: current}); + assert.equal(host.primary_action, (source.interaction_contract as JsonObject).agent_channel.primary_action); + assert.deepEqual(host.work_identity, {goal_id: "goal-turn-envelope", agent_id: "agent-ts", todo_id: "todo-1"}); + assert.equal(Object.hasOwn(evaluateTurnEnvelope({operation: "host_context", payload: complete, envelope: current}), + "work_identity"), false); + assert.equal(evaluateTurnEnvelope({operation: "complete_host_lineage", payload: complete, envelope: current}).goal_id, + source.goal_id); + assert.deepEqual(source, before); +}); + +test("read-only session lineage consumes one complete source without relaxing Host readers", () => { + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: payload(), scheduler_execution_args: ""}); + const before = structuredClone(complete); + assert.deepEqual(evaluateTurnEnvelope({operation: "complete_host_lineage", payload: complete}), + {goal_id: "goal-turn-envelope", agent_id: "agent-ts", todo_id: "todo-1"}); + // A supplied adapter/header remains a Host request, including malformed ones. + for (const extra of [{envelope: {}}, {envelope: null}, {complete_host_request: {}}, + {complete_host_request: null}]) { + assert.throws(() => evaluateTurnEnvelope({operation: "complete_host_lineage", payload: complete, ...extra})); + } + for (const operation of ["host_context", "host_dispatch_context", "driver_lineage", "execution_context"]) { + assert.throws(() => evaluateTurnEnvelope({operation, payload: complete})); + } + for (const source of [null, false, {}, {...complete, reason: "changed"}]) { + assert.throws(() => evaluateTurnEnvelope({operation: "complete_host_lineage", payload: source})); + } + assert.deepEqual(complete, before); +}); + +test("operation preflight optionally shares the existing normalized session lineage", () => { + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: payload(), scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + const before = structuredClone(complete); + for (const carrier of [{envelope}, {complete_host_request: { + schema_version: "loopx_turn_host_request_v1", turn_key: `sha256:${"a".repeat(64)}`, + }}]) { + const request = {operation: "host_dispatch_context", payload: complete, ...carrier}; + const original = evaluateTurnEnvelope(request); + assert.equal(Object.hasOwn(original, "session_lineage"), false); + assert.deepEqual(evaluateTurnEnvelope({...request, include_session_lineage: false}), original); + const shared = evaluateTurnEnvelope({...request, include_session_lineage: true}); + assert.deepEqual(shared.session_lineage, + evaluateTurnEnvelope({operation: "complete_host_lineage", payload: complete, ...carrier})); + delete shared.session_lineage; + assert.deepEqual(shared, original); + for (const invalid of [null, 0, 1, "true", {}, []]) { + assert.throws(() => evaluateTurnEnvelope({...request, include_session_lineage: invalid}), /must be a boolean/); + } + for (const damaged of [null, {...complete, reason: "changed source"}]) { + assert.throws(() => evaluateTurnEnvelope({...request, payload: damaged, include_session_lineage: true})); + } + } + assert.deepEqual(complete, before); +}); + +test("complete Host recovery preserves the existing driver route and hash preimage", () => { + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: payload(), scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + const before = structuredClone(complete); + const historicalCarrier = evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, envelope}); + const header = {schema_version: "loopx_turn_host_request_v1", turn_key: `sha256:${"a".repeat(64)}`}; + const current = evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, + envelope: {}, complete_host_request: header}); + assert.equal(current.scoped_settlement_identity, null); + delete current.scoped_settlement_identity; + assert.deepEqual(current, historicalCarrier); + for (const changed of [{...header, turn_key: "invalid"}, {...header, source_decision_hash: "wrong"}]) { + assert.throws(() => evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, + envelope: {}, complete_host_request: changed})); + } + assert.throws(() => evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, + envelope, complete_host_request: header})); + assert.deepEqual(complete, before); +}); + +test("complete recovery and historical readers share all admitted route facts", () => { + for (const [effectiveAction, shouldRun, mustAttempt, delivery, quiet, userAction, route] of [ + ["normal_run", true, true, true, false, false, "ready_for_host"], + ["control_plane_repair", true, true, true, false, false, "repair_required"], + ["autonomous_replan_required", true, true, false, false, false, "replan_required"], + ["normal_run", true, false, false, false, false, "blocked"], + ["normal_run", true, true, false, false, false, "blocked"], + ["normal_run", false, false, false, true, false, "wait"], + ["normal_run", false, false, false, false, true, "user_action_required"], + ] as const) { + const source = payload(); + source.effective_action = effectiveAction; + source.should_run = shouldRun; + const interaction = source.interaction_contract as JsonObject; + Object.assign(interaction.agent_channel as JsonObject, + {must_attempt: mustAttempt, delivery_allowed: delivery, quiet_noop_allowed: quiet}); + (interaction.user_channel as JsonObject).action_required = userAction; + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + const before = structuredClone({complete, envelope}); + const historical = evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, envelope}); + assert.equal(historical.route, route); + const current = evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, complete_host_request: { + schema_version: "loopx_turn_host_request_v1", turn_key: `sha256:${"a".repeat(64)}`, + }}); + assert.equal(current.scoped_settlement_identity, null); + delete current.scoped_settlement_identity; + assert.deepEqual(current, historical); + assert.deepEqual({complete, envelope}, before); + } +}); + +test("complete recovery keeps request and replan identity qualification with the historical owner", () => { + for (const requestScope of [false, true]) { + const scope = {goal_id: "goal-turn-envelope", agent_id: "agent-ts", turn_instance_id: "original-scope"}; + const binding = requestScope ? {request_subject: {request_id: "a".repeat(64), request_revision: 1, + request_digest: "b".repeat(64), execution_scope_sha256: "c".repeat(64)}} + : {replan_obligation_id: "replan-original"}; + const identity = settlementIdentityPayload({...scope, ...binding}); + const source = payload(); + source.selected_todo = null; + source.effective_action = requestScope ? "normal_run" : "autonomous_replan_required"; + if (!requestScope) source.replan_action_packet = { + schema_version: "replan_action_packet_v0", decision: "replan_required", obligation_id: "replan-original"}; + const interaction = source.interaction_contract as JsonObject; + (interaction.agent_channel as JsonObject).delivery_allowed = requestScope; + (interaction.cli_channel as JsonObject).settlement_plan = { + schema_version: "quota_settlement_plan_v1", identity, ordered_steps: []}; + const header = {schema_version: "loopx_turn_host_request_v1", turn_key: `sha256:${"a".repeat(64)}`}; + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + const current = evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, complete_host_request: header}); + assert.equal(current.route, requestScope ? "ready_for_host" : "replan_required"); + assert.deepEqual(current.scoped_settlement_identity, identity); + assert.deepEqual(turnEnvelopeScopedSettlementIdentity(envelope), identity); + delete current.scoped_settlement_identity; + assert.deepEqual(current, evaluateTurnEnvelope({operation: "driver_lineage", payload: complete, envelope})); + for (const changedScope of [{...scope, goal_id: "other-goal"}, {...scope, agent_id: "other-agent"}]) { + const changed = structuredClone(source); + const plan = ((changed.interaction_contract as JsonObject).cli_channel as JsonObject).settlement_plan as JsonObject; + plan.identity = settlementIdentityPayload({...changedScope, ...binding}); + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: changed, scheduler_execution_args: ""}); + assert.throws(() => turnEnvelopeScopedSettlementIdentity(buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}))); + assert.throws(() => evaluateTurnEnvelope({operation: "driver_lineage", payload: contract, complete_host_request: header})); + } + } +}); + +test("complete validation and replan reads preserve the original shared source", () => { + const source = payload(), interaction = source.interaction_contract as JsonObject; + const authoring = {instruction: "Preserve every original requirement and stop. ".repeat(40), + future: {approved: false, observations: [null, 0]}}; + const guidance = "Return the original complete evidence without granting publication. ".repeat(30); + (interaction.cli_channel as JsonObject).settlement_plan = {ordered_steps: [ + {kind: "durable_writeback", vision_authoring: authoring, + optional_attachments: [{capability_id: "explore", guidance}]}, + ]}; + (interaction.agent_channel as JsonObject).work_context = {complete: true, sources: [ + {kind: "explore_turn_context", content: {graph: {result_writeback_guidance: guidance}}}, + ]}; + source.replan_read_path = "/capture/original.json"; + source.replan_action_packet = {obligation_id: "original-obligation", + writeback_contract: {vision_authoring: structuredClone(authoring)}}; + source.autonomous_replan_obligation = {replan_context: { + obligation_id: "original-obligation", context_id: "original-context"}}; + const lane = [{todo_id: "original-work", text: "Complete original work. ".repeat(30), future: {stop: true}}]; + source.agent_todo_summary = {first_executable_items: lane, first_open_items: structuredClone(lane)}; + const original = structuredClone(source); + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + assert.deepEqual(source, original, "composition must not erase the caller's original authoring or evidence"); + assert.ok(contract.replan_action_packet.writeback_contract.vision_authoring_ref); + assert.equal(contract.agent_todo_summary.first_open_items_ref, "$.agent_todo_summary.first_executable_items"); + const before = structuredClone(contract); + for (let i = 0; i < 2; i++) { + evaluateTurnEnvelope({operation: "complete_consumer", payload: contract}); + const envelope = buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}); + const command = String((envelope.replan_action_packet as JsonObject).review); + const hash = /--source-hash (sha256:[a-f0-9]{64})/.exec(command); + assert.ok(hash); + const read = evaluateTurnEnvelope({operation: "replan_view", payload: contract, + source_hash: hash[1], stage: "review"}); + assert.equal(read.obligation_id, "original-obligation"); + assert.deepEqual(contract, before, "validation and repeated reads must retain the same immutable source"); + } +}); + +function completeAcceptanceRead(source: JsonObject, outcomeRef = ""): JsonObject { + const base = payload(), interaction = base.interaction_contract as JsonObject; + const incoming = (source.interaction_contract ?? {}) as JsonObject; + const captured = evaluateTurnEnvelope({operation: "compose_complete", scheduler_execution_args: "", payload: { + ...base, ...source, interaction_contract: {...interaction, ...incoming, + agent_channel: {...(interaction.agent_channel as JsonObject), ...(incoming.agent_channel as JsonObject ?? {})}}, + replan_read_path: "/capture/original.json", + replan_action_packet: {obligation_id: "original-obligation"}, + autonomous_replan_obligation: {replan_context: { + obligation_id: "original-obligation", context_id: "original-context"}}}}); + const sourceHash = (captured.contract_binding as JsonObject).content_hash; + const read = evaluateTurnEnvelope({operation: "replan_view", payload: captured, + source_hash: sourceHash, stage: outcomeRef ? "acceptance" : "review", outcome_ref: outcomeRef}); + return outcomeRef ? read : {outcomes: (read.acceptance_index as JsonObject | undefined)?.outcomes}; +} + +test("complete acceptance lookup shares exact content across registered carriers and retains revisions", () => { + const one = {id: "shared", owner_agent_id: "peer", objective: "Original", revision: 1, + requirements: [{id: "a", planning_satisfied: false, status: "unverified", description: "Full a"}, + {id: "b", planning_satisfied: false, status: "unverified", description: "Full b"}, + {id: "c", planning_satisfied: true, status: "unverified", description: "Full c", + agent_review: {conclusion: "met", basis_current: true, independently_verified: false}}], + work: [{todo_id: "dependency", status: "blocked"}], future: {negative: false, unknown: null}}; + const two = {...one, revision: 2, objective: "Changed"}; + const typedDifference = {...one, future: {negative: 0, unknown: null}}; + const contract = (outcome: unknown) => ({outcome_requirements: {outcomes: [outcome]}}); + const source = {ok: true, goal_id: "goal", agent_identity: {agent_id: "controller"}, + interaction_contract: {agent_channel: {work_context: {sources: [ + {source: "goal_acceptance", content: {status: "loaded", goal_acceptance_contract: contract(one)}}, + {source: "goal_acceptance", content: {status: "loaded", goal_acceptance_contract: contract(two)}}, + {source: "unregistered", content: {goal_acceptance_contract: contract({...one, id: "not-a-source"})}}, + ]}}}, planning_horizon: {core_goal: {acceptance_contract_ref: + "$.interaction_contract.agent_channel.work_context.sources[0].content.goal_acceptance_contract"}}, + agent_todo_summary: {goal_acceptance_contract: contract(typedDifference)}}; + const original = structuredClone(source); + const index = completeAcceptanceRead(source); + const rows = index.outcomes as JsonObject[]; + assert.equal(rows.length, 3); + assert.notEqual(rows[0].outcome_ref, rows[1].outcome_ref); + assert.deepEqual(rows[0].requirement_states, [ + {requirement_ids: ["a", "b"], planning_satisfied: false, status: "unverified"}, + {requirement_ids: ["c"], planning_satisfied: true, status: "unverified", + agent_review: {conclusion: "met", basis_current: true, independently_verified: false}}, + ]); + // The preferred work carrier appears first, followed by the registered + // summary carrier and the second loaded work source. + for (const [i, outcome] of [one, typedDifference, two].entries()) { + const detail = completeAcceptanceRead(source, String(rows[i].outcome_ref)); + assert.deepEqual(detail.outcome, outcome); + } + assert.deepEqual(source, original); +}); + +test("complete acceptance index refuses unknown sources and keeps missing facts unknown", () => { + const read = (payload: JsonObject) => completeAcceptanceRead(payload); + for (const payload of [{ok: false}, {ok: true, schema_version: "unknown"}, + {ok: true, agent_todo_summary: {goal_acceptance_contract: {outcome_requirements: {outcomes: null}}}}]) { + assert.throws(() => read(payload)); + } + assert.equal(read({ok: true}).outcomes, undefined); + const source = {ok: true, agent_todo_summary: {goal_acceptance_contract: {outcome_requirements: { + outcomes: [{id: "unknown", requirements: [{id: "r"}, {id: "null", agent_review: null}]}]}}}}; + const row = (read(source).outcomes as JsonObject[])[0]; + assert.deepEqual(row.requirement_states, [{requirement_ids: ["r"]}, + {requirement_ids: ["null"], agent_review: null}]); + assert.equal(row.work, undefined); + assert.throws(() => completeAcceptanceRead(source, "sha256:" + "0".repeat(64)), /exact captured outcome reference/); +}); test("task text commitment has one selected-Todo carrier without changing legacy context elision", () => { for (const retainDetail of [false, true]) { @@ -87,6 +456,63 @@ function payload(): Record { }; } +test("planning selection shares the work owner and complete sources ignore historical identity hints", () => { + for (const adaptive of [false, true]) { + const source = payload(); + if (adaptive) source.task_orchestration_contract = { + schema_version: "task_orchestration_contract_v2", mode: "adaptive", primary_todo_id: "todo-primary", + }; + const complete = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: complete, scheduler_execution_args: ""}); + const expected = adaptive ? {todo_id: "todo-primary", source: "task_orchestration_contract.primary_todo_id"} + : (envelope.action as JsonObject).selected_todo; + const snapshot = structuredClone({complete, envelope}); + const projection = evaluateTurnEnvelope({operation: "driver_lineage", envelope, payload: complete, + historical_route_text: {primary_todo_id: "foreign", effective_action: "foreign"}}); + assert.deepEqual((projection.action_signature as JsonObject).action, + {...(turnEnvelopeActionSignatureDocument(envelope).action as JsonObject), selected_todo: expected}); + assert.equal(projection.todo_id, adaptive ? "todo-primary" : "todo-1"); + assert.deepEqual({complete, envelope}, snapshot); + // An omitted source keeps the old decoder; supplied null remains invalid. + const historical = evaluateTurnEnvelope({operation: "driver_lineage", envelope}); + assert.deepEqual(((historical.action_signature as JsonObject).action as JsonObject).selected_todo, expected); + assert.throws(() => evaluateTurnEnvelope({operation: "driver_lineage", envelope, payload: null})); + } +}); + +test("managed controller consumes the complete producer source with the shared route semantics", () => { + for (const [action, delivery, shouldRun, quiet, expected] of [ + ["normal_run", true, true, false, "run_now"], + ["control_plane_repair", true, true, false, "repair"], + ["autonomous_replan_required", false, true, false, "replan"], + ["normal_run", false, true, false, "wait"], + ["normal_run", false, false, true, "wait"], + ] as const) { + const source = payload(); + source.effective_action = action; + source.should_run = shouldRun; + const channel = (source.interaction_contract as JsonObject).agent_channel as JsonObject; + channel.delivery_allowed = delivery; + channel.must_attempt = shouldRun; + channel.quiet_noop_allowed = quiet; + const request = {payload: source, scheduler_execution_args: ""}; + const before = structuredClone(source); + const complete = evaluateTurnEnvelope({operation: "compose_complete", ...request}); + const result = controllerRequest({operation: "disposition", decision: complete, receipt: null}); + assert.equal(result.ok, true); + assert.equal((result.value as JsonObject).disposition, expected); + const damaged = structuredClone(complete); + damaged.reason = "Substituted after composition"; + assert.match(String(controllerRequest({operation: "disposition", decision: damaged, receipt: null}).message), + /integrity mismatch/); + assert.deepEqual(source, before); + } + const future = {...payload(), effective_action: "future_repair"}; + assert.equal(turnEnvelopeRoute(buildTurnEnvelope({payload: future, scheduler_execution_args: ""})), "ready_for_host"); + assert.throws(() => evaluateTurnEnvelope({operation: "compose_complete", payload: future, + scheduler_execution_args: ""}), /Turn effective_action/); +}); + test("execution contracts retain complete clauses, nested data and empty values", () => { const source = payload(); const clause = "Preserve original identity / 保留原身份. ".repeat(40) + "Stop on source mismatch."; @@ -549,6 +975,19 @@ test("pending capability action outranks stale replan commands and remains signe assert.equal((envelope.required_reads as JsonObject[]).some(read => read.source === "selected_todo"), false); const signed = JSON.parse(JSON.stringify(turnEnvelopeActionSignatureDocument(envelope))); assert.deepEqual((signed.action as JsonObject).capability_intent, source.pending_capability_intent); + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const completeEnvelope = buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}); + const host = evaluateTurnEnvelope({operation: "host_context", payload: contract, envelope: completeEnvelope}); + assert.equal(Object.hasOwn(host, "decision"), false); // Do not echo the checked source over the bridge. + assert.equal(host.execution_profile_present, false); + assert.equal(host.primary_action, "advance one bounded segment"); + assert.equal(host.replan_admitted, false); + const replanContract = evaluateTurnEnvelope({operation: "compose_complete", + payload: {...source, effective_action: "autonomous_replan_required"}, scheduler_execution_args: ""}); + const replanHost = evaluateTurnEnvelope({operation: "host_context", payload: replanContract, + envelope: buildTurnEnvelope({payload: replanContract, scheduler_execution_args: ""})}); + assert.equal(replanHost.primary_action, "produce one required semantic outcome"); + assert.equal(replanHost.replan_admitted, true); (source.pending_capability_intent as JsonObject).command = "untrusted replacement"; assert.throws(() => buildTurnEnvelope({payload: source, scheduler_execution_args: ""}), /action is unsupported/); @@ -1225,3 +1664,89 @@ test("consumer version gate rejects explicit future/null declarations without re assertTurnEnvelopeConsumerVersion(legacy); assert.deepEqual(envelope, before); }); + + +test("registered route coverage and historical actions share the runtime classifier", () => { + const historical = ["autonomous_replan", "successor_replan_required"]; + assert.deepEqual(new Set(Object.keys(TURN_ACTION_ROUTES)), + new Set([...EFFECTIVE_ACTIONS, ...AGENT_SCOPE_FRONTIER_ACTIONS, ...historical])); + for (const [name, route] of Object.entries(TURN_ACTION_ROUTES)) { + const envelope = {schema_version: "loopx_turn_envelope_v0", should_run: true, + effective_action: name, goal_id: "g", agent_id: "a", + action_signature: {matches: true, source_hash: "same", envelope_hash: "same"}, + action: {must_attempt: true, delivery_allowed: true, selected_todo: {todo_id: "t"}, + capability_intent: {schema_version: "pending_capability_intent_projection_v0", + goal_id: "g", agent_id: "a", command: "work"}}}; + assert.equal(turnEnvelopeRoute(envelope), route, name); + } + for (const name of ["future_repair", "constructor", "toString", "__proto__"]) { + assert.equal(turnEnvelopeRoute({should_run: true, effective_action: name, + action_signature: {matches: true, source_hash: "same", envelope_hash: "same"}, + action: {must_attempt: true, delivery_allowed: true}}), "ready_for_host"); + } +}); + +test("complete driver projection ignores historical scalar decoding hints", () => { + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: payload(), scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}); + const normal = evaluateTurnEnvelope({operation: "driver_lineage", payload: contract, envelope}); + const forged = evaluateTurnEnvelope({operation: "driver_lineage", payload: contract, envelope, + historical_route_text: {source_hash: "", envelope_hash: "different", effective_action: "autonomous_replan"}}); + assert.equal(normal.route, "ready_for_host"); + assert.deepEqual(forged, normal); +}); + +test("complete execution reads one original selection, task commitment and effect conditions", () => { + const source = payload(); + const selected = source.selected_todo as JsonObject; + selected.text = "Full task declaration. ".repeat(40) + "Retain the final requirement.\n"; + selected.future_constraint = {unknown: null, allowed: false}; + const interaction = source.interaction_contract as JsonObject; + (interaction.agent_channel as JsonObject).work_context = { + complete: true, selected_todo_ref: "selected_todo", selected_todo_text_verified: true}; + (interaction.cli_channel as JsonObject).delivery_workspace_causality = { + requirement: "required", future: {empty: [], approved: false}}; + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}); + const before = structuredClone({contract, envelope}); + const result = evaluateTurnEnvelope({operation: "execution_context", payload: contract, envelope}); + assert.equal((result.selected_todo as JsonObject).todo_id, selected.todo_id); + assert.equal((result.selected_todo as JsonObject).task_repository, selected.task_repository); + assert.equal((result.selected_todo as JsonObject).text, selected.text); + assert.equal(result.sha256, createHash("sha256").update(String(selected.text), "utf8").digest("hex")); + assert.equal(result.effective_action, "normal_run"); + assert.deepEqual((result.writeback as JsonObject).delivery_workspace_causality, + (interaction.cli_channel as JsonObject).delivery_workspace_causality); + assert.equal(result.replan_guard_scoped, true); + assert.equal(result.replan_action_packet, null); + assert.deepEqual({contract, envelope}, before); + for (const payload of [null, {...contract, reason: "different source"}, {...contract, schema_version: "future"}]) { + assert.throws(() => evaluateTurnEnvelope({operation: "execution_context", payload, envelope})); + } + assert.throws(() => evaluateTurnEnvelope({operation: "execution_context", payload: contract, + envelope: {...envelope, writeback: {spend_allowed_now: true}}}), /captured executor source/); + for (const todoId of [42, true]) { + const malformed = structuredClone(source); + (malformed.selected_todo as JsonObject).todo_id = todoId; + const invalid = evaluateTurnEnvelope({operation: "compose_complete", payload: malformed, scheduler_execution_args: ""}); + const adapter = buildTurnEnvelope({payload: invalid, scheduler_execution_args: ""}); + assert.throws(() => evaluateTurnEnvelope({operation: "execution_context", payload: invalid, + envelope: adapter}), /selected Todo/); + } +}); + +test("complete execution keeps the adaptive primary and unbound selection distinct", () => { + for (const adaptive of [true, false]) { + const source = payload(); + source.selected_todo = adaptive ? {todo_id: "child", text: "Child work"} + : {future_evidence: {unknown: null}}; + if (adaptive) source.task_orchestration_contract = { + schema_version: "task_orchestration_contract_v2", mode: "adaptive", primary_todo_id: " primary "}; + const contract = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const envelope = buildTurnEnvelope({payload: contract, scheduler_execution_args: ""}); + const result = evaluateTurnEnvelope({operation: "execution_context", payload: contract, envelope}); + assert.deepEqual(result.selected_todo, adaptive + ? {todo_id: "primary", source: "task_orchestration_contract.primary_todo_id"} : {}); + assert.equal(result.sha256, null); + } +}); diff --git a/tests/test_ark_managed_agent_host.py b/tests/test_ark_managed_agent_host.py index f747bf2dad..52fffba209 100644 --- a/tests/test_ark_managed_agent_host.py +++ b/tests/test_ark_managed_agent_host.py @@ -127,7 +127,7 @@ def test_goal_prompt_projects_goal_only_host_contract() -> None: "quota_should_run.interaction_contract.cli_channel." "runtime_capability_reentry" ), - "cli_projection_ref": "quota_should_run.runtime_capability_reentry", + "cli_projection_ref": "quota_should_run.interaction_contract.cli_channel.runtime_capability_reentry", "packet_schema_version": "runtime_capability_reentry_v0", "delivery_channel": "quota_tool_result", "goal_prompt_mutated": False, diff --git a/tests/test_codex_operation_host.py b/tests/test_codex_operation_host.py index 5a677c033a..02e3c65781 100644 --- a/tests/test_codex_operation_host.py +++ b/tests/test_codex_operation_host.py @@ -86,6 +86,7 @@ def emit(value): answer = {name: "" for name, schema in properties.items() if schema["type"] == "string"} answer.update({"schema_version": "loopx_turn_result_v0", "turn_key": key, "result_kind": "wait", "completed_phases": ["host_execute", "typed_result"], "classification": "awaiting_operation_confirmation", "recommended_action": "Wait for actual human approval", "summary": "Owned native context retrieved; no approval or effect"}) answer.update(work_evidence=None, validation_evidence=None) + answer.update(json.loads(os.environ.get("FAKE_OPERATION_RESULT_FIELDS", "{}"))) emit({"method": "item/agentMessage/delta", "params": {"threadId": thread, "turnId": turn, "delta": json.dumps(answer)}}) emit({"method": "turn/completed", "params": {"threadId": thread, "turn": {"id": turn, "status": "completed"}}}) """ diff --git a/tests/test_delegation_turn_context.py b/tests/test_delegation_turn_context.py index 6afdb484f2..8a1cfee8d0 100644 --- a/tests/test_delegation_turn_context.py +++ b/tests/test_delegation_turn_context.py @@ -225,9 +225,10 @@ def run(*arguments): "read_complete": values[pointer.removesuffix("unfinished_delegations") + "read_complete"]} required_reads = values["/interaction_contract/agent_channel/required_reads"] else: - source = next(row for row in packet["work_context"]["sources"] if row.get("hook_id") == "manager.context_inbox") - content, required_reads = source["content"], packet["required_reads"] - assert packet["work_context"]["complete"] is True + channel = packet["interaction_contract"]["agent_channel"] + source = next(row for row in channel["work_context"]["sources"] if row.get("hook_id") == "manager.context_inbox") + content, required_reads = source["content"], channel["required_reads"] + assert channel["work_context"]["complete"] is True assert content["read_complete"] is False rows = {row["request_id"]: row for row in content["unfinished_delegations"]["items"]} assert set(rows) == {receipt["request_id"], queued["request_id"], peer["request_id"]} diff --git a/tests/test_dsh_goal_mode.py b/tests/test_dsh_goal_mode.py index a8a9b1cfed..8993e4549c 100644 --- a/tests/test_dsh_goal_mode.py +++ b/tests/test_dsh_goal_mode.py @@ -1581,3 +1581,28 @@ def runner(**kwargs): assert env["LOOPX_TURN_WORKSPACE"] == str(tmp_path.resolve()) assert env["DSH_PERMISSION_MODE"] == "read-only" assert pinned["LOOPX_TURN_TODO_ID"] == "stale-todo" + + +@pytest.mark.parametrize("goal,agent,todo", [ + (" goal ", " agent ", " todo "), (17, True, 23), (False, 0, ""), +]) +def test_dsh_historical_runner_retains_raw_lineage_and_tool_coercion( + tmp_path, monkeypatch, goal, agent, todo, +): + calls = [] + + def runner(**kwargs): + calls.append(kwargs) + return '{"result_kind":"wait"}' + + monkeypatch.setattr(turn_host_adapter, "run_dsh_turn", runner) + request = _signed_request(goal_id=goal, agent_id=agent, todo_id=todo) + turn_host_adapter.run_dsh_host(request, config=turn_host_adapter.DshHostConfig(workspace=tmp_path)) + [call] = calls + raw = [value if value else None for value in (goal, agent, todo)] + expected = ("dsh-lineage-v1-" + sha256(json.dumps(raw, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + if any(raw) else "dsh-" + TURN_KEY.removeprefix("sha256:")[:24]) + assert call["session_id"] == expected + assert [call["env"][key] for key in ("LOOPX_TURN_GOAL_ID", "LOOPX_TURN_AGENT_ID", "LOOPX_TURN_TODO_ID")] == [ + str(value or "") for value in (goal, agent, todo)] diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 3a6752796e..9c0c28557b 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -35,13 +35,14 @@ HOST = '''import json, os, sys, time from pathlib import Path from loopx.control_plane.turn_driver.host_candidate import build_result +from loopx.control_plane.quota.turn_envelope import turn_host_lineage from loopx.control_plane.collaboration.inbox import acknowledge from loopx.control_plane.collaboration.peers import return_result request = json.load(sys.stdin) workspace = Path.cwd() root = Path(sys.argv[1]) -envelope = request['turn_envelope'] -actor = envelope['agent_id'] +identity = turn_host_lineage(request) +actor = identity['agent_id'] counter = workspace / 'host-invocations' counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1)) if (root / 'ignore-term').exists(): @@ -55,8 +56,8 @@ while not (root / 'release').exists(): time.sleep(0.1) delegation = json.loads((workspace / 'DELEGATION.json').read_text()) if not (root / 'skip-adoption').exists(): - acknowledge(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'adopt', 'Independently checked the requested scope.') - return_result(root / 'runtime', envelope['goal_id'], actor, delegation['request_id'], 'Independent member conclusion; host acceptance is separate.') + acknowledge(root / 'runtime', identity['goal_id'], actor, delegation['request_id'], 'adopt', 'Independently checked the requested scope.') + return_result(root / 'runtime', identity['goal_id'], actor, delegation['request_id'], 'Independent member conclusion; host acceptance is separate.') print(json.dumps(build_result(request, {'result_kind':'validated_progress', 'classification':'artifact_written', 'summary':'Fixture host supplied output for independent verification.', 'next_action':'Return verified evidence.', 'work_evidence':['artifact:delegation-output'], 'validation_evidence':['check:independent-member-result']}, host_name='Fixture'))) ''' @@ -1266,7 +1267,9 @@ def lose_reply(*_args, **_kwargs): path.write_text("{damaged", encoding="utf-8") else: damaged = json.loads(original) - damaged["plan"]["turn_envelope"]["agent_id"] = "foreign-agent" + assert damaged["plan"]["schema_version"] == "loopx_turn_plan_v1" + assert "turn_envelope" not in damaged["plan"] + damaged["plan"]["work_contract"]["agent_identity"]["agent_id"] = "foreign-agent" path.write_text(json.dumps(damaged), encoding="utf-8") monkeypatch.setattr(runner, "_record_turn_result", record) runner.execute("analysis-recovery") diff --git a/tests/test_loop_turn_loop_controller.py b/tests/test_loop_turn_loop_controller.py index acc4e592f5..3606a52498 100644 --- a/tests/test_loop_turn_loop_controller.py +++ b/tests/test_loop_turn_loop_controller.py @@ -322,17 +322,17 @@ def test_no_receipt_with_delivery_decision_runs_now() -> None: _assert_markers(payload, "run_now") -@pytest.mark.parametrize("qualified", [False, True], ids=["mapping", "producer"]) +@pytest.mark.parametrize("complete", [False, True], ids=["historical", "current"]) @pytest.mark.parametrize("unit,repeats", [("G", 128), ("G", 2_420_000), ("界😀", 180_000)], ids=["small", "large", "escaped-unicode"]) def test_controller_preserves_complete_goal_over_the_real_transport( - qualified, unit, repeats, + complete, unit, repeats, ) -> None: import json import tempfile from pathlib import Path - from loopx.control_plane.quota.turn_envelope import build_turn_controller_input + from loopx.control_plane.quota.turn_envelope import build_turn_envelope, compose_turn_decision from loopx.control_plane.turn_driver import build_loopx_turn_plan from tests.test_turn_envelope import _full_decision @@ -343,21 +343,23 @@ def test_controller_preserves_complete_goal_over_the_real_transport( "complete": True, "sources": [{"source": "goal_state", "content": {"text": original_text}}], } - current = build_turn_controller_input(source) - envelope = current.envelope() + envelope = compose_turn_decision(source) if complete else build_turn_envelope(source) before = set(snapshot_root.glob("loopx-effect-*")) - assert envelope["work_context"]["sources"][0]["content"]["text"] == original_text - assert envelope["action_signature"]["matches"] is True + context = (envelope["interaction_contract"]["agent_channel"]["work_context"] + if complete else envelope["work_context"]) + assert context["sources"][0]["content"]["text"] == original_text + if not complete: + assert envelope["action_signature"]["matches"] is True assert (len(json.dumps(envelope).encode()) > 2 * 1024 * 1024) == (repeats > 128) if unit != "G": - assert len(current.envelope_json.encode()) < 2 * 1024 * 1024 + assert len(json.dumps(envelope, ensure_ascii=False).encode()) < 2 * 1024 * 1024 plan = build_loopx_turn_plan(envelope, host="codex-cli", execution_mode="interactive-visible") assert plan["route"]["kind"] == "ready_for_host" result = _decide_loop_disposition( - turn_receipt=None, quota_decision=current if qualified else envelope, + turn_receipt=None, quota_decision=envelope, ) _assert_markers(result, "run_now") - assert current.envelope() == envelope + assert context["sources"][0]["content"]["text"] == original_text assert set(snapshot_root.glob("loopx-effect-*")) == before @@ -371,7 +373,7 @@ def test_large_controller_decision_keeps_signature_rejection() -> None: @pytest.mark.parametrize("qualified", [False, True], ids=["mapping", "producer"]) def test_controller_snapshot_limit_rejects_before_sending(monkeypatch, qualified) -> None: from loopx.control_plane import effect_runtime - from loopx.control_plane.quota.turn_envelope import build_turn_controller_input + from loopx.control_plane.quota.turn_envelope import compose_turn_decision from tests.test_turn_envelope import _full_decision effect_runtime.effect_runtime_result("runtime.ping", {}) @@ -387,7 +389,7 @@ def test_controller_snapshot_limit_rejects_before_sending(monkeypatch, qualified source["interaction_contract"]["agent_channel"]["work_context"] = { "complete": True, "sources": [{"source": "goal_state", "content": {"text": text}}], } - decision = build_turn_controller_input(source) + decision = compose_turn_decision(source) else: decision = _envelope(should_run=True) decision["work_context"] = {"text": text} diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 341ef4ca2f..c0c9877175 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -142,6 +142,7 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: with log.open("a", encoding="utf-8") as handle: handle.write(json.dumps(args) + "\\n") turn_key = re.search(r'"turn_key":"([^"]+)"', prompt).group(1) +time.sleep(float(os.environ.get("FAKE_CODEX_BEFORE_SESSION_SLEEP", "0"))) print(json.dumps({ "type": "thread.started", "thread_id": "session-fixture-0001", @@ -862,7 +863,7 @@ def test_executable_legacy_goal_stop_after_selection_prevents_host_launch( run_codex_cli_host(request, **options) binding = _session_files(tmp_path / "runtime")[0] original_binding = binding.read_bytes() - original_prompt = codex_cli._prompt + original_input = codex_cli.prepare_codex_cli_input def stop_before_dispatch(current): with project_registry_transaction(registry_path, operation="fixture_stop_before_launch") as transaction: @@ -872,9 +873,9 @@ def stop_before_dispatch(current): "updated_at": "2026-10-10T12:00:00Z", "reason": "Owner stopped this Goal", } transaction.commit(registry) - return original_prompt(current) + return original_input(current) - monkeypatch.setattr(codex_cli, "_prompt", stop_before_dispatch) + monkeypatch.setattr(codex_cli, "prepare_codex_cli_input", stop_before_dispatch) request["session"]["context_policy"]["mode"] = context request["session"]["action"] = "resume" if context == "resume" else "start_new" with pytest.raises(FirstPartyHostRuntimeRejected, match="goal_stopped"): @@ -901,7 +902,7 @@ def test_default_turn_cli_blocks_native_launch_after_canonical_goal_stop( project, runtime, registry_path = _write_live_fixture(tmp_path, provider=provider) executable, log = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log)) - original_prompt = codex_cli._prompt + original_input = codex_cli.prepare_codex_cli_input reached_host = [] def stop_before_dispatch(current): @@ -913,9 +914,9 @@ def stop_before_dispatch(current): "updated_at": "2026-10-10T12:00:00Z", "reason": "Owner stopped this Goal", } transaction.commit(registry) - return original_prompt(current) + return original_input(current) - monkeypatch.setattr(codex_cli, "_prompt", stop_before_dispatch) + monkeypatch.setattr(codex_cli, "prepare_codex_cli_input", stop_before_dispatch) output = io.StringIO() with contextlib.redirect_stdout(output): code = cli_main([ @@ -953,7 +954,7 @@ def test_source_goal_change_after_session_selection_prevents_host_launch( run_codex_cli_host(request, **options) binding = _session_files(tmp_path / "runtime")[0] original_binding = binding.read_bytes() - original_prompt = codex_cli._prompt + original_input = codex_cli.prepare_codex_cli_input def change_before_dispatch(current): # Session selection has succeeded, but no new Host request exists yet. @@ -969,9 +970,9 @@ def change_before_dispatch(current): "updated_at": "2026-10-10T12:00:00Z", "reason": "Owner stopped this Goal", } transaction.commit(registry) - return original_prompt(current) + return original_input(current) - monkeypatch.setattr(codex_cli, "_prompt", change_before_dispatch) + monkeypatch.setattr(codex_cli, "prepare_codex_cli_input", change_before_dispatch) request["session"]["context_policy"]["mode"] = context request["session"]["action"] = "resume" if context == "resume" else "start_new" with pytest.raises(FirstPartyHostRuntimeRejected) as error: @@ -1277,6 +1278,58 @@ def test_codex_cli_host_preserves_observed_session_after_timeout( project.mkdir() request = _request() + # This case needs a known-observed Session, not a bet that a Python Host + # starts before the deadline on a loaded machine. Control only the native + # supervisor timer; actual spawn, pipes, parsing, cleanup and persistence run. + # The separate real-deadline case below covers timeout before observation. + from loopx.control_plane.turn_driver import codex_cli + + marker = tmp_path / "observed-session.json" + clock = tmp_path / "session-timeout.mjs" + clock.write_text(""" +import {writeFileSync} from 'node:fs'; +const timer = globalThis.setTimeout; +let expire, pending = ''; +globalThis.setTimeout = (callback, ms, ...args) => { + // The guarded bridge subtracts startup from the transport's one-second + // minimum. No other timer in this fixture uses that interval. + if (!expire && ms > 0 && ms <= 1000) { + const watchdog = timer(callback, 5000, ...args); + expire = () => { clearTimeout(watchdog); callback(...args); }; + return watchdog; + } + return timer(callback, ms, ...args); +}; +const write = process.stdout.write.bind(process.stdout); +process.stdout.write = (chunk, ...args) => { + const flushed = write(chunk, ...args); + const frame = JSON.parse(String(chunk)); + if (frame.kind === 'stdout') { + pending += frame.text; + let newline; + while ((newline = pending.indexOf('\\n')) >= 0) { + const line = pending.slice(0, newline); pending = pending.slice(newline + 1); + const event = JSON.parse(line); + if (event.type === 'thread.started') { + if (!expire) throw new Error('supervisor deadline not controlled'); + writeFileSync(__MARKER__, JSON.stringify({session_id: event.thread_id})); + queueMicrotask(expire); + } + } + } + return flushed; +}; +""".replace("__MARKER__", json.dumps(str(marker))), encoding="utf-8") + transport = codex_cli.run_host_process + + def observed_deadline(*args, **kwargs): + environment = dict(os.environ) + environment["NODE_OPTIONS"] = ( + environment.get("NODE_OPTIONS", "") + " --import=" + clock.as_uri() + ).strip() + return transport(*args, **kwargs, environment=environment) + + monkeypatch.setattr(codex_cli, "run_host_process", observed_deadline) with pytest.raises(RuntimeError, match="codex_cli_timeout"): run_codex_cli_host( request, @@ -1286,11 +1339,37 @@ def test_codex_cli_host_preserves_observed_session_after_timeout( timeout_seconds=0.1, ) + assert json.loads(marker.read_text()) == {"session_id": "session-fixture-0001"} envelope = request["turn_envelope"] assert isinstance(envelope, dict) assert codex_cli_session_binding(runtime_root, envelope) is not None +def test_codex_cli_host_timeout_before_session_does_not_invent_binding( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + executable, log_path = _fake_codex(tmp_path) + monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) + # The real transport's minimum is one second; this event cannot arrive + # before that deadline. No timer or timeout policy is replaced in this arm. + monkeypatch.setenv("FAKE_CODEX_BEFORE_SESSION_SLEEP", "2") + runtime_root = tmp_path / "runtime" + project = tmp_path / "project" + project.mkdir() + request = _request() + + with pytest.raises(BuiltInHostError, match="codex_cli_timeout") as failure: + run_codex_cli_host(request, runtime_root=runtime_root, project=project, + codex_bin=str(executable), timeout_seconds=0.1) + + assert failure.value.recovery_kind is None + envelope = request["turn_envelope"] + assert isinstance(envelope, dict) + assert codex_cli_session_binding(runtime_root, envelope) is None + assert _session_files(runtime_root) == [] + + def test_codex_cli_host_classifies_failure_without_persisting_stderr( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1684,7 +1763,7 @@ def test_default_turn_cli_rejects_stopped_source_behind_stale_global_mirror( 1, )) monkeypatch.setenv("FAKE_CODEX_LOG", str(native_log)) - original_prompt = codex_cli._prompt + original_input = codex_cli.prepare_codex_cli_input stopped = [] def stop_source_before_dispatch(request): @@ -1701,9 +1780,9 @@ def stop_source_before_dispatch(request): assert json.loads(source_registry.read_text())["goals"][0]["activation"]["state"] == "stopped" assert mirror.read_bytes() == original_mirror stopped.append(result) - return original_prompt(request) + return original_input(request) - monkeypatch.setattr(codex_cli, "_prompt", stop_source_before_dispatch) + monkeypatch.setattr(codex_cli, "prepare_codex_cli_input", stop_source_before_dispatch) output = io.StringIO() with contextlib.redirect_stdout(output): code = cli_main([ diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 3afb07ea43..a4021e867d 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -4,6 +4,7 @@ import io import json import os +import shlex import subprocess import sys from collections.abc import Mapping @@ -86,6 +87,51 @@ def _envelope( } +@pytest.mark.parametrize("primary, expected", [ + (None, None), (False, None), (0, None), (" \t", None), + (" todo_other ", "todo_other"), (True, "True"), (1e-7, "1e-07"), + (["owner"], "['owner']"), ({"id": "owner"}, "{'id': 'owner'}"), + ("\x1c todo_other \x1f", "todo_other"), ("\u0085todo_other\u0085", "todo_other"), + ("\ufefftodo_other\ufeff", "\ufefftodo_other\ufeff"), +]) +def test_planning_and_recovery_retain_historical_selected_work_codec(primary, expected): + """Old journal identity/hash encoding survives migration to the typed selector.""" + from hashlib import sha256 + from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document + from loopx.control_plane.turn_driver.driver import reconcile_failed_turn_session_request + + for schema, mode in (("task_orchestration_contract_v2", "adaptive"), + ("task_orchestration_contract_v1", "adaptive"), + ("task_orchestration_contract_v2", "parallel")): + envelope = _envelope() + envelope["task_orchestration_contract"] = { + "schema_version": schema, "mode": mode, "primary_todo_id": primary, + } + selected = ({"todo_id": expected, "source": "task_orchestration_contract.primary_todo_id"} + if expected is not None and schema == "task_orchestration_contract_v2" and mode == "adaptive" + else envelope["action"]["selected_todo"]) + signature = turn_envelope_action_signature_document(envelope) + signature["action"]["selected_todo"] = selected + action_hash = "sha256:" + sha256(json.dumps(signature, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + plan = build_loopx_turn_plan(envelope, host="codex-cli", execution_mode="isolated-headless", + session_scope="todo", turn_instance_id="legacy-selected-work") + assert plan["route"]["selected_todo"] == selected + identity = {"lineage": {"goal_id": "fixture-goal", "agent_id": "codex-fixture", + "todo_id": selected["todo_id"], "action_hash": action_hash}, + "host": "codex-cli", "execution_mode": "isolated-headless", + "scheduler_owner": plan["host"]["scheduler_owner"], "session_action": "start_new", + "turn_instance_id": "legacy-selected-work"} + assert plan["transaction"]["turn_key"] == "sha256:" + sha256(json.dumps(identity, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode()).hexdigest() + binding = {"schema_version": LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION, + "goal_id": "fixture-goal", "agent_id": "codex-fixture", "todo_id": selected["todo_id"]} + recovered = reconcile_failed_turn_session_request({"turn_envelope": envelope, "session": plan["session"]}, + session_binding=binding) + assert recovered["session"]["action"] == "resume" + assert recovered["session"]["binding_status"] == "failed_turn_recovery" + + def test_turn_plan_projects_ready_route_without_side_effects() -> None: envelope = _envelope() @@ -1613,6 +1659,7 @@ def test_quota_cli_without_scheduler_context_fails_closed(tmp_path: Path) -> Non assert hint["codex_app"]["applicability"] == "blocked_invalid_context" +@pytest.mark.parametrize("context_delivery", ["off", "on"]) @pytest.mark.parametrize( "profile_args", ( @@ -1623,34 +1670,79 @@ def test_quota_cli_without_scheduler_context_fails_closed(tmp_path: Path) -> Non def test_quota_cli_codex_app_profile_is_explicit_and_applicable( tmp_path: Path, profile_args: list[str], + context_delivery: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: + import hashlib + from tests.control_plane.codex_checkpoint_fixture import reference_metadata_oracle + from loopx.control_plane.turn_driver.codex_context import codex_context_hook + + monkeypatch.setenv("CODEX_THREAD_ID", "profile-fixture") + monkeypatch.delenv("CODEX_ROLLOUT_TRACE_ROOT", raising=False) project, runtime, registry = _write_live_fixture(tmp_path) - output = io.StringIO() + state = project / ".codex/goals/loopx-turn-fixture/ACTIVE_GOAL_STATE.md" + original = state.read_bytes() - with contextlib.redirect_stdout(output): - exit_code = cli_main( - [ - "--registry", - str(registry), - "--runtime-root", - str(runtime), - "--format", - "json", - "quota", - "should-run", - "--goal-id", - "loopx-turn-fixture", - "--agent-id", - "codex-fixture", - # Inspect the raw scheduler contract, independent of native presentation. - "--context-delivery", - "off", - *profile_args, - ] - ) + def invoke(arguments: list[str]) -> tuple[int, dict[str, Any]]: + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = cli_main( + ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", *arguments] + ) + return code, json.loads(output.getvalue()) - payload = json.loads(output.getvalue()) - assert exit_code == 0, payload + native_args = [] + if context_delivery == "on": + context, captures = tmp_path / "context", tmp_path / "captures" + context.mkdir(mode=0o700) + captures.mkdir(mode=0o700) + codex_context_hook(str(context), { + "session_id": "profile-fixture", "cwd": str(project), + "hook_event_name": "SessionStart", "source": "startup", + }) + native_args = ["--codex-context-dir", str(context), + "--decision-output-root", str(captures)] + + exit_code, payload = invoke([ + "quota", "should-run", "--goal-id", "loopx-turn-fixture", + "--agent-id", "codex-fixture", "--turn-instance-id", "profile-turn", + "--context-delivery", context_delivery, *profile_args, *native_args, + ]) + assert exit_code == 0 and payload["ok"], payload + ledger = runtime / "goals/loopx-turn-fixture/rollout-event-log.jsonl" + recorded = ledger.read_bytes() + admissions = [event for line in recorded.splitlines() + if (event := json.loads(line))["event_kind"] == "quota_should_run" + and event.get("run_id") == "profile-turn"] + assert len(admissions) == 1 + if context_delivery == "on": + assert payload["mode"] == "full" + command, observed = payload["recovery"]["command"], b"" + recovery = reference_metadata_oracle(payload)["recovery"] + while True: + reader = subprocess.run(shlex.split(command), capture_output=True, text=True, timeout=60) + assert reader.returncode == 0, reader.stderr + page = json.loads(reader.stdout) + assert page["ok"] and page["offset"] == len(observed) + observed += page["content"].encode("utf-8") + if page["eof"]: + assert len(observed) == page["total_bytes"] + break + command = page["next_command"] + assert hashlib.sha256(observed).hexdigest() == recovery["sha256"] + payload = json.loads(observed) + assert payload["heartbeat_receipt"]["turn_instance_id"] == "profile-turn" + else: + assert "recovery" not in payload + assert ledger.read_bytes() == recorded + assert state.read_bytes() == original + runs = runtime / "goals/loopx-turn-fixture/runs/index.jsonl" + if runs.exists(): + assert not any( + json.loads(line).get("classification") == "quota_slot_spent" + for line in runs.read_text().splitlines() + ) hint = payload["scheduler_hint"] assert "execution_context" not in hint assert "execution_phase" not in hint @@ -1762,11 +1854,72 @@ def test_turn_cli_consumes_live_state_without_writes( assert payload["route"]["kind"] == LoopXTurnRoute.READY_FOR_HOST.value assert payload["session"]["action"] == "start_new" assert "settlement_plan" not in payload["transaction"] - assert payload["turn_envelope"]["action_signature"]["matches"] is True + assert payload["schema_version"] == "loopx_turn_plan_v1" and "turn_envelope" not in payload + from loopx.control_plane.turn_driver.driver import turn_host_lineage + + assert turn_host_lineage(payload) == {"goal_id": "loopx-turn-fixture", "agent_id": "codex-fixture", + "todo_id": "todo_fixture0001"} assert payload["effects"]["state_written"] is False assert before == after +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("transaction_detail", [False, True]) +def test_turn_cli_inspection_cannot_become_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, + transaction_detail: bool, +) -> None: + from loopx.cli_commands import turn + + _, runtime, registry = _write_live_fixture(tmp_path, provider=provider) + original_plans: list[dict[str, Any]] = [] + real_plan = turn.build_loopx_turn_plan + compose = turn.compose_turn_decision + + def with_future_observation(source, **kwargs): + return compose({**source, "future_fixture_observation": { + "text": 'Complete unknown data: 保留 "quoted" text\nand the final line.', + "numbers": [1.0, -0.0, 2**60 + 1], + }}, **kwargs) + + def retain_executor_plan(*args: Any, **kwargs: Any) -> dict[str, Any]: + planned = real_plan(*args, **kwargs) + # Preserve the actual executor input independently of CLI formatting. + original_plans.append(json.loads(json.dumps(planned))) + return planned + + monkeypatch.setattr(turn, "build_loopx_turn_plan", retain_executor_plan) + monkeypatch.setattr(turn, "compose_turn_decision", with_future_observation) + output = io.StringIO() + args = ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "turn", "plan", "--goal-id", "loopx-turn-fixture", + "--agent-id", "codex-fixture"] + if transaction_detail: + args.append("--include-transaction-detail") + with contextlib.redirect_stdout(output): + code = cli_main(args) + inspection = json.loads(output.getvalue()) + assert code == 0, inspection + assert inspection["route"] == original_plans[0]["route"] + assert inspection["schema_version"] == "loopx_turn_plan_v1" and "turn_envelope" not in inspection + assert json.dumps(inspection["work_contract"], sort_keys=True) == json.dumps( + original_plans[0]["work_contract"], sort_keys=True) + assert inspection["work_contract"]["future_fixture_observation"]["numbers"][-1] == 2**60 + 1 + if transaction_detail: + # Explicit detail retains transport metadata, never a second source. + detailed = build_loopx_turn_host_request(inspection) + assert detailed["work_contract"] == original_plans[0]["work_contract"] + else: + # Even restoring valid transaction detail cannot promote inspection. + inspection["transaction"] = original_plans[0]["transaction"] + inspection["session"] = original_plans[0]["session"] + with pytest.raises(ValueError, match="CLI inspection"): + build_loopx_turn_host_request(inspection) + request = build_loopx_turn_host_request(original_plans[0]) + assert request["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" + assert request["work_contract"] == original_plans[0]["work_contract"] + + def test_turn_cli_resolves_its_decision_through_the_shared_owner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1813,7 +1966,8 @@ def recording_collect_status(*args: Any, **kwargs: Any) -> dict[str, Any]: payload = json.loads(output.getvalue()) assert exit_code == 0, payload - assert payload["turn_envelope"]["schema_version"] == "loopx_turn_envelope_v0" + assert payload["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" + assert "turn_envelope" not in payload assert len(status_reads) == 1, ( "`turn` must read its live status through the shared Turn decision " "owner so run-once and managed-step cannot drift; shared-owner status " @@ -1902,16 +2056,16 @@ def test_turn_cli_binds_advisory_primary_without_hiding_portfolio( payload = json.loads(output.getvalue()) assert exit_code == 0, payload assert payload["route"]["kind"] == LoopXTurnRoute.READY_FOR_HOST.value - envelope = payload["turn_envelope"] - assert envelope["action"]["delivery_allowed"] is True - assert envelope["action"]["selected_todo"]["selected_by"] == ( + source = payload["work_contract"] + assert source["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + assert source["selected_todo"]["selected_by"] == ( "turn_controller_advisory_primary" ) - portfolio = envelope["action"]["action_portfolio"] + portfolio = source["action_portfolio"] assert portfolio["schema_version"] == "quota_action_portfolio_v2" assert portfolio["primary"]["todo_id"] == "todo_fixture0001" assert portfolio["selection_policy"]["requires_explicit_turn_binding"] is True - assert envelope["writeback"].get("selection_required") is None + assert source["interaction_contract"]["cli_channel"].get("selection_required") is None @pytest.mark.parametrize("selection", [None, "todo_fixture0002", "todo_missing"]) @@ -1932,7 +2086,7 @@ def test_turn_cli_explicit_todo_keeps_default_and_never_falls_back(tmp_path, sel assert "no alternate task" in result["error"] else: assert code == 0, result - selected = result["turn_envelope"]["action"]["selected_todo"] + selected = result["work_contract"]["selected_todo"] assert selected["todo_id"] == (selection or "todo_fixture0001") assert selected["selected_by"] == ("turn_explicit_todo" if selection else "turn_controller_advisory_primary") @@ -2754,10 +2908,10 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( # Turn itself stays blocked until that replan creates a runnable successor. assert next_plan["route"]["kind"] == "blocked" assert ( - next_plan["turn_envelope"]["effective_action"] + next_plan["work_contract"]["effective_action"] == "autonomous_replan_required" ) - next_obligation = next_plan["turn_envelope"]["replan_action_packet"] + next_obligation = next_plan["work_contract"]["replan_action_packet"] assert next_obligation["decision"] == "replan_required" replayed_output = io.StringIO() @@ -3612,12 +3766,15 @@ def test_turn_run_once_cli_rejects_unproven_host_claim_before_writeback( @pytest.mark.parametrize( "result_kind", ["validated_progress", "repair_required", "replan_required"] ) +@pytest.mark.parametrize("verified_source", [False, True]) def test_turn_run_once_cli_uses_built_in_codex_host_and_typed_writeback( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, result_kind: str, + verified_source: bool, ) -> None: - from loopx.cli_commands.turn import build_turn_envelope as real_build_turn_envelope + from copy import deepcopy + from loopx.cli_commands.turn import compose_turn_decision as real_compose_turn_decision from loopx.cli_commands.turn_run_once import ( refresh_state_run as real_refresh_state_run, spend_quota_slot as real_spend_quota_slot, @@ -3662,19 +3819,28 @@ def test_turn_run_once_cli_uses_built_in_codex_host_and_typed_writeback( spend_workspace_paths: list[Path | None] = [] refresh_todo_ids: list[str] = [] - def adaptive_turn_envelope(*args: object, **kwargs: object) -> dict[str, object]: - envelope = real_build_turn_envelope(*args, **kwargs) - envelope["action"]["selected_todo"] = { + def adaptive_turn_decision(source: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]: + # Build the stale-selection scenario before signing either projection. + # The adaptive primary, not the provisional selection, owns writeback. + decision = deepcopy(source) + # This pre-orchestration selection is not a verified completion source. + # Preserve the historical unverified-selection case; claiming verified + # text for a different Todo must still reject in captured_todo_text_sha256. + if not verified_source: + decision["interaction_contract"]["agent_channel"]["work_context"].pop( + "selected_todo_text_verified", None + ) + decision["selected_todo"] = { "todo_id": "todo_stale_selection", "text": "A stale pre-orchestration selection", } - envelope["task_orchestration_contract"] = { + decision["task_orchestration_contract"] = { "schema_version": "task_orchestration_contract_v2", "mode": "adaptive", "primary_todo_id": "todo_fixture0001", "eligible_child_lanes": [], } - return envelope + return real_compose_turn_decision(decision, **kwargs) def recording_refresh_state_run(*args: object, **kwargs: object) -> dict[str, object]: refresh_workspace_paths.append(kwargs.get("delivery_workspace_path")) @@ -3685,7 +3851,10 @@ def recording_spend_quota_slot(*args: object, **kwargs: object) -> dict[str, obj spend_workspace_paths.append(kwargs.get("workspace_path")) return real_spend_quota_slot(*args, **kwargs) + host_calls: list[str] = [] + def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, object]: + host_calls.append(str(request["turn_key"])) return { "schema_version": "loopx_turn_result_v0", "turn_key": request["turn_key"], @@ -3705,8 +3874,8 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, fake_codex_host, ) monkeypatch.setattr( - "loopx.cli_commands.turn.build_turn_envelope", - adaptive_turn_envelope, + "loopx.cli_commands.turn.compose_turn_decision", + adaptive_turn_decision, ) monkeypatch.setattr( "loopx.cli_commands.turn_run_once.refresh_state_run", @@ -3750,6 +3919,12 @@ def fake_codex_host(request: dict[str, object], **_kwargs: object) -> dict[str, ) payload = json.loads(output.getvalue()) + if verified_source: + assert exit_code == 1, payload + assert "Turn task source does not match" in payload["error"] + assert not host_calls and not refresh_workspace_paths and not spend_workspace_paths + return + assert len(host_calls) == 1 assert exit_code == 0, payload assert payload["host"] == {"executable": "built-in", "kind": "codex-cli"} assert payload["effects"]["state_written"] is True @@ -4012,14 +4187,20 @@ def test_turn_run_once_cli_resumes_session_from_recoverable_failed_turn( session_available = False session_actions: list[str] = [] session_binding_calls = 0 + session_sources: list[dict[str, object]] = [] def fake_session_binding( _runtime_root: Path, - _turn_envelope: dict[str, object], **_kwargs: object, ) -> dict[str, str] | None: nonlocal session_binding_calls session_binding_calls += 1 + assert "turn_envelope" not in _kwargs + source = _kwargs.get("work_contract") + assert isinstance(source, dict) + assert source.get("schema_version") == "loopx_turn_envelope_v1" + assert source.get("goal_id") == "loopx-turn-fixture" + session_sources.append(source) if not session_available: return None return { @@ -4111,3 +4292,4 @@ def fake_codex_host( # state. Rebuilding a binding from the fresh decision can fail after the # selected Todo has already completed and disappeared from the live route. assert session_binding_calls == 2 + assert session_sources[0] == session_sources[1] diff --git a/tests/test_loopx_turn_managed_step.py b/tests/test_loopx_turn_managed_step.py index d57b55177a..a3f6ea5bd5 100644 --- a/tests/test_loopx_turn_managed_step.py +++ b/tests/test_loopx_turn_managed_step.py @@ -503,9 +503,9 @@ def _record_decision(payload, **kwargs): def _record_envelope(decision, *, scheduler_execution_context): envelopes.append((decision, scheduler_execution_context)) - return {"schema_version": "loopx_turn_envelope_v0"} + return {"schema_version": "loopx_turn_envelope_v1"} - monkeypatch.setattr(turn_decision, "build_turn_controller_input", _record_envelope) + monkeypatch.setattr(turn_decision, "compose_turn_decision", _record_envelope) turn_decision.build_fresh_envelope_for_managed_step( args, registry_path=registry_path, @@ -554,6 +554,7 @@ def test_native_cli_retry_and_saved_result_keep_one_original_settlement( source = executable.read_text().replace( output, "pathlib.Path('validated-artifact').write_text('independent marker')\n" + output, ) + assert "Path('validated-artifact')" in source executable.write_text(source) monkeypatch.setenv("FAKE_CODEX_LOG", str(invocations)) monkeypatch.setenv("FAKE_CODEX_FAIL", "1") diff --git a/tests/test_managed_complete_source.py b/tests/test_managed_complete_source.py new file mode 100644 index 0000000000..877532a11f --- /dev/null +++ b/tests/test_managed_complete_source.py @@ -0,0 +1,96 @@ +"""Normal managed observations consume the same full source as a fresh Turn.""" +from __future__ import annotations + +import contextlib +import copy +import io +import json + +import pytest + +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.test_loopx_turn_driver import _write_live_fixture +from tests.test_loopx_turn_managed_step import _failed_journal + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("source_fault", [None, "unknown_action", "missing_read_command", "unknown_read_order"]) +def test_real_managed_entrypoint_checks_and_preserves_the_complete_source( + tmp_path, monkeypatch, provider, source_fault, +): + from loopx.cli import main as cli_main + from loopx.cli_commands.turn_decision import FreshTurnDecisionOwner + from loopx.control_plane.turn_driver import loop_controller, turn_journal_path + + isolate_sqlite_runtime(tmp_path, monkeypatch) + _project, runtime, registry = _write_live_fixture(tmp_path, provider=provider) + lineage = {"goal_id": "loopx-turn-fixture", "agent_id": "codex-fixture", "todo_id": "todo_fixture0001"} + journal = _failed_journal(lineage=lineage) + path = turn_journal_path(runtime, goal_id=lineage["goal_id"], turn_key=journal["turn_key"]) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(journal)) + original_journal = path.read_bytes() + + # This is source data, not a prequalified route or a second permission. + evidence = {"original_tail": "Keep all original requirements and STOP at the budget.", + "large_integer": 10**45 + 17, "original_float": 1.0} + resolve = FreshTurnDecisionOwner.resolve + + def source_data(current): + current["future_supporting_evidence"] = copy.deepcopy(evidence) + if source_fault == "unknown_action": + current["effective_action"] = "unregistered_current_action" + # These declare an executable pre-work obligation, not optional evidence. + # A current contract cannot silently drop its command or defer its order. + read = {"source": "fixture_material", "command": "cat -- 'required material.md'", + "ordering": "before_work", "reason": "Read the complete original material."} + if source_fault == "missing_read_command": + read.pop("command") + elif source_fault == "unknown_read_order": + read["ordering"] = "after_spend" + current["interaction_contract"]["agent_channel"].setdefault("required_reads", []).append(read) + return current + + monkeypatch.setattr(FreshTurnDecisionOwner, "resolve", lambda self: source_data(resolve(self))) + observed = [] + request = loop_controller.effect_runtime_projection_result + + def inspect(method, params, **options): + if method == "turn.controller" and params.get("operation") == "managed": + assert "historical_route_text" not in params + observed.append(copy.deepcopy(params["decision"])) + return request(method, params, **options) + + monkeypatch.setattr(loop_controller, "effect_runtime_projection_result", inspect) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + argv = [*prefix, "turn", "managed-step", "--goal-id", lineage["goal_id"], "--agent-id", lineage["agent_id"], + "--turn-key", journal["turn_key"], "--host", "dsh", "--execution-mode", "isolated-headless", + "--observed-attempt", "1", "--observed-max-attempts", "3"] + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = cli_main(argv) + result = json.loads(output.getvalue()) + if source_fault: + assert code == 1 and result["ok"] is False, result + expected = {"unknown_action": "Turn effective_action", + "missing_read_command": "required read command", + "unknown_read_order": "required read ordering"}[source_fault] + assert expected in result["error"], result + assert observed == [] + else: + assert code == 0 and result["disposition"] == "wait", result + assert len(observed) == 1 + source = observed[0] + assert source["schema_version"] == "loopx_turn_envelope_v1" + assert "action_signature" not in source + assert source["future_supporting_evidence"] == evidence + assert type(source["future_supporting_evidence"]["original_float"]) is float + assert source["selected_todo"]["todo_id"] == lineage["todo_id"] + read = next(item for item in source["interaction_contract"]["agent_channel"]["required_reads"] + if item["source"] == "fixture_material") + assert read["command"] == "cat -- 'required material.md'" + assert read["ordering"] == "before_work" + assert path.read_bytes() == original_journal + index = runtime / "goals" / lineage["goal_id"] / "runs/index.jsonl" + rows = [json.loads(line) for line in index.read_text().splitlines()] if index.exists() else [] + assert not any(row.get("classification") == "quota_slot_spent" for row in rows) diff --git a/tests/test_managed_research_team.py b/tests/test_managed_research_team.py index 4d78f925bf..2d1f3a5fd9 100644 --- a/tests/test_managed_research_team.py +++ b/tests/test_managed_research_team.py @@ -61,7 +61,9 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey # Both choices are available; explicit request must select exactly the second, # independently of canonical ordering / the controller's default. selected = plan(root, "analyst", "initial") - assert selected["turn_envelope"]["action"]["selected_todo"]["todo_id"] == "todo_analyst-initial", selected + assert selected["schema_version"] == "loopx_turn_plan_v1" + assert "turn_envelope" not in selected + assert selected["work_contract"]["selected_todo"]["todo_id"] == "todo_analyst-initial", selected assert inspect_goal_acceptance(**route)["provider_revision"] == before["provider_revision"] # All artifact hashes and cached accepted files exist, but canonical work is open. with pytest.raises(ValueError, match="canonical_dependency_incomplete"): @@ -106,7 +108,11 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey configure_goal_acceptance(**route, document=document, agent_id="lead", expected_provider_revision=provider_revision, execute=True) held = plan(root, "reviewer", "initial") - assert not held.get("turn_envelope", {}).get("action", {}).get("delivery_allowed", False), held + assert held["error_code"] == "turn_todo_selection_rejected", held + assert held["selection_rejection"]["requested_todo_id"] == "todo_reviewer-initial" + assert held["selection_rejection"]["state"] == "rejected" + assert held["effects"] == {"host_invoked": False, "state_written": False, + "scheduler_acknowledged": False, "quota_spent": False} # Explicit owner amendment for this negative fixture, never done by delegate(). configure_goal_acceptance(**route, document=json.loads((root / "bootstrap.json").read_text())["document"], expected_provider_revision=inspect_goal_acceptance(**route)["provider_revision"], execute=True) @@ -187,8 +193,9 @@ def test_bootstrap_refuses_existing_state(team): root = team before = canonical_tasks(root) repeated = subprocess.run(["node", "--no-warnings", "--experimental-sqlite", "--experimental-strip-types", - str(demo.HERE / "bootstrap.ts"), str(root)], capture_output=True) + str(demo.HERE / "bootstrap.ts"), str(root), "file", str(demo.PACKAGE_ROOT)], capture_output=True) assert repeated.returncode != 0 + assert b"EEXIST" in repeated.stderr with pytest.raises(ValueError, match="new_disposable"): demo.prepare(root) assert canonical_tasks(root) == before diff --git a/tests/test_native_steward_turn_host.py b/tests/test_native_steward_turn_host.py index 56c9e675af..41e550de8b 100644 --- a/tests/test_native_steward_turn_host.py +++ b/tests/test_native_steward_turn_host.py @@ -262,6 +262,81 @@ def stopped(adapter, message, sink): assert capture.read_bytes() == after +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("fault", ["valid", "historical", "missing_complete", "changed_complete", "null_complete"]) +def test_managed_postcondition_checks_complete_source_before_argv(steward, tmp_path, monkeypatch, provider, fault): + from loopx.capabilities.native_chat.steward_managed_mode import ManagedSessionTurn + from loopx.control_plane.turn_driver import command_validation + + service, session, actor, todo_id, _, capture = managed_fixture(steward, tmp_path, monkeypatch, provider) + runtime = steward[1] + invoked = [] + if fault == "historical": + from loopx.cli_commands import turn + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + planner = turn.build_loopx_turn_plan + + def recorded_legacy(source, **kwargs): + plan = planner(build_turn_envelope(source), **kwargs) + assert plan["schema_version"] == "loopx_turn_plan_v0" + return plan + + monkeypatch.setattr(turn, "build_loopx_turn_plan", recorded_legacy) + build = command_validation.build_loopx_turn_command_validator + + def instrument(*args, **kwargs): + validate = build(*args, **kwargs) + + def observed(plan, result): + invoked.append(True) + return validate(plan, result) # Keep the real independent argv. + + return observed + + monkeypatch.setattr(command_validation, "build_loopx_turn_command_validator", instrument) + create = ManagedSessionTurn.validator + + def interrupted(self, *args, **kwargs): + validate = create(self, *args, **kwargs) + + def after_model(plan, result): + # Fault only the complete source at the actual post-model boundary; + # actor admission, native Host, validation and settlement stay real. + assert "work_contract" in plan + if fault == "changed_complete": + plan["work_contract"] = {**plan["work_contract"], "reason": "changed after model work"} + elif fault == "null_complete": + plan["work_contract"] = None + elif fault in {"historical", "missing_complete"}: + plan.pop("work_contract") + return validate(plan, result) + + return after_model + + monkeypatch.setattr(ManagedSessionTurn, "validator", interrupted) + before = capture.read_bytes() + accepted, _ = service.submit(session["session_id"], agent_id=actor["agent_id"], + operation_id="complete-postcondition", todo_id=todo_id) + completed = finish(runtime, accepted) + calls = [json.loads(row) for row in capture.read_bytes()[len(before):].splitlines()] + assert sum(row["method"] == "turn/start" for row in calls) == 1 + if fault in {"valid", "historical"}: + assert invoked == [True] + native = completed["response"]["native_loopx"] + assert native["status"] == "committed" and native["effects"]["quota_spent"] + else: + assert invoked == [] # Refuse before even the independent command runs. + native = completed["response"]["native_loopx"] + assert native["status"] == "failed" + assert not native["effects"]["state_written"] and not native["effects"]["quota_spent"] + after = capture.read_bytes() + replay, created = service.submit(session["session_id"], agent_id=actor["agent_id"], + operation_id="complete-postcondition", todo_id=todo_id) + assert not created and replay["turn_id"] == accepted["turn_id"] + assert capture.read_bytes() == after + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_managed_turn_http_correlates_readback_without_accepting_commands(steward, tmp_path, monkeypatch, provider): import http.client diff --git a/tests/test_turn_controller_projection.py b/tests/test_turn_controller_projection.py index 955ebfdb29..d53c93075e 100644 --- a/tests/test_turn_controller_projection.py +++ b/tests/test_turn_controller_projection.py @@ -10,10 +10,10 @@ @pytest.mark.parametrize("user_action_required", [False, True]) -def test_managed_projection_uses_one_controller_call_without_trusting_a_mutable_wire( +def test_managed_current_source_uses_one_controller_call_and_rejects_modified_content( monkeypatch, user_action_required, ) -> None: - from dataclasses import FrozenInstanceError, replace + from copy import deepcopy from loopx.control_plane.quota import turn_envelope from loopx.control_plane.turn_driver import driver, loop_controller @@ -23,7 +23,8 @@ def test_managed_projection_uses_one_controller_call_without_trusting_a_mutable_ agent_identity={"agent_id": AGENT_ID}, selected_todo={"todo_id": TODO_ID, "text": "Advance."}) decision["interaction_contract"]["user_channel"]["action_required"] = user_action_required - expected_envelope = turn_envelope.build_turn_envelope(decision) + historical = turn_envelope.build_turn_envelope(decision) + expected_source = turn_envelope.compose_turn_decision(decision) calls = [] original = turn_envelope._turn_envelope_result @@ -33,28 +34,17 @@ def record(operation, **params): monkeypatch.setattr(turn_envelope, "_turn_envelope_result", record) monkeypatch.setattr(driver, "_turn_envelope_result", record) - current = turn_envelope.build_turn_controller_input(decision) - assert current.envelope() == expected_envelope - assert calls == ["build_controller_input"] - assert not hasattr(current, "__dict__") - with pytest.raises(FrozenInstanceError): - current.route = "ready_for_host" - with pytest.raises(TypeError): - turn_envelope.TurnControllerInput(current.envelope_json, "ready_for_host") - with pytest.raises(TypeError): - replace(current, route="ready_for_host") - with pytest.raises(TypeError, match="original quota source"): - turn_envelope.TurnControllerInput() - - # A returned object may be edited for another purpose; it cannot edit the - # producer's snapshot or attach its route qualification to that copy. - changed = current.envelope() - changed["action"]["delivery_allowed"] = False - changed["action_signature"]["matches"] = False - changed["route"] = current.route - assert current.envelope() == expected_envelope + current = turn_envelope.compose_turn_decision(decision) + assert current == expected_source + assert calls == ["compose_complete"] + + # Integrity is checked at the real consumer. A mutable transport copy + # cannot attach the qualification of the original complete source. + changed = deepcopy(current) + changed["interaction_contract"]["agent_channel"]["delivery_allowed"] = False + assert current == expected_source journal = _failed_journal() - expected = _decide(journal, envelope=expected_envelope) + expected = _decide(journal, envelope=historical) assert expected["disposition"] == ("user_action_required" if user_action_required else "wait") calls.clear() controller_calls = [] @@ -70,7 +60,7 @@ def record_runtime(method, params, **options): assert actual == expected assert calls == [] assert controller_calls == [("turn.controller", "managed")] - with pytest.raises(ValueError, match="shared envelope contract"): + with pytest.raises(ValueError, match="integrity"): _decide(journal, envelope=changed) assert calls == [] assert controller_calls == [("turn.controller", "managed")] * 2 @@ -120,7 +110,8 @@ def record(operation, **params): assert code == 0 and result["disposition"] == "wait", result assert result["retry_continuation"]["retry_after_seconds"] == 30 assert result["retry_continuation"]["max_attempts"] == 3 - assert calls.count("build_controller_input") == 1 + assert calls.count("compose_complete") == 1 + assert "build_controller_input" not in calls and "build" not in calls assert "driver_route" not in calls assert path.read_bytes() == before # The observation discrepancy must still fail without offering a retry. From bff3b29b60b3b16bdfd7fe7ecc005bbf63bfdedf Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:34:55 +0800 Subject: [PATCH 03/33] docs(turn): define the staged lifecycle extraction boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../contract-and-context.md | 20 ++++++++++++- .../contract-and-context.zh-CN.md | 13 ++++++++- packages/loopx-ark-turn/README.md | 8 +++-- packages/loopx-memt-turn/README.md | 29 ++++++++++++++----- packages/loopx-memt-turn/README.zh-CN.md | 7 +++-- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md index 661db8882c..710dddda55 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md @@ -549,13 +549,31 @@ are reconciled. Rollback likewise separates outstanding work from new admission. A version transition does not rewrite saved prompts, journals or source hashes as if they had always used the new contract. -The first C1 PR must include the two production composition callsites above and +The first cross-entrypoint C1 cutover must include the two production composition callsites above and at least one complete producer-to-final-input journey, with an explicit list of remaining readers. Qualifying that bounded slice does not enable the new writer for an unqualified host or close C1. The final cutover removes active format choice only after all supported readers qualify. Temporary decoders have named callers and removal conditions; they are not user-facing compatibility views. +A Turn lifecycle extraction may be reviewed separately under the parent C1 +proposal when it includes planning, final Host input, capture publication, +journal inspection, original-session recovery and settlement consumers together. +The migrated Turn uses one complete source and its checked readers; the ordinary +quota writer and thin-heartbeat presentation retain their existing contract in +this staged boundary. Shared projection changes must preserve those unmigrated +callers. The historical v0 decoder serves recorded plans, requests and journals; +it cannot rescue an invalid supplied v1 source. Its removal still depends on +reconciling outstanding work and the supported recovery window. + +This extraction is partial C1 delivery, not qualification of the cross-entrypoint +cutover or a context/cost improvement. The parent retains ordinary quota and +heartbeat migration, final-input/material-read coverage, all-host configuration, +and the original single-session outcome and complete-cost comparison. Review +must include matched baseline/head real-provider outcomes, rejection sensitivity +and packaged recovery. Adoption reduces the parent's diff only after the slice +lands on main and main is integrated into the parent; opening a PR does not. + For each normal, selection, replan, wait, recovery and capability-off fixture, check independently authored obligations at **three boundaries**: composed contract, reconstructed delivery, and final model input plus required material diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md index 54feab7b88..1603c57577 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md @@ -405,11 +405,22 @@ Draft,不是仅等待评审。尚无模型或成本收益证据。历史 effec 受支持恢复窗口核清前保留历史 decoder。回退同样区分未结工作与新准入。版本迁移不能 把保存的 prompt、journal 或源 hash 改写成仿佛一直使用新契约。 -C1 首个 PR 必须覆盖上述两个生产组合调用点,并交付至少一条从生产端到最终输入的 +C1 首次跨入口切换必须覆盖上述两个生产组合调用点,并交付至少一条从生产端到最终输入的 完整旅程,同时明确剩余 reader。这个有界切片通过资格,不代表可向未验收 host 开启 新 writer,也不关闭 C1。最终切换须在全部受支持 reader 验收后删除活跃格式选择。 临时 decoder 有具名 caller 与退出条件,不是面向用户的兼容视图。 +Turn 生命周期可以在 C1 父提案下单独评审,但须一起覆盖规划、最终 Host 输入、capture +发布、journal 检查、原会话恢复及结算消费者。已迁移 Turn 使用一个完整来源及其已校验 +reader;这个分阶段边界保留普通 quota writer 和 thin-heartbeat 的现有契约。共享投影 +改动须保留这些未迁移 caller。历史 v0 decoder 服务已记录的 plan、request 和 journal, +不能为非法的已提供 v1 来源兜底;其删除仍依赖未结工作及受支持恢复窗口核清。 + +这属于 C1 的部分交付,不代表跨入口切换取得资格,也不证明上下文或成本收益。父提案 +仍负责普通 quota 与 heartbeat 迁移、最终输入/材料读取覆盖、全宿主配置,以及原单会话 +成果与完整成本对照。评审须包含匹配基线/候选版本的真实 provider 成果、拒绝敏感性和 +打包恢复。只有切片合入 main、父提案集成 main 后,父提案的差异才会减少;开 PR 不算。 + 对 normal、selection、replan、wait、recovery 和 capability-off 的每个 fixture, 在**三个边界**核对独立编写的义务:组合契约、重建交付、最终模型输入及必需材料读取。 包含旧 action signature 未覆盖的源变化、必需来源缺失、不兼容消费者和结算响应丢失。 diff --git a/packages/loopx-ark-turn/README.md b/packages/loopx-ark-turn/README.md index 814aa7d9ea..135ae4df5f 100644 --- a/packages/loopx-ark-turn/README.md +++ b/packages/loopx-ark-turn/README.md @@ -7,8 +7,12 @@ This adapter does not activate a native Goal or add a second scheduler. ## Install and select -Install both packages from the same checkout containing the shared -`loopx.control_plane.turn_driver.host_candidate` contract: +Install both packages from the same integrated LoopX 2.2 checkout containing +`loopx.extensions.turn_host.extract_turn_work_identity` and +`assert_turn_host_request_version`. The shared decoder accepts complete-source +Host request v1 and historical v0; identity and provider permissions still use +their existing owners. The version floor alone +does not establish that an older wheel contains this shared contract: ```bash uv pip install -e . -e packages/loopx-ark-turn diff --git a/packages/loopx-memt-turn/README.md b/packages/loopx-memt-turn/README.md index 043b3ff95c..4477fd565d 100644 --- a/packages/loopx-memt-turn/README.md +++ b/packages/loopx-memt-turn/README.md @@ -19,8 +19,13 @@ memory setting. ## Prerequisites and licensing -Use LoopX 2.1.0+ with its supported `extensions.turn_host` seam, Python -3.11+, and a separately operated Nano SGLang service. See the upstream +Use Python 3.11+ and install both packages from the same integrated LoopX 2.2 +checkout containing `extensions.turn_host.extract_turn_work_identity` and +`assert_turn_host_request_version`. Complete-source Host request v1 and historical +v0 share that decoder; exact Goal lifetime and memory/effect gates still apply. +The version +floor alone does not establish that an older wheel contains this shared contract. +A separately operated Nano SGLang service is required. See the upstream [inference guide](https://github.com/MemTensor/MemT-1.0/blob/e75c13d090a6a9d34b29146d901bd84323185f24/inference/README.md) for GPU deployment. The local client needs matching tokenizer and configuration assets; it does not load model weights. This adapter supports Nano only. @@ -96,8 +101,17 @@ authentication. Provider credentials and arbitrary parent environment values are not forwarded. This adapter is not an OS sandbox. Do not select a server that grants unrestricted effects based on model arguments or remembered text. -For an existing registered Goal, Agent and eligible claimed Todo, use the -normal Turn arguments and independent validator, with these explicit options: +The integrated launch path is currently unqualified: executable legacy Goal +registries do not supply the exact `goal_ref` required by this adapter, while +`source_session_v1` is lifecycle-only and the generic Turn entrypoint rejects it. +The adapter fails before provider IO when the reference is absent. Do not inject +an instance identifier, relax that check or change registry profiles to work +around it. The shared Goal/Turn owner must first provide admitted exact-lifetime +identity through planning and recovery; see the +[controller migration checkpoint](../../docs/architecture/rfcs/controller-executor-unified-contract-v0.md). + +The intended command shape, once that prerequisite is delivered and qualified, +uses a registered Goal, Agent, eligible claimed Todo and independent validator: ```sh loopx turn run-once --goal-id demo --agent-id analyst --todo-id todo_demo \ @@ -106,9 +120,10 @@ loopx turn run-once --goal-id demo --agent-id analyst --todo-id todo_demo \ --validation-command-json '["python3","/srv/operator/check_artifact.py"]' ``` -This previews the Turn. Supply an operator-authored validator that independently -checks the real artifact, then add `--execute` to the reviewed command. Existing -admission and permission requirements still apply. The adapter returns one +This is a preview command, not evidence that the current integrated path works. +After the exact-lifetime prerequisite is qualified, supply an operator-authored +validator that independently checks the real artifact before adding `--execute`. +Existing admission and permission requirements still apply. The adapter returns one `loopx_turn_result_v0` candidate; a successful model reply is not acceptance. Execution must start in the configured workspace; the normal Turn launcher sets that working directory. A mismatch fails before any provider calls. diff --git a/packages/loopx-memt-turn/README.zh-CN.md b/packages/loopx-memt-turn/README.zh-CN.md index 018c51bd1f..666512d0ee 100644 --- a/packages/loopx-memt-turn/README.zh-CN.md +++ b/packages/loopx-memt-turn/README.zh-CN.md @@ -14,8 +14,11 @@ MemT 客户端使用 Python,所以此包作为 Python 传输适配器;没有 ## 安装与许可证 -需要 Python 3.11+、含受支持 `extensions.turn_host` 接缝的 LoopX 2.1.0+, -以及独立部署的 Nano SGLang 服务。GPU 部署按上游 +需要 Python 3.11+,并从同一份已集成的 LoopX 2.2 源码安装两个包,源码须包含 +`extensions.turn_host.extract_turn_work_identity` 与 +`assert_turn_host_request_version`。完整来源 Host request v1 与历史 v0 共用此 decoder, +精确 Goal lifetime 和 memory/effect 门保持。仅满足版本下限不能证明旧 wheel +已包含此共享契约。另需独立部署的 Nano SGLang 服务。GPU 部署按上游 [推理指南](https://github.com/MemTensor/MemT-1.0/blob/e75c13d090a6a9d34b29146d901bd84323185f24/inference/README.md) 操作。本地客户端只加载匹配的 tokenizer 和配置,不加载模型权重;首版仅支持 Nano。 From e489b6b984dd8f834f3f419dcbf6c815d7d7b5d7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:16:02 +0800 Subject: [PATCH 04/33] fix(turn): qualify native execution and preserve Host obligations Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/managed-research-team/bootstrap.ts | 34 +++++++++----- .../managed-research-team/research_team.py | 4 +- .../capabilities/native_chat/steward_turn.py | 19 +++++++- loopx/control_plane/turn_driver/codex_cli.py | 7 +++ .../turn_driver/host_candidate.py | 47 +++++++++++++------ 5 files changed, 81 insertions(+), 30 deletions(-) diff --git a/examples/managed-research-team/bootstrap.ts b/examples/managed-research-team/bootstrap.ts index c229196c98..f776d3c185 100644 --- a/examples/managed-research-team/bootstrap.ts +++ b/examples/managed-research-team/bootstrap.ts @@ -4,21 +4,31 @@ */ import {readFile, mkdir} from "node:fs/promises"; import {resolve, join, isAbsolute} from "node:path"; +import {pathToFileURL} from "node:url"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; -import {canonicalAuthoritySha256, authorityUnicodeCompare} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; -import {canonicalTodoDomainRecord, TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA} - from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; -import {coordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; -import {openLocalAuthorityStore, selectLocalSqliteAuthority} - from "../../loopx/control_plane/coordination/local_authority_provider.ts"; -import {engageLegacyCoordinationWriterFence} - from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; -import {configureGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_authority.ts"; -const [directory, provider = "file"] = process.argv.slice(2); -if (!directory || !isAbsolute(directory) || !["file", "sqlite"].includes(provider)) { - throw new Error("absolute disposable directory and file|sqlite provider required"); +const [directory, provider = "file", packageRoot] = process.argv.slice(2); +if (!directory || !isAbsolute(directory) || !packageRoot || !isAbsolute(packageRoot) + || !["file", "sqlite"].includes(provider)) { + throw new Error("absolute disposable directory, package root and file|sqlite provider required"); } +// The Python launcher pins its actually loaded package, including a wheel. +// Keep this disposable initializer on that same production authority owner. +const moduleUrl = (path: string) => pathToFileURL(resolve(packageRoot, "control_plane", path)).href; +const [codec, contract, projectionOwner, providerOwner, fenceOwner, acceptanceOwner] = await Promise.all([ + import(moduleUrl("coordination/authority_store_codec.ts")), + import(moduleUrl("coordination/coordination_state_contract.ts")), + import(moduleUrl("coordination/coordination_projection.ts")), + import(moduleUrl("coordination/local_authority_provider.ts")), + import(moduleUrl("coordination/legacy_writer_fence.ts")), + import(moduleUrl("goals/acceptance_authority.ts")), +]); +const {canonicalAuthoritySha256, authorityUnicodeCompare} = codec; +const {canonicalTodoDomainRecord, TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA} = contract; +const {coordinationTodoReadModel} = projectionOwner; +const {openLocalAuthorityStore, selectLocalSqliteAuthority} = providerOwner; +const {engageLegacyCoordinationWriterFence} = fenceOwner; +const {configureGoalAcceptance} = acceptanceOwner; const root = resolve(directory); const setup = JSON.parse(await readFile(join(root, "bootstrap.json"), "utf8")); const goalId = "synthetic-managed-research"; diff --git a/examples/managed-research-team/research_team.py b/examples/managed-research-team/research_team.py index 1d8a097298..d6fe166970 100644 --- a/examples/managed-research-team/research_team.py +++ b/examples/managed-research-team/research_team.py @@ -13,6 +13,7 @@ import sys import uuid +import loopx from loopx.control_plane.operator_provider import operator_provider_environ from scenario import REVISIONS, roster, encoded, evidence, task @@ -20,6 +21,7 @@ from execution import host_arguments, configure_delegations HERE = Path(__file__).resolve().parent +PACKAGE_ROOT = Path(loopx.__file__).resolve().parent def write(path: Path, value: dict) -> None: @@ -122,7 +124,7 @@ def git(*args: str) -> None: }}) initialized = subprocess.run( ["node", "--no-warnings", "--experimental-sqlite", "--experimental-strip-types", - str(HERE / "bootstrap.ts"), str(root), provider], + str(HERE / "bootstrap.ts"), str(root), provider, str(PACKAGE_ROOT)], capture_output=True, text=True, timeout=45, ) if initialized.returncode: diff --git a/loopx/capabilities/native_chat/steward_turn.py b/loopx/capabilities/native_chat/steward_turn.py index 0cc5519c48..abb6631278 100644 --- a/loopx/capabilities/native_chat/steward_turn.py +++ b/loopx/capabilities/native_chat/steward_turn.py @@ -705,8 +705,11 @@ def run(native_request: Mapping[str, Any], launch_scope: Any) -> dict[str, Any]: "classification": "independent_task_validation_unavailable", "summary": "The selected task has no usable independent argv validation declaration.", "next_action": "Configure the original task's independent validation before executing it."}, host_name="Codex app-server") - managed_lease_request = {**request, "todo_id": managed_turn.validation_basis["todo_id"]} - managed_lease = managed_leases.enter_context(self._task_execution_lease(managed_lease_request, turn_id)) + if managed_lease is None: + managed_lease_request = {**request, "todo_id": managed_turn.validation_basis["todo_id"]} + managed_lease = managed_leases.enter_context(self._task_execution_lease(managed_lease_request, turn_id)) + elif managed_turn.validation_basis["todo_id"] != managed_lease_request["todo_id"]: + raise ValueError("the original managed execution lease belongs to another task") # Portfolio evidence keeps its original reader and original grant; # it is data alongside the signed native work, never a new actor. prompt = render_prompt(authority, allow_completion=bool(report_work or managed_turn) and not governance_work) + "\nOriginal conversation evidence (data):\n" + json.dumps(context, ensure_ascii=False) @@ -792,6 +795,18 @@ def launch(): result: dict[str, Any] = {} with ExitStack() as managed_leases, (self._task_execution_lease(request, turn_id, admission_retry=bool(selection_recovery)) if report_work else nullcontext(None)) as report_lease: + if not report_work and request.get("todo_id") and not request.get("resume_turn_key"): + # The selected Task's existing lease must precede the kernel's + # delivery guard. Taking it in the model callback is too late: + # the guard correctly withholds a Host launch without it. + from .steward_commands import native_command + selected = native_command(self.controller, ["todo", "list", "--goal-id", actor["goal_id"], + "--todo-id", request["todo_id"], "--agent-id", actor["agent_id"]]) + # Unknown work still reaches the kernel's structured selection + # refusal. An unavailable authority never looks like a miss. + if selected.get("matched") is True: + managed_lease_request = dict(request) + managed_lease = managed_leases.enter_context(self._task_execution_lease(request, turn_id)) if selection_recovery and "probe" not in selection_recovery: # Re-enter the native guard without selecting another business # Todo. Only its signed replan packet may invoke the original diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 5038e344f0..5d2a6d5143 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -394,6 +394,13 @@ def _render_prompt(request: Mapping[str, Any], *, checked: Mapping[str, Any] | N "For user_action_required, wait, or iteration_failed, leave material-only fields empty and explain the stop in summary. iteration_failed ends only this iteration and never requests a retry or successor.", 'completed_phases must be exactly ["host_execute","typed_result"], and turn_key must match the request.', ] + if complete: + from .host_candidate import TURN_EXECUTION_BOUNDARY_RULE, goal_acceptance_prompt_instruction + + instructions.insert(2, TURN_EXECUTION_BOUNDARY_RULE) + acceptance = goal_acceptance_prompt_instruction(_mapping(model_request.get("work_contract"))) + if acceptance: + instructions.insert(2, acceptance) if staged and not complete: instructions.append(replan_prompt_instruction(authority)) elif staged: diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index 6e8956a61c..569256429f 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -40,6 +40,19 @@ } MATERIAL_KINDS = {"validated_progress", "repair_required", "replan_required"} +TURN_EXECUTION_BOUNDARY_RULE = ( + "User notification policy does not grant or cancel execution authority. " + "Respect the supplied must_attempt, delivery_allowed and quiet_noop_allowed; " + "alternatives and capability intents do not override admission or scope. " + "Obey execution_policy and task_orchestration_contract when supplied. " +) + +GOAL_ACCEPTANCE_READ_RULE = ( + "Read vision_continuation_audit before judging Goal acceptance or proposing " + "closeout; preserve its ordered required_before_closeout, evidence requirements " + "and current vision_gap_judge. " +) + TEXT_LIMITS = { "classification": 120, "recommended_action": 1_200, @@ -295,6 +308,11 @@ def replan_prompt_instruction( ) +def goal_acceptance_prompt_instruction(authority: Mapping[str, Any]) -> str: + """Share the declared audit's read trigger without inventing missing reads.""" + return GOAL_ACCEPTANCE_READ_RULE if authority.get("vision_continuation_audit") else "" + + def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = False) -> str: """Wrap one signed Turn authority packet in a typed JSON result request. @@ -319,11 +337,11 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals "when its contract allows it. Obey every supplied write scope, workspace " "restriction, approval boundary and stop condition. Checkpointed approval " "covers only active_write_scope; other approval gates remain.\n" + f"{TURN_EXECUTION_BOUNDARY_RULE}\n" "Read agent_context before planning; unavailable_context is a failed read, " "not empty context: discard affected cached context and hold dependent work " "until recovery. Independent work retains its existing authority.\n" - "Before Goal closeout, preserve vision_continuation_audit's ordered " - "required_before_closeout, evidence requirements and vision_gap_judge. " + f"{goal_acceptance_prompt_instruction(authority)}" "The selected Todo summary is not its full requirements, and Todo " "completion cannot close the Goal. Use execution_profile to size and " "qualify work, including artifact, validation, outcome and degradation rules.\n" @@ -355,19 +373,18 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals if authority.get("agent_context") else "" ) replan_instruction = replan_prompt_instruction(authority) - acceptance_instruction = ( - "Read vision_continuation_audit before judging Goal acceptance or proposing " - "closeout; preserve its ordered required_before_closeout, evidence requirements " - "and current vision_gap_judge. Selected Todo completion alone cannot close the Goal. " - "For additional acceptance or coverage evidence, use detail_ref.full_decision " - "only for the saved original observation identified by detail_ref.captured_decision; " - "check its Goal/Agent/Turn and action_signature.source_decision_hash. " - "Never re-enter quota admission for a detail read. Missing or invalid evidence " - "holds dependent closure; independent work retains its existing authority. " - "Do not execute its state or scheduler commands; report the semantic result " - "for the outer Turn executor. The audit grants no new authority.\n" - if authority.get("vision_continuation_audit") else "" - ) + acceptance_instruction = goal_acceptance_prompt_instruction(authority) + if acceptance_instruction: + acceptance_instruction += ( + "Selected Todo completion alone cannot close the Goal. " + "For additional acceptance or coverage evidence, use detail_ref.full_decision " + "only for the saved original observation identified by detail_ref.captured_decision; " + "check its Goal/Agent/Turn and action_signature.source_decision_hash. " + "Never re-enter quota admission for a detail read. Missing or invalid evidence " + "holds dependent closure; independent work retains its existing authority. " + "Do not execute its state or scheduler commands; report the semantic result " + "for the outer Turn executor. The audit grants no new authority.\n" + ) work_instruction = ( "Use execution_profile to size and qualify the work, including its required " "artifact, validation, outcome and degradation rules. Return the work and " From f6530786ea5c500f5e19ed93ec42a72f233c92c0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:16:02 +0800 Subject: [PATCH 05/33] test(turn): preserve quota and complete-source consumer boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_complete_host_request.py | 73 +++++++++++++++++++ .../test_turn_envelope_capability_facts.py | 14 +--- .../test_turn_envelope_settlement.py | 4 +- .../test_turn_host_context_delivery.py | 50 ++++++++----- tests/test_delegation_turn_context.py | 8 +- tests/test_native_steward_turn_host.py | 30 ++++++++ 6 files changed, 143 insertions(+), 36 deletions(-) diff --git a/tests/control_plane/test_complete_host_request.py b/tests/control_plane/test_complete_host_request.py index 3306cd7d95..463c3e3e2c 100644 --- a/tests/control_plane/test_complete_host_request.py +++ b/tests/control_plane/test_complete_host_request.py @@ -47,6 +47,79 @@ def test_complete_wire_roundtrip_has_one_source_and_original_readers(complete_ho assert internal == before +@pytest.mark.parametrize("host", ["codex", "structured"]) +@pytest.mark.parametrize("declared_audit", [True, False]) +def test_open_goal_acceptance_judgment_keeps_its_read_audit( + complete_host_request, tmp_path, host, declared_audit, +): + from loopx.control_plane.turn_driver.codex_cli import _prompt + from loopx.control_plane.turn_driver.host_candidate import render_prompt + from loopx.control_plane.quota.turn_envelope import compose_turn_decision + + # This is ordinary admitted work, with no Goal-completion result requested. + # Judging its acceptance must still read the original ordered audit first. + _, original = complete_host_request + source = deepcopy({key: value for key, value in original.items() + if key not in {"schema_version", "contract_binding"}}) + audit = {"required": True, + "required_before_closeout": ["derive_requirements_from_active_vision_and_current_todo", + "name_authoritative_evidence_for_each_requirement"], + "vision_gap_judge": {"done": False, "decision": "continue"}} + source.pop("vision_continuation_audit", None) + source["interaction_contract"]["cli_channel"].pop("vision_continuation_audit", None) + if declared_audit: + source["vision_continuation_audit"] = audit + source["interaction_contract"]["cli_channel"]["vision_continuation_audit"] = deepcopy(audit) + contract = compose_turn_decision(source) + plan = build_loopx_turn_plan(contract, host="generic-cli", + execution_mode="isolated-headless", turn_instance_id="open-goal-acceptance") + plan = prepare_turn_decision_capture(plan, journal_path=tmp_path / "audit" / "journal.json", + decision=contract, existing_journal=False) + request = build_loopx_turn_host_request(plan) + before = deepcopy(request) + wire = complete_host_input(request) + assert wire["work_contract"]["should_run"] is True + if declared_audit: + assert wire["work_contract"]["vision_continuation_audit"] == audit + else: + assert "vision_continuation_audit" not in wire["work_contract"] + prompt = (_prompt(request) if host == "codex" + else render_prompt(extract_turn_authority(request), allow_completion=False)) + instructions, encoded = prompt.split("Turn request:\n", 1) + if declared_audit: + assert "Read vision_continuation_audit before judging Goal acceptance or proposing closeout" in instructions + assert "ordered required_before_closeout" in instructions + assert "evidence requirements" in instructions and "vision_gap_judge" in instructions + else: + assert "vision_continuation_audit" not in instructions + assert "Do not write LoopX state" in instructions + assert "owns those effects" in instructions + assert json.JSONDecoder().raw_decode(encoded)[0] == wire + assert request == before + + +@pytest.mark.parametrize("host", ["codex", "structured"]) +def test_complete_host_keeps_notification_and_execution_boundaries( + complete_host_request, tmp_path, host, +): + from loopx.control_plane.turn_driver.codex_cli import _prompt + from loopx.control_plane.turn_driver.host_candidate import render_prompt + + request = _captured_request(complete_host_request, tmp_path) + wire = complete_host_input(request) + prompt = (_prompt(request) if host == "codex" + else render_prompt(extract_turn_authority(request), allow_completion=False)) + instructions, encoded = prompt.split("Turn request:\n", 1) + assert "User notification policy does not grant or cancel execution authority" in instructions + for field in ("must_attempt", "delivery_allowed", "quiet_noop_allowed", + "execution_policy", "task_orchestration_contract"): + assert field in instructions + assert "alternatives and capability intents do not override admission or scope" in instructions + assert "Do not write LoopX state" in instructions + assert "owns those effects" in instructions + assert json.JSONDecoder().raw_decode(encoded)[0] == wire + + def test_private_plan_lineage_is_not_a_host_wire(complete_host_request): request, source = complete_host_request plan = build_loopx_turn_plan(request["turn_envelope"], work_contract=source, diff --git a/tests/control_plane/test_turn_envelope_capability_facts.py b/tests/control_plane/test_turn_envelope_capability_facts.py index 576ac760e0..b2b8880f5e 100644 --- a/tests/control_plane/test_turn_envelope_capability_facts.py +++ b/tests/control_plane/test_turn_envelope_capability_facts.py @@ -56,11 +56,9 @@ def test_real_captured_guard_preserves_required_and_missing_capabilities( "--turn-envelope", "--decision-output-dir", str(capture), cwd=project) assert code == (0 if network_available else 1), envelope full = json.loads((capture / "decision.json").read_text()) - # Both admission and refusal carry the same complete source. Keep the - # historical decoder checks below without choosing a second producer path. - from loopx.control_plane.quota.turn_envelope import build_turn_envelope - assert envelope == full and full["schema_version"] == "loopx_turn_envelope_v1" - envelope = build_turn_envelope(full, captured_decision_path=str(capture / "decision.json")) + # This ordinary quota producer retains its recorded v0 contract; the + # current Turn producer is qualified independently in the cutover tests. + assert envelope["schema_version"] == "loopx_turn_envelope_v0" gate = full["capability_gate"] assert "network" in gate["required"] assert ("network" in gate["missing"]) is (not network_available) @@ -135,11 +133,7 @@ def test_captured_vision_contract_preserves_closeout_and_original_observation( "--decision-output-dir", str(capture), cwd=project) assert code == 0, envelope full = json.loads((capture / "decision.json").read_text()) - assert envelope == full and full["schema_version"] == "loopx_turn_envelope_v1" - # The real CLI now emits the complete contract. Retain the historical - # decoder and its independent signature/refusal checks below. - from loopx.control_plane.quota.turn_envelope import build_turn_envelope - envelope = build_turn_envelope(full, captured_decision_path=str(capture / "decision.json")) + assert envelope["schema_version"] == "loopx_turn_envelope_v0" audit = full["vision_continuation_audit"] assert audit["required"] and audit["required_before_closeout"] assert audit["vision_gap_judge"]["done"] is False diff --git a/tests/control_plane/test_turn_envelope_settlement.py b/tests/control_plane/test_turn_envelope_settlement.py index 351b8b44b9..8fd7d50477 100644 --- a/tests/control_plane/test_turn_envelope_settlement.py +++ b/tests/control_plane/test_turn_envelope_settlement.py @@ -102,8 +102,8 @@ def test_generated_json_commands_settle_original_turn_once(tmp_path, profile_arg assert rc == 0, full rc, envelope = _run_cli(registry, runtime, *args, "--turn-envelope") assert rc == 0, envelope - assert envelope["schema_version"] == full["schema_version"] == "loopx_turn_envelope_v1" - plan = envelope["interaction_contract"]["cli_channel"]["settlement_plan"] + assert envelope["schema_version"] == "loopx_turn_envelope_v0" + plan = envelope["writeback"]["settlement_plan"] assert plan == full["interaction_contract"]["cli_channel"]["settlement_plan"] assert plan["identity"] == full["heartbeat_receipt"]["settlement_identity"] assert [step["kind"] for step in plan["ordered_steps"]] == [ diff --git a/tests/control_plane/test_turn_host_context_delivery.py b/tests/control_plane/test_turn_host_context_delivery.py index dfa3334ef5..7b8435015a 100644 --- a/tests/control_plane/test_turn_host_context_delivery.py +++ b/tests/control_plane/test_turn_host_context_delivery.py @@ -11,7 +11,11 @@ import pytest -from loopx.control_plane.quota.turn_envelope import build_turn_envelope, turn_envelope_action_signature_document +import loopx + +from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, compose_turn_decision, turn_envelope_action_signature_document, +) from loopx.control_plane.turn_driver.decision_capture import prepare_turn_decision_capture from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request @@ -31,9 +35,9 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monkeypatch, provider, mode): isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry = _write_fixture(tmp_path) - # Child cwd is the disposable Goal, so the interpreter's installed Core - # may differ from this checkout. Bind and independently check test-only IO. - checkout = Path(__file__).resolve().parents[2] + # Keep subprocess IO on the Core under test, including an isolated wheel; + # the test harness directory must never become a borrowed Core checkout. + checkout = Path(loopx.__file__).resolve().parents[1] monkeypatch.setenv("PYTHONPATH", os.pathsep.join(filter(None, [str(checkout), os.environ.get("PYTHONPATH")]))) imported = subprocess.run([sys.executable, "-c", "import loopx; print(loopx.__file__)"], @@ -73,8 +77,7 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monk original = (capture / "decision.json").read_bytes() source = json.loads(original) if mode == "legacy_reference": - assert json.loads(produced.stdout) == source - assert source["schema_version"] == "loopx_turn_envelope_v1" + assert json.loads(produced.stdout)["schema_version"] == "loopx_turn_envelope_v0" envelope = build_turn_envelope(source, captured_decision_path=str(capture / "decision.json")) context = source["interaction_contract"].get("agent_context") assert bool(context) is (mode != "off") @@ -99,10 +102,14 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monk ) command = [sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(project), "--dsh-home", str(tmp_path / "host-home"), "--dsh-runner", str(runner)] - plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", - turn_instance_id="host-context-turn") + # Ordinary quota still emits v0 in this extraction. Its recorded envelope + # must not be paired with a newly normalized source. Current Turn planning + # accepts that complete source directly and publishes its own capture. + plan = build_loopx_turn_plan(envelope if mode == "legacy_reference" else compose_turn_decision(source), + host="dsh", execution_mode="isolated-headless", turn_instance_id="host-context-turn") if mode != "legacy_reference": - plan["work_contract"] = source + plan = prepare_turn_decision_capture(plan, journal_path=tmp_path / "current-turn" / "journal.json", + decision=plan["work_contract"], existing_journal=False) request = build_loopx_turn_host_request(plan) before = {p: p.read_bytes() for p in runtime.rglob("*") if p.is_file()} result = subprocess.run(command, input=json.dumps(request), text=True, capture_output=True, timeout=30) @@ -135,9 +142,12 @@ def test_captured_context_reaches_actual_host_without_readmission(tmp_path, monk assert prompt_path.read_text() == prompt else: actual, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) - # Compare at the real subprocess/provider seam with the independent - # immutable capture, including full acceptance, context and stop facts. - assert actual["work_contract"] == source + # The current producer's own immutable capture is the transport oracle; + # the original long task and optional context remain independent inputs. + detail = actual["detail_ref"]["captured_decision"] + assert json.loads(Path(detail["path"]).read_text()) == actual["work_contract"] + assert actual["work_contract"] == plan["work_contract"] + assert "Validate the complete acceptance: " + "preserve original evidence; " * 180 in json.dumps(actual["work_contract"]) assert "turn_envelope" not in actual and "turn_authority" not in actual delivered_context = actual["work_contract"]["interaction_contract"].get("agent_context") assert delivered_context == context @@ -192,16 +202,14 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke assert code == 0, envelope original = (capture / "decision.json").read_bytes() source = json.loads(original) - assert envelope == source - assert source["schema_version"] == "loopx_turn_envelope_v1" + assert envelope["schema_version"] == "loopx_turn_envelope_v0" agent_channel = source["interaction_contract"]["agent_channel"] assert agent_channel["delivery_allowed"] is False assert agent_channel["must_attempt"] is True - envelope = build_turn_envelope(source) - plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", + plan = build_loopx_turn_plan(compose_turn_decision(source), host="dsh", execution_mode="isolated-headless", turn_instance_id=turn) plan = prepare_turn_decision_capture(plan, journal_path=tmp_path / "turn" / "journal.json", - decision=source, existing_journal=False) + decision=plan["work_contract"], existing_journal=False) assert plan["route"]["kind"] == "replan_required" assert plan["route"]["would_invoke_host"] is True assert source["heartbeat_receipt"]["settlement_identity"]["todo_id"] == SELECTED_REPLAN_TODO_ID @@ -215,7 +223,7 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke ) command = [sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(project), "--dsh-home", str(tmp_path / "host-home"), "--dsh-runner", str(runner)] - request = build_loopx_turn_host_request({**plan, "work_contract": source}) + request = build_loopx_turn_host_request(plan) codex_prompt, _ = prepare_codex_cli_input(request) assert "Read detail_ref.replan_action_packet.review" in codex_prompt assert "before reading detail_ref.replan_action_packet.writeback" in codex_prompt @@ -226,7 +234,9 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke prompt_path = project / "received.txt" prompt = prompt_path.read_text() actual, _ = json.JSONDecoder().raw_decode(prompt.split("Turn request:\n", 1)[1]) - assert actual["work_contract"] == source + assert actual["work_contract"] == plan["work_contract"] + # This Goal body is a pending original read, exercised below; a complete + # contract does not require every retained source to be inline. assert "turn_envelope" not in actual and "turn_authority" not in actual # Execute the original observation readers actually delivered to the provider. detail = actual["detail_ref"] @@ -237,7 +247,7 @@ def test_bound_replan_reaches_actual_host_with_original_evidence(tmp_path, monke captured = detail["captured_decision"] assert (captured["goal_id"], captured["agent_id"], captured["turn_instance_id"]) == (GOAL_ID, AGENT_ID, turn) read = subprocess.run(shlex.split(detail["full_decision"]), capture_output=True, check=True).stdout - assert read == original + assert json.loads(read) == plan["work_contract"] assert build_turn_envelope(json.loads(read))["action_signature"]["source_decision_hash"] == actual["source_decision_hash"] assert context["obligation_id"] == source["replan_action_packet"]["obligation_id"] goal_read = next(r for r in agent_channel["required_reads"] if r["source"] == "goal_state") diff --git a/tests/test_delegation_turn_context.py b/tests/test_delegation_turn_context.py index 8a1cfee8d0..b7c21a5231 100644 --- a/tests/test_delegation_turn_context.py +++ b/tests/test_delegation_turn_context.py @@ -225,10 +225,10 @@ def run(*arguments): "read_complete": values[pointer.removesuffix("unfinished_delegations") + "read_complete"]} required_reads = values["/interaction_contract/agent_channel/required_reads"] else: - channel = packet["interaction_contract"]["agent_channel"] - source = next(row for row in channel["work_context"]["sources"] if row.get("hook_id") == "manager.context_inbox") - content, required_reads = source["content"], channel["required_reads"] - assert channel["work_context"]["complete"] is True + assert packet["schema_version"] == "loopx_turn_envelope_v0" + source = next(row for row in packet["work_context"]["sources"] if row.get("hook_id") == "manager.context_inbox") + content, required_reads = source["content"], packet["required_reads"] + assert packet["work_context"]["complete"] is True assert content["read_complete"] is False rows = {row["request_id"]: row for row in content["unfinished_delegations"]["items"]} assert set(rows) == {receipt["request_id"], queued["request_id"], peer["request_id"]} diff --git a/tests/test_native_steward_turn_host.py b/tests/test_native_steward_turn_host.py index 41e550de8b..28de72cd0b 100644 --- a/tests/test_native_steward_turn_host.py +++ b/tests/test_native_steward_turn_host.py @@ -169,6 +169,17 @@ def test_managed_identity_rejections_do_not_create_or_resume_a_host(steward, tmp @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_local_managed_session_reuses_native_turn_and_declared_validation(steward, tmp_path, monkeypatch, provider): service, session, actor, todo_id, observation, capture = managed_fixture(steward, tmp_path, monkeypatch, provider) + import loopx.cli_commands.turn as turn_owner + original_plan = turn_owner.build_loopx_turn_plan + prepared_sources = [] + def checked_plan(source, *args, **kwargs): + preparation = source["interaction_contract"]["agent_channel"]["execution_preparation"] + assert preparation["state"] == "ready" + assert preparation["todo_id"] == todo_id + assert preparation["agent_id"] == actor["agent_id"] + prepared_sources.append(source["schema_version"]) + return original_plan(source, *args, **kwargs) + monkeypatch.setattr(turn_owner, "build_loopx_turn_plan", checked_plan) store, runtime, *_ = steward sid = session["session_id"] before_sessions = store.list_sessions() @@ -184,6 +195,7 @@ def test_local_managed_session_reuses_native_turn_and_declared_validation(stewar assert result.get("status") == "committed", json.dumps(result) assert result["effects"]["host_invoked"] and result["effects"]["quota_spent"] assert result["validation"]["status"] == "passed" + assert prepared_sources == ["loopx_turn_envelope_v1"] assert observation.read_text() == "ready" assert store.load_session(sid)["upstream_thread_id"] == thread assert {row["session_id"] for row in store.list_sessions()} == {row["session_id"] for row in before_sessions} @@ -678,3 +690,21 @@ def changed(*args, **kwargs): calls = [json.loads(line) for line in capture.read_bytes()[len(before):].decode().splitlines()] assert not [row for row in calls if row["method"] in {"thread/start", "turn/start", "thread/inject_items"}] assert store.load_session(sid)["upstream_thread_id"] == thread + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_managed_host_does_not_borrow_another_execution_lease(steward, tmp_path, monkeypatch, provider): + service, session, actor, todo_id, _, capture = managed_fixture(steward, tmp_path, monkeypatch, provider) + store, runtime, *_ = steward + original = cli(runtime, "task-lease", "acquire", "--goal-id", actor["goal_id"], + "--todo-id", todo_id, "--owner", actor["agent_id"], "--idempotency-key", "another-execution")["lease"] + before_calls = capture.read_bytes() + accepted, _ = service.submit(session["session_id"], agent_id=actor["agent_id"], + operation_id="lease-conflict", todo_id=todo_id) + failed = finish(runtime, accepted) + assert failed["status"] == "failed" + calls = [json.loads(row) for row in capture.read_bytes()[len(before_calls):].splitlines()] + assert not [row for row in calls if row["method"] == "turn/start"] + retained = cli(runtime, "task-lease", "inspect", "--goal-id", actor["goal_id"], "--todo-id", todo_id) + assert retained["active"] is True and retained["lease"] == original + assert store.load_session(session["session_id"])["upstream_thread_id"] == session["upstream_thread_id"] From f016cb138b8d1063198c4891cdc6d82c9a35ba16 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:23:51 +0800 Subject: [PATCH 06/33] docs(turn): preserve consumer and packaged qualification boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../contract-and-context.md | 29 +++++++++++++++++++ .../contract-and-context.zh-CN.md | 20 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md index 710dddda55..905e40817b 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md @@ -609,6 +609,35 @@ readback and downgrade behavior; retain the old reader until the supported retention/upgrade boundary is satisfied. No destructive migration is authorized by this document alone. +#### Turn consumer preparation and instruction parity + +The separate Turn proposal keeps the ordinary quota producer on its recorded +contract; the parent retains the broader quota/heartbeat cutover. Tests of an +unmigrated producer must preserve that boundary. Current Turn planning consumes +its complete source directly and publishes its own immutable capture; pairing a +newly composed source with an older executor envelope is rejected. + +Native selected-task execution prepares its ordinary managed lease before the +kernel delivery guard and reuses that proof during post-model validation. A +claim label alone is insufficient, and another execution's lease remains +unavailable. Source checks precede the independent task argv; rejection and +original-result recovery retain the original session and once-only effects. + +Modern Codex and structured Host instructions share the existing audit read +rule: read a declared audit before judging Goal acceptance or proposing +closeout, including when the Goal stays open. Notification policy does not grant +or cancel execution authority; supplied delivery, attempt, quiet-noop, execution +and orchestration constraints still apply. Core retains state, quota and +scheduler effects. A complete contract can declare a pending original material +read: publication does not fulfill it, and tests must execute its reader and +preserve the original body and stops. + +The disposable research-team launcher and initializer locate the actually loaded +Core together, including an isolated wheel, while retaining exclusive runtime +creation. Source and packaged consumer checks are distinct from installed App +adoption, live optional-provider/model behavior and original-quota complete-cost +benefit. These repairs do not close C1 or authorize rollout or merge. + ### C1 baseline boundary and reuse inventory The baseline is the **original caller-visible quota delivery**, not the raw diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md index 1603c57577..eaebf5b397 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md @@ -446,6 +446,26 @@ reader;这个分阶段边界保留普通 quota writer 和 thin-heartbeat 的 decoder 前证明 export、旧版本 readback 与降级行为;满足受支持保留/升级边界前保留旧 reader。本文不单独授权破坏性迁移。 +#### Turn 消费方准备与指令保真 + +独立 Turn 提案让普通 quota producer 保持已记录契约;更广的 quota/heartbeat 切换 +仍归父提案。未迁移 producer 的测试须守住这个边界。当前 Turn 规划直接消费完整来源, +发布自己的不可变 capture;新组合来源与较早 executor envelope 错配时继续拒绝。 + +原生所选 Task 在 kernel delivery guard 之前准备普通 managed lease,并在模型后验证时 +复用该 proof。claim 标签本身不足,不能借用另一次执行的 lease。来源检查先于独立 +Task argv;拒绝和原结果恢复保留原会话与一次性效果。 + +现代 Codex 与结构化 Host 指令共用既有 audit 读取规则:在判断 Goal acceptance 或提出 +closeout 前读取已声明 audit,即使 Goal 保持开放。通知策略不授予或取消执行权;所供 +delivery、attempt、quiet-noop、execution 与 orchestration 约束仍须遵守。状态、quota +和 scheduler 效果仍归 Core。完整契约可以声明待执行的原材料读取:发布不等于已读, +测试须真正执行 reader,并保留原正文与停止条件。 + +一次性 research-team launcher 与 initializer 共同定位实际加载的 Core,包括隔离 wheel, +仍保留 runtime 独占创建。源码和打包消费者验证,分别区别于已安装 App 采用、真实可选 +provider/模型行为及相对原 quota 的完整成本收益。这些修复不关闭 C1,也不授权部署或合并。 + ### C1 基线边界与复用盘点 基线是**原路径中调用者实际收到的 quota 交付**,不是内部原始 decision。 From d9cedec1ee70aabd88b3edff21b3a8b8903ee941 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:13:52 +0800 Subject: [PATCH 07/33] Qualify generic CLI adapters for the complete Turn request Declare the adapter's supported wire version before a new invocation. Use the shared complete request renderer and result fields for TraeX. Keep v0 journal recovery and the original capture identity in the process interruption tests. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../deepseek-harness-connector.md | 1 + .../codex-cli/codex-cli-automation-driver.md | 7 +- docs/reference/protocols/loopx-turn-v0.md | 31 +++-- examples/loopx-turn-dsh-e2e-smoke.py | 1 + examples/loopx-turn-dsh-real-e2e-smoke.py | 3 +- .../loopx-turn-path-delta-acceptance-smoke.py | 1 + examples/loopx-turn-traex-e2e-smoke.py | 1 + examples/managed-research-team/execution.py | 5 +- loopx/cli_commands/turn_host_request.py | 2 + loopx/cli_commands/turn_registration.py | 6 + loopx/cli_commands/turn_run_once.py | 1 + loopx/control_plane/quota/turn_envelope.py | 1 - .../turn_driver/decision_capture.py | 3 +- loopx/control_plane/turn_driver/executor.py | 19 +++- packages/loopx-ark-turn/README.md | 3 +- packages/loopx-memt-turn/README.md | 1 + packages/loopx-memt-turn/README.zh-CN.md | 1 + scripts/traex_turn_host_adapter.py | 73 ++++++++---- .../test_periodic_report_turn_replan.py | 15 +-- tests/control_plane/test_cli_output_budget.py | 1 + .../test_host_result_semantics.py | 1 + .../test_reward_memory_host_isolation.py | 2 +- .../test_turn_host_decision_capture.py | 107 +++++++++++++++++- .../test_turn_interruption_recovery.py | 14 ++- tests/test_local_delegation.py | 2 +- tests/test_loopx_turn_driver.py | 11 +- 26 files changed, 250 insertions(+), 63 deletions(-) diff --git a/docs/integrations/deepseek-harness-connector.md b/docs/integrations/deepseek-harness-connector.md index 3f96e7b65e..bcb60c0c26 100644 --- a/docs/integrations/deepseek-harness-connector.md +++ b/docs/integrations/deepseek-harness-connector.md @@ -96,6 +96,7 @@ loopx turn run-once \ --host generic-cli \ --execution-mode isolated-headless \ --project "$PWD" \ + --host-adapter-request-schema loopx_turn_host_request_v1 \ --host-adapter-command-json '["python3", "-m", "loopx.dsh_goal_mode", "--dsh-home", "/path/to/dsh-home", "--cordis", "/path/to/cordis.yml", "--model", "deepseek-flash"]' \ --validation-command-json '["python3", "/path/to/verify-postcondition.py"]' \ --execute diff --git a/docs/product/runtimes/codex-cli/codex-cli-automation-driver.md b/docs/product/runtimes/codex-cli/codex-cli-automation-driver.md index bfb6134dfd..45cd648097 100644 --- a/docs/product/runtimes/codex-cli/codex-cli-automation-driver.md +++ b/docs/product/runtimes/codex-cli/codex-cli-automation-driver.md @@ -21,8 +21,8 @@ the Codex CLI adapter policy and the current parity gap. `loopx turn plan` and `loopx turn run-once` now provide the host-neutral driver for explicit `isolated-headless` work. The built-in `codex-cli` host and the -typed `generic-cli` adapter route both consume a live TurnEnvelope. A material -result commits only after independent task validation, durable state writeback, +typed `generic-cli` adapter both consume the checked complete source for a new +Turn. A material result commits only after independent task validation, durable state writeback, one quota spend, and a final scheduler check. The Codex host keeps its resume id in private local runtime state keyed by goal, @@ -88,7 +88,8 @@ loopx turn run-once \ `plan` is read-only. `run-once` composes a live decision, one host attempt, typed closeout, independent validation, writeback, spend, and scheduler final -check. Use `--host generic-cli --host-adapter-command-json ...` for Trae or +check. Use `--host generic-cli --host-adapter-request-schema loopx_turn_host_request_v1` +and `--host-adapter-command-json ...` for a compatible Trae or another CLI after its wrapper implements the typed stdin/stdout contract in the host-neutral protocol. diff --git a/docs/reference/protocols/loopx-turn-v0.md b/docs/reference/protocols/loopx-turn-v0.md index 07e33bf7a1..008c048d02 100644 --- a/docs/reference/protocols/loopx-turn-v0.md +++ b/docs/reference/protocols/loopx-turn-v0.md @@ -84,18 +84,19 @@ and an independent validator: 1. Run `loopx turn plan` to inspect the live typed decision without launching the host or changing state. -2. The host adapter reads one `loopx_turn_host_request_v0` JSON object from - stdin, invokes the selected agent CLI in the governed workspace, and writes - exactly one `loopx_turn_host_result_v0` JSON object to stdout. +2. A new Turn sends one `loopx_turn_host_request_v1` object with a complete + `work_contract`. The adapter reads it on stdin, invokes the selected agent + CLI in the governed workspace, and writes one `loopx_turn_result_v0` object + to stdout. 3. The validator reads the normalized host result from stdin and independently checks the claimed postcondition. Exit zero means passed; non-zero means the result is rejected. A timeout or unavailable validator is inconclusive. 4. `loopx turn run-once --execute` performs writeback and quota spend only when the typed result and independent validation both pass. -The adapter and validator executable names below are placeholders supplied by -the integration. They are separate programs because the executor must not -validate its own completion claim. +The example uses the repository's TraeX adapter and an integration-supplied +validator. The two programs separate the executor's completion claim from +validation of its artifact. ```bash loopx turn plan \ @@ -110,7 +111,8 @@ loopx turn run-once \ --host generic-cli \ --execution-mode isolated-headless \ --project "$PWD" \ - --host-adapter-command-json '["./tools/turn-host-adapter","--agent-cli","trae","chat"]' \ + --host-adapter-request-schema loopx_turn_host_request_v1 \ + --host-adapter-command-json '["python3","./scripts/traex_turn_host_adapter.py"]' \ --validation-command-json '["./tools/verify-turn-postcondition"]' \ --execute ``` @@ -123,10 +125,17 @@ Turn request/result objects and that CLI's prompt, session, and output model. Raw transcript text, process exit zero, and the host's own completion claim are never sufficient validation. -A reference headless adapter for TraeX (`traex exec`) lives at -`scripts/traex_turn_host_adapter.py`; it reads the bounded action text from the -Turn envelope and writes one typed result, leaving goal/todo authority and -validation to LoopX. +The TraeX reference adapter (`traex exec`) renders the checked complete +request and accepts the shared typed result fields. LoopX owns Goal and Todo +authority, independent validation, writeback and quota. For another generic +adapter, verify that the installed version accepts v1 before declaring it with +`--host-adapter-request-schema`. A missing or incompatible declaration stops +a v1 invocation before the adapter process starts. + +A recorded v0 Turn keeps its original wire version. If recovery still needs +the adapter, use a v0-compatible adapter and omit the declaration or pass +`loopx_turn_host_request_v0`. Recovery with a saved typed result continues +without launching an adapter. A DeepSeek Harness adapter lives in the `loopx.dsh_goal_mode` subpackage (run with `python -m loopx.dsh_goal_mode`; the legacy diff --git a/examples/loopx-turn-dsh-e2e-smoke.py b/examples/loopx-turn-dsh-e2e-smoke.py index 40cd7d4f3f..88a42dc223 100755 --- a/examples/loopx-turn-dsh-e2e-smoke.py +++ b/examples/loopx-turn-dsh-e2e-smoke.py @@ -215,6 +215,7 @@ def _base_argv( "isolated-headless", "--project", str(workspace), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", host_command, "--validation-command-json", diff --git a/examples/loopx-turn-dsh-real-e2e-smoke.py b/examples/loopx-turn-dsh-real-e2e-smoke.py index 7ec305d6ff..38552f77d4 100755 --- a/examples/loopx-turn-dsh-real-e2e-smoke.py +++ b/examples/loopx-turn-dsh-real-e2e-smoke.py @@ -217,7 +217,8 @@ def _base_argv( str(workspace), ] if host == "generic-cli": - argv.extend(["--host-command-json", host_command]) + argv.extend(["--host-adapter-request-schema", "loopx_turn_host_request_v1", + "--host-command-json", host_command]) else: argv.extend( [ diff --git a/examples/loopx-turn-path-delta-acceptance-smoke.py b/examples/loopx-turn-path-delta-acceptance-smoke.py index 8c6005bacc..2a0caa979d 100644 --- a/examples/loopx-turn-path-delta-acceptance-smoke.py +++ b/examples/loopx-turn-path-delta-acceptance-smoke.py @@ -213,6 +213,7 @@ def _run_turn( ), "--project", str(workspace), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps(_host_command(host, mode)), "--validation-command-json", diff --git a/examples/loopx-turn-traex-e2e-smoke.py b/examples/loopx-turn-traex-e2e-smoke.py index 05ec59246f..3b09c3b0fe 100644 --- a/examples/loopx-turn-traex-e2e-smoke.py +++ b/examples/loopx-turn-traex-e2e-smoke.py @@ -211,6 +211,7 @@ def _base_argv( "isolated-headless", "--project", str(workspace), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", host_command, "--validation-command-json", diff --git a/examples/managed-research-team/execution.py b/examples/managed-research-team/execution.py index 017b21e9d3..42063c1e09 100644 --- a/examples/managed-research-team/execution.py +++ b/examples/managed-research-team/execution.py @@ -55,8 +55,9 @@ def host_arguments(root: Path, actor: str, revision: str, *, host: str, attempt: "mcp_env": ["LOOPX_RESEARCH_DEMO_ROOT", *(["DEEPSEEK_API_KEY"] if coordinator or actor == "cloud-analyst" else [])], "tool_names": selected_tools, })) - return ["--host", "generic-cli", "--iteration-context", "fresh", "--host-command-json", - json.dumps([sys.executable, "-m", "loopx_ark_turn.cli", "--config", str(profile)])] + return ["--host", "generic-cli", "--iteration-context", "fresh", + "--host-adapter-request-schema", "loopx_turn_host_request_v1", + "--host-command-json", json.dumps([sys.executable, "-m", "loopx_ark_turn.cli", "--config", str(profile)])] def configure_delegations(root: Path) -> Path: diff --git a/loopx/cli_commands/turn_host_request.py b/loopx/cli_commands/turn_host_request.py index 8827a70875..eed51c03f9 100644 --- a/loopx/cli_commands/turn_host_request.py +++ b/loopx/cli_commands/turn_host_request.py @@ -51,6 +51,8 @@ def prepare_turn_host_request( else: if args.host_command_json: raise ValueError(f"{args.host} does not accept --host-command-json") + if getattr(args, "host_adapter_request_schema", None): + raise ValueError("--host-adapter-request-schema is only for generic-cli") raw_argv = None if native_task_validator is not None and args.host != "codex-app-server": raise ValueError("a native task validator cannot replace a selected CLI validator") diff --git a/loopx/cli_commands/turn_registration.py b/loopx/cli_commands/turn_registration.py index 1d24d09486..ae80a5fba6 100644 --- a/loopx/cli_commands/turn_registration.py +++ b/loopx/cli_commands/turn_registration.py @@ -7,6 +7,7 @@ from collections.abc import Callable from ..control_plane.operator_provider import operator_provider_environ +from ..control_plane.quota.turn_envelope import COMPLETE_TURN_HOST_REQUEST_SCHEMA, LOOPX_TURN_HOST_REQUEST_SCHEMA from ..control_plane.turn_driver.driver import SessionBindingScope, SUPPORTED_ITERATION_CONTEXT_POLICIES from ..control_plane.turn_driver.host_binding import ( MANAGED_TURN_HOST, @@ -191,6 +192,11 @@ def register_turn_commands( "parsing is never used." ), ) + run_once.add_argument( + "--host-adapter-request-schema", + choices=[LOOPX_TURN_HOST_REQUEST_SCHEMA, COMPLETE_TURN_HOST_REQUEST_SCHEMA], + help="Request schema supported by the generic CLI adapter. Required for a v1 Host invocation.", + ) run_once.add_argument( "--validation-command-json", help=( diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 5b9668d452..438b411144 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -887,6 +887,7 @@ def on_managed_start_admitted() -> None: decision_capture_payload=decision, require_decision_capture=True, host_argv=raw_argv, + host_adapter_request_schema=getattr(args, "host_adapter_request_schema", None), host_runner=host_runner, session_binding_resolver=session_binding_resolver, project=project, diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index b7509853cd..01969879ce 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -4,7 +4,6 @@ from collections.abc import Mapping from copy import deepcopy -from dataclasses import dataclass from typing import Any from ..effect_runtime import effect_runtime_projection_result, source_number_tokens diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index 1f0cc6171b..3a8f4661b3 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -704,7 +704,8 @@ def context_selection_view(payload: dict[str, Any], *, select: list[str] | None, if plan.get("rejected_selection"): return plan, None, None presentation = initial_read if present_goal_narrative is None else present_goal_narrative - def render(plan: dict[str, Any], present: bool): + + def render(plan: dict[str, Any], present: bool) -> tuple[bytes, str, list[str]]: raw, displayed = _context_selection_bytes(payload, plan["selections"], list_fields=list_fields, list_depth=list_depth, present_goal_narrative=present) return raw, hashlib.sha256(raw).hexdigest(), displayed diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 89f1b5c9ec..44bb677f44 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -37,7 +37,7 @@ normalize_reward_memory_reflection_validation, reward_memory_reflection_digest, ) -from ..quota.turn_envelope import turn_plan_host_source +from ..quota.turn_envelope import LOOPX_TURN_HOST_REQUEST_SCHEMA, turn_plan_host_source from .driver import ( COMPLETE_TURN_PLAN_SCHEMA_VERSION, selected_turn_todo, turn_host_lineage, @@ -1477,6 +1477,7 @@ def run_loopx_turn_once( plan: Mapping[str, Any], *, host_argv: Sequence[str] | None = None, + host_adapter_request_schema: str | None = None, host_runner: HostRunner | None = None, session_binding_resolver: SessionBindingResolver | None = None, project: Path, @@ -1648,6 +1649,22 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: ) if needs_host and goal_admission is not None: goal_admission.require_current() + if needs_host and argv is not None: + required_schema = request["schema_version"] + declared_schema = ( + LOOPX_TURN_HOST_REQUEST_SCHEMA + if host_adapter_request_schema is None + else host_adapter_request_schema + ) + if declared_schema != required_schema: + return execution_payload( + plan, + {"status": "unavailable", "host": host_projection, "reason": ( + f"Generic CLI adapter must support {required_schema}. " + f"Use a compatible adapter and pass --host-adapter-request-schema {required_schema}." + )}, + execute=True, replayed=False, effects=empty_effects, + ) capture = stage_turn_decision_capture( journal["plan"] if journal is not None else plan, journal_path=journal_path, decision=decision_capture_payload, existing_journal=journal is not None, diff --git a/packages/loopx-ark-turn/README.md b/packages/loopx-ark-turn/README.md index 135ae4df5f..c7c189dbd9 100644 --- a/packages/loopx-ark-turn/README.md +++ b/packages/loopx-ark-turn/README.md @@ -27,7 +27,8 @@ requests; it never selects the model or grants a tool permission. Set Select this command through the existing `loopx turn run-once --host generic-cli --iteration-context fresh` interface, -with `--host-command-json` containing the adapter argv above without `--doctor`. +with `--host-adapter-request-schema loopx_turn_host_request_v1` and +`--host-command-json` containing the adapter argv above without `--doctor`. Supply the normal Goal, Agent, project, Todo and independent validation command. The caller must use a registered LoopX Agent and current work admission. The adapter's cloud Agent definition and session are execution resources, not new diff --git a/packages/loopx-memt-turn/README.md b/packages/loopx-memt-turn/README.md index 4477fd565d..bb1ea0b7ec 100644 --- a/packages/loopx-memt-turn/README.md +++ b/packages/loopx-memt-turn/README.md @@ -116,6 +116,7 @@ uses a registered Goal, Agent, eligible claimed Todo and independent validator: ```sh loopx turn run-once --goal-id demo --agent-id analyst --todo-id todo_demo \ --project /srv/work/project --host generic-cli --iteration-context fresh \ + --host-adapter-request-schema loopx_turn_host_request_v1 \ --host-command-json '["loopx-memt-turn","--config","/srv/operator/memt.json"]' \ --validation-command-json '["python3","/srv/operator/check_artifact.py"]' ``` diff --git a/packages/loopx-memt-turn/README.zh-CN.md b/packages/loopx-memt-turn/README.zh-CN.md index 666512d0ee..6cb2c4d0fc 100644 --- a/packages/loopx-memt-turn/README.zh-CN.md +++ b/packages/loopx-memt-turn/README.zh-CN.md @@ -92,6 +92,7 @@ MCP 服务必须自行强制执行当前签名请求的资源与操作范围。 ```sh loopx turn run-once --goal-id demo --agent-id analyst --todo-id todo_demo \ --project /srv/work/project --host generic-cli --iteration-context fresh \ + --host-adapter-request-schema loopx_turn_host_request_v1 \ --host-command-json '["loopx-memt-turn","--config","/srv/operator/memt.json"]' \ --validation-command-json '["python3","/srv/operator/check_artifact.py"]' ``` diff --git a/scripts/traex_turn_host_adapter.py b/scripts/traex_turn_host_adapter.py index 576aa63f60..100f481456 100644 --- a/scripts/traex_turn_host_adapter.py +++ b/scripts/traex_turn_host_adapter.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """Thin TraeX host adapter for the LoopX governed Turn contract. -LoopX runs this adapter with one ``loopx_turn_host_request_v0`` JSON object on -stdin. The adapter: +LoopX supplies the versioned Turn Host request on stdin. The adapter: -1. extracts the bounded action text from the Turn envelope, +1. checks the recorded request version and renders the governed Turn input, 2. invokes one headless ``traex exec`` in the governed workspace, 3. asks TraeX for one schema-constrained public-safe result file, 4. emits exactly one ``loopx_turn_result_v0`` JSON object on stdout. @@ -33,16 +32,28 @@ CappedProcessResult, run_capped_process, ) +from loopx.control_plane.turn_driver.executor import ( # noqa: E402 + HOST_AGENT_VISION_JSON_MAX_CHARS, + HOST_PATH_DELTA_MODES, + HOST_PROGRESS_OBSERVATION_JSON_MAX_CHARS, + HOST_RESULT_EVIDENCE_LIMITS, +) from loopx.control_plane.turn_driver.host_candidate import ( # noqa: E402 + ACCEPTED_RESULT_KINDS as COMPLETE_RESULT_KINDS, + LOOPX_TURN_HOST_REQUEST_SCHEMA as LOOPX_TURN_HOST_REQUEST_SCHEMA, COMPLETED_PHASES as COMPLETED_PHASES, - LOOPX_TURN_HOST_REQUEST_SCHEMA, + assert_turn_host_request_version, LOOPX_TURN_RESULT_SCHEMA as LOOPX_TURN_RESULT_SCHEMA, TEXT_LIMITS, build_result as build_host_result, extract_action_text as extract_action_text, extract_turn_authority, + render_prompt as render_host_prompt, ) +from loopx.control_plane.work_items.delivery_batch_scale import DELIVERY_BATCH_SCALE_CHOICES # noqa: E402 +from loopx.control_plane.work_items.delivery_outcome import DELIVERY_OUTCOME_CHOICES # noqa: E402 + TRAEX_OUTPUT_LIMIT_BYTES = 1_000_000 ACCEPTED_RESULT_KINDS = { @@ -52,6 +63,8 @@ "user_action_required", "wait", } + + def render_prompt(authority: Mapping[str, Any]) -> str: """Wrap one signed Turn authority packet in the result-block framing. @@ -59,11 +72,10 @@ def render_prompt(authority: Mapping[str, Any]) -> str: reads back as a typed candidate. It stays public-safe and bounded. """ + if "_model_input" in authority: + return render_host_prompt(authority) authority_json = json.dumps( - dict(authority), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), + dict(authority), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return ( "You are executing one bounded LoopX-governed work segment.\n" @@ -87,34 +99,45 @@ def render_prompt(authority: Mapping[str, Any]) -> str: ) -def traex_result_schema() -> dict[str, Any]: - properties = { +def traex_result_schema(*, complete: bool = False) -> dict[str, Any]: + properties: dict[str, Any] = { "result_kind": { "type": "string", - "enum": sorted(ACCEPTED_RESULT_KINDS), + "enum": sorted(COMPLETE_RESULT_KINDS if complete else ACCEPTED_RESULT_KINDS), }, **{ field: {"type": "string", "maxLength": limit} for field, limit in TEXT_LIMITS.items() }, } + if complete: + properties.update({ + "delivery_batch_scale": {"type": "string", "enum": list(DELIVERY_BATCH_SCALE_CHOICES)}, + "delivery_outcome": {"type": "string", "enum": list(DELIVERY_OUTCOME_CHOICES)}, + "path_delta_mode": {"type": "string", "enum": sorted(HOST_PATH_DELTA_MODES - {""})}, + "agent_vision_json": {"type": "string", "maxLength": HOST_AGENT_VISION_JSON_MAX_CHARS}, + "progress_observation_json": {"type": "string", "maxLength": HOST_PROGRESS_OBSERVATION_JSON_MAX_CHARS}, + **{ + name: {"type": "array", "minItems": 1, "maxItems": count, + "items": {"type": "string", "minLength": 1, "maxLength": width}} + for name, (count, width, _) in HOST_RESULT_EVIDENCE_LIMITS.items() + }, + }) return { "type": "object", "properties": properties, - "required": list(properties), + "required": ["result_kind"] if complete else list(properties), "additionalProperties": False, } -def read_structured_result(path: Path) -> dict[str, Any] | None: +def read_structured_result(path: Path, *, complete: bool = False) -> dict[str, Any] | None: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None - if ( - not isinstance(value, dict) - or str(value.get("result_kind") or "") not in ACCEPTED_RESULT_KINDS - ): + accepted_kinds = COMPLETE_RESULT_KINDS if complete else ACCEPTED_RESULT_KINDS + if not isinstance(value, dict) or str(value.get("result_kind") or "") not in accepted_kinds: return None return value @@ -202,23 +225,23 @@ def main(argv: Sequence[str] | None = None) -> int: except (json.JSONDecodeError, ValueError) as exc: print(f"adapter: invalid request JSON on stdin: {exc}", file=sys.stderr) return 2 - if not isinstance(request, dict) or request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA: - print("adapter: stdin is not a loopx_turn_host_request_v0 object", file=sys.stderr) - return 2 - try: + if not isinstance(request, dict): + raise ValueError("Host request must be an object") + assert_turn_host_request_version(request) authority = extract_turn_authority(request) - except ValueError as exc: - print(f"adapter: invalid TurnEnvelope authority: {exc}", file=sys.stderr) + except (ValueError, RuntimeError) as exc: + print(f"adapter: invalid LoopX Turn Host request: {exc}", file=sys.stderr) return 2 + complete = "_model_input" in authority with tempfile.TemporaryDirectory(prefix="loopx-turn-traex-") as directory: temporary = Path(directory) schema_path = temporary / "result-schema.json" output_path = temporary / "last-message.json" schema_path.write_text( json.dumps( - traex_result_schema(), + traex_result_schema(complete=complete), ensure_ascii=False, sort_keys=True, separators=(",", ":"), @@ -255,7 +278,7 @@ def main(argv: Sequence[str] | None = None) -> int: ) return completed.returncode if completed.returncode > 0 else 1 - candidate = read_structured_result(output_path) + candidate = read_structured_result(output_path, complete=complete) result = build_result(request, candidate) print(json.dumps(result, ensure_ascii=False, separators=(",", ":")), flush=True) return 0 diff --git a/tests/capabilities/test_periodic_report_turn_replan.py b/tests/capabilities/test_periodic_report_turn_replan.py index af6ede1fe4..3f84402014 100644 --- a/tests/capabilities/test_periodic_report_turn_replan.py +++ b/tests/capabilities/test_periodic_report_turn_replan.py @@ -119,9 +119,11 @@ def test_turn_milestone_intent_is_replay_stable( author_candidate = ( "import json, shlex, subprocess\n" f"candidate = {candidate!r}\n" - "def authored_candidate(authority):\n" + "def authored_candidate(request):\n" + " assert request['schema_version'] == 'loopx_turn_host_request_v1'\n" + " packet = request['detail_ref']['replan_action_packet']\n" " review = json.loads(subprocess.check_output(\n" - " shlex.split(authority['replan_action_packet']['review']), text=True))\n" + " shlex.split(packet['review']), text=True))\n" " writeback = json.loads(subprocess.check_output(\n" " shlex.split(review['next']['writeback']), text=True))\n" " vision = json.loads(candidate['agent_vision_json'])\n" @@ -137,28 +139,27 @@ def test_turn_milestone_intent_is_replay_stable( "reasoning_effort, max_tokens, cordis, runtime_bin, request_timeout_seconds):\n" " counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" " Path(workspace, 'validated-artifact.txt').write_text('validated')\n" - " authority = json.loads(prompt.split('Turn authority JSON:\\n', 1)[1].split('\\n\\nWhen finished', 1)[0])\n" - " return json.dumps(authored_candidate(authority))\n", + " request, _ = json.JSONDecoder().raw_decode(prompt.split('Turn request:\\n', 1)[1])\n" + " return json.dumps(authored_candidate(request))\n", encoding="utf-8", ) else: host_runner.write_text( author_candidate + "import pathlib, sys\n" - "from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority\n" "request = json.load(sys.stdin)\n" "counter = pathlib.Path('host-count.txt')\n" "counter.write_text(str(int(counter.read_text()) + 1 if counter.exists() else 1))\n" "pathlib.Path('validated-artifact.txt').write_text('validated')\n" "json.dump({'schema_version':'loopx_turn_result_v0','turn_key':request['turn_key']," "'completed_phases':['host_execute','typed_result']," - "**authored_candidate(extract_turn_authority(request))},sys.stdout)\n", + "**authored_candidate(request)},sys.stdout)\n", encoding="utf-8", ) validator = 'import pathlib; raise SystemExit(0 if pathlib.Path("validated-artifact.txt").read_text() == "validated" else 7)' host_args = ( ["--host", "dsh", "--dsh-runner", str(host_runner)] if host_kind == "dsh" - else ["--host", "generic-cli", "--host-adapter-command-json", json.dumps([sys.executable, str(host_runner)])] + else ["--host", "generic-cli", "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, str(host_runner)])] ) args = [ *base, "turn", "run-once", *host_args, "--goal-id", GOAL_ID, diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 745b6c716e..e5d1816461 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -620,6 +620,7 @@ def _mode_variant_commands( AGENT_IDS[0], "--project", str(project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", '["python3","-c","raise SystemExit(9)"]', "--no-global-sync", diff --git a/tests/control_plane/test_host_result_semantics.py b/tests/control_plane/test_host_result_semantics.py index 8e90b195c7..a886fca771 100644 --- a/tests/control_plane/test_host_result_semantics.py +++ b/tests/control_plane/test_host_result_semantics.py @@ -66,6 +66,7 @@ def test_native_turn_rejects_invalid_candidate_before_writeback( "pathlib.Path('validator-ran').write_text('verified')"] base = [*prefix, "turn", "run-once", "--goal-id", f.GOAL_ID, "--agent-id", f.AGENT_ID, "--host", "generic-cli", "--execution-mode", "isolated-headless", "--project", str(workspace), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", json.dumps([sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(workspace), "--dsh-home", str(tmp_path / "dsh-home"), "--dsh-runner", str(runner)]), "--validation-command-json", json.dumps(validator), diff --git a/tests/control_plane/test_reward_memory_host_isolation.py b/tests/control_plane/test_reward_memory_host_isolation.py index e65e37cad4..7d8142a36c 100644 --- a/tests/control_plane/test_reward_memory_host_isolation.py +++ b/tests/control_plane/test_reward_memory_host_isolation.py @@ -419,7 +419,7 @@ def run_dsh_turn(*, prompt, **kwargs): 'vision_unchanged_reason': 'The original fixture remains open.', 'reward_memory_reflection_json': '{"status":"no_evidence"}'}, sys.stdout) ''', encoding="utf-8") - host_args = ["--host", host_kind, "--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] + host_args = ["--host", host_kind, "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] monkeypatch.setenv("FIXTURE_HOST_CAPTURE", str(capture)) output = io.StringIO() with contextlib.redirect_stdout(output): diff --git a/tests/control_plane/test_turn_host_decision_capture.py b/tests/control_plane/test_turn_host_decision_capture.py index 2acbea8a0d..2c9f62e63f 100644 --- a/tests/control_plane/test_turn_host_decision_capture.py +++ b/tests/control_plane/test_turn_host_decision_capture.py @@ -130,6 +130,8 @@ def unexpected_settlement(*_args, **_kwargs): "from pathlib import Path; import sys; sys.stdin.read(); " f"Path({str(marker)!r}).write_text('started'); raise SystemExit(9)", ]} + if launch == "argv" and mode != "historical": + host["host_adapter_request_schema"] = "loopx_turn_host_request_v1" def execute(): return run_loopx_turn_once(plan, **host, project=tmp_path / "project", @@ -615,7 +617,7 @@ def test_real_dsh_cli_preserves_complete_input_and_once_only_settlement(tmp_path validator = [sys.executable, "-c", "import json,pathlib,sys; json.load(sys.stdin); " f"assert pathlib.Path({fixture.MARKER_NAME!r}).read_text() == {fixture._marker_value(1)!r}"] host_args = (["--host", "dsh", "--dsh-runner", str(runner)] if launch == "builtin" else [ - "--host", "generic-cli", "--host-adapter-command-json", json.dumps([ + "--host", "generic-cli", "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([ sys.executable, "-m", "loopx.dsh_goal_mode", "--workspace", str(workspace), "--dsh-runner", str(runner), "--dsh-home", str(tmp_path / "stdio-dsh-home")])]) argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", @@ -636,6 +638,109 @@ def test_real_dsh_cli_preserves_complete_input_and_once_only_settlement(tmp_path assert current["authority_read"]["source_authority"] == provider + "_v0" + +@pytest.mark.skipif(os.name != "posix", reason="the deterministic TraeX executable uses a POSIX shebang") +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_traex_cli_checks_the_adapter_before_dispatch_and_replays_once( + tmp_path, monkeypatch, provider, +): + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = _fixture_module() + project, runtime, workspace, registry = fixture._write_fixture(tmp_path, turn_count=2) + state = project / ".codex/goals" / fixture.GOAL_ID / "ACTIVE_GOAL_STATE.md" + summary = parse_active_state_todos(state.read_text(), state_path=state, item_limit=None) + initialize_canonical_authority(runtime, fixture.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=fixture.GOAL_ID, handoff_mode="soft_claim", + todos=summary["agent_todos"]["items"]), state_path=state, provider=provider) + + traex = tmp_path / "traex-fixture" + traex_calls = tmp_path / "traex-calls.txt" + traex.write_text( + f"#!{sys.executable}\n" + "import json,sys\nfrom pathlib import Path\nfrom jsonschema import validate\n" + f"open({str(traex_calls)!r}, 'a').write('invoked\\n')\n" + "args = sys.argv[1:]\n" + "prompt = args[-1]\n" + "assert 'Never re-enter admission for detail reads.' in prompt\n" + "source, _ = json.JSONDecoder().raw_decode(prompt.split('Turn request:\\n', 1)[1])\n" + "assert source['schema_version'] == 'loopx_turn_host_request_v1'\n" + "assert 'turn_envelope' not in source\n" + "contract = source['work_contract']\n" + f"assert contract['selected_todo']['todo_id'] == {fixture.TODO_ID!r}\n" + "ref = source['detail_ref']['captured_decision']\n" + "assert ref['turn_key'] == source['turn_key']\n" + "original = Path(ref['path']).read_bytes()\n" + "assert ref['source_hash_ref'] == '$.source_decision_hash'\n" + "assert source['source_decision_hash'] == contract['contract_binding']['content_hash']\n" + "assert json.loads(original) == contract\n" + f"Path({fixture.MARKER_NAME!r}).write_text({fixture._marker_value(1)!r})\n" + "candidate = {'result_kind':'validated_progress','classification':'traex_complete_work',\n" + " 'delivery_batch_scale':'implementation','delivery_outcome':'outcome_progress',\n" + " 'path_delta_mode':'unchanged','vision_unchanged_reason':'Step 2 remains.',\n" + " 'next_action':'Advance step 2.','work_evidence':['artifact:marker'],\n" + " 'validation_evidence':['fixture:independent-marker-validator']}\n" + "schema = Path(args[args.index('--output-schema') + 1])\n" + "validate(candidate, json.loads(schema.read_text()))\n" + "Path(args[args.index('--output-last-message') + 1]).write_text(json.dumps(candidate))\n", + encoding="utf-8", + ) + traex.chmod(0o700) + + adapter = Path(__file__).resolve().parents[2] / "scripts/traex_turn_host_adapter.py" + adapter_calls = tmp_path / "adapter-calls.txt" + adapter_runner = [ + sys.executable, "-c", + "import os,sys; " + f"open({str(adapter_calls)!r}, 'a').write('invoked\\n'); " + "os.execv(sys.executable, [sys.executable, *sys.argv[1:]])", + str(adapter), "--traex-bin", str(traex), "--workspace", str(workspace), + ] + validation_calls = tmp_path / "validation-calls.txt" + validator = [sys.executable, "-c", "import json,pathlib,sys; json.load(sys.stdin); " + f"assert pathlib.Path({fixture.MARKER_NAME!r}).read_text() == {fixture._marker_value(1)!r}; " + f"open({str(validation_calls)!r}, 'a').write('validated\\n')"] + argv = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "turn", "run-once", "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, + "--host", "generic-cli", "--execution-mode", "isolated-headless", "--project", str(workspace), + "--host-adapter-command-json", json.dumps(adapter_runner), + "--validation-command-json", json.dumps(validator), "--no-global-sync", "--timeout-seconds", "30"] + fresh = [*argv, "--turn-instance-id", "traex-complete-source", "--execute"] + for incompatible in ([], ["--host-adapter-request-schema", "loopx_turn_host_request_v0"]): + code, refused = fixture._run_cli([*fresh, *incompatible]) + assert code == 1 and refused["status"] == "unavailable", refused + assert "--host-adapter-request-schema loopx_turn_host_request_v1" in refused["reason"] + assert not any(refused["effects"].values()) + assert not turn_journal_path(runtime, goal_id=fixture.GOAL_ID, turn_key=refused["resume_turn_key"]).exists() + assert not adapter_calls.exists() and not validation_calls.exists() + assert fixture._quota_spend_count(runtime) == 0 + + compatible = ["--host-adapter-request-schema", "loopx_turn_host_request_v1"] + code, first = fixture._run_cli([*fresh, *compatible]) + assert code == 0 and first["status"] == "committed", json.dumps(first, ensure_ascii=False) + assert first["result_kind"] == "validated_progress" + assert first["effects"]["state_written"] and first["effects"]["quota_spent"] + code, replay = fixture._run_cli([*argv, "--resume-turn-key", first["resume_turn_key"], "--execute"]) + assert code == 0 and replay["status"] == "committed", replay + assert replay["replayed"] and not any(replay["effects"].values()) + assert adapter_calls.read_text().splitlines() == ["invoked"] + assert validation_calls.read_text().splitlines() == ["validated"] + assert fixture._quota_spend_count(runtime) == 1 + + from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request + + plan = load_loopx_turn_plan_from_journal(runtime, goal_id=fixture.GOAL_ID, + agent_id=fixture.AGENT_ID, turn_key=first["resume_turn_key"]) + request = build_loopx_turn_host_request(plan) + for invalid in ({**request, "turn_envelope": {}}, {**request, "work_contract": None}): + result = subprocess.run( + [sys.executable, str(adapter), "--traex-bin", str(traex), "--workspace", str(workspace)], + input=json.dumps(invalid), text=True, capture_output=True, cwd=workspace, timeout=15, + ) + assert result.returncode == 2 and not result.stdout + assert "invalid LoopX Turn Host request" in result.stderr + assert traex_calls.read_text().splitlines() == ["invoked"] + + def test_historical_tool_identity_preserves_raw_values(complete_host_request): from copy import deepcopy from hashlib import sha256 diff --git a/tests/control_plane/test_turn_interruption_recovery.py b/tests/control_plane/test_turn_interruption_recovery.py index f767c4142b..671299c4d5 100644 --- a/tests/control_plane/test_turn_interruption_recovery.py +++ b/tests/control_plane/test_turn_interruption_recovery.py @@ -30,6 +30,10 @@ assert_host_stopped, spend_records, ) +from loopx.control_plane.quota.turn_envelope import ( + COMPLETE_TURN_PLAN_SCHEMA_VERSION, + LOOPX_TURN_PLAN_SCHEMA_VERSION, +) from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, ) @@ -234,11 +238,11 @@ def test_export_interruption_preserves_requirements_and_original_settlement( ).fetchall() ) ) - capture_path = Path( - before["plan"]["turn_envelope"]["detail_ref"]["captured_decision"][ - "path" - ] - ) + plan = before["plan"] + version = plan["schema_version"] + assert version in (LOOPX_TURN_PLAN_SCHEMA_VERSION, COMPLETE_TURN_PLAN_SCHEMA_VERSION) + source = plan if version == COMPLETE_TURN_PLAN_SCHEMA_VERSION else plan["turn_envelope"] + capture_path = Path(source["detail_ref"]["captured_decision"]["path"]) capture = capture_path.read_bytes() assert REQUIREMENT in capture.decode() running.kill() diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index 9c0c28557b..d76b1ae942 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -85,7 +85,7 @@ def retire_runtime(): config.write_text(json.dumps({"schema_version": "loopx_local_delegation_v0", "bindings": [{ "id": "analysis", "agent_id": "analyst", "todo_id": "todo_analyst-initial", "requesters": ["lead"], "workspace": str(root / "analyst" / "initial"), "timeout_seconds": 60, "output_refs": ["output.json"], - "host_args": ["--host", "generic-cli", "--iteration-context", "fresh", "--host-command-json", + "host_args": ["--host", "generic-cli", "--iteration-context", "fresh", "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", json.dumps([sys.executable, str(host), str(root)])], }]})) return root, Delegations(root / "runtime", root / "registry.json", demo.GOAL, "lead", config) diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index b6b61c7c67..ce90c318db 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -2285,6 +2285,7 @@ def interrupt_checkpoint(path, journal): "codex-fixture", "--project", str(host_project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", @@ -2383,6 +2384,7 @@ def interrupt_checkpoint(path, journal): "codex-fixture", "--project", str(host_project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", @@ -2418,7 +2420,7 @@ def interrupt_checkpoint(path, journal): "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "turn", "run-once", "--host", "generic-cli", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", "--turn-instance-id", "next-cadence-fixture", - "--project", str(host_project), "--host-command-json", + "--project", str(host_project), "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", json.dumps([sys.executable, "-c", validation_script]), "--no-global-sync", "--execute", @@ -2467,6 +2469,7 @@ def test_managed_start_interruption_preserves_reservation_and_attempt( "--format", "json", "turn", "run-once", "--host", "generic-cli", "--goal-id", scope["goal_id"], "--agent-id", scope["agent_id"], "--turn-instance-id", "start-checkpoint-fixture", "--project", str(project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, "-c", host_script]), "--no-global-sync", "--execute"] persist, make_cadence = executor._write_journal, turn_cadence.managed_cadence_start @@ -2838,6 +2841,7 @@ def test_turn_run_once_cli_completes_selected_todo_after_validation( "codex-fixture", "--project", str(host_project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", @@ -3096,6 +3100,7 @@ def _turn_run_once_completion_argv( "codex-fixture", "--project", str(project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-adapter-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", @@ -3755,6 +3760,7 @@ def test_turn_run_once_cli_rejects_unproven_host_claim_before_writeback( "codex-fixture", "--project", str(host_project), + "--host-adapter-request-schema", "loopx_turn_host_request_v1", "--host-command-json", json.dumps([sys.executable, "-c", host_script]), "--validation-command-json", @@ -4107,7 +4113,8 @@ def fake_ingest(**kwargs: object) -> dict[str, object]: "json.dump(result, sys.stdout)\n", encoding="utf-8", ) - host_args += ["--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] + host_args += ["--host-adapter-request-schema", "loopx_turn_host_request_v1", + "--host-adapter-command-json", json.dumps([sys.executable, str(runner)])] args = [ "--registry", str(registry), From ea34e580b1d36074a09f39dcdf2e62041d5672bd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:55:47 +0800 Subject: [PATCH 08/33] Reuse observed Goal narrative in progressive first reads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/guides/context-delivery.md | 5 ++ docs/guides/context-delivery.zh-CN.md | 4 + loopx/cli_commands/quota_capture.py | 46 ++++++---- .../quota/progressive_context.ts | 54 +++++++++--- .../turn_driver/codex_context.py | 2 +- .../turn_driver/native_first_read.py | 52 ++++++++++-- .../control_plane/test_context_first_read.py | 84 +++++++++++++++++++ 7 files changed, 210 insertions(+), 37 deletions(-) diff --git a/docs/guides/context-delivery.md b/docs/guides/context-delivery.md index 6b09d061f5..93934c0632 100644 --- a/docs/guides/context-delivery.md +++ b/docs/guides/context-delivery.md @@ -129,6 +129,11 @@ from file deltas and provider caching; it does not prove model retention or lower total cost. Partial reads and lost continuity cannot qualify this reuse. Explicit same-capture selections always recover the complete current value. +An unchanged Goal narrative can also be referenced after an exact read of the +same registered source in the current session and epoch. The current source +revision and Turn controls still appear. A change to the narrative or its +source, an incomplete read, or compaction restores the current text. + ### Read and recover the right object Use the path, digest and command returned by the current response. A native diff --git a/docs/guides/context-delivery.zh-CN.md b/docs/guides/context-delivery.zh-CN.md index fcfabbe2b7..757807833c 100644 --- a/docs/guides/context-delivery.zh-CN.md +++ b/docs/guides/context-delivery.zh-CN.md @@ -102,6 +102,10 @@ Full 表示完整保存视图可用,不要求判断任何问题前先遍历所 这种工具投递假设与文件差分、provider 缓存分别成立,不证明模型保留或完整成本下降。 未读完或连续性丢失不能取得该复用资格;显式同 capture 选择仍可恢复完整当前值。 +当前原生会话在同一上下文周期内完整读取同一注册来源后,也可引用未变的 Goal 正文。 +首读仍交付当前来源修订和 Turn 控制条件。正文或来源变化、读取不完整或发生压缩时, +读者恢复当前正文。 + ### 读对对象,再恢复 使用当前响应返回的路径、摘要与命令。原生响应的 `recovery` 指向当前完整必要视图; diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 4bf1e7449a..635197f099 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -143,6 +143,17 @@ def _validate_context_page_window(offset: int, size: int, output_limit: int | No "larger pages require a sufficient --max-page-output-bytes within the capture limit.") +def _validate_pinned_selection(selection: dict[str, Any]) -> None: + if (set(selection) - {"present_goal_narrative"} != {"select", "initial_read", "list_fields", "list_depth", "sha256"} + or type(selection["initial_read"]) is not bool or type(selection["list_fields"]) is not bool + or ("present_goal_narrative" in selection and ( + selection["present_goal_narrative"] is not True + or selection["list_fields"] or selection["initial_read"])) + or not isinstance(selection["sha256"], str) + or BARE_SHA256_PATTERN.fullmatch(selection["sha256"]) is None): + raise ValueError("reader selection reference is incomplete or unsupported") + + def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, Any] | None = None, next_page_bytes: int | None = None, next_page_output_bytes: int | None = None, @@ -204,30 +215,31 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An if pinned_selection is not None: if selectors is not None or initial_read or list_fields or list_depth is not None or selection_digest is not None: raise ValueError("a pinned selection reference cannot change selectors; use the original capture reference") - if (set(pinned_selection) - {"present_goal_narrative"} != - {"select", "initial_read", "list_fields", "list_depth", "sha256"} - or type(pinned_selection["initial_read"]) is not bool or type(pinned_selection["list_fields"]) is not bool - or ("present_goal_narrative" in pinned_selection and ( - pinned_selection["present_goal_narrative"] is not True - or pinned_selection["list_fields"] or pinned_selection["initial_read"])) - or not isinstance(pinned_selection["sha256"], str) - or BARE_SHA256_PATTERN.fullmatch(pinned_selection["sha256"]) is None): - raise ValueError("reader selection reference is incomplete or unsupported") + _validate_pinned_selection(pinned_selection) if observation := reference.get("observed_read"): - from ..control_plane.turn_driver.native_first_read import observation_current + from ..control_plane.turn_driver.native_first_read import observation_current, observed_first_read_fallback if not observation_current(observation, path, digest): + fallback = observed_first_read_fallback(observation, path, digest) + if fallback is not None: + try: + _validate_pinned_selection(fallback[1]) + except ValueError: + fallback = None if offset: return {"ok": False, "reason": "Observed first-read scope changed during paging.", - "recovery_command": context_capture_read_command(path, digest, initial_read=True, + "recovery_command": context_capture_read_command(path, digest, initial_read=fallback is None, + reference_file=fallback[0] if fallback is not None else None, page_bytes=size, max_page_output_bytes=output_limit, reader_argv=reader_argv)} # Restart in the ordinary complete view of this same capture. - # Subsequent pages must pin that view even if the journal changes. - pinned_selection, initial_read = None, True - try: - reference_file = write_context_read_reference(path, digest) - except OSError: - reference_file = None + if fallback is not None: + reference_file, pinned_selection = fallback + else: + pinned_selection, initial_read = None, True + try: + reference_file = write_context_read_reference(path, digest) + except OSError: + reference_file = None elif offset == 0: presentation = {**(presentation or {}), "previously_delivered_fields": list(observation["fields"]), "field_recovery": "These unchanged fields still apply. If needed or uncertain, append --select " diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index c0479ab174..943cbd4aee 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -3,6 +3,7 @@ import {EffectiveAction} from "./effective_action.generated.ts"; * addressed in the immutable source. This plan changes no canonical state. */ import {isDeepStrictEqual} from "node:util"; import {visionAuthoringContract} from "../goals/vision_checkpoint.ts"; +import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireBoolean, requireJsonObject} from "../runtime_decode.ts"; import {GOAL_OUTCOMES_SCHEMA, OUTCOME_CONTEXT_NAVIGATION_SCHEMA} from "../goals/outcome_requirements.ts"; @@ -17,6 +18,7 @@ import {createHash} from "node:crypto"; type Path = (string | number)[]; +type ReusableReadField = {path: Path; freshKeys: string[]; minimumBytes: number; sourceFile?: string}; const MIN_REUSABLE_BODY_BYTES = 1024; const pointer = (path: Path): string => path.length ? "/" + path.map(key => String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/") : ""; @@ -26,14 +28,21 @@ const known = (row: JsonObject, fields: string[]): boolean => Object.keys(row).e /** One registry for bodies eligible for observed reuse, regardless of whether * the reader delivered the body itself or packaged it in a complete parent. */ -function reusableFirstReadFields(context: JsonObject): {path: Path; freshKeys: string[]; minimumBytes: number}[] { +function reusableFirstReadFields(context: JsonObject): ReusableReadField[] { const base: Path = ["interaction_contract", "agent_channel", "work_context", "sources"]; const sources = at(context, base); if (!Array.isArray(sources)) return []; - return sources.flatMap((value, i) => { + return sources.flatMap((value, i) => { const source = jsonObject(value), content = jsonObject(source?.content); if (!content) return []; const path = [...base, i, "content"]; + // The current source and full-file revision still reach the first read. + // Only the literal narrative may carry forward across generated Todo edits. + if (source?.source === "goal_state" && content.ok !== false && + (content.status === undefined || content.status === "loaded") && + typeof content.state_file === "string" && completeGoalNarrative(content)) + return [{path: [...path, "text"], freshKeys: [], minimumBytes: MIN_REUSABLE_BODY_BYTES, + sourceFile: content.state_file}]; if (source?.kind === "agent_preferences") return [{path, freshKeys: ["ok", "revision", "instruction"], minimumBytes: MIN_REUSABLE_BODY_BYTES}]; if (source?.source !== "goal_acceptance") return []; @@ -66,15 +75,22 @@ function reusableFirstReadFields(context: JsonObject): {path: Path; freshKeys: s }); } +function observedSectionDigest(body: string, field?: ReusableReadField): string { + // Same text at a reused array index is insufficient after a Goal file rebind. + const value = field?.sourceFile === undefined ? body : JSON.stringify([field.sourceFile, body]); + return createHash("sha256").update(value).digest("hex"); +} + /** Digests stay in the private observed-read journal, never repeated to the model. * The existing exact-source identity preserves non-JS numeric spellings. */ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): JsonObject { const context = requireJsonObject(request.context, "read section source"); const identity = contextSourceIdentity(request, request.source_number_tokens); const selected = new Set(paths.map(pointer)); + const fields = new Map(reusableFirstReadFields(context).map(field => [pointer(field.path), field])); const covered = new Map(paths.map(path => [pointer(path), {path, minimumBytes: MIN_REUSABLE_BODY_BYTES}])); - for (const field of reusableFirstReadFields(context)) - if (paths.some(parent => pathStartsWith(field.path, parent))) covered.set(pointer(field.path), field); + for (const [address, field] of fields) + if (paths.some(parent => pathStartsWith(field.path, parent))) covered.set(address, field); const inherited = jsonObject(request.consumed_pointers), coverage = new Set(paths.map(pointer)); // The IO owner authenticates the reader's native observation. Recheck each // inherited value against this exact source before combining it with the @@ -86,7 +102,7 @@ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): J const read = capturedContextSelection({context, select: [address], recover_selection_errors: true}); if (read.rejected_selection) continue; const path = (read.selections as {path: Path}[])[0].path; - if (createHash("sha256").update(identity(at(context, path), ["context", ...path])).digest("hex") === expected) + if (observedSectionDigest(identity(at(context, path), ["context", ...path]), fields.get(address)) === expected) coverage.add(address); } const complete = (path: Path): boolean => { @@ -95,18 +111,18 @@ export function contextReadSectionDigests(request: JsonObject, paths: Path[]): J return value !== null && typeof value === "object" && Object.keys(value).length > 0 && Object.keys(value).every(key => complete([...path, Array.isArray(value) ? Number(key) : key])); }; - for (const field of reusableFirstReadFields(context)) - if (complete(field.path)) covered.set(pointer(field.path), field); + for (const [address, field] of fields) + if (complete(field.path)) covered.set(address, field); } return Object.fromEntries([...covered].flatMap(([address, {path, minimumBytes}]) => { const content = identity(at(context, path), ["context", ...path]); // Parent delivery must not multiply tiny bodies into a larger journal. return selected.has(address) || Buffer.byteLength(content) >= minimumBytes - ? [[address, createHash("sha256").update(content).digest("hex")]] : []; + ? [[address, observedSectionDigest(content, fields.get(address))]] : []; })); } -/** Only previously delivered supplementary bodies may use a reference. Current +/** Only registered, previously delivered bodies may use a reference. Current * decisions, stops, selection, settlement and unknown source shapes stay inline. */ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject | undefined { const context = requireJsonObject(request.context, "current first read"); @@ -126,7 +142,7 @@ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject const body = at(context, path); if (typeof seen[address] === "string" && field) { const content = identity(body, ["context", ...path]); - if (Buffer.byteLength(content) >= field.minimumBytes && createHash("sha256").update(content).digest("hex") === seen[address]) { + if (Buffer.byteLength(content) >= field.minimumBytes && observedSectionDigest(content, field) === seen[address]) { reused[address] = seen[address]; return field.freshKeys.filter(key => body !== null && typeof body === "object" && Object.hasOwn(body, key)).map(key => ({pointer: pointer([...path, key]), path: [...path, key]})); @@ -148,8 +164,22 @@ function observedFirstRead(request: JsonObject, pointers: string[]): JsonObject JSON.stringify({pointer: row.pointer}).slice(0, -1) + ',"value":' + identity(at(context, row.path), ["context", ...row.path]) + '}'), 0); // Splitting a container repeats addresses. Count those and the reuse index; // retain a margin for the existing recovery guidance, or deliver it whole. - return bytes(grouped) + Buffer.byteLength(JSON.stringify(Object.keys(reused))) + MIN_REUSABLE_BODY_BYTES < bytes(original) - ? {selections: grouped, reused} : undefined; + if (bytes(grouped) + Buffer.byteLength(JSON.stringify(Object.keys(reused))) + MIN_REUSABLE_BODY_BYTES >= bytes(original)) + return undefined; + if (request.include_initial_read_plan === true) { + // The IO adapter already receives the ordinary plan. Send the changed + // middle of that list, keeping its exact order and coalescing decisions. + let start = 0, end = original.length, stop = grouped.length; + while (start < end && start < stop && original[start].pointer === grouped[start].pointer) start++; + while (end > start && stop > start && original[end - 1].pointer === grouped[stop - 1].pointer) { + end--; + stop--; + } + const selection_splice = {start, end, selections: grouped.slice(start, stop)}; + if (Buffer.byteLength(JSON.stringify(selection_splice)) < Buffer.byteLength(JSON.stringify(grouped))) + return {selection_splice, reused}; + } + return {selections: grouped, reused}; } function inlineReadSections(request: JsonObject, required: JsonObject): JsonObject { diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 72abed39c0..9d1796d926 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -785,7 +785,7 @@ def publish_recovery(reference: dict[str, Any]) -> None: from .native_first_read import observed_first_read_reference reference = observed_first_read_reference(directory, result["target"], recovery, - transaction["context"], plan) + transaction["context"], plan, transaction.get("initial_read_plan")) if reference is not None: state["pending"]["observed_first_read"] = reference _save(root, state) diff --git a/loopx/control_plane/turn_driver/native_first_read.py b/loopx/control_plane/turn_driver/native_first_read.py index 9e805f6f70..1f0c646a5e 100644 --- a/loopx/control_plane/turn_driver/native_first_read.py +++ b/loopx/control_plane/turn_driver/native_first_read.py @@ -1,25 +1,63 @@ """Pin a smaller initial selection to previously observed native tool output.""" from __future__ import annotations +import hashlib import os from pathlib import Path from typing import Any -from .decision_capture import write_initial_context_read_reference +from .decision_capture import read_context_read_reference, write_initial_context_read_reference def observed_first_read_reference(directory: str, target: dict[str, Any], recovery: dict[str, Any], - context: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any] | None: - """Write the typed delivery plan once. The host renders it from the immutable source.""" + context: dict[str, Any], plan: dict[str, Any], + initial_read_plan: dict[str, Any] | None = None) -> dict[str, Any] | None: + """Pin the reduced view and its ordinary fallback from the same capture.""" try: - reference, _ = write_initial_context_read_reference(Path(recovery["path"]), recovery["sha256"], - context, plan["selections"], - observed_read={"directory": directory, "target": target, "fields": plan["reused"]}) + path, digest = Path(recovery["path"]), recovery["sha256"] + observation = {"directory": directory, "target": target, "fields": plan["reused"]} + selections = plan.get("selections") + if initial_read_plan is not None and initial_read_plan.get("initial") is True: + ordinary = initial_read_plan["plan"]["selections"] + if splice := plan.get("selection_splice"): + start, end = splice["start"], splice["end"] + if type(start) is not int or type(end) is not int or not 0 <= start <= end <= len(ordinary): + return None + selections = [*ordinary[:start], *splice["selections"], *ordinary[end:]] + fallback, body = write_initial_context_read_reference(path, digest, context, ordinary) + observation["fallback_reference"] = {"filename": fallback.name, + "selection_sha256": hashlib.sha256(body).hexdigest()} + if selections is None: + return None + reference, _ = write_initial_context_read_reference(path, digest, context, selections, + observed_read=observation) return {"path": str(reference), "pointer_bytes": sum(len(key.encode()) + 4 for key in plan["reused"])} - except (OSError, ValueError): + except (OSError, ValueError, KeyError, TypeError): return None # The ordinary first read remains available. +def observed_first_read_fallback(observation: dict[str, Any], path: Path, digest: str) -> tuple[Path, dict[str, Any]] | None: + """Locate the ordinary pinned selection of this exact capture.""" + fallback = observation.get("fallback_reference") if isinstance(observation, dict) else None + if not isinstance(fallback, dict): + return None + filename = fallback.get("filename") + if not isinstance(filename, str) or not filename.startswith(path.stem + ".") or not filename.endswith(".reader.json"): + return None + try: + reference, metadata = read_context_read_reference(str(path.with_name(filename))) + selection = metadata.get("selection") + source = Path(metadata["path"]).expanduser().absolute() + if (metadata["sha256"] == digest and source.parent.resolve(strict=True) / source.name == path + and "observed_read" not in metadata and isinstance(selection, dict) + and isinstance(fallback.get("selection_sha256"), str) + and selection.get("sha256") == fallback["selection_sha256"]): + return reference, selection + except (OSError, ValueError): + pass + return None + + def observation_current(observation: dict[str, Any], path: Path, digest: str, *, hook_session: str | None = None) -> bool: from .codex_context import _private_root, _state diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 79c617830e..4987601630 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -1049,3 +1049,87 @@ def facts(pages): assert facts(restarted)[prefix + "/0"] == outcomes[0] recovered = pages(second["first_read_command"]) assert facts(recovered)[prefix + "/0"] == outcomes[0] + + +@pytest.mark.parametrize("transition", ["unchanged", "corrected", "different_source", "incomplete"]) +def test_native_first_read_goal_narrative_reuse(tmp_path, monkeypatch, decision, transition): + from test_codex_context_lifecycle import deliver, setup + + event = setup(tmp_path, monkeypatch) + narrative = "保留完整目标、独立验收、权限边界和停止条件。\n" * 140 + source = decision["interaction_contract"]["agent_channel"]["work_context"]["sources"][0] + plan = decision["interaction_contract"]["cli_channel"]["settlement_plan"] + prefix = "/interaction_contract/agent_channel/work_context/sources/0/content" + body_pointer = prefix + "/text" + source.update(source="goal_state", ordering="before_work") + + def goal_source(path, text, generated): + def revision(value): + return "sha256:" + hashlib.sha256(value.encode()).hexdigest() + + line = len(text.splitlines()) + 1 + return {"state_file": path, "text": text, "revision": revision(text + generated), + "excluded_todo_regions": [{"role": "agent", "start_line": line, "end_line": line, + "revision": revision(generated)}]} + + def capture(name, content): + source.update(content=content, command=f"cat -- {content['state_file']}") + plan["identity"]["turn_instance_id"] = name + directory = tmp_path / name + directory.mkdir(mode=0o700) + prepared = codex_context.prepare_codex_context(decision, directory=str(tmp_path), + capture_directory=directory, goal_id="fixture", agent_id="agent", + surface="quota_packet", capture_id=name) + return model_context_reference(native_context_capture_view(prepared.delivery, directory, + context_directory=str(tmp_path), initial_read_plan=prepared.initial_read_plan)) + + def read(command): + pages, size, offset = [], 0, 0 + while command: + words = shlex.split(command) + stdout = subprocess.check_output([sys.executable, "-m", "loopx.entrypoint", "--format", "json", + *words[words.index("quota"):]]) + page = json.loads(stdout) + assert page["ok"] and page["offset"] == offset, page + pages.append(page) + size += len(stdout) + offset += len(page["content"].encode()) + command = page.get("next_command") + assert pages[-1]["eof"] and offset == pages[-1]["total_bytes"] + return pages, selected_context_values("".join(page["content"] for page in pages)), size + + first = capture("first", goal_source("goal.md", narrative, "old generated todo\n")) + pages, facts, _ = read(first["first_read_command"]) + assert facts[body_pointer] == narrative and "previously_delivered_fields" not in pages[0] + deliver(tmp_path, event, pages) + assert body_pointer in codex_context._state(tmp_path)["consumed_pointers"] + + text = narrative + "Use the revised owner decision.\n" if transition == "corrected" else narrative + current = goal_source("alternate.md" if transition == "different_source" else "goal.md", + text, "new generated todo\n") + if transition == "incomplete": + current.update(read_complete=False, recovery="Restore the complete Goal source.") + decision["goal_boundary"]["stop_condition"] = "Stop before an external write." + second = capture("second", current) + pages, facts, warm_bytes = read(second["first_read_command"]) + assert facts[prefix + "/revision"] == current["revision"] + assert facts["/goal_boundary"] == decision["goal_boundary"] + assert facts["/interaction_contract/cli_channel/settlement_plan/identity/turn_instance_id"] == "second" + if transition != "unchanged": + assert facts[body_pointer] == text and "previously_delivered_fields" not in pages[0] + if transition == "incomplete": + assert facts[prefix + "/recovery"] == current["recovery"] + return + + assert pages[0]["previously_delivered_fields"] == [body_pointer] and body_pointer not in facts + observed = json.loads(Path(pages[0]["reference_file"]).read_text())["observed_read"] + ordinary = Path(pages[0]["reference_file"]).with_name(observed["fallback_reference"]["filename"]) + recovery = second["recovery"]["command"] + _, restored, _ = read(recovery + " --select " + body_pointer) + assert restored[body_pointer] == narrative + codex_context.codex_context_hook(str(tmp_path), {**event, "hook_event_name": "PostCompact"}) + fallback, after, fallback_bytes = read(second["first_read_command"]) + assert "previously_delivered_fields" not in fallback[0] and after[body_pointer] == narrative + assert Path(fallback[0]["reference_file"]) == ordinary + assert {key: value for key, value in after.items() if key != body_pointer} == facts + assert fallback_bytes - warm_bytes > 3000 From b0e7bfb458cd62b6b174a28ea103f83b4624169f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:30:29 +0800 Subject: [PATCH 09/33] fix(qualification): align model recovery and result instructions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../testing/doubao_model_behavior_actor.py | 6 ++- .../scoped_gate_successor_tool_behavior.py | 9 ++-- ..._doubao_onboarding_model_behavior_actor.py | 42 +++++++++++++++++++ ...est_scoped_gate_successor_tool_behavior.py | 12 +++++- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/testing/doubao_model_behavior_actor.py b/loopx/control_plane/testing/doubao_model_behavior_actor.py index e5cc483e51..d906370ec9 100644 --- a/loopx/control_plane/testing/doubao_model_behavior_actor.py +++ b/loopx/control_plane/testing/doubao_model_behavior_actor.py @@ -459,7 +459,11 @@ def _onboarding_decision_instruction(phase: str) -> str: } Copy the requested phase exactly. Output JSON only, without markdown or reasoning. The semantic_contract must contain exactly the phase-specific fields -described below. Use null where instructed and never invent identifiers.""" +described below. Use null where instructed and never invent identifiers. +reason_codes is required and must contain 1 to 12 distinct, non-empty strings. +Each reason code must match ^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,159}$; use short +English tokens derived from the observed packet. Never return an empty list, +prose, or non-ASCII reason codes, including when the observation is healthy.""" if phase == "entry": return ( common diff --git a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index c837f1bf1c..0787ac018a 100644 --- a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py +++ b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py @@ -59,9 +59,12 @@ "writeback, spend, or state changes that were not executed." ) SCOPED_GATE_READ_RETRY_MESSAGE = ( - "The hermetic host rejected this read shape without executing it. Retry with " - "one bounded read-only command inside the current workspace using pwd, ls, " - "find, rg --files, cat, head, or sed." + "The hermetic host rejected this read shape without executing it; the " + "selected Todo action has not completed. Follow the latest quota result: " + "finish any still-required context read, then perform the selected Todo's " + "bounded read. When selected_todo.text names one relative file, use " + "`cat `; do not rediscover the workspace or inspect state. " + "The rejection grants no additional read scope or tool budget." ) diff --git a/tests/control_plane/test_doubao_onboarding_model_behavior_actor.py b/tests/control_plane/test_doubao_onboarding_model_behavior_actor.py index ed6568d792..b838d5c5bd 100644 --- a/tests/control_plane/test_doubao_onboarding_model_behavior_actor.py +++ b/tests/control_plane/test_doubao_onboarding_model_behavior_actor.py @@ -4,10 +4,13 @@ from collections.abc import Mapping from typing import Any +import pytest + from loopx.control_plane.testing.doubao_model_behavior_actor import ( DOUBAO_2_1_PRO_MODEL, DOUBAO_CHAT_COMPLETIONS_ENDPOINT, DoubaoOnboardingModelBehaviorActor, + _onboarding_decision_instruction, ) from loopx.control_plane.testing.onboarding_model_behavior_qualification import ( ONBOARDING_MODEL_BEHAVIOR_DECISION_SCHEMA_VERSION, @@ -121,3 +124,42 @@ def transport( assert result["schema_version"] == ONBOARDING_MODEL_BEHAVIOR_RESULT_SCHEMA_VERSION assert result["tool_calls"] == [] assert "fixture-key" not in json.dumps(result, sort_keys=True) + + +@pytest.mark.parametrize("phase", ["entry", "postcondition"]) +def test_onboarding_reason_code_instruction_matches_the_strict_result_boundary(phase): + instruction = _onboarding_decision_instruction(phase) + assert "reason_codes is required and must contain 1 to 12" in instruction + assert "^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,159}$" in instruction + assert "including when the observation is healthy" in instruction + + +@pytest.mark.parametrize( + "reasons", [[], ["not a token"], [f"reason_{index}" for index in range(13)]], +) +def test_onboarding_invalid_reason_codes_are_not_repaired_or_retried(reasons): + calls = 0 + decision = { + "schema_version": ONBOARDING_MODEL_BEHAVIOR_DECISION_SCHEMA_VERSION, + "phase": "entry", + "next_action": "connect_if_needed", + "semantic_contract": onboarding_entry_semantic_contract(_packet()), + "reason_codes": reasons, + } + + def transport(**kwargs): + nonlocal calls + calls += 1 + return {"choices": [{"message": {"content": json.dumps(decision)}}]} + + actor = DoubaoOnboardingModelBehaviorActor( + api_key="fixture-key-not-a-secret", transport=transport, + ) + request = build_onboarding_model_behavior_actor_request( + _packet(), qualification_id="onboarding-invalid-reasons", phase="entry", + ) + raw = actor(request) + with pytest.raises(ValueError, match="reason_codes"): + normalize_onboarding_model_behavior_actor_result(raw, phase="entry") + assert raw["decision"]["reason_codes"] == decision["reason_codes"] + assert calls == 1 diff --git a/tests/control_plane/test_scoped_gate_successor_tool_behavior.py b/tests/control_plane/test_scoped_gate_successor_tool_behavior.py index a4a0770be6..6a26d03bdc 100644 --- a/tests/control_plane/test_scoped_gate_successor_tool_behavior.py +++ b/tests/control_plane/test_scoped_gate_successor_tool_behavior.py @@ -176,9 +176,11 @@ def test_model_can_recover_from_one_unexecuted_read_shape( _stop_response("User gate: review the unrelated product first screen."), ] call_count = 0 + requests: list[dict[str, Any]] = [] - def transport(**_: Any) -> Mapping[str, Any]: + def transport(**kwargs: Any) -> Mapping[str, Any]: nonlocal call_count + requests.append(json.loads(kwargs["body"])) response = responses[call_count] call_count += 1 return response @@ -197,6 +199,14 @@ def transport(**_: Any) -> Mapping[str, Any]: "workspace_read_rejected", "selected_action", ] + feedback = requests[2]["messages"][-1] + assert feedback["role"] == "tool" + assert "without executing it" in feedback["content"] + assert "`cat `" in feedback["content"] + assert "do not rediscover the workspace or inspect state" in feedback["content"] + assert "no additional read scope or tool budget" in feedback["content"] + assert "using pwd, ls" not in feedback["content"] + assert receipt["tool_call_limit"] == SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS def test_mutating_command_is_not_retried_as_read_intent(tmp_path: Path) -> None: From 7c18d78a235111aa3f618ccb369b41440a3dd7db Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:02:48 +0800 Subject: [PATCH 10/33] Preserve bounded recovery after isolated draft shell errors Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../terminal_settlement_tool_behavior.py | 23 ++++- .../test_terminal_settlement_tool_behavior.py | 96 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 2515a37491..d9094f3fca 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -995,6 +995,15 @@ def _execute_terminal_cli( return output, kind +def _protected_terminal_fixture(state: _QualificationState) -> dict[str, tuple[Any, ...]]: + root = state.fixture.project_root.parent + scratch = str(state.fixture.project_root.relative_to(root) / ".scratch") + return { + path: value for path, value in snapshot_disposable_fixture(root).items() + if path != scratch and not path.startswith(scratch + "/") + } + + def _execute_terminal_workspace( command: str, state: _QualificationState, workspace_shell: VisionShellHost | None, resources: ExitStack, ) -> tuple[str, str, VisionShellHost | None]: @@ -1011,9 +1020,18 @@ def reject_nested_cli(_argv: list[str], _cwd: Path, _output: str) -> str: ) resources.callback(workspace_shell.close) state.workspace_shell_executed = True + before = _protected_terminal_fixture(state) output, exit_code = workspace_shell.execute(command) if exit_code: - raise _TerminalToolViolation("workspace_authoring_failed") + if _protected_terminal_fixture(state) != before: + raise _TerminalToolViolation("workspace_authoring_failed") + # The native exec host returns failed draft commands to the model. + # Correction consumes this same budget and grants no CLI effect. + output = json.dumps({ + "error_code": "shell_nonzero", "exit_code": exit_code, + "output": output[:4096], "truncated": len(output) > 4096, + }) + return output, "shell_nonzero", workspace_shell kind = "workspace_authoring" return output, kind, workspace_shell @@ -1161,6 +1179,9 @@ def receipt(*, passed: bool, failure_code: str | None) -> dict[str, Any]: "kind": kind if failure_code is None else failure_code, "command_digest": digest_text(tool_call.command), } + if kind == "shell_nonzero" and failure_code is None: + step_receipt["error_code"] = "shell_nonzero" + step_receipt["exit_code"] = json.loads(output)["exit_code"] if failure_code is not None: step_receipt["redacted_command_shape"] = _redacted_command_shape( tool_call.command diff --git a/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index 18165729d4..528877d7d8 100644 --- a/tests/control_plane/test_terminal_settlement_tool_behavior.py +++ b/tests/control_plane/test_terminal_settlement_tool_behavior.py @@ -497,6 +497,102 @@ def writeback(request): assert (tmp_path / "actor/project/fixture/settlement-proof.json").read_bytes() == fixture.proof_target.read_bytes() +@pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") +@pytest.mark.parametrize("failed_drafts", [1, 7]) +def test_terminal_shell_error_can_be_corrected_without_resetting_budget( + tmp_path: Path, failed_drafts: int, +) -> None: + fixture = _build_fixture(tmp_path / "oracle") + private_output = "synthetic-private-draft-output" + vision = { + "schema_version": "goal_vision_replan_contract_v0", + "state": "vision_closed", + "vision_patch": {"acceptance_summary": "Original proof and Todo validation were checked."}, + "path_delta": { + "outcome": "no_change", "prior_assumption": "Delivery needed validation.", + "observed_reality": "The proof and original Todo validation were checked.", + "retained": ["Validated delivery"], "evidence_refs": ["fixture-settlement-proof"], + }, + } + + def corrected_draft(request): + feedback = json.loads(request["messages"][-1]["content"]) + assert feedback["error_code"] == "shell_nonzero" + assert feedback["exit_code"] == 7 + assert private_output in feedback["output"] + return ScriptedExecToolAction( + command="cat > .scratch/vision.json <<'JSON'\n" + json.dumps(vision) + "\nJSON", + ) + + def writeback(request): + return ScriptedExecToolAction( + command=_writeback_action(request).command + " --agent-vision-json .scratch/vision.json", + ) + + transport = ScriptedDoubaoExecTransport([ + ScriptedExecToolAction(command=fixture.quota_guard_command), + ScriptedExecToolAction(command="cat fixture/settlement-proof.json"), + partial(_completion_action, kind="validation"), + *([ScriptedExecToolAction(command=( + "python3 -c " + shlex.quote( + "from pathlib import Path; Path('.scratch/partial.json').write_text('{'); " + f"print({private_output!r}); raise SystemExit(7)" + ) + ))] * failed_drafts), + corrected_draft, writeback, _spend_action, _completion_action, + ]) + receipt = DoubaoTerminalSettlementToolBehaviorActor( + api_key="test-only-placeholder", transport=transport, + ).qualify(qualification_id="terminal-shell-correction", fixture_root=tmp_path / "actor") + + assert receipt["qualification_passed"] is (failed_drafts == 1), receipt + assert receipt["tool_call_count"] == (8 if failed_drafts == 1 else 10) + assert receipt["observed_tool_sequence"].count("shell_nonzero") == failed_drafts + assert receipt["settlement_receipt_steps"] == ( + ["validation", "durable_writeback", "quota_spend", "terminal_closeout"] + if failed_drafts == 1 else ["validation"] + ) + assert receipt["failure_code"] == (None if failed_drafts == 1 else "tool_call_budget_exhausted") + assert private_output not in json.dumps(receipt) + assert (tmp_path / "actor/project/fixture/settlement-proof.json").read_bytes() == fixture.proof_target.read_bytes() + + +@pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") +@pytest.mark.parametrize("protected_target", ["proof", "registry"]) +def test_terminal_shell_error_with_protected_mutation_stops_before_writeback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, protected_target: str, +) -> None: + from loopx.control_plane.testing.vision_shell_host import VisionShellHost + + fixture = _build_fixture(tmp_path / "oracle") + execute = VisionShellHost.execute + + def faulty_host(shell, command): + output, exit_code = execute(shell, command) + # Simulate a broken isolation host, not an authorized draft mutation. + target = (shell.project / "fixture/settlement-proof.json" + if protected_target == "proof" else shell.fixture_root / "runtime/registry.global.json") + target.write_text("synthetic-protected-mutation\n") + return output, exit_code + + monkeypatch.setattr(VisionShellHost, "execute", faulty_host) + transport = ScriptedDoubaoExecTransport([ + ScriptedExecToolAction(command=fixture.quota_guard_command), + ScriptedExecToolAction(command="cat fixture/settlement-proof.json"), + partial(_completion_action, kind="validation"), + ScriptedExecToolAction(command="python3 -c 'raise SystemExit(7)'"), + _writeback_action, _spend_action, _completion_action, + ]) + receipt = DoubaoTerminalSettlementToolBehaviorActor( + api_key="test-only-placeholder", transport=transport, + ).qualify(qualification_id="terminal-mutated-shell-failure", fixture_root=tmp_path / "actor") + + assert receipt["qualification_passed"] is False + assert receipt["failure_code"] == "workspace_authoring_failed" + assert receipt["tool_call_count"] == len(transport.requests) == 4 + assert receipt["settlement_receipt_steps"] == ["validation"] + + def test_terminal_input_rejections_do_not_reset_the_call_budget(tmp_path: Path) -> None: fixture = _build_fixture(tmp_path / "oracle") From 5238c9c962682359791aa69e2ed971e781d1b7e0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:51:42 +0800 Subject: [PATCH 11/33] fix(turn): qualify bounded inspection and original-session recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../control_plane/cli-output-probe-runner.py | 26 +++ loopx/cli_commands/context_machine.py | 5 +- loopx/cli_commands/turn.py | 4 + loopx/cli_commands/turn_rendering.py | 34 ++++ .../quota/progressive_context.ts | 3 +- loopx/control_plane/quota/turn_envelope.py | 4 - loopx/control_plane/quota/turn_envelope.ts | 44 ++++- .../testing/cli_output_budget.py | 13 +- .../testing/cli_output_differential.py | 44 ++++- .../testing/cli_output_semantics.py | 57 ++++++ .../turn_driver/command_validation.py | 10 + loopx/control_plane/turn_driver/driver.py | 4 + loopx/control_plane/turn_driver/executor.py | 24 +-- .../turn_driver/session_recovery.py | 19 +- tests/control_plane/cli_output_fixture.py | 28 +-- tests/control_plane/test_cli_output_budget.py | 130 ++++++++++-- .../test_cli_output_differential.py | 34 ++++ .../test_cli_output_probe_runner.py | 28 ++- .../test_complete_session_binding.py | 185 ++++++++++++++++++ 19 files changed, 639 insertions(+), 57 deletions(-) diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 6abe11e964..275ef9cb98 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -5,6 +5,8 @@ import argparse import importlib.util +import hashlib +import shlex import json import shutil import sys @@ -59,6 +61,27 @@ def _receipt_row( output_contract_version: str | None = None, ) -> dict: payload = measurement.get("resolved_payload", measurement.get("payload")) + plan, material, detail_cost = payload, None, None + if isinstance(plan, dict) and plan.get("schema_version") == "loopx_turn_plan_v1": + if "detail_ref" in plan: + from tests.control_plane.cli_output_fixture import read_pinned_cli_body + body, calls, reader_bytes = read_pinned_cli_body(plan["detail_ref"]["command"]) + plan = {**plan, "work_contract": json.loads(body)} + detail_cost = {"reader_calls": calls, "reader_stdout_utf8_bytes": reader_bytes, + "body_utf8_bytes": len(body.encode("utf-8"))} + sources = plan["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["sources"] + for row in sources: + content = row.get("content", {}) + if row.get("source") == "goal_state" and content.get("full_source") is True: + revision = "sha256:" + hashlib.sha256(content["text"].encode("utf-8")).hexdigest() + assert revision == content["revision"] and row["ordering"] == "before_work" + material = {"command": row["command"], "revision": revision, "ordering": row["ordering"]} + elif isinstance(plan, dict) and plan.get("schema_version") == "loopx_turn_plan_v0": + for row in plan["turn_envelope"]["required_reads"]: + command = shlex.split(row.get("command", "")) + if row.get("source") == "goal_state" and row.get("ordering") == "before_work" and command[:2] == ["cat", "--"] and len(command) == 3: + material = {"command": row["command"], "ordering": row["ordering"], + "revision": "sha256:" + hashlib.sha256(Path(command[2]).read_bytes()).hexdigest()} return { "row_id": row_id, "surface_id": surface_id, @@ -83,6 +106,8 @@ def _receipt_row( ), "markdown_headings": semantics.markdown_headings(text), "markdown_anchor": markdown_anchor, + "turn_plan_controls": semantics.turn_plan_control_observation(plan, goal_state=material), + **({"inspection_source_read": detail_cost} if detail_cost is not None else {}), "action_signature_sha256": ( semantics.action_signature_semantic_sha256(payload) if isinstance(payload, dict) @@ -323,6 +348,7 @@ def _variant_rows( scenario="small", output_format=output_format, qualification_policy="explicit_opt_in_cold_path", + output_contract_version=getattr(variant, "output_contract_version", None), semantic_json_keys=variant.semantic_json_keys, markdown_anchor=variant.markdown_anchor, measurement=measurement, diff --git a/loopx/cli_commands/context_machine.py b/loopx/cli_commands/context_machine.py index f031a70fdb..5c08fc4517 100644 --- a/loopx/cli_commands/context_machine.py +++ b/loopx/cli_commands/context_machine.py @@ -42,7 +42,8 @@ def _private_transport_root(runtime_root: Path) -> str: return str(root) -def capture_context_recovery(value: dict[str, Any], *, runtime_root: Path) -> dict[str, Any]: +def capture_context_recovery(value: dict[str, Any], *, runtime_root: Path, + index_and_select: bool = True) -> dict[str, Any]: """Publish exact write-time facts through the existing generic file reader. This does not present a delta, advance a native baseline or rerun admission. @@ -56,6 +57,8 @@ def capture_context_recovery(value: dict[str, Any], *, runtime_root: Path) -> di try: directory = Path(tempfile.mkdtemp(prefix="recovery-", dir=_private_transport_root(runtime_root))) reference = capture_context_projection(value, directory=directory, filename="required-context.json") + if not index_and_select: + return {key: reference[key] for key in ("path", "sha256", "command")} path, digest = Path(reference["path"]), reference["sha256"] return {"path": str(path), "sha256": digest, "index_command": context_capture_read_command(path, digest, list_fields=True, list_depth=2), diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index a7a3a05c9b..bd051e4d29 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -24,6 +24,7 @@ from .turn_inspection import handle_turn_journal_inspection from .turn_registration import register_turn_commands as register_turn_commands from .turn_rendering import ( + project_turn_plan_inspection, build_turn_error_payload, render_loopx_turn_execution_markdown as _render_loopx_turn_execution_markdown, render_loopx_turn_plan_markdown as _render_loopx_turn_plan_markdown, @@ -310,6 +311,8 @@ def read_decision_owner(): iteration_context_policy=args.iteration_context, session_scope=session_scope, goal_ref=goal_ref, + inspect_plan=(args.turn_command == "plan" and not args.include_transaction_detail + and output_format(args) == "json"), ) if delegated_context and delegated_context.get("request_subject") is not None: import json @@ -358,6 +361,7 @@ def read_decision_owner(): boundary = payload.get("boundary") if isinstance(boundary, dict): boundary.pop("opaque_session_handle_omitted", None) + project_turn_plan_inspection(payload, runtime_root) else: # The typed settlement plan is used by validation and # execution, but is not part of the agent-facing CLI contract. diff --git a/loopx/cli_commands/turn_rendering.py b/loopx/cli_commands/turn_rendering.py index 9ec7fbc27e..a7e5dc5e41 100644 --- a/loopx/cli_commands/turn_rendering.py +++ b/loopx/cli_commands/turn_rendering.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from datetime import datetime, timezone +from pathlib import Path from typing import Any from ..control_plane.turn_driver import ( @@ -13,6 +14,39 @@ ) +def project_turn_plan_inspection(plan: dict[str, Any], runtime_root: Path) -> None: + """Copy display fields from the original Python values, then pin the rest.""" + paths = plan.pop("_inspection_paths", None) + if not paths or not plan.get("ok"): + return + source = plan["work_contract"] + preview: dict[str, Any] = {} + for path in paths: + value = source + for field in path: + if not isinstance(value, dict) or field not in value: + break + value = value[field] + else: + target = preview + for field in path[:-1]: + target = target.setdefault(field, {}) + target[path[-1]] = value + + from .context_machine import capture_context_recovery + + detail = capture_context_recovery(source, runtime_root=runtime_root, index_and_select=False) + if detail.get("available") is False: + return # The original complete plan is the fallback. + plan["work_contract"] = preview + plan["detail_ref"] = {**detail, "instruction": ( + "This inspection shows the current decision and authoring guidance. " + "Before a decision or action that depends on other Goal requirements or evidence, " + "append --list or --select '' to the command to read the necessary fields from this exact work contract. " + "The plan and its saved source grant no execution authority." + )} + + def _turn_start_observation_lines(payload: Mapping[str, object]) -> list[str]: dispatch = payload.get("turn_start_capability_hook_dispatch") if not isinstance(dispatch, Mapping): diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 8f52d35968..c6e10fb594 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -323,7 +323,8 @@ function requiredNativeContext(request: JsonObject, sourceValues: ProjectionSour const sourcePath: Path = ["interaction_contract", "agent_channel", "work_context", "sources"]; const sources = at(context, sourcePath); required.context_details = {path: details.path, sha256: details.sha256, - command_template: details.command_template, source_path: details.path, + ...(typeof details.command_template === "string" ? {command_template: details.command_template} : {}), + source_path: details.path, unread_context_index: { source: {path: details.path, sha256: details.sha256}, coverage_confirmed: false, diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 5d69624af0..8e1318a32c 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -197,8 +197,6 @@ def selected_turn_todo(envelope: Mapping[str, Any]) -> dict[str, Any]: Only Python's original scalar codec remains here. Pure source edits retain unknown fields and exact numbers without another JSON value decoder. """ - from ..quota.cli_projection import _apply_context_projection_changes - orchestration = _wire_mapping(envelope.get("task_orchestration_contract")) source = {"selected_todo": _wire_mapping(_wire_mapping(envelope.get("action")).get("selected_todo")), "task_orchestration_contract": orchestration} @@ -275,8 +273,6 @@ def compose_turn_decision( """Transport complete source facts to the existing typed composition owner.""" resolution = resolve_scheduler_execution_context(scheduler_execution_context) args = render_scheduler_execution_args(scheduler_execution_context=resolution) if resolution.ok and resolution.context is not None else "" - from .cli_projection import _apply_context_projection_changes - source = dict(payload) result = _turn_envelope_result("compose_complete", payload=source, scheduler_execution_args=args, source_edits=True) diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index ea0b77128c..868ea997cc 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1863,7 +1863,10 @@ export function completePlanningFacts(request: JsonObject): JsonObject { } return {...lineage, work, source_decision_hash: decisionSourceHash(decision), action_source_hash: canonicalHash(turnEnvelopeActionSignatureDocument(work)), - scoped_settlement_identity: scopedIdentity, scoped_identity_error: identityError}; + scoped_settlement_identity: scopedIdentity, scoped_identity_error: identityError, + ...(request.inspect_plan === true && + (lineage.route === "ready_for_host" || lineage.route === "replan_required") + ? {inspection_paths: turnPlanInspectionPaths(decision, lineage.route)} : {})}; } function completeHostDecision(request: JsonObject): JsonObject { @@ -2203,6 +2206,45 @@ const INITIAL_CONTEXT_DECISION_FIELDS = ["ok", "schema_version", "goal_id", "age "safe_bypass_allowed", "safe_bypass_kind", "blocked_action_scope", "reason", "execution_obligation", "goal_boundary", "heartbeat_receipt", "work_lane_contract", "selected_todo"]; +/** The read-only plan needs the current decision and executable authoring + * guidance. Evidence and diagnostics remain in its exact source capture. */ +function turnPlanInspectionPaths(decision: JsonObject, route: "ready_for_host" | "replan_required"): string[][] { + const paths = INITIAL_CONTEXT_DECISION_FIELDS.filter(field => field !== "schema_version").map(field => [field]); + for (const field of ["agent_identity", "decision", "recommended_action", "execution_preparation", "requires_user_action", + "action_portfolio", "delivery_workspace_isolation", "replan_action_packet"]) + paths.push([field]); + const userTodos = object(decision.user_todo_summary); + if (userTodos.open_count === 0 && userTodos.all_open_count === 0) { + paths.push(["user_todo_summary", "open_count"], ["user_todo_summary", "all_open_count"]); + } else { + paths.push(["user_todo_summary"]); + } + for (const field of Object.keys(object(decision.autonomous_replan_obligation))) + if (field !== "replan_context") paths.push(["autonomous_replan_obligation", field]); + for (const field of Object.keys(object(decision.vision_continuation_audit))) + if (field !== "vision_gap_judge") paths.push(["vision_continuation_audit", field]); + const interaction = object(decision.interaction_contract); + for (const field of ["schema_version", "mode", "user_channel"]) + paths.push(["interaction_contract", field]); + for (const field of Object.keys(object(interaction.agent_channel))) + if (field !== "work_context" && field !== "vision_continuation_audit") + paths.push(["interaction_contract", "agent_channel", field]); + paths.push(["interaction_contract", "agent_channel", "work_context", "instruction"]); + for (const field of Object.keys(object(interaction.cli_channel))) + if (field !== "vision_continuation_audit") paths.push(["interaction_contract", "cli_channel", field]); + if (route === "ready_for_host") { + for (const field of ["status_health_ok", "state", "status", "waiting_on", "quota", "scheduler_hint", + "action_required", "action_selection_qualification", "execution_profile", "task_scope", + "handoff_readiness", "planning_horizon"]) + paths.push([field]); + for (const field of ["next_step", "next_action_basis"]) + paths.push(["agent_lane_next_action", field]); + for (const field of ["selected_todo_ref", "selected_todo_authority", "user_todos", "unavailable_context", "failures"]) + paths.push(["interaction_contract", "agent_channel", "work_context", field]); + } + return paths; +} + /** Navigation over captured facts, never a summary or a source-read receipt. */ function capturedDecisionSelection(request: JsonObject): JsonObject { const initial = request.initial_read === true || request.list_fields === true diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 8f98b366bf..0e54894cd7 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -57,6 +57,7 @@ class CliOutputModeVariantSpec: markdown_anchor: str max_chars: dict[OutputFormat, int] max_lines: dict[OutputFormat, int] + output_contract_version: str | None = None @dataclass(frozen=True) @@ -199,7 +200,7 @@ class CliOutputCommandClassification: "--include-transaction-detail, turn run-once, TurnEnvelope detail_ref " "commands, and full quota should-run decision" ), - semantic_json_keys=("route", "turn_envelope", "effects", "boundary"), + semantic_json_keys=("route", "work_contract", "effects", "boundary"), markdown_anchor="# LoopX Turn Plan", max_chars={ "small": {"json": 14_000, "markdown": 600}, @@ -523,13 +524,17 @@ class CliOutputCommandClassification: "route", "session", "transaction", - "turn_envelope", + "work_contract", "effects", "boundary", ), markdown_anchor=None, - max_chars={"json": 15_000}, + # Complete v1 retains the original source, session and transaction; + # the matched small fixture is 26,886 chars. Historical v0 keeps its + # 15,000 ceiling in its own checkout; default plan stays at 26,000. + max_chars={"json": 30_000}, max_lines={"json": 380}, + output_contract_version="loopx_turn_plan_v1", ), CliOutputModeVariantSpec( variant_id="loopx_turn_run_once_preview", @@ -957,6 +962,8 @@ def assert_cli_output_mode_variant( payload = measurement.get("resolved_payload", measurement.get("payload")) if not isinstance(payload, dict): raise AssertionError(f"{spec.variant_id} did not emit a JSON object") + if spec.output_contract_version is not None and payload.get("schema_version") != spec.output_contract_version: + raise AssertionError(f"{spec.variant_id} output contract version mismatch") missing = [key for key in spec.semantic_json_keys if key not in payload] if missing: raise AssertionError( diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 53e292e4b8..491205a2b9 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -694,6 +694,21 @@ def _schema_migration_growth_allowance( } +def _complete_turn_plan_migration(base: dict[str, Any], candidate: dict[str, Any]) -> bool: + """One declared v0 -> v1 plan transition with unchanged measured controls.""" + row = base.get("row_id") + before, after = base.get("turn_plan_controls"), candidate.get("turn_plan_controls") + return bool(row == candidate.get("row_id") and base.get("format") == candidate.get("format") == "json" + and row in {"surface/loopx_turn_plan/small/json", "surface/loopx_turn_plan/crowded/json", + "surface/loopx_turn_plan/multi_agent/json", "variant/loopx_turn_plan_transaction_detail/small/json"} + and isinstance(before, dict) and isinstance(after, dict) + and before.get("plan_version") == "loopx_turn_plan_v0" and after.get("plan_version") == "loopx_turn_plan_v1" + and isinstance(before.get("controls_sha256"), str) and len(before["controls_sha256"]) == 64 + and before["controls_sha256"] == after.get("controls_sha256") + and base.get("output_contract_version") is None + and candidate.get("output_contract_version") == ("loopx_turn_plan_v1" if row.startswith("variant/") else None)) + + def _projection_envelope_migration( base: dict[str, Any], candidate: dict[str, Any], *, output_format: str, ) -> tuple[dict[Metric, int], list[str], list[str]]: @@ -702,6 +717,8 @@ def _projection_envelope_migration( after = tuple(candidate.get("projection_envelope_schema_versions") or []) if before == after: return {}, [], [] + if _complete_turn_plan_migration(base, candidate): + return {}, [], ["complete Turn plan exposes the full source projection metadata"] surface_id = str(base.get("surface_id") or "") row_surface = str(base.get("row_id") or "").partition("/")[2].partition("/")[0] if (surface_id == "todo_list" and row_surface in { @@ -897,7 +914,8 @@ def _turn_projection_migration( # this one-time allowance; later growth, removed facts and other paths do not. stride_routes = { "surface/quota_should_run/crowded/json": ("$", 6_800), - "surface/loopx_turn_plan/crowded/json": ("$.turn_envelope", 6_500), + "surface/loopx_turn_plan/crowded/json": ( + "$.work_contract" if "$.work_contract" in new_paths else "$.turn_envelope", 6_500), } stride_route = stride_routes.get(str(base.get("row_id"))) if stride_route and candidate.get("row_id") == base.get("row_id"): @@ -1006,6 +1024,13 @@ def _compare_row_growth( review_signals.extend(continuation_signals) context_allowance, context_signals = _turn_projection_migration(base, candidate) review_signals.extend(context_signals) + # The source-complete detail replaces the old summary; default inspection + # retains a small pinned-source route. Absolute ceilings stay unchanged. + if _complete_turn_plan_migration(base, candidate): + bound = 16_000 if base["row_id"].startswith("variant/") else 2_000 + context_allowance = {metric: max(context_allowance.get(metric, 0), bound) + for metric in ("chars", "utf8_bytes", "compact_payload_chars")} + review_signals.append("Turn plan v0 -> v1: unchanged current controls; full-source costs require review") deltas: dict[str, int | None] = {} allowances: dict[str, int | None] = {} for metric in ("chars", "utf8_bytes", "lines", "compact_payload_chars"): @@ -1107,6 +1132,15 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A if candidate.get("format") != output_format: failures.append("format changed") + before_controls, after_controls = base.get("turn_plan_controls"), candidate.get("turn_plan_controls") + if (isinstance(before_controls, dict) and isinstance(after_controls, dict) + and before_controls.get("plan_version") == "loopx_turn_plan_v0" + and after_controls.get("plan_version") == "loopx_turn_plan_v1" + and before_controls.get("controls_sha256") != after_controls.get("controls_sha256")): + old, new = before_controls["field_sha256"], after_controls["field_sha256"] + changed = sorted(key for key in old.keys() | new.keys() if old.get(key) != new.get(key)) + failures.append("Turn plan current controls changed: " + ", ".join(changed)) + base_output_contract = base.get("output_contract_version") candidate_output_contract = candidate.get("output_contract_version") heartbeat_agent_input_migration = bool( @@ -1118,6 +1152,7 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A if ( candidate_output_contract != base_output_contract and not heartbeat_agent_input_migration + and not _complete_turn_plan_migration(base, candidate) ): failures.append("output_contract_version changed") @@ -1143,7 +1178,7 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A preview = ", ".join(missing[:5]) suffix = f" (+{len(missing) - 5} more)" if len(missing) > 5 else "" message = f"semantic_json_keys removed: {preview}{suffix}" - if heartbeat_agent_input_migration: + if heartbeat_agent_input_migration or _complete_turn_plan_migration(base, candidate): review_signals.append(message) else: failures.append(message) @@ -1165,7 +1200,10 @@ def _compare_row(base: dict[str, Any], candidate: dict[str, Any]) -> dict[str, A failures.append("markdown_anchor changed") if migration.signature_changed: if migration.signature_migration is None: - failures.append("action_signature semantic digest changed") + if _complete_turn_plan_migration(base, candidate): + review_signals.append("historical action signature replaced by the checked complete source") + else: + failures.append("action_signature semantic digest changed") else: review_signals.append( f"action_signature coverage migrated: {migration.signature_migration}" diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 2ec77d247a..5869307ad9 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -355,6 +355,63 @@ def collect(current: Any, *, path: str) -> None: return hashlib.sha256(canonical.encode("utf-8")).hexdigest() +def turn_plan_control_observation(value: Any, *, goal_state: dict[str, str] | None = None) -> dict[str, Any] | None: + """Compare current controls across the explicit plan codec migration. + + This fixture observation grants no authority and does not prove full source + parity; complete-source and real outcome qualification remain separate. + Required reads and authoring clauses are compared as original JSON values. + """ + if not isinstance(value, dict) or value.get("schema_version") not in { + "loopx_turn_plan_v0", "loopx_turn_plan_v1", + }: + return None + version = value["schema_version"] + work = value["turn_envelope"] if version == "loopx_turn_plan_v0" else value["work_contract"] + if version == "loopx_turn_plan_v0": + actor, channel, capsule, boundary = work["agent_id"], work["action"], work["contract_capsule"], work["boundary"] + reads, actions = work["required_reads"], work["writeback"]["next_cli_actions"] + selected = work["action"].get("selected_todo") + else: + actor, channel = work["agent_identity"]["agent_id"], work["interaction_contract"]["agent_channel"] + capsule, boundary = work, work["goal_boundary"] + reads, actions = channel["required_reads"], work["interaction_contract"]["cli_channel"]["next_cli_actions"] + selected = work.get("selected_todo") + # v0 asks for the original Goal file; v1 delivers those exact bytes inline. + # Only this known obligation may move from a read command to captured data. + # The frozen caller independently verifies the full-file revision first. + if goal_state is not None and version == "loopx_turn_plan_v0": + goal_read = {"command": goal_state["command"], "source": "goal_state", "ordering": "before_work", + "reason": "Read full Goal intent, acceptance, stops. No summaries; failed/changed sources need a fresh guard."} + reads = [row for row in reads if row != goal_read] + route = value["route"] + controls = { + "goal_id": work["goal_id"], "agent_id": actor, + "decision": work["decision"], "should_run": work["should_run"], + "effective_action": work["effective_action"], + "selected_todo_id": selected["todo_id"] if isinstance(selected, dict) else None, + "route": {key: route[key] for key in ( + "kind", "effective_action", "would_invoke_host", "host_invocation_allowed")}, + "execution": {key: channel[key] for key in ( + "must_attempt", "delivery_allowed", "quiet_noop_allowed")}, + "boundary": {key: boundary[key] for key in ("write_scope", "stop_condition")}, + "required_reads": reads, "goal_state_material": goal_state, "next_cli_actions": actions, + "work_lane_contract": capsule["work_lane_contract"], + "execution_obligation": capsule["execution_obligation"], + "replan_action_packet": work.get("replan_action_packet"), + "effects": value["effects"], + } + if route["kind"] == "ready_for_host": + controls["execution_profile"] = capsule["execution_profile"] + controls["task_scope"] = capsule["task_scope"] + assert value["ok"] is True and not any(value["effects"].values()) + encoded = json.dumps(controls, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return {"plan_version": version, "controls_sha256": hashlib.sha256(encoded.encode("utf-8")).hexdigest(), + "field_sha256": {key: hashlib.sha256(json.dumps(child, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + for key, child in controls.items()}} + + def action_signature_coverages(value: Any) -> list[str]: coverages: set[str] = set() diff --git a/loopx/control_plane/turn_driver/command_validation.py b/loopx/control_plane/turn_driver/command_validation.py index 1a765025c4..3280cfc276 100644 --- a/loopx/control_plane/turn_driver/command_validation.py +++ b/loopx/control_plane/turn_driver/command_validation.py @@ -39,6 +39,16 @@ def normalize_host_argv(value: Sequence[str]) -> list[str]: return _normalize_argv(value, label="host") +def host_request_schema_mismatch(required_schema: str, declared_schema: str | None) -> str | None: + """Refuse incompatible generic adapters before capture or host admission.""" + from ..quota.turn_envelope import LOOPX_TURN_HOST_REQUEST_SCHEMA + + declared = LOOPX_TURN_HOST_REQUEST_SCHEMA if declared_schema is None else declared_schema + if declared == required_schema: + return None + return (f"Generic CLI adapter must support {required_schema}. " + f"Use a compatible adapter and pass --host-adapter-request-schema {required_schema}.") + def reward_memory_reflection_digest(value: object) -> str: """Bind a validator attestation to the exact typed host reflection.""" diff --git a/loopx/control_plane/turn_driver/driver.py b/loopx/control_plane/turn_driver/driver.py index 03ec08a491..375a73ccff 100644 --- a/loopx/control_plane/turn_driver/driver.py +++ b/loopx/control_plane/turn_driver/driver.py @@ -430,6 +430,7 @@ def build_loopx_turn_plan( session_scope: str | None = None, goal_ref: Mapping[str, object] | None = None, work_contract: object = _ABSENT_WORK_CONTRACT, + inspect_plan: bool = False, ) -> dict[str, Any]: """Plan from one complete source; retain the recorded v0 input codec. @@ -481,6 +482,7 @@ def build_loopx_turn_plan( if current: projection = _turn_envelope_result("planning_facts", payload=work_contract, turn_instance_id=turn_instance_id, + **({"inspect_plan": True} if inspect_plan else {}), **({} if source_only_input else {"envelope": supplied})) envelope = _mapping(projection["work"]) else: @@ -609,6 +611,8 @@ def build_loopx_turn_plan( if current: payload["work_contract"] = work_contract payload["source_decision_hash"] = projection["source_decision_hash"] + if "inspection_paths" in projection: + payload["_inspection_paths"] = projection["inspection_paths"] if not source_only_input: # Older callers may already carry an original quota capture. Keep # its exact header without retaining that caller's second source. diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 44bb677f44..21004cb544 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -34,10 +34,11 @@ TaskValidator, build_loopx_turn_command_validator, normalize_host_argv, + host_request_schema_mismatch, normalize_reward_memory_reflection_validation, reward_memory_reflection_digest, ) -from ..quota.turn_envelope import LOOPX_TURN_HOST_REQUEST_SCHEMA, turn_plan_host_source +from ..quota.turn_envelope import turn_plan_host_source from .driver import ( COMPLETE_TURN_PLAN_SCHEMA_VERSION, selected_turn_todo, turn_host_lineage, @@ -1649,22 +1650,13 @@ def persist_source_journal(source_admission: Mapping[str, Any]) -> None: ) if needs_host and goal_admission is not None: goal_admission.require_current() - if needs_host and argv is not None: - required_schema = request["schema_version"] - declared_schema = ( - LOOPX_TURN_HOST_REQUEST_SCHEMA - if host_adapter_request_schema is None - else host_adapter_request_schema + if needs_host and argv is not None and (reason := host_request_schema_mismatch( + request["schema_version"], host_adapter_request_schema, + )) is not None: + return execution_payload( + plan, {"status": "unavailable", "host": host_projection, "reason": reason}, + execute=True, replayed=False, effects=empty_effects, ) - if declared_schema != required_schema: - return execution_payload( - plan, - {"status": "unavailable", "host": host_projection, "reason": ( - f"Generic CLI adapter must support {required_schema}. " - f"Use a compatible adapter and pass --host-adapter-request-schema {required_schema}." - )}, - execute=True, replayed=False, effects=empty_effects, - ) capture = stage_turn_decision_capture( journal["plan"] if journal is not None else plan, journal_path=journal_path, decision=decision_capture_payload, existing_journal=journal is not None, diff --git a/loopx/control_plane/turn_driver/session_recovery.py b/loopx/control_plane/turn_driver/session_recovery.py index 788775efe7..52b5cff51b 100644 --- a/loopx/control_plane/turn_driver/session_recovery.py +++ b/loopx/control_plane/turn_driver/session_recovery.py @@ -14,6 +14,9 @@ HOST_RECOVERY_SCHEMA_VERSION = "loopx_turn_host_recovery_v0" HOST_RECOVERY_KINDS = frozenset({"resume_session"}) +# Receive the checked complete decision for a current request, or its original +# envelope for historical input. The provider resolves its existing Session; +# the shared lineage owner below still decides whether that binding matches. SessionBindingResolver = Callable[ [Mapping[str, Any]], Mapping[str, Any] | None, @@ -101,10 +104,20 @@ def rejected(reason: str, message: str) -> tuple[ "binding_resolver_unavailable", "failed-Turn session recovery has no binding resolver", ) - envelope_value = request.get("turn_envelope") - envelope = dict(envelope_value) if isinstance(envelope_value, Mapping) else {} try: - session_binding = session_binding_resolver(envelope) + from ..quota.turn_envelope import ( + COMPLETE_TURN_HOST_REQUEST_SCHEMA, + complete_turn_host_source, + ) + + # Validate any supplied complete source before provider IO, but retain + # the recorded v0 callback input even when it has a complete sidecar. + complete_turn_host_source(request) + source_value = (request["work_contract"] + if request.get("schema_version") == COMPLETE_TURN_HOST_REQUEST_SCHEMA + else request.get("turn_envelope")) + source = dict(source_value) if isinstance(source_value, Mapping) else {} + session_binding = session_binding_resolver(source) except Exception: # noqa: BLE001 - provider boundary fails closed return rejected( "binding_resolution_failed", diff --git a/tests/control_plane/cli_output_fixture.py b/tests/control_plane/cli_output_fixture.py index 6135926a11..59ef10a4a2 100644 --- a/tests/control_plane/cli_output_fixture.py +++ b/tests/control_plane/cli_output_fixture.py @@ -42,7 +42,21 @@ def read_cli_context_for_oracle(text: str) -> tuple[str, dict[str, int]]: payload = json.loads(text) if not isinstance(payload, dict) or payload.get("schema_version") != "context_capture_navigation_v1": return text, {} - command = payload["recovery"]["command"] + body, calls, reader_bytes = read_pinned_cli_body(payload["recovery"]["command"]) + navigation_bytes = len(text.encode("utf-8")) + return body, { + "navigation_chars": len(text), + "navigation_lines": len(text.splitlines()), + "navigation_utf8_bytes": navigation_bytes, + "reader_calls": calls, + "reader_stdout_utf8_bytes": reader_bytes, + "body_utf8_bytes": len(body.encode("utf-8")), + "total_stdout_utf8_bytes": navigation_bytes + reader_bytes, + } + + +def read_pinned_cli_body(command: str) -> tuple[str, int, int]: + """Read one digest-bound fixture body and account for every reader response.""" observed = bytearray() reader_bytes = calls = 0 identity = total = digest = None @@ -60,7 +74,7 @@ def read_cli_context_for_oracle(text: str) -> tuple[str, dict[str, int]]: del scope[index:index + 2] if identity is None: identity = scope - assert scope == identity, "context continuation changed its source or reader route" + assert scope == identity, ("context continuation changed its source or reader route", scope, identity) result = subprocess.run(words, capture_output=True, text=True, encoding="utf-8", errors="strict", check=False, timeout=30) assert result.returncode == 0, result.stdout + result.stderr @@ -78,15 +92,7 @@ def read_cli_context_for_oracle(text: str) -> tuple[str, dict[str, int]]: assert len(observed) == total, "context reader ended before the complete body" body = observed.decode("utf-8") assert isinstance(json.loads(body), dict) - return body, { - "navigation_chars": len(text), - "navigation_lines": len(text.splitlines()), - "navigation_utf8_bytes": len(text.encode("utf-8")), - "reader_calls": calls, - "reader_stdout_utf8_bytes": reader_bytes, - "body_utf8_bytes": len(observed), - "total_stdout_utf8_bytes": len(text.encode("utf-8")) + reader_bytes, - } + return body, calls, reader_bytes assert page["next_offset"] == len(observed) < total command = page["next_command"] diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index e5d1816461..ee0ee65b04 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -34,6 +34,7 @@ from tests.control_plane.cli_output_fixture import ( budget_fixture_path, read_cli_context_for_oracle, + read_pinned_cli_body, measure_cli_context_for_oracle, stable_budget_fixture_root as _stable_budget_fixture_root, ) @@ -520,7 +521,16 @@ def _assert_turn_plan_writeback_routes( measurement: dict, registry_path: Path, runtime: Path ) -> None: # Budget compaction must not discard or redirect the writeback target. - action = _measured_payload(measurement)["turn_envelope"]["writeback"]["next_cli_actions"][0] + plan = _measured_payload(measurement) + assert not any(plan["effects"].values()) + if plan["schema_version"] == "loopx_turn_plan_v0": + # A frozen caller also measures historical main under its original + # contract; v0 did not declare the v1 inspection marker or carrier. + action = plan["turn_envelope"]["writeback"]["next_cli_actions"][0] + else: + assert plan["schema_version"] == "loopx_turn_plan_v1" + assert plan["boundary"]["inspection_only"] is True + action = plan["work_contract"]["interaction_contract"]["cli_channel"]["next_cli_actions"][0] argv = shlex.split(action) assert argv.count("--registry") == argv.count("--runtime-root") == 1 # Windows drive-rooted aliases are canonicalized by the runtime owner. @@ -555,6 +565,11 @@ def _mode_variant_commands( "--agent-scope", "Public CLI output qualification.", ] + from loopx.control_plane.quota import turn_envelope + + current_host_schema = getattr(turn_envelope, "COMPLETE_TURN_HOST_REQUEST_SCHEMA", None) + host_version_args = (["--host-adapter-request-schema", current_host_schema] + if current_host_schema is not None else []) quota = _surface_commands( project=project, runtime=runtime, registry_path=registry_path, state_file=state_file, output_format=output_format, @@ -620,7 +635,7 @@ def _mode_variant_commands( AGENT_IDS[0], "--project", str(project), - "--host-adapter-request-schema", "loopx_turn_host_request_v1", + *host_version_args, "--host-command-json", '["python3","-c","raise SystemExit(9)"]', "--no-global-sync", @@ -804,6 +819,46 @@ def test_real_cli_output_stays_inside_baseline_and_growth_contracts( _assert_scenario_matrix(scenarios) +@pytest.mark.parametrize("scenario", [SCENARIOS[0], SCENARIOS[2]], ids=["small", "multi-agent"]) +def test_host_ready_turn_plan_preserves_current_controls(tmp_path: Path, scenario: Scenario) -> None: + with _stable_budget_fixture_root(tmp_path / scenario.name) as root: + project, runtime, registry_path, state_file = _write_fixture(root, scenario) + import_fixture_authority(registry_path, GOAL_ID) + command = _surface_commands(project=project, runtime=runtime, registry_path=registry_path, + state_file=state_file, output_format="json")["loopx_turn_plan"] + before = state_file.read_bytes() + code, text = _invoke_cli(command) + assert code == 0, text + payload = json.loads(text) + assert payload["route"]["kind"] == "ready_for_host" + assert payload["boundary"]["inspection_only"] is True + assert not any(payload["effects"].values()) and state_file.read_bytes() == before + if os.name != "posix": + assert payload["work_contract"]["contract_binding"] + return + assert_cli_output_baseline(CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"], scenario=scenario.name, + output_format="json", text=text, measurement=measure_cli_output(text, output_format="json")) + detail = payload["detail_ref"] + original = json.loads(Path(detail["path"]).read_bytes()) + plan = payload["work_contract"] + for field in ("selected_todo", "goal_boundary", "scheduler_hint", "handoff_readiness", + "action_portfolio", "planning_horizon"): + assert plan.get(field) == original.get(field) + channel = "agent_channel" + context = plan["interaction_contract"][channel] + expected = original["interaction_contract"][channel] + assert context["required_reads"] == expected["required_reads"] + for field in ("selected_todo_ref", "selected_todo_authority", "user_todos", + "unavailable_context", "failures"): + assert context["work_context"].get(field) == expected["work_context"].get(field) + assert "sources" not in context["work_context"] + assert "complete" not in context["work_context"] + selector = "/interaction_contract/agent_channel/work_context/sources" + body, _, _ = read_pinned_cli_body(detail["command"] + " --select " + selector) + assert json.loads(body)["selections"] == [{"pointer": selector, + "value": expected["work_context"]["sources"]}] + + def test_diagnose_keeps_selected_reads_once_and_out_of_the_goal_overview( tmp_path: Path, ) -> None: @@ -1202,7 +1257,19 @@ def test_quota_cli_keeps_complete_vision_rules_with_reachable_duplicate_refs( def test_crowded_turn_plan_budget_preserves_executable_vision_authoring( tmp_path: Path, include_agent_vision: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: + from loopx.cli_commands import turn + + build = turn.build_loopx_turn_plan + originals = [] + + def record_source(*args, **kwargs): + plan = build(*args, **kwargs) + originals.append(plan["work_contract"]) + return plan + + monkeypatch.setattr(turn, "build_loopx_turn_plan", record_source) with _stable_budget_fixture_root(tmp_path / "turn-plan-vision") as stable_root: project, runtime, registry_path, state_file = _write_fixture( stable_root, @@ -1217,11 +1284,46 @@ def test_crowded_turn_plan_budget_preserves_executable_vision_authoring( state_file=state_file, output_format="json", )["loopx_turn_plan"] - exit_code, text = _invoke_cli(command, read_context=True) + before = state_file.read_bytes() + exit_code, text = _invoke_cli(command) + assert exit_code == 0, text + payload = _json_payload(text) + assert payload["boundary"]["inspection_only"] is True + assert not any(payload["effects"].values()) and state_file.read_bytes() == before + [source] = originals + if os.name == "posix": + detail = payload["detail_ref"] + assert json.loads(Path(detail["path"]).read_bytes()) == source + assert payload["work_contract"]["replan_action_packet"] == source["replan_action_packet"] + assert payload["work_contract"]["goal_boundary"] == source["goal_boundary"] + # The later read must remain bound to this preview when live facts change. + state_file.write_text("# Different Goal after the inspection\n", encoding="utf-8") + selector = "/interaction_contract/agent_channel/work_context" + reader = shlex.split(detail["command"]) + rc, page_text = _invoke_cli(["--format", "json", *reader[reader.index("quota"):], "--select", selector]) + page = json.loads(page_text) + assert rc == 0 and page["eof"], page + [selected] = json.loads(page["content"])["selections"] + assert selected == {"pointer": selector, + "value": source["interaction_contract"]["agent_channel"]["work_context"]} + assert "sources" not in payload["work_contract"]["interaction_contract"]["agent_channel"]["work_context"] + assert "contract_binding" not in payload["work_contract"] + if not include_agent_vision: + state_file.write_bytes(before) + # An insecure existing transport must not receive private data. + transport = runtime / "context-delivery" + transport.chmod(0o755) + captures = list(transport.iterdir()) + fallback_code, fallback_text = _invoke_cli(command) + assert fallback_code == 0, fallback_text + fallback = _json_payload(fallback_text) + assert "detail_ref" not in fallback and fallback["work_contract"] == originals[-1] + assert fallback["boundary"]["inspection_only"] is True + assert list(transport.iterdir()) == captures and state_file.read_bytes() == before + else: + assert payload["work_contract"] == source - assert exit_code == 0, text - payload = _json_payload(text) - writeback = payload["turn_envelope"]["replan_action_packet"][ + writeback = payload["work_contract"]["replan_action_packet"][ "writeback_contract" ] assert "path_delta.evidence_refs" in writeback["required_fields"] @@ -1288,7 +1390,7 @@ def test_native_stride_projection_rejects_each_missing_decision_field( payload = _json_payload(text) def envelope(value): - return value["turn_envelope"] if surface == "loopx_turn_plan" else value + return value["work_contract"] if surface == "loopx_turn_plan" else value def row(value): return { @@ -1652,12 +1754,14 @@ def _assert_collection_growth_and_bootstrap_duplication( assert 0 < len(context["evidence"]) <= 24 elif fixed_semantic_growth: assert spec.surface_id == "loopx_turn_plan" - small_packet = _measured_payload(small[spec.surface_id]["json"])[ - "turn_envelope" - ].get("replan_action_packet") - crowded_packet = _measured_payload(crowded[spec.surface_id]["json"])[ - "turn_envelope" - ]["replan_action_packet"] + def replan_packet(measurement): + plan = _measured_payload(measurement) + if plan["schema_version"] == "loopx_turn_plan_v0": + return plan["turn_envelope"].get("replan_action_packet") + assert plan["schema_version"] == "loopx_turn_plan_v1" + return plan["work_contract"].get("replan_action_packet") + small_packet = replan_packet(small[spec.surface_id]["json"]) + crowded_packet = replan_packet(crowded[spec.surface_id]["json"]) small_writeback = ( small_packet.get("writeback_contract") if isinstance(small_packet, dict) diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index f0863d3bd3..8cd6b165e9 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -2095,3 +2095,37 @@ def test_inherited_stride_projection_growth_is_one_time_and_bounded(surface, roo for missing in review_paths: partial = {**complete, "json_shape_paths": [p for p in complete["json_shape_paths"] if p != missing]} assert _compare_row(base, partial)["failures"], missing + + +@pytest.mark.parametrize("damage", ["none", "same-version", "changed-controls", "missing-controls", "other-row", "wrong-format", "wrong-declaration", "excess-growth"]) +def test_complete_plan_migration_is_scoped_to_matching_current_controls(damage): + from loopx.control_plane.testing.cli_output_differential import compare_cli_output_receipts + + controls = {"plan_version": "loopx_turn_plan_v0", "controls_sha256": "a" * 64, + "field_sha256": {"required_reads": "b" * 64}} + base = _row(row_id="surface/loopx_turn_plan/small/json", surface_id="loopx_turn_plan", + chars=10_000, utf8_bytes=10_000, compact_payload_chars=10_000, + semantic_json_keys=["route", "turn_envelope", "effects", "boundary"], + turn_plan_controls=controls) + head = copy.deepcopy(base) + head.update(chars=11_800, utf8_bytes=11_800, compact_payload_chars=11_800, + semantic_json_keys=["route", "work_contract", "effects", "boundary"], + action_signature_sha256=None) + head["turn_plan_controls"]["plan_version"] = "loopx_turn_plan_v1" + if damage == "same-version": + base["turn_plan_controls"]["plan_version"] = "loopx_turn_plan_v1" + elif damage == "changed-controls": + head["turn_plan_controls"]["controls_sha256"] = "c" * 64 + head["turn_plan_controls"]["field_sha256"]["required_reads"] = "d" * 64 + elif damage == "missing-controls": + head.pop("turn_plan_controls") + elif damage == "other-row": + base["row_id"] = head["row_id"] = "surface/status/small/json" + elif damage == "wrong-format": + base["format"] = head["format"] = "markdown" + elif damage == "wrong-declaration": + head["output_contract_version"] = "unsupported" + elif damage == "excess-growth": + head["chars"] = head["utf8_bytes"] = head["compact_payload_chars"] = 12_001 + result = compare_cli_output_receipts(_receipt(base), _receipt(head)) + assert result["ok"] is (damage == "none") diff --git a/tests/control_plane/test_cli_output_probe_runner.py b/tests/control_plane/test_cli_output_probe_runner.py index 44fb7cf8f9..1ae0b1d026 100644 --- a/tests/control_plane/test_cli_output_probe_runner.py +++ b/tests/control_plane/test_cli_output_probe_runner.py @@ -136,7 +136,7 @@ def capture_stdout(command): assert rows[0]["row_id"] == "surface/loopx_turn_plan/crowded/json" assert rows[0]["chars"] == len(emitted[0]) assert ( - "$.turn_envelope.replan_action_packet.writeback_contract.vision_authoring" + "$.work_contract.replan_action_packet.writeback_contract.vision_authoring" in (rows[0]["json_shape_paths"]) ) @@ -280,3 +280,29 @@ def test_public_quota_reader_preserves_long_goal_and_is_read_only(tmp_path, prov with pytest.raises(AssertionError): cli_output_fixture.read_cli_context_for_oracle(navigation) assert events.read_bytes() == before + + +def test_complete_plan_receipt_checks_original_goal_bytes(tmp_path): + receipt_row = runpy.run_path(str(RUNNER))["_receipt_row"] + with probe._stable_budget_fixture_root(tmp_path) as root: + project, runtime, registry, state = probe._write_fixture(root, probe.SCENARIOS[0]) + from loopx.control_plane.testing.canary_harness import import_fixture_authority + import_fixture_authority(registry, probe.GOAL_ID) + command = probe._mode_variant_commands(project=project, runtime=runtime, + registry_path=registry, state_file=state, output_format="json")["loopx_turn_plan_transaction_detail"] + code, text = probe._invoke_cli(command) + assert code == 0, text + kwargs = dict(semantics=cli_output_semantics, + row_id="variant/loopx_turn_plan_transaction_detail/small/json", + surface_id="loopx_turn_plan", scenario="small", output_format="json", + qualification_policy="explicit_opt_in_cold_path", semantic_json_keys=("work_contract",), + markdown_anchor="", text=text) + measure = probe.measure_cli_output(text, output_format="json") + observation = receipt_row(**kwargs, measurement=measure) + assert observation["turn_plan_controls"]["plan_version"] == "loopx_turn_plan_v1" + source = measure["payload"]["work_contract"] + goal = next(row for row in source["interaction_contract"]["agent_channel"]["work_context"]["sources"] + if row["source"] == "goal_state") + goal["content"]["text"] += "\nChanged acceptance or stop condition\n" + with pytest.raises(AssertionError): + receipt_row(**kwargs, measurement=measure) diff --git a/tests/control_plane/test_complete_session_binding.py b/tests/control_plane/test_complete_session_binding.py index dce0395e30..bcc5ecb9c6 100644 --- a/tests/control_plane/test_complete_session_binding.py +++ b/tests/control_plane/test_complete_session_binding.py @@ -129,3 +129,188 @@ def lookup(*args, **kwargs): assert bindings == [{"schema_version": "loopx_turn_session_binding_v0", **lineage}] assert result["dry_run"] and result["status"] == "preview" assert {p: p.read_bytes() for p in before} == before + + +def _failed_session_retry(original, tmp_path, *, historical=False): + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.executor import BuiltInHostError + from loopx.control_plane.turn_driver.host_candidate import build_result + from loopx.control_plane.turn_driver.journal_store import turn_journal_path + + _, contract = original + plan = build_loopx_turn_plan(build_turn_envelope(contract) if historical else contract, + host="generic-cli", execution_mode="isolated-headless", + turn_instance_id="failed-session-source") + goal, agent = contract["goal_id"], contract["agent_identity"]["agent_id"] + todo = contract["selected_todo"]["todo_id"] + runtime = tmp_path / "retry-runtime" + artifact = tmp_path / "retry-result.txt" + calls = dict(host=0, writeback=0, spend=0, scheduler=0) + sources, actions = [], [] + + def session_binding(source): + sources.append(deepcopy(source)) + identity = source.get("agent_identity") if source.get("schema_version") == "loopx_turn_envelope_v1" else source + if source.get("goal_id") != goal or not isinstance(identity, dict) or identity.get("agent_id") != agent: + raise ValueError("the original Session belongs to another actor") + return {"schema_version": "loopx_turn_session_binding_v0", + "goal_id": goal, "agent_id": agent, "todo_id": todo} + + def host(request): + calls["host"] += 1 + actions.append(request["session"]["action"]) + if not historical: + assert request["work_contract"] == contract and "turn_envelope" not in request + if calls["host"] == 1: + raise BuiltInHostError("codex_cli_timeout", recovery_kind="resume_session") + artifact.write_text("ready", encoding="utf-8") + return build_result(request, {"result_kind": "validated_progress", + "classification": "fixture_recovered", "summary": "The original work resumed.", + "next_action": "Continue the original work.", "delivery_batch_scale": "implementation", + "delivery_outcome": "outcome_progress", "vision_unchanged_reason": "The original task remains open.", + "work_evidence": ["retry-result.txt contains ready"], + "validation_evidence": ["The independent artifact check passed"]}) + + def validate(_plan, _result): + assert artifact.read_text(encoding="utf-8") == "ready" + return {"status": "passed", "validator_kind": "fixture", + "summary": "The resumed work produced the expected artifact."} + + def writeback(_result): + calls["writeback"] += 1 + return {"ok": True, "appended": True, "classification": "fixture_recovered"} + + def spend(): + calls["spend"] += 1 + return {"ok": True, "appended": True, "slots": 1} + + def scheduler(_spend): + calls["scheduler"] += 1 + return {"completed": True, "acknowledged": False, "disposition": "not_required"} + + common = dict(host_runner=host, session_binding_resolver=session_binding, + project=tmp_path / "project", runtime_root=runtime, goal_id=goal, + timeout_seconds=5, execute=True, task_validator=validate, + writeback=writeback, spend=spend, scheduler=scheduler, + **({} if historical else {"decision_capture_payload": contract, "require_decision_capture": True})) + journal = turn_journal_path(runtime, goal_id=goal, turn_key=plan["transaction"]["turn_key"]) + return plan, common, calls, sources, actions, journal, artifact + + +@pytest.mark.parametrize("historical", [False, True]) +def test_failed_session_retry_delivers_original_source_and_settles_once( + complete_host_request, tmp_path, historical, +): + from loopx.control_plane.turn_driver.executor import run_loopx_turn_once, inspect_loopx_turn_journal + from loopx.control_plane.turn_driver.journal_store import load_loopx_turn_plan_from_journal + + plan, common, calls, sources, actions, journal, artifact = _failed_session_retry( + complete_host_request, tmp_path, historical=historical) + contract = complete_host_request[1] + before = deepcopy(contract) + failed = run_loopx_turn_once(plan, **common) + assert failed["status"] == "failed" and not artifact.exists() + assert calls == dict(host=1, writeback=0, spend=0, scheduler=0) + saved = load_loopx_turn_plan_from_journal(common["runtime_root"], goal_id=common["goal_id"], + turn_key=plan["transaction"]["turn_key"]) + original_journal = journal.read_bytes() + original_capture = {} + if not historical: + capture = Path(saved["detail_ref"]["captured_decision"]["path"]) + assert json.loads(capture.read_bytes()) == contract + original_capture[capture] = capture.read_bytes() + inspected = inspect_loopx_turn_journal(common["runtime_root"], goal_id=common["goal_id"], + agent_id=contract["agent_identity"]["agent_id"], turn_key=plan["transaction"]["turn_key"], + retry_failed=True, session_binding_resolver=common["session_binding_resolver"]) + assert inspected["recovery_decision"]["can_continue"] is True + assert journal.read_bytes() == original_journal + recovered = run_loopx_turn_once(saved, retry_failed=True, **common) + assert recovered["status"] == "committed" + assert actions == ["start_new", "resume"] + expected = plan["turn_envelope"] if historical else contract + assert sources == [expected, expected] + assert run_loopx_turn_once(saved, **common)["replayed"] is True + assert calls == dict(host=2, writeback=1, spend=1, scheduler=1) + assert artifact.read_text(encoding="utf-8") == "ready" + assert contract == before + assert all(path.read_bytes() == data for path, data in original_capture.items()) + + +@pytest.mark.parametrize("field", ["goal_id", "agent_id", "todo_id"]) +def test_complete_failed_retry_refuses_identity_drift_without_effects( + complete_host_request, tmp_path, field, +): + from loopx.control_plane.turn_driver.executor import run_loopx_turn_once, inspect_loopx_turn_journal + from loopx.control_plane.turn_driver.recovery import TurnRecoveryBlockedError + + plan, common, calls, sources, _actions, journal, artifact = _failed_session_retry(complete_host_request, tmp_path) + run_loopx_turn_once(plan, **common) + original = journal.read_bytes() + resolver = common["session_binding_resolver"] + + def drifted(source): + return {**resolver(source), field: "another-identity"} + + common["session_binding_resolver"] = drifted + inspected = inspect_loopx_turn_journal(common["runtime_root"], goal_id=common["goal_id"], + agent_id=complete_host_request[1]["agent_identity"]["agent_id"], + turn_key=plan["transaction"]["turn_key"], retry_failed=True, session_binding_resolver=drifted) + assert inspected["recovery_decision"]["reason"] == "session_binding_identity_mismatch" + with pytest.raises(TurnRecoveryBlockedError): + run_loopx_turn_once(plan, retry_failed=True, **common) + assert sources == [complete_host_request[1], complete_host_request[1]] + assert journal.read_bytes() == original and not artifact.exists() + assert calls == dict(host=1, writeback=0, spend=0, scheduler=0) + + +@pytest.mark.parametrize("fault", ["missing", "null", "changed", "mixed", "future"]) +def test_failed_retry_never_resolves_an_invalid_current_source(complete_host_request, fault): + from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan + from loopx.control_plane.turn_driver.executor import build_loopx_turn_host_request + from loopx.control_plane.turn_driver.session_recovery import assess_failed_turn_retry_request, build_host_recovery_record + + legacy, contract = complete_host_request + plan = build_loopx_turn_plan(contract, host="generic-cli", execution_mode="isolated-headless") + request = build_loopx_turn_host_request(plan) + if fault == "missing": + request.pop("work_contract") + elif fault == "null": + request["work_contract"] = None + elif fault == "changed": + request["work_contract"] = {**contract, "reason": "changed after capture"} + elif fault == "mixed": + request["turn_envelope"] = legacy["turn_envelope"] + else: + request["schema_version"] = "loopx_turn_host_request_v999" + journal = {"host_recovery": build_host_recovery_record("resume_session"), + "receipt": {"failed_phase": "host_execute"}} + before = deepcopy((request, journal)) + observed = [] + _resolved, check, error = assess_failed_turn_retry_request(request, journal, + session_binding_resolver=lambda source: observed.append(source)) + assert error is not None and check["reason"] == "binding_resolution_failed" + assert observed == [] and (request, journal) == before + + +def test_historical_retry_callback_keeps_its_recorded_envelope(complete_host_request): + from loopx.control_plane.turn_driver.session_recovery import assess_failed_turn_retry_request, build_host_recovery_record + + legacy, _contract = complete_host_request + assert legacy["schema_version"] == "loopx_turn_host_request_v0" and "work_contract" in legacy + observed = [] + + def resolver(source): + observed.append(deepcopy(source)) + return {"schema_version": "loopx_turn_session_binding_v0", + "goal_id": source["goal_id"], "agent_id": source["agent_id"], + "todo_id": source["action"]["selected_todo"]["todo_id"]} + + before = deepcopy(legacy) + journal = {"host_recovery": build_host_recovery_record("resume_session"), + "receipt": {"failed_phase": "host_execute"}} + recovered, check, error = assess_failed_turn_retry_request(legacy, journal, + session_binding_resolver=resolver) + assert error is None and check["outcome"] == "passed" + assert observed == [legacy["turn_envelope"]] + assert recovered["session"]["action"] == "resume" and legacy == before From e3797e29574cff6160f5642b905b3de30fb43bc9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:51:42 +0800 Subject: [PATCH 12/33] docs(turn): record inspection costs and recovery migration boundary Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../contract-and-context.md | 29 +++++++++++++++++++ .../contract-and-context.zh-CN.md | 20 +++++++++++++ .../references/repair-patterns.md | 1 + tests/fixtures/self_repair_queries.json | 3 +- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md index 905e40817b..f565e960cf 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md @@ -638,6 +638,35 @@ creation. Source and packaged consumer checks are distinct from installed App adoption, live optional-provider/model behavior and original-quota complete-cost benefit. These repairs do not close C1 or authorize rollout or merge. +The default JSON Turn inspection retains the current route, permissions, stops, +selected work and executable Vision authoring. Supporting evidence and diagnostic +body remain addressable in one immutable, digest-bound capture of that same +complete source; a later live Goal change cannot redirect this read. An +unavailable private capture falls back to the complete source. The projection is +inspection-only and cannot be promoted into execution or source admission. This +retains the existing 26,000-character crowded default ceiling. Explicit v1 +transaction detail carries the full source plus transaction and session metadata; +the matched small fixture measures 26,886 characters against the retained v0 +15,000 failure. Only that declared v1 detail has a 30,000-character ceiling. +The frozen differential caller uses each checkout's supported Host argv and +recorded plan schema; ordinary quota remains unchanged in this extraction. +The matched migration observes route, actor/work, execution duties, scope/stops, +required reads, authoring and effects. The old Goal-file read and the new complete +inline file must have the same full-file digest. The observation records every +inspection-detail reader call and byte separately. Only this qualified v0-to-v1 +transition permits 2,000 additional default characters/bytes or 16,000 for full +transaction detail; subsequent v1 growth and absolute ceilings remain guarded. +This is migration qualification, not a net context-efficiency claim. + +Failed Session retry also delivers its callback input by the recorded request +version after the existing complete-source check. Current requests supply the +checked complete source; historical v0 supplies its original envelope, including +when it carries a complete sidecar. Corrupt or mixed input is rejected before +provider lookup. Real File/SQLite retry checks exercise callbacks that read their +argument, original Session resume, actor/work mismatch refusal, unchanged saved +sources and once-only Host/result effects. None establishes live model adoption +or the parent C1's original-quota complete-cost benefit. + ### C1 baseline boundary and reuse inventory The baseline is the **original caller-visible quota delivery**, not the raw diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md index eaebf5b397..14fff076bb 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md @@ -466,6 +466,26 @@ delivery、attempt、quiet-noop、execution 与 orchestration 约束仍须遵守 仍保留 runtime 独占创建。源码和打包消费者验证,分别区别于已安装 App 采用、真实可选 provider/模型行为及相对原 quota 的完整成本收益。这些修复不关闭 C1,也不授权部署或合并。 +默认 JSON Turn inspection 保留当前路由、权限、停止条件、所选工作与可执行的 Vision +编写指引。支撑证据与诊断正文仍可从同一完整来源的不可变、摘要绑定 capture 读取; +之后实时 Goal 改变也不能重定向该读取。私有 capture 不可用时回退到完整来源。 +投影仅供 inspection,不能提升为执行或来源 admission。拥挤默认入口仍保持既有 +26,000 字符上限。显式 v1 事务详情包含完整来源及事务、会话元数据;同一小型 fixture +实测 26,886 字符,保留旧 v0 15,000 上限失败记录,仅该声明的 v1 详情采用 +30,000 字符上限。冻结的差分 caller 按各 checkout 支持的 Host argv 和记录的计划 +schema 测量;本拆分中的普通 quota 保持不变。 +同源迁移对照检查路由、actor/work、执行义务、范围/停止条件、必要读取、编写指引和 +效果。旧 Goal 文件读取与新版完整内联文件必须有相同的完整文件摘要;inspection +详情的每次 reader 调用及字节另行计入。仅这一次合格的 v0→v1 迁移允许默认入口增加 +2,000 字符/字节,完整事务详情增加 16,000;后续 v1 增长和绝对上限仍受约束。 +这是迁移资格验证,不是上下文效率净收益结论。 + +失败 Session 重试也在既有完整来源校验之后,按请求记录版本传入回调参数:当前请求 +传入经校验的完整来源;历史 v0 仍传原 envelope,即使附带完整 sidecar。损坏或混合 +输入在 provider lookup 前拒绝。真实 File/SQLite 重试测试让回调实际读取参数,覆盖 +原 Session 恢复、actor/work 错配拒绝、保存来源不变及 Host/结果效果只发生一次。 +这些检查不证明真实模型采用,也不证明父 C1 相对原 quota 的完整成本收益。 + ### C1 基线边界与复用盘点 基线是**原路径中调用者实际收到的 quota 交付**,不是内部原始 decision。 diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 02a65b952e..b61990ed43 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `complete_source_retry_callback_drift` | A valid complete-source Host request reaches normal execution, but its failed Session retry gives the binding callback an empty envelope and rejects the original actor. | Declared current and historical request versions, a provider callback that reads its argument, real File/SQLite sources, saved journal/capture bytes, retry actions and settlement observations. | The writer removed the current envelope while a recovery reader still selects that field; callbacks that ignore their argument hide the migration gap. | Validate supplied complete material with the existing source owner, then deliver the callback body by the recorded wire version. Current requests use the complete decision; v0 keeps its original envelope even with a complete sidecar. Refuse malformed/mixed current input before provider IO. Test original-Session resume, actor/work drift refusal, immutable source/journal recovery and once-only results/effects. Recorded callback counts do not establish real model adoption or canonical settlement. | | `outcome_review_same_batch_link_invalidation` | A successful review-plus-result refresh immediately reports its Agent review basis stale; an unchanged standalone review is then repeated. | Exact refinement and result-delivery receipts, original CAS revision, retained conclusions and canonical Todo links before/after; distinguish the authored effect from other new evidence. | Refinement hashes pre-link work, then post-writeback delivery adds a work-basis input. | Validate the complete intent and same-Goal ownership before determining relevance in the typed owner. Bind only the newly authored complete review whose outcome includes that work to the exact normalized same-batch additive link using the shared append rule and original CAS snapshot. Keep it stale until actual commit; a valid separate-work result leaves the reviewed outcome unchanged. Preserve independent verification, all retained requirements, separate/legacy reviews, initial CAS refusal, failed delivery, exact receipt replay and concurrent work/evidence invalidation through real File/SQLite CLI. Never exclude all evidence links, refresh old review digests against an arbitrary later head, reorder effects before primary admission or claim model/score benefit from this repair. | | `answer_question_replaced_by_delivery_status` | A user asks why a result occurred or whether it is expected, but receives measurements, tests or PR progress even though the reply was delivered completely. | The actual question and relevant corrections, current receiver brief, shared answer guidance, and the complete message on its original return route. | The author answered implementation progress instead of the causal question; successful transport or correct counters were mistaken for a useful answer. | Retain the correction in the existing Task and outcome, then repair the existing shared generation guidance: explain meaning, supported mechanism, expectations and the specific unknown before supporting data. Keep hypotheses separate from confirmed causes and preserve the qualified investigation owner. Verify guidance at native callers and read back the actual explanatory answer. Do not add keyword-based answer scoring, a second model rewrite, another display or a duplicate investigation; a prompt test or delivery receipt does not prove model compliance or user adoption. | | `queue_action_type_coercion` | A queued input action such as `["edit"]` is accepted but applies cancellation, or a stored operation accepts a non-string action. | Original JSON types, the typed planner and receipt decoder, real HTTP responses, canonical Turn/transcript/events/Session snapshots and native IO before/after refusal. | Membership checks stringify the action while effect selection and replay compare its original literal value. | Validate the original literal action at the existing owner before branching and when decoding pending or committed receipts. Cover arrays, nested arrays, objects, nulls, numbers, booleans and unknown strings; require rejection without state or native effects, then verify legal edit/cancel, original-operation replay and queue races. Preserve malformed historical evidence rather than coercing or rewriting receipts. | diff --git a/tests/fixtures/self_repair_queries.json b/tests/fixtures/self_repair_queries.json index 5754da85e0..02195adc51 100644 --- a/tests/fixtures/self_repair_queries.json +++ b/tests/fixtures/self_repair_queries.json @@ -9,5 +9,6 @@ {"query": "shadow capture oversized RPC response", "relevant": "shadow_proof_transport_amplification"}, {"query": "review retains duplicate protocol compatibility decoders", "relevant": "review_compatibility_assumption_gap"}, {"query": "contract criteria runner failed workspace completion", "relevant": "acceptance_validation_failure_flattened"}, - {"query": "canonical activation stopped peer delegation still accepted", "relevant": "goal_activation_consumer_drift"} + {"query": "canonical activation stopped peer delegation still accepted", "relevant": "goal_activation_consumer_drift"}, + {"query": "complete source failed Session retry callback empty envelope", "relevant": "complete_source_retry_callback_drift"} ] From e9856eba46767b5f9bf6ed751e74517443dcb98f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:08:45 +0800 Subject: [PATCH 13/33] chore(quality): bound reviewed Lark reply context growth Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/canary/module_metric_baseline.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index cb12b2d3d3..c28eb6d44c 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -83,6 +83,9 @@ "dict_any_count": 0, "lines": 2760 }, + "loopx/extensions/lark/private_conversations.py": { + "lines": 2012 + }, "loopx/heartbeat_prompt.py": { "any_count": 17, "dict_any_count": 0, From e0c27cefaea8e91241c41ad237bd20cdbe64e417 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:20:46 +0800 Subject: [PATCH 14/33] test(turn): scope frozen comparisons and recover inspection detail Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../contract-and-context.md | 7 +++ .../contract-and-context.zh-CN.md | 6 ++ .../control_plane/cli-output-probe-runner.py | 60 ++++++++++++++++++- .../testing/cli_output_semantics.py | 57 ------------------ tests/test_loopx_turn_driver.py | 23 ++++++- 5 files changed, 92 insertions(+), 61 deletions(-) diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md index f565e960cf..343c4e0da9 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md @@ -617,6 +617,13 @@ unmigrated producer must preserve that boundary. Current Turn planning consumes its complete source directly and publishes its own immutable capture; pairing a newly composed source with an older executor envelope is rejected. +Native selected-task preparation still adapts the same checked source privately +for `StewardTurnHost.prepare_native_task` and its existing validation receiver. +That temporary v0 adapter is neither a second published Host input nor a saved +alternative decision. Migrating this co-deployed preparation API remains part of +the parent's active-reader retirement; its lease rules stay with their current +owner and the normal request/capture uses only the complete source. + Native selected-task execution prepares its ordinary managed lease before the kernel delivery guard and reuses that proof during post-model validation. A claim label alone is insufficient, and another execution's lease remains diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md index 14fff076bb..2969d8359e 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md @@ -452,6 +452,12 @@ reader。本文不单独授权破坏性迁移。 仍归父提案。未迁移 producer 的测试须守住这个边界。当前 Turn 规划直接消费完整来源, 发布自己的不可变 capture;新组合来源与较早 executor envelope 错配时继续拒绝。 +原生所选 Task 的准备阶段仍把同一经校验来源私下适配给 +`StewardTurnHost.prepare_native_task` 及其现有验证 receiver。该临时 v0 adapter +不会成为第二份发布的 Host 输入或保存的候选 decision。这个共同部署的准备 API +仍由父提案在 active-reader 退役时迁移;lease 规则继续归原 owner,正常请求和 +capture 只使用完整来源。 + 原生所选 Task 在 kernel delivery guard 之前准备普通 managed lease,并在模型后验证时 复用该 proof。claim 标签本身不足,不能借用另一次执行的 lease。来源检查先于独立 Task argv;拒绝和原结果恢复保留原会话与一次性效果。 diff --git a/examples/control_plane/cli-output-probe-runner.py b/examples/control_plane/cli-output-probe-runner.py index 275ef9cb98..38e2c44319 100644 --- a/examples/control_plane/cli-output-probe-runner.py +++ b/examples/control_plane/cli-output-probe-runner.py @@ -45,6 +45,64 @@ def _load_module(name: str, path: Path) -> ModuleType: return module +def _turn_plan_control_observation(value: Any, *, goal_state: dict[str, str] | None = None) -> dict[str, Any] | None: + """Compare current controls across the explicit plan codec migration. + + This fixture observation grants no authority and does not prove full source + parity; complete-source and real outcome qualification remain separate. + Required reads and authoring clauses are compared as original JSON values. + """ + if not isinstance(value, dict) or value.get("schema_version") not in { + "loopx_turn_plan_v0", "loopx_turn_plan_v1", + }: + return None + version = value["schema_version"] + work = value["turn_envelope"] if version == "loopx_turn_plan_v0" else value["work_contract"] + if version == "loopx_turn_plan_v0": + actor, channel, capsule, boundary = work["agent_id"], work["action"], work["contract_capsule"], work["boundary"] + reads, actions = work["required_reads"], work["writeback"]["next_cli_actions"] + selected = work["action"].get("selected_todo") + else: + actor, channel = work["agent_identity"]["agent_id"], work["interaction_contract"]["agent_channel"] + capsule, boundary = work, work["goal_boundary"] + reads, actions = channel["required_reads"], work["interaction_contract"]["cli_channel"]["next_cli_actions"] + selected = work.get("selected_todo") + # v0 asks for the original Goal file; v1 delivers those exact bytes inline. + # Only this known obligation may move from a read command to captured data. + # The frozen caller independently verifies the full-file revision first. + if goal_state is not None and version == "loopx_turn_plan_v0": + goal_read = {"command": goal_state["command"], "source": "goal_state", "ordering": "before_work", + "reason": "Read full Goal intent, acceptance, stops. No summaries; failed/changed sources need a fresh guard."} + reads = [row for row in reads if row != goal_read] + route = value["route"] + controls = { + "goal_id": work["goal_id"], "agent_id": actor, + "decision": work["decision"], "should_run": work["should_run"], + "effective_action": work["effective_action"], + "selected_todo_id": selected["todo_id"] if isinstance(selected, dict) else None, + "route": {key: route[key] for key in ( + "kind", "effective_action", "would_invoke_host", "host_invocation_allowed")}, + "execution": {key: channel[key] for key in ( + "must_attempt", "delivery_allowed", "quiet_noop_allowed")}, + "boundary": {key: boundary[key] for key in ("write_scope", "stop_condition")}, + "required_reads": reads, "goal_state_material": goal_state, "next_cli_actions": actions, + "work_lane_contract": capsule["work_lane_contract"], + "execution_obligation": capsule["execution_obligation"], + "replan_action_packet": work.get("replan_action_packet"), + "effects": value["effects"], + } + if route["kind"] == "ready_for_host": + controls["execution_profile"] = capsule["execution_profile"] + controls["task_scope"] = capsule["task_scope"] + assert value["ok"] is True and not any(value["effects"].values()) + encoded = json.dumps(controls, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return {"plan_version": version, "controls_sha256": hashlib.sha256(encoded.encode("utf-8")).hexdigest(), + "field_sha256": {key: hashlib.sha256(json.dumps(child, ensure_ascii=False, + sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + for key, child in controls.items()}} + + + def _receipt_row( *, semantics: ModuleType, @@ -106,7 +164,7 @@ def _receipt_row( ), "markdown_headings": semantics.markdown_headings(text), "markdown_anchor": markdown_anchor, - "turn_plan_controls": semantics.turn_plan_control_observation(plan, goal_state=material), + "turn_plan_controls": _turn_plan_control_observation(plan, goal_state=material), **({"inspection_source_read": detail_cost} if detail_cost is not None else {}), "action_signature_sha256": ( semantics.action_signature_semantic_sha256(payload) diff --git a/loopx/control_plane/testing/cli_output_semantics.py b/loopx/control_plane/testing/cli_output_semantics.py index 5869307ad9..2ec77d247a 100644 --- a/loopx/control_plane/testing/cli_output_semantics.py +++ b/loopx/control_plane/testing/cli_output_semantics.py @@ -355,63 +355,6 @@ def collect(current: Any, *, path: str) -> None: return hashlib.sha256(canonical.encode("utf-8")).hexdigest() -def turn_plan_control_observation(value: Any, *, goal_state: dict[str, str] | None = None) -> dict[str, Any] | None: - """Compare current controls across the explicit plan codec migration. - - This fixture observation grants no authority and does not prove full source - parity; complete-source and real outcome qualification remain separate. - Required reads and authoring clauses are compared as original JSON values. - """ - if not isinstance(value, dict) or value.get("schema_version") not in { - "loopx_turn_plan_v0", "loopx_turn_plan_v1", - }: - return None - version = value["schema_version"] - work = value["turn_envelope"] if version == "loopx_turn_plan_v0" else value["work_contract"] - if version == "loopx_turn_plan_v0": - actor, channel, capsule, boundary = work["agent_id"], work["action"], work["contract_capsule"], work["boundary"] - reads, actions = work["required_reads"], work["writeback"]["next_cli_actions"] - selected = work["action"].get("selected_todo") - else: - actor, channel = work["agent_identity"]["agent_id"], work["interaction_contract"]["agent_channel"] - capsule, boundary = work, work["goal_boundary"] - reads, actions = channel["required_reads"], work["interaction_contract"]["cli_channel"]["next_cli_actions"] - selected = work.get("selected_todo") - # v0 asks for the original Goal file; v1 delivers those exact bytes inline. - # Only this known obligation may move from a read command to captured data. - # The frozen caller independently verifies the full-file revision first. - if goal_state is not None and version == "loopx_turn_plan_v0": - goal_read = {"command": goal_state["command"], "source": "goal_state", "ordering": "before_work", - "reason": "Read full Goal intent, acceptance, stops. No summaries; failed/changed sources need a fresh guard."} - reads = [row for row in reads if row != goal_read] - route = value["route"] - controls = { - "goal_id": work["goal_id"], "agent_id": actor, - "decision": work["decision"], "should_run": work["should_run"], - "effective_action": work["effective_action"], - "selected_todo_id": selected["todo_id"] if isinstance(selected, dict) else None, - "route": {key: route[key] for key in ( - "kind", "effective_action", "would_invoke_host", "host_invocation_allowed")}, - "execution": {key: channel[key] for key in ( - "must_attempt", "delivery_allowed", "quiet_noop_allowed")}, - "boundary": {key: boundary[key] for key in ("write_scope", "stop_condition")}, - "required_reads": reads, "goal_state_material": goal_state, "next_cli_actions": actions, - "work_lane_contract": capsule["work_lane_contract"], - "execution_obligation": capsule["execution_obligation"], - "replan_action_packet": work.get("replan_action_packet"), - "effects": value["effects"], - } - if route["kind"] == "ready_for_host": - controls["execution_profile"] = capsule["execution_profile"] - controls["task_scope"] = capsule["task_scope"] - assert value["ok"] is True and not any(value["effects"].values()) - encoded = json.dumps(controls, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - return {"plan_version": version, "controls_sha256": hashlib.sha256(encoded.encode("utf-8")).hexdigest(), - "field_sha256": {key: hashlib.sha256(json.dumps(child, ensure_ascii=False, - sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() - for key, child in controls.items()}} - - def action_signature_coverages(value: Any) -> list[str]: coverages: set[str] = set() diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index ce90c318db..b748707010 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -1902,9 +1902,16 @@ def retain_executor_plan(*args: Any, **kwargs: Any) -> dict[str, Any]: assert code == 0, inspection assert inspection["route"] == original_plans[0]["route"] assert inspection["schema_version"] == "loopx_turn_plan_v1" and "turn_envelope" not in inspection - assert json.dumps(inspection["work_contract"], sort_keys=True) == json.dumps( + recovered = inspection["work_contract"] + if not transaction_detail and recovered.get("schema_version") != "loopx_turn_envelope_v1": + from tests.control_plane.cli_output_fixture import read_pinned_cli_body + + body, _, _ = read_pinned_cli_body(inspection["detail_ref"]["command"]) + recovered = json.loads(body) + assert "future_fixture_observation" not in inspection["work_contract"] + assert json.dumps(recovered, sort_keys=True) == json.dumps( original_plans[0]["work_contract"], sort_keys=True) - assert inspection["work_contract"]["future_fixture_observation"]["numbers"][-1] == 2**60 + 1 + assert recovered["future_fixture_observation"]["numbers"][-1] == 2**60 + 1 if transaction_detail: # Explicit detail retains transport metadata, never a second source. detailed = build_loopx_turn_host_request(inspection) @@ -1915,6 +1922,10 @@ def retain_executor_plan(*args: Any, **kwargs: Any) -> dict[str, Any]: inspection["session"] = original_plans[0]["session"] with pytest.raises(ValueError, match="CLI inspection"): build_loopx_turn_host_request(inspection) + # Reading the exact complete source grants no execution authority. + inspection["work_contract"] = recovered + with pytest.raises(ValueError, match="CLI inspection"): + build_loopx_turn_host_request(inspection) request = build_loopx_turn_host_request(original_plans[0]) assert request["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" assert request["work_contract"] == original_plans[0]["work_contract"] @@ -1966,7 +1977,13 @@ def recording_collect_status(*args: Any, **kwargs: Any) -> dict[str, Any]: payload = json.loads(output.getvalue()) assert exit_code == 0, payload - assert payload["work_contract"]["schema_version"] == "loopx_turn_envelope_v1" + source = payload["work_contract"] + if source.get("schema_version") != "loopx_turn_envelope_v1": + from tests.control_plane.cli_output_fixture import read_pinned_cli_body + + body, _, _ = read_pinned_cli_body(payload["detail_ref"]["command"]) + source = json.loads(body) + assert source["schema_version"] == "loopx_turn_envelope_v1" assert "turn_envelope" not in payload assert len(status_reads) == 1, ( "`turn` must read its live status through the shared Turn decision " From 245cb599db79b1bb2cd9c0ecbb6f1e60a26067f1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:44:44 +0800 Subject: [PATCH 15/33] test(quality): retain terminal correction room with complete context reads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../terminal_settlement_tool_behavior.py | 8 ++ .../test_terminal_settlement_tool_behavior.py | 93 +++++++++++++++++-- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index d9094f3fca..6fe4e61f0b 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -1097,6 +1097,14 @@ def qualify( "A receipt without command templates does not replace " "that plan. " f"{QUOTA_FIRST_TOOL_INSTRUCTION}" + "For the captured first read, use the reader's supported " + "bounded page size: replace the existing --page-bytes and " + "--max-page-output-bytes values with 64000 together. " + "Keep its original source, selection and --initial-read; " + "do not add duplicate options or read the whole capture. " + "If it is still paginated, consume next_command through eof " + "before depending on that selection. Read the complete " + "vision_authoring attachment before composing a Vision. " "The exec tool's current workspace is the connected goal " "project root, so task " "paths are directly addressable. Read the exact proof target " diff --git a/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index 528877d7d8..9ae70b1e7c 100644 --- a/tests/control_plane/test_terminal_settlement_tool_behavior.py +++ b/tests/control_plane/test_terminal_settlement_tool_behavior.py @@ -17,6 +17,7 @@ ScriptedAssistantAction, ScriptedDoubaoExecTransport, ScriptedExecToolAction, + execute_loopx_cli, ) from loopx.control_plane.testing.terminal_settlement_tool_behavior import ( TERMINAL_SETTLEMENT_TOOL_BEHAVIOR_MAX_CALLS, @@ -363,14 +364,19 @@ def failed_writeback(command, state): @pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") -@pytest.mark.parametrize(("draft_target", "coverage_observation", "correct_input"), [ - (".scratch/vision.json", False, False), (".scratch/vision.json", True, False), - (".scratch/vision.json", True, True), - ("fixture/settlement-proof.json", False, False), ("outside", False, False), +@pytest.mark.parametrize(("draft_target", "coverage_observation", "correct_input", "page_bytes"), [ + (".scratch/vision.json", False, False, None), (".scratch/vision.json", True, False, None), + (".scratch/vision.json", True, True, None), + ("fixture/settlement-proof.json", False, False, None), ("outside", False, False, None), + (".scratch/vision.json", True, True, 12000), + (".scratch/vision.json", True, True, 64000), ]) def test_terminal_native_draft_preserves_proof_and_authority( - tmp_path: Path, draft_target: str, coverage_observation: bool, correct_input: bool, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, draft_target: str, + coverage_observation: bool, correct_input: bool, page_bytes: int | None, ) -> None: + if page_bytes is not None: + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) fixture = _build_fixture(tmp_path / "oracle") outside = tmp_path / "owner-private.json" outside.write_text("Owner artifact.\n") @@ -394,6 +400,36 @@ def authority_files(): for path in tree.rglob("*") if path.is_file()} before_rejection = {} + initial_parts: list[str] = [] + + def context_pages(request): + values = [json.loads(message["content"]) for message in request["messages"] + if message.get("role") == "tool" and message["content"].startswith("{")] + return [value for value in values + if value.get("schema_version") == "context_capture_selection_page_v1"] + + def initial_read(request): + navigation = json.loads(request["messages"][-1]["content"]) + tokens = shlex.split(navigation["first_read_command"]) + for option in ("--page-bytes", "--max-page-output-bytes"): + tokens[tokens.index(option) + 1] = str(page_bytes) + return ScriptedExecToolAction(command=shlex.join(tokens)) + + def next_initial_page(request): + page = json.loads(request["messages"][-1]["content"]) + assert page["eof"] is False + initial_parts.append(page["content"]) + return ScriptedExecToolAction(command=page["next_command"]) + + def vision_read(request): + pages = context_pages(request) + assert pages[-1]["eof"] is True + selection = json.loads("".join([*initial_parts, pages[-1]["content"]])) + assert selection["selections"] + navigation = next(json.loads(message["content"]) for message in request["messages"] + if message.get("role") == "tool") + return ScriptedExecToolAction(command=navigation["recovery"]["command"] + + " --select /interaction_contract/cli_channel/settlement_plan/ordered_steps/1/vision_authoring") def writeback(request, *, omit_scope=False): if correct_input and not omit_scope: @@ -402,6 +438,11 @@ def writeback(request, *, omit_scope=False): assert rejected["error_code"] == "progress_observation_invalid_arguments" assert "--progress-coverage-scope-id" in rejected["error"] assert authority_files() == before_rejection + if page_bytes is not None and omit_scope: + authoring = context_pages(request)[-1] + assert authoring["eof"] is True + syntax = json.loads(authoring["content"])["selections"][0]["value"] + assert syntax["minimal_example"]["schema_version"] == "goal_vision_replan_contract_v0" command = _writeback_action(request).command + " --agent-vision-json .scratch/vision.json" if coverage_observation: packet = _latest_quota_packet(request) @@ -423,7 +464,10 @@ def completion(request): receipt = DoubaoTerminalSettlementToolBehaviorActor( api_key="test-only-placeholder", transport=ScriptedDoubaoExecTransport([ ScriptedExecToolAction(command=fixture.quota_guard_command), + *([initial_read] if page_bytes is not None else []), + *([next_initial_page] if page_bytes == 12000 else []), ScriptedExecToolAction(command="cat fixture/settlement-proof.json"), + *([vision_read] if page_bytes is not None else []), completion, ScriptedExecToolAction(command="cat > " + shlex.quote(target) + " <<'JSON'\n" + json.dumps(vision) + "\nJSON"), @@ -431,17 +475,50 @@ def completion(request): writeback, _spend_action, _completion_action, ]), ).qualify(qualification_id="native-terminal-draft", fixture_root=tmp_path / "actor") - passed = draft_target == ".scratch/vision.json" + passed = draft_target == ".scratch/vision.json" and page_bytes != 12000 assert receipt["qualification_passed"] is passed, receipt assert receipt["boundary"]["shell_commands_executed"] is True assert receipt["settlement_receipt_steps"] == ( - ["validation", "durable_writeback", "quota_spend", "terminal_closeout"] if passed else ["validation"] + ["validation", "durable_writeback", "quota_spend", "terminal_closeout"] if passed + else ["validation", "durable_writeback", "quota_spend"] if page_bytes == 12000 + else ["validation"] ) assert (tmp_path / "actor/project/fixture/settlement-proof.json").read_bytes() == fixture.proof_target.read_bytes() assert outside.read_text() == "Owner artifact.\n" if correct_input: - assert receipt["tool_call_count"] == 8 + assert receipt["tool_call_count"] == (10 if page_bytes is not None else 8) assert receipt["observed_tool_sequence"].count("progress_observation_invalid_arguments") == 1 + assert receipt["failure_code"] == ("tool_call_budget_exhausted" if page_bytes == 12000 else None) + + +def test_terminal_first_read_page_size_preserves_complete_original_selection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) + fixture = _build_fixture(tmp_path / "fixture") + + def execute(command): + return json.loads(execute_loopx_cli( + command, source_root=fixture.source_root, project_root=fixture.project_root, + )) + + navigation = execute(fixture.quota_guard_command.replace( + '"${LOOPX_TURN:?}"', "terminal-page-selection", + )) + command = navigation["first_read_command"] + parts: list[str] = [] + while command: + page = execute(command) + parts.append(page["content"]) + command = page.get("next_command") if not page["eof"] else None + assert len(parts) <= 4 + assert len(parts) > 1 + tokens = shlex.split(navigation["first_read_command"]) + for option in ("--page-bytes", "--max-page-output-bytes"): + tokens[tokens.index(option) + 1] = "64000" + bounded = execute(shlex.join(tokens)) + assert bounded["eof"] is True + assert bounded["content"] == "".join(parts) @pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") From d53b402a8ecb04c186b3074c6f8c2e0d41e8f301 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:45:36 +0800 Subject: [PATCH 16/33] fix(turn): keep read-only source inspection portable Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../contract-and-context.md | 6 ++ .../contract-and-context.zh-CN.md | 5 ++ loopx/cli_commands/context_machine.py | 20 +++++- tests/control_plane/test_cli_output_budget.py | 71 ++++++++++--------- .../test_windows_snapshot_privacy.py | 22 ++++++ 5 files changed, 88 insertions(+), 36 deletions(-) diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md index 343c4e0da9..efe6de3e5a 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.md @@ -665,6 +665,12 @@ transition permits 2,000 additional default characters/bytes or 16,000 for full transaction detail; subsequent v1 growth and absolute ceilings remain guarded. This is migration qualification, not a net context-efficiency claim. +Read-only capture publication uses the existing cross-platform private-IO owner: +new files receive private POSIX permissions or a protected Windows DACL, while +an unsafe existing transport directory is rejected without repair. This does +not enable native continuity or change its platform qualification. Native +Windows producer coverage is retained separately from local POSIX execution. + Failed Session retry also delivers its callback input by the recorded request version after the existing complete-source check. Current requests supply the checked complete source; historical v0 supplies its original envelope, including diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md index 2969d8359e..237fd35a57 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0/contract-and-context.zh-CN.md @@ -486,6 +486,11 @@ schema 测量;本拆分中的普通 quota 保持不变。 2,000 字符/字节,完整事务详情增加 16,000;后续 v1 增长和绝对上限仍受约束。 这是迁移资格验证,不是上下文效率净收益结论。 +只读 capture 发布复用既有跨平台私有 IO owner:新文件设置私有 POSIX 权限或受保护的 +Windows DACL;不安全的既有 transport 目录直接拒绝,不自动修复。它不启用原生 +连续性,也不改变原生连续性的平台资格。保留 Windows 原生产者覆盖,并与本地 +POSIX 执行证据分开报告。 + 失败 Session 重试也在既有完整来源校验之后,按请求记录版本传入回调参数:当前请求 传入经校验的完整来源;历史 v0 仍传原 envelope,即使附带完整 sidecar。损坏或混合 输入在 provider lookup 前拒绝。真实 File/SQLite 重试测试让回调实际读取参数,覆盖 diff --git a/loopx/cli_commands/context_machine.py b/loopx/cli_commands/context_machine.py index 5c08fc4517..0d08a4e630 100644 --- a/loopx/cli_commands/context_machine.py +++ b/loopx/cli_commands/context_machine.py @@ -55,8 +55,26 @@ def capture_context_recovery(value: dict[str, Any], *, runtime_root: Path, ) try: - directory = Path(tempfile.mkdtemp(prefix="recovery-", dir=_private_transport_root(runtime_root))) + from ..control_plane.runtime.private_permissions import protect_private_path, require_private_path + + # This is a write-once recovery file, not native continuity. Reuse the + # cross-platform private IO owner without enabling a native baseline. + runtime_root = runtime_root.expanduser().resolve() + runtime_root.mkdir(mode=0o700, parents=True, exist_ok=True) + root = runtime_root / "context-delivery" + try: + root.mkdir(mode=0o700) + except FileExistsError: + try: + require_private_path(root, directory=True) # Never repair an existing leaf. + except OSError as error: + raise ValueError("machine context transport requires a private physical directory owned by this user") from error + else: + protect_private_path(root, directory=True) + directory = Path(tempfile.mkdtemp(prefix="recovery-", dir=root)) + protect_private_path(directory, directory=True) reference = capture_context_projection(value, directory=directory, filename="required-context.json") + protect_private_path(Path(reference["path"])) if not index_and_select: return {key: reference[key] for key in ("path", "sha256", "command")} path, digest = Path(reference["path"]), reference["sha256"] diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index ee0ee65b04..4250010db7 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -820,7 +820,14 @@ def test_real_cli_output_stays_inside_baseline_and_growth_contracts( @pytest.mark.parametrize("scenario", [SCENARIOS[0], SCENARIOS[2]], ids=["small", "multi-agent"]) -def test_host_ready_turn_plan_preserves_current_controls(tmp_path: Path, scenario: Scenario) -> None: +def test_host_ready_turn_plan_preserves_current_controls(tmp_path: Path, scenario: Scenario, + monkeypatch: pytest.MonkeyPatch) -> None: + from loopx.cli_commands import context_machine + + def native_unavailable(_runtime): + raise ValueError("native continuity is unavailable") + + monkeypatch.setattr(context_machine, "_private_transport_root", native_unavailable) with _stable_budget_fixture_root(tmp_path / scenario.name) as root: project, runtime, registry_path, state_file = _write_fixture(root, scenario) import_fixture_authority(registry_path, GOAL_ID) @@ -833,9 +840,6 @@ def test_host_ready_turn_plan_preserves_current_controls(tmp_path: Path, scenari assert payload["route"]["kind"] == "ready_for_host" assert payload["boundary"]["inspection_only"] is True assert not any(payload["effects"].values()) and state_file.read_bytes() == before - if os.name != "posix": - assert payload["work_contract"]["contract_binding"] - return assert_cli_output_baseline(CLI_OUTPUT_BUDGET_BY_ID["loopx_turn_plan"], scenario=scenario.name, output_format="json", text=text, measurement=measure_cli_output(text, output_format="json")) detail = payload["detail_ref"] @@ -1291,37 +1295,34 @@ def record_source(*args, **kwargs): assert payload["boundary"]["inspection_only"] is True assert not any(payload["effects"].values()) and state_file.read_bytes() == before [source] = originals - if os.name == "posix": - detail = payload["detail_ref"] - assert json.loads(Path(detail["path"]).read_bytes()) == source - assert payload["work_contract"]["replan_action_packet"] == source["replan_action_packet"] - assert payload["work_contract"]["goal_boundary"] == source["goal_boundary"] - # The later read must remain bound to this preview when live facts change. - state_file.write_text("# Different Goal after the inspection\n", encoding="utf-8") - selector = "/interaction_contract/agent_channel/work_context" - reader = shlex.split(detail["command"]) - rc, page_text = _invoke_cli(["--format", "json", *reader[reader.index("quota"):], "--select", selector]) - page = json.loads(page_text) - assert rc == 0 and page["eof"], page - [selected] = json.loads(page["content"])["selections"] - assert selected == {"pointer": selector, - "value": source["interaction_contract"]["agent_channel"]["work_context"]} - assert "sources" not in payload["work_contract"]["interaction_contract"]["agent_channel"]["work_context"] - assert "contract_binding" not in payload["work_contract"] - if not include_agent_vision: - state_file.write_bytes(before) - # An insecure existing transport must not receive private data. - transport = runtime / "context-delivery" - transport.chmod(0o755) - captures = list(transport.iterdir()) - fallback_code, fallback_text = _invoke_cli(command) - assert fallback_code == 0, fallback_text - fallback = _json_payload(fallback_text) - assert "detail_ref" not in fallback and fallback["work_contract"] == originals[-1] - assert fallback["boundary"]["inspection_only"] is True - assert list(transport.iterdir()) == captures and state_file.read_bytes() == before - else: - assert payload["work_contract"] == source + detail = payload["detail_ref"] + assert json.loads(Path(detail["path"]).read_bytes()) == source + assert payload["work_contract"]["replan_action_packet"] == source["replan_action_packet"] + assert payload["work_contract"]["goal_boundary"] == source["goal_boundary"] + # The later read must remain bound to this preview when live facts change. + state_file.write_text("# Different Goal after the inspection\n", encoding="utf-8") + selector = "/interaction_contract/agent_channel/work_context" + reader = shlex.split(detail["command"]) + rc, page_text = _invoke_cli(["--format", "json", *reader[reader.index("quota"):], "--select", selector]) + page = json.loads(page_text) + assert rc == 0 and page["eof"], page + [selected] = json.loads(page["content"])["selections"] + assert selected == {"pointer": selector, + "value": source["interaction_contract"]["agent_channel"]["work_context"]} + assert "sources" not in payload["work_contract"]["interaction_contract"]["agent_channel"]["work_context"] + assert "contract_binding" not in payload["work_contract"] + if os.name == "posix" and not include_agent_vision: + state_file.write_bytes(before) + # An insecure existing transport must not receive private data. + transport = runtime / "context-delivery" + transport.chmod(0o755) + captures = list(transport.iterdir()) + fallback_code, fallback_text = _invoke_cli(command) + assert fallback_code == 0, fallback_text + fallback = _json_payload(fallback_text) + assert "detail_ref" not in fallback and fallback["work_contract"] == originals[-1] + assert fallback["boundary"]["inspection_only"] is True + assert list(transport.iterdir()) == captures and state_file.read_bytes() == before writeback = payload["work_contract"]["replan_action_packet"][ "writeback_contract" diff --git a/tests/control_plane/test_windows_snapshot_privacy.py b/tests/control_plane/test_windows_snapshot_privacy.py index c580adb0cb..27640473ec 100644 --- a/tests/control_plane/test_windows_snapshot_privacy.py +++ b/tests/control_plane/test_windows_snapshot_privacy.py @@ -76,6 +76,28 @@ def recv(self,size):return json.dumps({'schema_version':er.EFFECT_RUNTIME_RESPON assert len(seen)==1 and not Path(seen[0]['response_sink']).exists() +def test_readonly_context_capture_protects_without_native_continuity(tmp_path, monkeypatch): + from loopx.cli_commands import context_machine + from loopx.control_plane.turn_driver.decision_capture import read_context_capture_file + + def native_unavailable(_runtime): + raise AssertionError("read-only recovery must not request native continuity") + + monkeypatch.setattr(context_machine, "_private_transport_root", native_unavailable) + runtime = tmp_path / "readonly-runtime" + value = {"scope": "synthetic", "acceptance": "complete" * 10000} + reference = context_machine.capture_context_recovery(value, runtime_root=runtime) + path = Path(reference["path"]) + for leaf in (path.parent.parent, path.parent, path): + assert_private_acl(leaf) + assert read_context_capture_file(str(path), reference["sha256"])[2] == value + broaden(path.parent.parent) + children = list(path.parent.parent.iterdir()) + rejected = context_machine.capture_context_recovery(value, runtime_root=runtime) + assert rejected["available"] is False and "path" not in rejected + assert list(path.parent.parent.iterdir()) == children + + @pytest.mark.parametrize( ("broaden_target", "fact_bytes"), [(None, er.MAX_LOCAL_SNAPSHOT_BYTES), ("file", 8192), ("parent", 8192)], From 9b1e52458f479c9725797c11b69a7a676008ab09 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:47:57 +0800 Subject: [PATCH 17/33] Resume verified conversation history after transport interruptions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/bound-conversation-http-fixture.py | 5 ++ .../smoke/bound-conversation-smoke.ts | 65 +++++++++++++++++- apps/presentation/dashboard/src/data/chat.ts | 60 +++++++++++++--- .../history-recovery/README.md | 38 ++++++++++ .../history-recovery/complete.png | Bin 0 -> 78696 bytes .../history-recovery/narrow.png | Bin 0 -> 40638 bytes .../history-recovery/unavailable.png | Bin 0 -> 55682 bytes docs/development/frontend-delivery.md | 7 ++ 8 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 docs/assets/personal-workspace/history-recovery/README.md create mode 100644 docs/assets/personal-workspace/history-recovery/complete.png create mode 100644 docs/assets/personal-workspace/history-recovery/narrow.png create mode 100644 docs/assets/personal-workspace/history-recovery/unavailable.png diff --git a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py index f033b80a74..491d7a5349 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py +++ b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py @@ -216,6 +216,11 @@ def unavailable(handler): "Verified results retain their source. Unconfirmed returns remain open for recovery." for part in range(6))) result = {"populated": True, "messages": len(server.chat_store.messages(sid))} + elif command == "history": + for index in range(450): + server.chat_store.append_message(sid, role="agent", + text="Original full body. " * 1000 if index == 201 else f"Paged bound row {index}") + result = {"history_rows": len(server.chat_store.messages(sid))} elif command == "inspect": saved = server.chat_store.load_session(sid) result = {"sessions": len(server.chat_store.list_sessions()), "original_thread": saved["upstream_thread_id"], diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts index b5d3828561..6d1eaddeed 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts +++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts @@ -80,12 +80,73 @@ try { assert.equal(calls[0].path, `/api/chat/sessions?conversation_binding_id=${bindingId}`); const routedRead = globalThis.fetch; if (!fallback) { + // Repeated transport loss at later pages must advance one original prefix + // through the existing retry caller, without ever publishing partial text. + child.stdin.write("history\n"); + const expectedRows = (await next()).history_rows; + const complete = await fetchChatSessionHistory(original.session); + assert.equal(complete.messages.length, expectedRows); + const recoveredOffsets: number[] = []; + const failedOffsets = new Set(); + globalThis.fetch = async (input, init) => { + const response = await routedRead(input, init); + if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response; + const value = await response.clone().json(); + const offset = value.message_page.offset; + recoveredOffsets.push(offset); + if (offset > 0 && !failedOffsets.has(offset)) { + failedOffsets.add(offset); + throw new Error("Synthetic interrupted transport after a verified prefix"); + } + return response; + }; + const interrupted = await fetchChatHistory(options); + assert.deepEqual(interrupted.unavailableSessionIds, [sessionId]); + assert.deepEqual(interrupted.messages, []); + assert.deepEqual(interrupted.snapshots, []); + const interruptedAgain = await fetchChatHistory(options, interrupted); + assert.deepEqual(interruptedAgain.messages, []); + const recovered = await fetchChatHistory(options, interruptedAgain); + assert.deepEqual(recovered.unavailableSessionIds, []); + assert.deepEqual(recovered.snapshots[0].messages, complete.messages); + assert.deepEqual(recoveredOffsets, [0, 200, 200, 400, 400], "Only failed pages are read again"); + assert.equal(interrupted.pendingReads?.get(sessionId)?.messages.length, 200, + "Later retries cannot mutate the previously published read state"); + assert.equal(recovered.pendingReads?.size, 0); + assert.ok(calls.every(row => row.method === "GET"), "Recovery never starts work"); + globalThis.fetch = routedRead; + // Current source revocation blocks recovery before another history page. + child.stdin.write("deny\n"); assert.equal((await next()).source_verified, false); + calls.length = 0; + await assert.rejects(() => fetchChatHistory(options, interrupted)); + assert.ok(calls.every(row => new URL(row.path, origin).pathname === "/api/chat/sessions")); + child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true); + // Permanent denial, a changed source or malformed prefix cannot be retained + // as transport progress. The following read must validate page zero again. + for (const fault of ["denied", "source", "digest"]) { + globalThis.fetch = async (input, init) => { + const response = await routedRead(input, init); + if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response; + if (fault === "denied") return new Response("{}", {status: 403}); + const value = await response.json(); + if (fault === "source") value.session.goal_id = "unrelated-goal"; + else value.message_page.snapshot_sha256 = "0".repeat(64); + return new Response(JSON.stringify(value)); + }; + const rejected = await fetchChatHistory(options, interrupted); + assert.deepEqual(rejected.messages, []); + assert.equal(rejected.pendingReads?.size, 0); + globalThis.fetch = routedRead; + calls.length = 0; + assert.deepEqual((await fetchChatHistory(options, rejected)).snapshots[0].messages, complete.messages); + assert.equal(new URL(calls.find(row => row.path.includes(`${sessionId}?`))!.path, origin).searchParams.has("message_cursor"), false); + } // Real HTTP responses beyond the old interactive deadline must still hydrate // the complete original history. Source verification keeps its own deadline. child.stdin.write("slow-history\n"); assert.equal((await next()).slow_history, true); const slow = await fetchChatHistory(options); - assert.deepEqual(slow.messages, history.messages); + assert.deepEqual(slow.messages, recovered.messages); assert.deepEqual(slow.unavailableSessionIds, []); // Delay only the post-history fresh source check, not initial discovery. const beforeSlowSource = calls.length; @@ -98,7 +159,7 @@ try { return response; }; const sourceSlow = await fetchChatHistory(options); - assert.deepEqual(sourceSlow.messages, history.messages); + assert.deepEqual(sourceSlow.messages, recovered.messages); assert.deepEqual(sourceSlow.unavailableSessionIds, []); assert.ok(calls.slice(beforeSlowSource).every(row => row.method === "GET")); globalThis.fetch = routedRead; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 407205102a..933f241741 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1055,15 +1055,40 @@ const chatMessagePageSchema = z.object({ // its pages and fresh source checks; caller cancellation still takes priority. const CHAT_HISTORY_READ_TIMEOUT_MS = 30_000; +type ChatHistoryReadProgress = { + first: ChatSessionSnapshot; + messages: ChatVisibleMessage[]; + cursors: Set; + cursor: string; + total: number; + digest: string; +}; + +function sameHistorySource(actual: ChatSessionSummary, expected: ChatSessionSummary) { + return actual.session_id === expected.session_id && + (["agent_id", "goal_id", "channel_id", "managed_agent_id", "goal_instance_id", "project_ref"] as const) + .every(key => (actual[key] ?? null) === (expected[key] ?? null)) && + (actual.session_mode ?? "managed_runtime") === (expected.session_mode ?? "managed_runtime"); +} + /** Hydrate one complete original prefix; partial pages never become a snapshot. */ export async function fetchChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string): Promise { + return readChatSessionHistory(session, signal, turnId); +} + +async function readChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string, + progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void): Promise { const sessionId = typeof session === "string" ? session : session.session_id; // A delegation has the original Session ID; pin its first verified page's // scope. History discovery callers keep validating against their own source. let expectedSession = typeof session === "string" ? undefined : session; - let first: ChatSessionSnapshot | undefined, cursor: string | null = null; - let total: number | undefined, digest: string | undefined; - const messages: ChatVisibleMessage[] = [], cursors = new Set(), ids = new Set(); + // Recovery is derived browser memory, scoped again by current discovery. It + // grants no source authority and never exposes an incomplete transcript. + if (progress && expectedSession && !sameHistorySource(progress.first.session, expectedSession)) progress = undefined; + let first = progress?.first, cursor: string | null = progress?.cursor ?? null; + let total = progress?.total, digest = progress?.digest; + const messages = [...(progress?.messages ?? [])], cursors = new Set(progress?.cursors), + ids = new Set(messages.map(message => message.message_id)); do { signal?.throwIfAborted(); const query = new URLSearchParams({message_limit: "200"}); @@ -1074,10 +1099,7 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri {signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)]) : AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)}); signal?.throwIfAborted(); const expected = expectedSession; - const sameSource = snapshot.session?.session_id === sessionId && (!expected || ( - (["agent_id", "goal_id", "channel_id", "managed_agent_id", "goal_instance_id", "project_ref"] as const) - .every(key => (snapshot.session[key] ?? null) === (expected[key] ?? null)) && - (snapshot.session.session_mode ?? "managed_runtime") === (expected.session_mode ?? "managed_runtime"))); + const sameSource = snapshot.session?.session_id === sessionId && (!expected || sameHistorySource(snapshot.session, expected)); if (snapshot.ok !== true || snapshot.schema_version !== "loopx_chat_store_v1" || !sameSource || !Array.isArray(snapshot.messages)) { throw new Error("The original Session scope changed during history read."); } @@ -1106,7 +1128,10 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri } first ??= snapshot; total = page.total; digest = page.snapshot_sha256; cursor = page.next_cursor; - if (cursor !== null) cursors.add(cursor); + if (cursor !== null) { + cursors.add(cursor); + checkpoint?.({first, messages: [...messages], cursors: new Set(cursors), cursor, total, digest}); + } } while (cursor !== null); // Keep the first read's execution/revision hint. An append between pages is // intentionally outside this prefix and will trigger the normal next refresh. @@ -1206,6 +1231,7 @@ export type ChatHistory = { sessions: ChatSessionSummary[]; snapshots: ChatSessionSnapshot[]; unavailableSessionIds: string[]; + pendingReads?: Map; }; export async function fetchChatHistory(options: { @@ -1223,6 +1249,8 @@ export async function fetchChatHistory(options: { const listed = options.managed ? {sessions: [await resolveManagedChatSession(options.managed, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS))]} : (!options.bound ? previous : undefined) ?? await fetchChatSessions({ ...options, signal: AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS) }); const known = new Map(previous?.snapshots.map((snapshot) => [snapshot.session.session_id, snapshot])); + const pendingReads = new Map(previous?.pendingReads); + for (const id of pendingReads.keys()) if (!listed.sessions.some(session => session.session_id === id)) pendingReads.delete(id); // A failed historical read is not an empty transcript. Retrying only the // missing snapshots keeps this recovery read-only and bounds repeated work. const missing = listed.sessions.filter((session) => { @@ -1234,7 +1262,20 @@ export async function fetchChatHistory(options: { }); if (exact) for (const session of missing) known.delete(session.session_id); const results = await Promise.allSettled(missing.map(async (session) => { - const snapshot = await fetchChatSessionHistory(session); + const savedProgress = pendingReads.get(session.session_id); + if (savedProgress && !sameHistorySource(savedProgress.first.session, session)) pendingReads.delete(session.session_id); + let snapshot: ChatSessionSnapshot; + try { + snapshot = await readChatSessionHistory(session, undefined, undefined, pendingReads.get(session.session_id), + progress => pendingReads.set(session.session_id, progress)); + } catch (error) { + // Reuse the hook's existing retry schedule only for a lost transport. + // Revoked sources, invalid pages and stale cursors must start a fresh read. + if (!(error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable" + && (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500))) pendingReads.delete(session.session_id); + throw error; + } + pendingReads.delete(session.session_id); if (snapshot.session.session_id !== session.session_id || snapshot.session.agent_id !== session.agent_id) { throw new Error("The original Session identity changed during history read."); } @@ -1258,6 +1299,7 @@ export async function fetchChatHistory(options: { sessions: listed.sessions, snapshots, unavailableSessionIds: listed.sessions.filter((session) => !known.has(session.session_id)).map((session) => session.session_id), + pendingReads, }; } diff --git a/docs/assets/personal-workspace/history-recovery/README.md b/docs/assets/personal-workspace/history-recovery/README.md new file mode 100644 index 0000000000..c0ba1eb701 --- /dev/null +++ b/docs/assets/personal-workspace/history-recovery/README.md @@ -0,0 +1,38 @@ +# Original history recovery after repeated transport loss + +Ordinary task: open the existing bound conversation and read its full reply. +Previously, a lost later page discarded every verified page. Automatic retry +started from page zero, repeating already completed work. The same reader now +keeps a browser-memory checkpoint and continues from the failed cursor under +the existing retry schedule. No extra user action, new timeout or server-side +state is introduced. Reload and explicit refresh remain fresh reads. + +The immutable base `4a4fe1495b89eac3af9bd1f956183748c2944909` and the changed +reader were challenged with transport interruptions on offsets 200 and 400. +The base requested `0, 200, 0, 200, 400, 0, 200, 400`; the changed reader requested +`0, 200, 200, 400, 400`. Both eventually read the same complete source; only +the changed reader avoids the repeated prefix. The regression belongs to +`npm run smoke:bound-conversation` in `apps/presentation/dashboard`. + +These captures use the built Chat bundle and the production HTTP/SQLite/Core +binding owner in a disposable synthetic runtime. Its 464 original rows include +a 19,000-character body. The packaged UI recovered automatically through both +interruptions, displayed the complete body once, retained the draft, and kept +Send disabled until the complete history was verified. Independent store +readback remained identical, with no message/Session creation or model Turns. +The focused HTTP smoke also challenges source revocation, permission denial, +changed source identity and inconsistent prefix digest. + +- `unavailable.png` (1440×1000): incomplete history is unavailable, with the + retained draft, an existing retry action and disabled Send. +- `complete.png` (1440×1000): automatic recovery exposes the original tail and + enables Send. The long body is checked in the DOM; this tail viewport alone + does not show the full body. +- `narrow.png` (390×844): the same recovered conversation and draft fit without + horizontal overflow. No layout or wording change is claimed. + +All imagery is synthetic and contains no live conversation or credentials. +PNG pixels and metadata were inspected. This qualifies the packaged browser +reader, not the installed native App, live IM transport, server latency or +business completion. Current source authorization and every page's frozen +prefix/identity checks remain mandatory; checkpoints do not grant authority. diff --git a/docs/assets/personal-workspace/history-recovery/complete.png b/docs/assets/personal-workspace/history-recovery/complete.png new file mode 100644 index 0000000000000000000000000000000000000000..8f799764aa692d52588fe774a871455176148228 GIT binary patch literal 78696 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 literal 0 HcmV?d00001 diff --git a/docs/assets/personal-workspace/history-recovery/unavailable.png b/docs/assets/personal-workspace/history-recovery/unavailable.png new file mode 100644 index 0000000000000000000000000000000000000000..b4989ec85776958127aaa6772a03c0393d12e391 GIT binary patch literal 55682 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 Gi~k4EVQ>Bb literal 0 HcmV?d00001 diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 6a8d863c7c..aa95b20afb 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -265,6 +265,13 @@ Both check read-only browsing. Source/receipt negatives live in `tests/test_manager_context_roundtrip.py`; captures and limits are described in [the history evidence](../assets/personal-workspace/conversation-pagination/README.md). +When a later history page loses its transport, the existing read retry retains +the verified prefix in browser memory and resumes its next cursor. Partial text +never becomes a readable snapshot or enables sending. Each recovery rechecks +the current source; source conflicts, invalid cursors/pages and permission +denials discard the checkpoint. Reload and explicit refresh start a new read. +See [the repeated-interruption evidence](../assets/personal-workspace/history-recovery/README.md). + ### Workspace reading and request continuity / 工作区阅读与请求连续性 The home brief counts observed native attention items separately from affected From 078ae6fe393f1ad7c354b450d7aebdb9f9979d7e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:49:43 +0800 Subject: [PATCH 18/33] fix(turn): require the Core release exposing complete-source adapters Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- packages/loopx-ark-turn/pyproject.toml | 2 +- packages/loopx-memt-turn/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/loopx-ark-turn/pyproject.toml b/packages/loopx-ark-turn/pyproject.toml index ac2efe19e8..f5c0cfc992 100644 --- a/packages/loopx-ark-turn/pyproject.toml +++ b/packages/loopx-ark-turn/pyproject.toml @@ -9,7 +9,7 @@ description = "Optional Ark Managed Agent host for independently validated LoopX readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" -dependencies = ["loopx>=2.2.0", "arkruntime[mcp]>=0.8.0,<0.9", "mcp==1.30.0"] +dependencies = ["loopx>=2.3.0", "arkruntime[mcp]>=0.8.0,<0.9", "mcp==1.30.0"] [project.scripts] loopx-ark-turn = "loopx_ark_turn.cli:main" diff --git a/packages/loopx-memt-turn/pyproject.toml b/packages/loopx-memt-turn/pyproject.toml index b2ec97ac52..5a105708c6 100644 --- a/packages/loopx-memt-turn/pyproject.toml +++ b/packages/loopx-memt-turn/pyproject.toml @@ -9,7 +9,7 @@ description = "Opt-in MemT Nano host with scoped native memory for LoopX Turns" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" -dependencies = ["loopx>=2.2.0", "mcp==1.30.0", "jinja2>=3.1.6,<4"] +dependencies = ["loopx>=2.3.0", "mcp==1.30.0", "jinja2>=3.1.6,<4"] [project.scripts] loopx-memt-turn = "loopx_memt_turn.cli:main" From 75d012e7fe3f19bab3bb040b7a885466dace9f9b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:51:32 +0800 Subject: [PATCH 19/33] perf(chat): read delivery state only for visible history results Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../history-recovery/README.md | 20 ++++- .../capabilities/manager_context/roundtrip.py | 9 +- loopx/control_plane/collaboration/inbox.py | 9 +- tests/test_manager_context_roundtrip.py | 83 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/docs/assets/personal-workspace/history-recovery/README.md b/docs/assets/personal-workspace/history-recovery/README.md index c0ba1eb701..682b826d66 100644 --- a/docs/assets/personal-workspace/history-recovery/README.md +++ b/docs/assets/personal-workspace/history-recovery/README.md @@ -16,7 +16,7 @@ the changed reader avoids the repeated prefix. The regression belongs to These captures use the built Chat bundle and the production HTTP/SQLite/Core binding owner in a disposable synthetic runtime. Its 464 original rows include -a 19,000-character body. The packaged UI recovered automatically through both +a 20,000-character body. The packaged UI recovered automatically through both interruptions, displayed the complete body once, retained the draft, and kept Send disabled until the complete history was verified. Independent store readback remained identical, with no message/Session creation or model Turns. @@ -36,3 +36,21 @@ PNG pixels and metadata were inspected. This qualifies the packaged browser reader, not the installed native App, live IM transport, server latency or business completion. Current source authorization and every page's frozen prefix/identity checks remain mandatory; checkpoints do not grant authority. + +The server also selects the current page's immutable result keys before reading +answer bodies and delivery sidecars. This matters when an unmatched legacy row +requires the existing exhaustive route fallback: off-page results no longer +need to be deserialized. The fallback still examines every candidate route; +Session/source checks, current status validation and unreadable-state handling +remain unchanged. No delivery state or authorization is cached between reads. + +A controlled production HTTP/SQLite/Core fixture contains 404 messages, 96 +request routes and 768 complete 20,000-character results. In alternating +base/head/head/base runs (seven uninstrumented samples each), the first page's +result-file reads fell from 1,536 to 80. Per-run median HTTP latency was +0.17–0.26 seconds on the base and 0.04–0.05 seconds after the change. Both arms +returned the identical complete history and left the original source unchanged. +These local synthetic measurements are neither a production p95 nor evidence +that other service contention is fixed. The durable HTTP and fallback +regressions live in `tests/test_manager_context_roundtrip.py`; they also retain +late verification changes, malformed state and the original full long body. diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index 8431f52edf..41db7b6d09 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -310,7 +310,14 @@ def candidate_paths(): request_id = str(route.get("request_id") or "") if path.stem != request_id or not BARE_SHA256_PATTERN.fullmatch(request_id): continue - for item in reply_status(root, route): + # A legacy/unmatched row can require the exhaustive route fallback. + # Match immutable result keys before reading potentially long reply + # bodies and delivery sidecars for off-page results. The existing + # result owner still validates every selected status on this read. + for item in reply_status( + root, route, + include_result_key=lambda key: "handoff." + _hash([request_id, key]) in pending_message_ids, + ): message_id = "handoff." + _hash([request_id, item.get("result_key", item["phase"])]) if message_id not in pending_message_ids: continue diff --git a/loopx/control_plane/collaboration/inbox.py b/loopx/control_plane/collaboration/inbox.py index 2e3107327a..29f1011caf 100644 --- a/loopx/control_plane/collaboration/inbox.py +++ b/loopx/control_plane/collaboration/inbox.py @@ -538,9 +538,16 @@ def iter_result_paths(folder: Path): DELIVERY_ERRORS = set(_delivery_contract["delivery_errors"]) -def reply_status(root, row): +def reply_status(root, row, *, include_result_key=None): + """Read current delivery state, optionally selecting result identities first. + + The selector narrows IO only; retained results use the same validation and + unreadable-state handling. No result content or status is cached. + """ result = [] for path in result_paths(_root(root) / "replies" / row["request_id"]): + if include_result_key is not None and not include_result_key(path.stem): + continue phase = "decision" if path.stem == "decision" or path.stem.startswith("decision-") else "conclusion" state_path = path.with_name(path.stem + ".delivery.json") try: diff --git a/tests/test_manager_context_roundtrip.py b/tests/test_manager_context_roundtrip.py index 7dce9ea044..7d82311906 100644 --- a/tests/test_manager_context_roundtrip.py +++ b/tests/test_manager_context_roundtrip.py @@ -1153,6 +1153,89 @@ def test_history_original_receipt_is_only_a_locator_not_route_authority(returned assert project_chat_return_deliveries(root, sid, messages, source_store=store) == messages +def test_history_fallback_reads_only_visible_result_states(returned_history, monkeypatch): + from loopx.control_plane.collaboration import inbox + + root, _, store, session, turn, receipt = returned_history + sid, rid = session["session_id"], receipt["request_id"] + # Lost locators and an unmatched legacy message must retain the exhaustive + # fallback, without deserializing all other requests' complete answer bodies. + store.update_turn(sid, turn["turn_id"], response=None) + store.append_message(sid, role="agent", origin="manager_followup", + message_id="handoff." + "0" * 64, text="Legacy result without a known receipt") + replies = _root(root) / "replies" + for index in range(30): + other = f"{index:064x}" + _write(_root(root) / "roundtrips" / f"{other}.json", {"request_id": other, "session_id": sid}) + for version in range(4): + _write(replies / other / f"conclusion-{version:08}.json", { + "phase": "conclusion", "created_at": "2026-09-14T00:00:00+00:00", + "text": "A complete off-page answer. " * 1000, + }) + expected = {"schema_version": "manager_return_delivery_status_v0", **reply_status(root, receipt)[0]} + before = {path: path.read_bytes() for path in root.rglob("*.json")} + original_read, reads = inbox._read, [] + + def read(path): + if path.parent.parent == replies: + reads.append(path) + return original_read(path) + + monkeypatch.setattr(inbox, "_read", read) + messages = store.messages(sid) + projected = project_chat_return_deliveries(root, sid, messages, source_store=store) + returned = [message for message in projected if message.get("return_delivery")] + assert len(returned) == 1 and returned[0]["return_delivery"] == expected + assert set(reads) == {replies / rid / "conclusion.json", replies / rid / "conclusion.delivery.json"} + assert {path: path.read_bytes() for path in root.rglob("*.json")} == before + assert store.messages(sid) == messages + + +def test_paged_history_filters_result_io_and_rereads_late_state(frozen_private_reply, private_snapshot, monkeypatch): + from loopx.control_plane.collaboration import inbox + + store, session, turn, *_ = frozen_private_reply + root, sid, rid = store.root.parent, session["session_id"], "e" * 64 + replies = _root(root) / "replies" / rid + _write(_root(root) / "roundtrips" / f"{rid}.json", {"request_id": rid, "session_id": sid}) + body = "Original full body. " * 1000 + keys = ["decision", "conclusion-00000001"] + for index, key in enumerate(keys): + _write(replies / f"{key}.json", {"created_at": "2026-09-14T00:00:00+00:00", "text": body}) + _write(replies / f"{key}.delivery.json", {"status": "delivered"}) + store.append_message(sid, role="agent", origin="manager_followup", + message_id="handoff." + _hash([rid, key]), turn_id=turn["turn_id"], text=body) + if index == 0: + for filler in range(205): + store.append_message(sid, role="agent", message_id=f"filler-{filler}", text=f"Earlier row {filler}") + original_read, reads = inbox._read, [] + + def read(path): + if path.parent == replies: + reads.append(path.name) + return original_read(path) + + monkeypatch.setattr(inbox, "_read", read) + first = private_snapshot(message_limit=200) + assert set(reads) == {"decision.json", "decision.delivery.json"} + assert next(message for message in first["messages"] if message.get("return_delivery"))["text"] == body + _write(replies / "conclusion-00000001.delivery.json", { + "status": "verification_required", "error": "provider_delivery_unverified", + }) + reads.clear() + tail = private_snapshot(message_limit=200, message_cursor=first["message_page"]["next_cursor"]) + returned = next(message for message in tail["messages"] if message.get("return_delivery")) + assert returned["text"] == body + assert returned["return_delivery"]["result_key"] == "conclusion-00000001" + assert returned["return_delivery"]["status"] == "verification_required" + assert set(reads) == {"conclusion-00000001.json", "conclusion-00000001.delivery.json"} + (replies / "conclusion-00000001.delivery.json").write_text("{") + fresh = private_snapshot(message_limit=200, message_cursor=first["message_page"]["next_cursor"]) + failed = next(message for message in fresh["messages"] if message.get("return_delivery")) + assert failed["return_delivery"]["status"] == "explicit_unverified" + assert failed["return_delivery"]["error"] == "delivery_state_unreadable" + + def test_provider_verification_outage_retries_read_only_without_resend(flow): root, registry, store, create = flow _, _, receipt = create(True) From 8106fc79980f9f49bf6af8fc315e8a5d9a76f3b6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:17 +0800 Subject: [PATCH 20/33] Preserve correctable missing-draft feedback within the terminal tool budget Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../terminal_settlement_tool_behavior.py | 2 +- .../test_terminal_settlement_tool_behavior.py | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 6fe4e61f0b..8f938eeb4b 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -717,7 +717,7 @@ def _execute_cli(command: str, state: _QualificationState) -> dict[str, Any]: vision = argument_value(tokens or [], "--agent-vision-json") if vision: draft = (state.fixture.project_root / vision).resolve() - if not draft.is_relative_to(state.fixture.project_root.resolve()) or not draft.is_file(): + if not draft.is_relative_to(state.fixture.project_root.resolve()): raise _TerminalToolViolation("vision_draft_outside_workspace") if tokens and "--format" not in tokens: command = shlex.join([tokens[0], "--format", "json", *tokens[1:]]) diff --git a/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index 9ae70b1e7c..2f25f98141 100644 --- a/tests/control_plane/test_terminal_settlement_tool_behavior.py +++ b/tests/control_plane/test_terminal_settlement_tool_behavior.py @@ -574,6 +574,118 @@ def writeback(request): assert (tmp_path / "actor/project/fixture/settlement-proof.json").read_bytes() == fixture.proof_target.read_bytes() +@pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") +def test_terminal_authors_missing_draft_after_unchanged_cli_rejection(tmp_path: Path) -> None: + fixture = _build_fixture(tmp_path / "oracle") + before_rejection: dict[str, bytes] = {} + + def authority_files(): + root = tmp_path / "actor" + return {str(path.relative_to(root)): path.read_bytes() + for tree in (root / "runtime", root / "project/.codex", root / "project/.loopx", root / "home") + for path in tree.rglob("*") if path.is_file()} + + def initial_read(request): + navigation = json.loads(request["messages"][-1]["content"]) + tokens = shlex.split(navigation["first_read_command"]) + for option in ("--page-bytes", "--max-page-output-bytes"): + tokens[tokens.index(option) + 1] = "64000" + return ScriptedExecToolAction(command=shlex.join(tokens)) + + def latest_context_page(request): + for message in reversed(request["messages"]): + if message.get("role") != "tool" or not message["content"].startswith("{"): + continue + page = json.loads(message["content"]) + if page.get("schema_version") == "context_capture_selection_page_v1": + return page + raise AssertionError("Expected the current capture's completed selected read") + + def vision_read(request): + page = latest_context_page(request) + assert page["eof"] is True + assert json.loads(page["content"])["selections"] + navigation = json.loads(next(message["content"] for message in request["messages"] + if message.get("role") == "tool")) + return ScriptedExecToolAction(command=navigation["recovery"]["command"] + + " --select /interaction_contract/cli_channel/settlement_plan/ordered_steps/1/vision_authoring") + + def missing_writeback(request): + page = latest_context_page(request) + assert page["eof"] is True + assert json.loads(page["content"])["selections"][0]["value"]["minimal_example"] + assert not (tmp_path / "actor/project/.scratch/vision.json").exists() + before_rejection.update(authority_files()) + return writeback(request) + + def draft(request): + rejected = QuotaToolContext.from_output(request["messages"][-1]["content"]).packet + assert rejected["ok"] is False and rejected["appended"] is False + assert authority_files() == before_rejection + vision = { + "schema_version": "goal_vision_replan_contract_v0", "state": "vision_closed", + "vision_patch": {"acceptance_summary": "Original proof and Todo validation were checked."}, + "path_delta": { + "outcome": "no_change", "prior_assumption": "Delivery needed validation.", + "observed_reality": "The proof and original Todo validation were checked.", + "retained": ["Validated delivery"], "evidence_refs": ["fixture-settlement-proof"], + }, + } + return ScriptedExecToolAction(command="cat > .scratch/vision.json <<'JSON'\n" + + json.dumps(vision) + "\nJSON") + + def writeback(request): + return ScriptedExecToolAction(command=_writeback_action(request).command + + " --agent-vision-json .scratch/vision.json") + + transport = ScriptedDoubaoExecTransport([ + ScriptedExecToolAction(command=fixture.quota_guard_command), initial_read, + ScriptedExecToolAction(command="cat fixture/settlement-proof.json"), vision_read, + partial(_completion_action, kind="validation"), missing_writeback, draft, + writeback, _spend_action, _completion_action, + ]) + receipt = DoubaoTerminalSettlementToolBehaviorActor( + api_key="test-only-placeholder", transport=transport, + ).qualify(qualification_id="missing-draft-correction", fixture_root=tmp_path / "actor") + assert receipt["qualification_passed"] is True, receipt + assert receipt["tool_call_count"] == len(transport.requests) == TERMINAL_SETTLEMENT_TOOL_BEHAVIOR_MAX_CALLS + assert receipt["settlement_receipt_steps"] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert (tmp_path / "actor/project/fixture/settlement-proof.json").read_bytes() == fixture.proof_target.read_bytes() + + +@pytest.mark.parametrize("path_kind", ["outside", "symlink"]) +def test_terminal_missing_draft_recovery_does_not_allow_path_escape(tmp_path: Path, path_kind: str) -> None: + if path_kind == "symlink" and os.name == "nt": + pytest.skip("Windows symlink creation needs separate privileges") + fixture = _build_fixture(tmp_path / "oracle") + outside = tmp_path / "owner-vision.json" + outside.write_text("Private owner artifact.\n") + + def writeback(request): + target = outside + if path_kind == "symlink": + target = tmp_path / "actor/project/.scratch/vision.json" + target.parent.mkdir(exist_ok=True) + target.symlink_to(outside) + return ScriptedExecToolAction(command=_writeback_action(request).command + + " --agent-vision-json " + shlex.quote(str(target))) + + transport = ScriptedDoubaoExecTransport([ + ScriptedExecToolAction(command=fixture.quota_guard_command), + ScriptedExecToolAction(command="cat fixture/settlement-proof.json"), + partial(_completion_action, kind="validation"), writeback, + ]) + receipt = DoubaoTerminalSettlementToolBehaviorActor( + api_key="test-only-placeholder", transport=transport, + ).qualify(qualification_id="escaped-vision-draft", fixture_root=tmp_path / "actor") + assert receipt["qualification_passed"] is False + assert receipt["failure_code"] == "vision_draft_outside_workspace" + assert receipt["settlement_receipt_steps"] == ["validation"] + assert outside.read_text() == "Private owner artifact.\n" + + @pytest.mark.skipif(not shell_isolation_available(), reason="Draft authoring requires native OS isolation") @pytest.mark.parametrize("failed_drafts", [1, 7]) def test_terminal_shell_error_can_be_corrected_without_resetting_budget( From c207ba3e506c77163e8c66d4d958990da012c8ac Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:18 +0800 Subject: [PATCH 21/33] Keep selected context values exact without inflating short scalar arrays Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../turn_driver/decision_capture.py | 9 +++--- .../test_context_selection_transport.py | 31 +++++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index aeeb970ec3..5fb60d02a3 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -555,13 +555,14 @@ def context_selection_transport(payload: dict[str, Any], select: list[str] | Non if isinstance(value, (dict, list)): subtree = branch.get(token, {}) # Length matters when validating array indices or listing children. - child: Any = {} if isinstance(value, dict) else [None] * len(value) + child: Any = {} if isinstance(value, dict) else [0] * len(value) target[key] = child pending.append((value, child, subtree, depth - 1)) else: - # Every scalar has the same address behavior. Keep original scalar - # types, precision, and byte counts at the file rendering boundary. - target[key] = None + # Every scalar has the same address behavior. A one-byte marker + # also prevents short scalar arrays growing during transport. + # Original types, precision and byte counts stay at the renderer. + target[key] = 0 return result diff --git a/tests/control_plane/test_context_selection_transport.py b/tests/control_plane/test_context_selection_transport.py index 8825f3c6f0..c5d76bd084 100644 --- a/tests/control_plane/test_context_selection_transport.py +++ b/tests/control_plane/test_context_selection_transport.py @@ -48,13 +48,13 @@ def test_explicit_reader_transports_less_prose_and_returns_original_values(tmp_p assert len(transported_contexts) == 1 transported = transported_contexts[0] assert len(json.dumps(transported, ensure_ascii=False).encode()) < len(raw) // 2 - assert transported["rows"][0] == {"text": None, "small": "ab", "empty": {}, "array": []} - assert transported["numbers"] == value["numbers"] + # The planning transport carries addresses; exact scalar values above come + # from the independently verified original, including non-JS-safe numbers. assert text not in json.dumps(transported, ensure_ascii=False) @pytest.mark.parametrize("mode", ["initial", "root_index", "nested_index"]) -def test_navigation_keeps_full_scalar_input(tmp_path, transported_contexts, mode): +def test_navigation_preserves_original_values_and_index_metadata(tmp_path, transported_contexts, mode): instruction = "Preserve all current requirements and stop before publication. " * 10 value = {"ok": True, "goal_id": "synthetic", "interaction_contract": {"schema_version": "loopx_interaction_contract_v0", @@ -64,18 +64,37 @@ def test_navigation_keeps_full_scalar_input(tmp_path, transported_contexts, mode path, raw, args = captured_reader(tmp_path, value, **options) page = read_context_capture(args) assert page["ok"] and page["eof"], page - assert transported_contexts == [value] assert path.read_bytes() == raw if mode == "initial": + assert transported_contexts == [value] assert instruction in page["content"] + else: + parent = value if mode == "root_index" else value["interaction_contract"] + prefix = "" if mode == "root_index" else "/interaction_contract" + fields = json.loads(page["content"])["selections"][0]["fields"] + expected = {} + for key, item in parent.items(): + pointer = prefix + "/" + key + field = {"pointer": pointer, + "type": "object" if isinstance(item, dict) else "boolean" if isinstance(item, bool) else "string", + "bytes": len(json.dumps(item, ensure_ascii=False, separators=(",", ":")).encode())} + if isinstance(item, dict): + field["count"] = len(item) + expected[pointer] = field + assert {field["pointer"]: field for field in fields} == expected + if mode == "root_index": + assert transported_contexts == [value] + else: + assert instruction not in json.dumps(transported_contexts, ensure_ascii=False) def test_short_scalar_arrays_do_not_grow_the_planning_payload(tmp_path, transported_contexts): value = {"values": [0, 1, False, True, None, "", "a", "ab"] * 60} - _, _, args = captured_reader(tmp_path, value, select=["/values"]) + _, raw, args = captured_reader(tmp_path, value, select=["/values"]) page = read_context_capture(args) assert page["ok"] and page["eof"], page - assert transported_contexts == [value] + assert len(transported_contexts) == 1 + assert len(json.dumps(transported_contexts[0], ensure_ascii=False).encode()) <= len(raw) assert json.loads(page["content"])["selections"][0]["value"] == value["values"] From d71cb1cfe5422e196f3580efd6b57370ab05ee70 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:18 +0800 Subject: [PATCH 22/33] Keep bounded reader and refresh settlement adapters with their existing owners Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../project_lifecycle_refresh_state.py | 31 +------- loopx/cli_commands/quota_capture.py | 71 +------------------ loopx/cli_commands/quota_context_reader.py | 69 ++++++++++++++++++ loopx/cli_commands/refresh_settlement.py | 28 ++++++++ 4 files changed, 102 insertions(+), 97 deletions(-) diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 8da4de7164..e535120fbd 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -81,6 +81,7 @@ stage_reward_memory_outcome_candidate, ) from ..control_plane.quota.cli_projection import compact_refresh_state_cli_payload +from .refresh_settlement import settle_requested_refresh def register_refresh_state_command( @@ -882,7 +883,7 @@ def handle_refresh_state_command( ) ) if not material_refresh_ready: - _settle_requested_refresh(payload, args, registry_path, goal_ref) + settle_requested_refresh(payload, args, registry_path, goal_ref) print_payload(compact_refresh_state_cli_payload(payload) if fmt == "json" else payload, fmt, render_state_refresh_markdown) return 0 if payload.get("ok") else 1 if payload.get("explore_result") is not None: @@ -972,32 +973,6 @@ def handle_refresh_state_command( ) ), ) - _settle_requested_refresh(payload, args, registry_path, goal_ref) + settle_requested_refresh(payload, args, registry_path, goal_ref) print_payload(compact_refresh_state_cli_payload(payload) if fmt == "json" else payload, fmt, render_state_refresh_markdown) return 0 if payload.get("ok") else 1 - - -def _settle_requested_refresh(payload, args, registry_path, goal_ref): - if not getattr(args, "settle_turn", False): - return - from .refresh_settlement import present_settled_refresh, settle_refreshed_turn - - runtime_root = resolve_runtime_root(load_registry(registry_path), args.runtime_root) - settle_refreshed_turn(payload, registry_path=registry_path, runtime_root=runtime_root, - goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, - replan_obligation_id=args.replan_obligation_id, - turn_instance_id=args.turn_instance_id, goal_ref=goal_ref, - delivery_workspace_path=( - Path(args.delivery_workspace_path) - if args.delivery_workspace_path else None - )) - if getattr(args, "settlement_receipt", False): - from ..control_plane.quota.error_codes import QuotaDecisionTransportError - try: - view = present_settled_refresh(payload, runtime_root=runtime_root) - except QuotaDecisionTransportError as exc: - payload.update(ok=False, error=str(exc)) - return # Exact committed receipts stay visible; never re-admit. - if view is not payload: - payload.clear() - payload.update(view) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index edf8dc17e1..5cb184c385 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -29,7 +29,8 @@ native_context_page_budget, ) from .quota_context_reader import ( - _capture_page, + _saved_context_read_page, + _context_page_output_enabled, _capture_read_reference, _native_context_read_bytes, _native_context_reference, @@ -263,74 +264,6 @@ def progressive_context_capture_view(payload: dict[str, Any], args: argparse.Nam return readable if page_attached or len(_native_output_bytes(readable)) < len(_native_output_bytes(required)) else required -def _bounded_initial_page(navigation: dict[str, Any], plan: dict[str, Any], *, - page_bytes: int, output_bytes: int, reader_argv: list[str]) -> dict[str, Any] | None: - """Try the typed opening and keep the remainder pinned to the same capture.""" - if "opening" not in plan or "first_read_command" not in navigation: - return None - try: - path, _, document = _read_capture(plan["capture"]["path"], plan["capture"]["sha256"]) - digest = plan["capture"]["sha256"] - opening, content = write_initial_context_read_reference(path, digest, document, plan["opening"]["selections"]) - if len(content) > page_bytes or len(content) >= output_bytes: - return None - remaining, _ = write_initial_context_read_reference(path, digest, document, plan["remaining"]["selections"]) - - def command(reference: Path) -> str: - return context_capture_read_command(path, digest, page_bytes=page_bytes, - max_page_output_bytes=output_bytes, reference_file=reference, reader_argv=reader_argv) - - navigation = {**navigation, "first_read_command": command(opening), "remaining_read_command": command(remaining), - "instruction": ("This opening covers the decision and read obligations. Before work, read its content, " - "then follow remaining_read_command and its cursor through the rest of the initial selection. " - + navigation["instruction"])} - page = _capture_page(path, content, digest, offset=0, size=len(content), output_limit=None, - instruction=False, select=[], reference_file=opening, reader_argv=reader_argv) - # An opening is useful only as a complete, independently readable - # page. Keep the ordinary cursor when even the opening cannot fit. - page["read_instruction"] = ( - "EOF ends the opening. Before work, read its content and follow navigation.remaining_read_command " - "and its next_command cursor through the remaining initial context. Both views pin the same capture. " - "EOF does not establish retention, authority or freshness.") - page["navigation"] = navigation - return page if len(_native_output_bytes(page)) <= output_bytes else None - except (OSError, ValueError): - return None # The same-capture ordinary reader is already available. - - -def _saved_context_read_page(args: argparse.Namespace, navigation: dict[str, Any], *, - initial_reference: str | None, initial_read_plan: dict[str, Any] | None, - output_limit: int | None, reader_argv: list[str]) -> dict[str, Any]: - """Use the ordinary verified reader; the first page and navigation share its output budget.""" - recovery = navigation["recovery"] - pinned = initial_reference if args.initial_read else None - reference = pinned or recovery.get("reference_file") - read_args = argparse.Namespace(**{**vars(args), "quota_command": "context-read", - "reference_file": reference, "capture_file": None if reference else recovery["path"], - "capture_sha256": None if reference else recovery["sha256"], - "max_page_output_bytes": output_limit, - "codex_context_dir": None, "codex_context_root": None, "initial_read": bool(args.initial_read and not pinned)}) - page = read_context_capture(read_args, presentation={"navigation": navigation}, reader_argv=reader_argv) - if page["ok"]: - if (pinned and initial_read_plan and output_limit is not None and not page["eof"] - and "previously_delivered_fields" not in page - and (opening := _bounded_initial_page(navigation, initial_read_plan, - page_bytes=args.page_bytes if args.page_bytes is not None else output_limit, - output_bytes=output_limit, reader_argv=reader_argv)) is not None): - return opening - # Keep the already-budgeted page and cursor ahead of recovery metadata. - page["navigation"] = page.pop("navigation") - elif "recovery_command" not in page: - page["recovery_command"] = recovery["command"] - return page - - -def _context_page_output_enabled(args: argparse.Namespace, activation: dict[str, Any]) -> bool: - return activation["enabled"] or ( - args.quota_command == "context-project" and getattr(args, "initial_read", False) - and activation["progressive_enabled"]) - - def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str | None = None) -> dict[str, Any]: """Present an immutable already-decided quota capture, without authority IO. diff --git a/loopx/cli_commands/quota_context_reader.py b/loopx/cli_commands/quota_context_reader.py index 668305f281..e7c7f7aafc 100644 --- a/loopx/cli_commands/quota_context_reader.py +++ b/loopx/cli_commands/quota_context_reader.py @@ -20,6 +20,7 @@ read_context_capture_file as _read_capture, read_context_read_reference, write_context_read_reference, + write_initial_context_read_reference, ) from ..control_plane.turn_driver.native_context_profile import ( NATIVE_CONTEXT_PAGE_BYTES, @@ -495,3 +496,71 @@ def _native_context_read_bytes(reference: dict[str, Any], raw: bytes, *, if page["eof"] or (stop_after is not None and total > stop_after): return total offset = page["next_offset"] + + +def _bounded_initial_page(navigation: dict[str, Any], plan: dict[str, Any], *, + page_bytes: int, output_bytes: int, reader_argv: list[str]) -> dict[str, Any] | None: + """Try the typed opening and keep the remainder pinned to the same capture.""" + if "opening" not in plan or "first_read_command" not in navigation: + return None + try: + path, _, document = _read_capture(plan["capture"]["path"], plan["capture"]["sha256"]) + digest = plan["capture"]["sha256"] + opening, content = write_initial_context_read_reference(path, digest, document, plan["opening"]["selections"]) + if len(content) > page_bytes or len(content) >= output_bytes: + return None + remaining, _ = write_initial_context_read_reference(path, digest, document, plan["remaining"]["selections"]) + + def command(reference: Path) -> str: + return context_capture_read_command(path, digest, page_bytes=page_bytes, + max_page_output_bytes=output_bytes, reference_file=reference, reader_argv=reader_argv) + + navigation = {**navigation, "first_read_command": command(opening), "remaining_read_command": command(remaining), + "instruction": ("This opening covers the decision and read obligations. Before work, read its content, " + "then follow remaining_read_command and its cursor through the rest of the initial selection. " + + navigation["instruction"])} + page = _capture_page(path, content, digest, offset=0, size=len(content), output_limit=None, + instruction=False, select=[], reference_file=opening, reader_argv=reader_argv) + # An opening is useful only as a complete, independently readable + # page. Keep the ordinary cursor when even the opening cannot fit. + page["read_instruction"] = ( + "EOF ends the opening. Before work, read its content and follow navigation.remaining_read_command " + "and its next_command cursor through the remaining initial context. Both views pin the same capture. " + "EOF does not establish retention, authority or freshness.") + page["navigation"] = navigation + return page if len(_native_output_bytes(page)) <= output_bytes else None + except (OSError, ValueError): + return None # The same-capture ordinary reader is already available. + + +def _saved_context_read_page(args: argparse.Namespace, navigation: dict[str, Any], *, + initial_reference: str | None, initial_read_plan: dict[str, Any] | None, + output_limit: int | None, reader_argv: list[str]) -> dict[str, Any]: + """Use the ordinary verified reader; the first page and navigation share its output budget.""" + recovery = navigation["recovery"] + pinned = initial_reference if args.initial_read else None + reference = pinned or recovery.get("reference_file") + read_args = argparse.Namespace(**{**vars(args), "quota_command": "context-read", + "reference_file": reference, "capture_file": None if reference else recovery["path"], + "capture_sha256": None if reference else recovery["sha256"], + "max_page_output_bytes": output_limit, + "codex_context_dir": None, "codex_context_root": None, "initial_read": bool(args.initial_read and not pinned)}) + page = read_context_capture(read_args, presentation={"navigation": navigation}, reader_argv=reader_argv) + if page["ok"]: + if (pinned and initial_read_plan and output_limit is not None and not page["eof"] + and "previously_delivered_fields" not in page + and (opening := _bounded_initial_page(navigation, initial_read_plan, + page_bytes=args.page_bytes if args.page_bytes is not None else output_limit, + output_bytes=output_limit, reader_argv=reader_argv)) is not None): + return opening + # Keep the already-budgeted page and cursor ahead of recovery metadata. + page["navigation"] = page.pop("navigation") + elif "recovery_command" not in page: + page["recovery_command"] = recovery["command"] + return page + + +def _context_page_output_enabled(args: argparse.Namespace, activation: dict[str, Any]) -> bool: + return activation["enabled"] or ( + args.quota_command == "context-project" and getattr(args, "initial_read", False) + and activation["progressive_enabled"]) diff --git a/loopx/cli_commands/refresh_settlement.py b/loopx/cli_commands/refresh_settlement.py index 6cf4019eaa..e9cbd28847 100644 --- a/loopx/cli_commands/refresh_settlement.py +++ b/loopx/cli_commands/refresh_settlement.py @@ -18,6 +18,34 @@ ) +def settle_requested_refresh(payload, args, registry_path, goal_ref): + """Keep refresh's optional settlement composition with its existing owner.""" + if not getattr(args, "settle_turn", False): + return + from ..history import load_registry + from ..paths import resolve_runtime_root + + runtime_root = resolve_runtime_root(load_registry(registry_path), args.runtime_root) + settle_refreshed_turn(payload, registry_path=registry_path, runtime_root=runtime_root, + goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, + replan_obligation_id=args.replan_obligation_id, + turn_instance_id=args.turn_instance_id, goal_ref=goal_ref, + delivery_workspace_path=( + Path(args.delivery_workspace_path) + if args.delivery_workspace_path else None + )) + if getattr(args, "settlement_receipt", False): + from ..control_plane.quota.error_codes import QuotaDecisionTransportError + try: + view = present_settled_refresh(payload, runtime_root=runtime_root) + except QuotaDecisionTransportError as exc: + payload.update(ok=False, error=str(exc)) + return # Exact committed receipts stay visible; never re-admit. + if view is not payload: + payload.clear() + payload.update(view) + + def settle_refreshed_turn( payload: dict[str, Any], *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str, todo_id: str | None, From 244a269ce15c043b99f2c65b3c87646a1e9ad883 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:18 +0800 Subject: [PATCH 23/33] Validate native fixture recovery and retain complete monitor closeout guidance Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/development/testing-and-quality.md | 25 ++++++++++++++++ .../capabilities/benchmark_toolkit/README.md | 2 +- .../test_explore_settlement_attachment.py | 30 +++++++++++++++---- .../test_goal_state_context_transport.py | 9 +++--- .../test_host_research_recovery_cli.py | 6 ++-- .../test_local_provider_settlement_journey.py | 16 ++++++++-- .../test_monitor_observation_admission.py | 23 +++++++++++--- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 9c16c7e517..3484bd5411 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1682,6 +1682,31 @@ Heartbeat 的正文回归计量先归一化已选择的 runtime、registry 和 a or promotion thresholds stay fixed for that result; revised thresholds belong to a new qualification, never a relabeled historical pass. +The auxiliary-monitor recovery fixture measures the complete original +writeback step with indented JSON, using fixed-width disposable routes on +both File and SQLite. The release base emits 8,642 characters and fails the +former 7,500-character regression ceiling; that failure remains recorded. +The current step carries the complete Vision field and path-decision bounds, +evidence-linked continuation/cost review, original identity and commands. +Deleting those obligations or splitting this small recovery step into another +reference would change its consumer contract. Calibrate only this regression +ceiling to 9,336, retaining the former 694 characters of headroom. Compare +complete authoring bounds and parsed semantic-closeout arguments with the +original admitted contract; recovery specializes the completed Task's boundary. +Spend and settled +recovery keep their 2,000-character ceilings. This preserves useful guidance; +it is neither an output reduction nor a change to any external host limit or +frozen performance criterion. Candidate measurements must still pass. + +辅助 Monitor 的恢复夹具用缩进 JSON 测量完整的原始 writeback 步骤,File 与 SQLite +均使用固定宽度的临时路由。发布基线均为 8,642 字符,原 7,500 字符回归上限的失败 +继续保留。当前步骤包含完整 Vision 字段与路径决策约束、带证据的继续执行及成本 +判断、原始身份和命令。删除这些义务或给这段小型恢复指令另加引用都会改变消费 +合同,因此只将该回归上限校准为 9,336,保留原 694 字符余量,并与最初准入合同 +逐字段比较完整 authoring 约束与解析后的 semantic-closeout 命令参数;恢复步骤保留 +已完成 Task 所需的具体结算边界。spend 和 settled 恢复仍受 2,000 字符上限约束。这不代表输出 +缩小,不改变外部宿主硬限制或冻结性能标准;候选版仍须重新测量并通过检查。 + Native progressive-context qualification found an existing default-output budget failure: immutable base `fb331f45c` and candidate `edbd56b14` both emit 25,836 JSON characters for `quota_should_run/multi_agent` with the unchanged diff --git a/loopx/capabilities/benchmark_toolkit/README.md b/loopx/capabilities/benchmark_toolkit/README.md index 4b998c809f..05a1c25a7c 100644 --- a/loopx/capabilities/benchmark_toolkit/README.md +++ b/loopx/capabilities/benchmark_toolkit/README.md @@ -973,7 +973,7 @@ running shard can therefore be merged again without an equal-time ambiguity. This command never stops workers, deletes artifacts, restarts an experiment, changes duration allowances or upgrades score qualification. Keep accepted -grading alive until it drains. The provider/controller owns bounded retry after +grading alive until it drains. The benchmark provider owns bounded retry after an unavailable writer; no scheduler or polling loop is installed. A terminal board row alone is not a release receipt. Read `board_readback_verified` and `slot_readback_verified`; new admission still needs its own resource checks. diff --git a/tests/control_plane/test_explore_settlement_attachment.py b/tests/control_plane/test_explore_settlement_attachment.py index f2ab717b6a..491d1760c6 100644 --- a/tests/control_plane/test_explore_settlement_attachment.py +++ b/tests/control_plane/test_explore_settlement_attachment.py @@ -96,9 +96,12 @@ def plans(value): @pytest.mark.parametrize("mode", ["off", "evidence", "planning"]) def test_driver_owned_turn_plan_does_not_invent_cli_attachment_command(tmp_path, mode): + import shlex import subprocess import sys - from tests.test_loopx_turn_driver import _write_live_fixture + from tests.test_loopx_turn_driver import ( + _write_live_fixture, _completion_host_and_validation_scripts, _turn_run_once_completion_argv, + ) from loopx.configure_goal import configure_goal project, runtime, path = _write_live_fixture(tmp_path) @@ -111,10 +114,27 @@ def test_driver_owned_turn_plan_does_not_invent_cli_attachment_command(tmp_path, assert result.returncode == 0, result.stdout + result.stderr packet = json.loads(result.stdout) assert "settlement_plan" not in packet["turn_envelope"]["writeback"] - # This host owns writeback through callbacks, not an agent refresh command. - # Its read hook still exposes Explore, without promising a CLI attachment - # transport in the callback plan. - hooks = packet.get("turn_start_capability_hook_dispatch", {}).get("results", []) + # Inspection is pure. Execute the original driver-owned path to observe its + # activation-bound hook and the exact input actually received by the Host. + assert "turn_start_capability_hook_dispatch" not in packet + workspace = project / "host-workspace" + workspace.mkdir() + host, validate = _completion_host_and_validation_scripts() + host = host.replace('request = json.load(sys.stdin)', + 'request = json.load(sys.stdin)\npathlib.Path("received.json").write_text(json.dumps(request))') + executed = subprocess.run([ + sys.executable, "-m", "loopx.cli", + *_turn_run_once_completion_argv(workspace, runtime, path, host, validate), + ], capture_output=True, text=True, timeout=90) + assert executed.returncode == 0, executed.stdout + executed.stderr + request = json.loads((workspace / "received.json").read_text()) + envelope = request["turn_envelope"] + assert "settlement_plan" not in envelope["writeback"] + read = subprocess.run(shlex.split(envelope["detail_ref"]["full_decision"]), + capture_output=True, text=True, timeout=30) + assert read.returncode == 0, read.stdout + read.stderr + source = json.loads(read.stdout) + hooks = source.get("turn_start_capability_hook_dispatch", {}).get("results", []) assert any(h.get("capability_id") == "explore" for h in hooks) == (mode != "off") diff --git a/tests/control_plane/test_goal_state_context_transport.py b/tests/control_plane/test_goal_state_context_transport.py index 6690e28469..73850271e7 100644 --- a/tests/control_plane/test_goal_state_context_transport.py +++ b/tests/control_plane/test_goal_state_context_transport.py @@ -39,12 +39,10 @@ def test_goal_availability_transport_preserves_full_read_and_recovery(tmp_path, state_path=state, provider=provider) source_args = dict(registry_path=registry, runtime_root=runtime, goal_id=goal, todo_id=todo_id) original_effect = context_readback.effect_runtime_result - requests, plans = [], [] + requests, packets = [], [] omit_delivery_profile = [False] def observe(operation, request, **kwargs): - if operation == "work_item.context.plan": - plans.append(deepcopy(request["packet"])) if operation == "work_item.context.project": requests.append(deepcopy(request)) result = original_effect(operation, request, **kwargs) @@ -62,6 +60,7 @@ def guard(): "--runtime-profile", "generic_cli", "--context-delivery", "off"]) packet = json.loads(output.getvalue()) assert code == 0 and packet["should_run"] is True, packet.get("reason") + packets.append(deepcopy(packet)) return packet["interaction_contract"]["agent_channel"] small_channel = guard() @@ -116,7 +115,9 @@ def guard(): state.write_bytes(large) large_channel = guard() large_request = requests[-1] - captured_plan = plans[-1] + # Exercise the public decision, including its work-read obligations. The + # internal planner input deliberately carries only selection metadata. + captured_plan = packets[-1] assert state.read_bytes() == large # Source growth changes neither the complete Agent contract nor transport # size. The full Goal read remains pending; availability is not consumption. diff --git a/tests/control_plane/test_host_research_recovery_cli.py b/tests/control_plane/test_host_research_recovery_cli.py index 8ef7fe9325..ee9eff72d8 100644 --- a/tests/control_plane/test_host_research_recovery_cli.py +++ b/tests/control_plane/test_host_research_recovery_cli.py @@ -17,6 +17,7 @@ import pytest from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload from loopx.cli import main as cli_main from loopx.cli_commands import turn_run_once as turn_command from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane @@ -33,7 +34,7 @@ def _cli(registry, runtime, *args, code=0): completed = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *args], cwd=ROOT, capture_output=True, text=True, timeout=90) - result = json.loads(completed.stdout) + result = read_cli_policy_payload(completed.stdout, returncode=completed.returncode) assert completed.returncode == code, json.dumps(result, ensure_ascii=False) return result @@ -77,11 +78,12 @@ def _worker_args(project, runtime, registry, script, request): from hashlib import sha256 from pathlib import Path import subprocess +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload request=json.loads(Path(sys.argv[1]).read_text()) def cli(*args): result=subprocess.run([sys.executable,"-m","loopx.cli","--registry",request["registry"], "--runtime-root",request["runtime"],"--format","json",*args],capture_output=True,text=True,timeout=60) - payload=json.loads(result.stdout) + payload=read_cli_policy_payload(result.stdout,returncode=result.returncode) if result.returncode: Path(request["result"]).write_text(json.dumps({"failed_command":args,"result":payload})) assert result.returncode==0,payload diff --git a/tests/control_plane/test_local_provider_settlement_journey.py b/tests/control_plane/test_local_provider_settlement_journey.py index ff61dd7c89..5b0195c0b5 100644 --- a/tests/control_plane/test_local_provider_settlement_journey.py +++ b/tests/control_plane/test_local_provider_settlement_journey.py @@ -13,6 +13,7 @@ from pathlib import Path from canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload import test_quota_settlement_cli as settlement from test_todo_list_record_references import expand_references from test_quota_authority_settlement_journey import _source @@ -23,7 +24,7 @@ def test_file_sqlite_file_retains_new_todos_results_and_settled_turn(tmp_path, monkeypatch): isolate_sqlite_runtime(tmp_path, monkeypatch) - project, runtime, registry, state, _ = _source( + project, runtime, registry, state, goal_intent = _source( tmp_path, provider="file", handoff_mode="hard_lease", extra=f"claimed_by={settlement.AGENT_ID}", ) @@ -39,7 +40,7 @@ def run(*args, expected=0): capture_output=True, text=True, timeout=90, check=False, ) assert process.returncode == expected, process.stdout + process.stderr - return json.loads(process.stdout) + return read_cli_policy_payload(process.stdout, returncode=process.returncode) def listed(): payload = run("todo", "list", "--goal-id", goal) @@ -78,6 +79,9 @@ def history(name): assert listed()["authority_read"]["source_authority"] == "file_v0" assert run(*migration, "--execute")["selected_provider"] == "sqlite" state.unlink() # Neither restart nor rollback may restore old display truth. + # Keep registered Goal intent available without restoring any Todo rows. + # Canonical work continuity is distinct from permission to lose intent. + state.write_text(goal_intent) add_args = ("todo", "add", "--goal-id", goal, "--role", "agent", "--text", "Validate work added after the upgrade", "--priority", "P0", @@ -90,11 +94,17 @@ def history(name): assert added_again["status"] == "replayed" guard = run(*guard_args) - assert guard["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + assert guard["should_run"] is True + assert guard["execution_preparation"]["state"] == "claim_required" + assert guard["interaction_contract"]["agent_channel"]["delivery_allowed"] is False identity = guard["heartbeat_receipt"]["settlement_identity"] acquired = run("task-lease", "acquire", *lease_args, "--expected-version", "0", "--ttl-seconds", "3600", "--write-scope", "tests/**") version = str(acquired["lease"]["version"]) + prepared = run(*guard_args) + assert prepared["execution_preparation"]["state"] == "ready" + assert prepared["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + assert prepared["heartbeat_receipt"]["settlement_identity"] == identity proof = ("--task-lease-idempotency-key", "retained-work", "--task-lease-expected-version", version) note = "New result after SQLite upgrade; 中文说明与原操作回执必须保留。" diff --git a/tests/control_plane/test_monitor_observation_admission.py b/tests/control_plane/test_monitor_observation_admission.py index 3755e53035..7b5511b9aa 100644 --- a/tests/control_plane/test_monitor_observation_admission.py +++ b/tests/control_plane/test_monitor_observation_admission.py @@ -382,15 +382,30 @@ def test_completed_advancement_retains_auxiliary_monitor_admission( assert resume["identity"]["turn_instance_id"] == turn assert resume["grants_new_delivery"] is False assert resume["progress_ref"] == "$.settlement_progress" - # Matched complete canonical fixtures emit 6,806 writeback characters on - # both File and SQLite, unchanged across the compared source revisions. - # Keep the full bounded vision/decision guidance, with 694 chars headroom. + # The complete current contract emits 8,642 writeback characters on both + # providers, including Vision bounds and continuation/cost review. Retain + # the historical 7,500 failure and the original 694 chars of headroom. # Spend (1,432) and settled (572) retain their 2,000-character ceiling. resume_budget = ( - 7_500 if (resume["next_step"] or {}).get("kind") == "durable_writeback" + 9_336 if (resume["next_step"] or {}).get("kind") == "durable_writeback" else 2_000 ) assert len(json.dumps(resume, ensure_ascii=False, indent=2)) < resume_budget + if (resume["next_step"] or {}).get("kind") == "durable_writeback": + # Recovery specializes the completed Task to semantic closeout. Keep + # complete authoring bounds and every argument from the admitted path. + from loopx.cli import build_parser + import shlex + + original_writeback = first["interaction_contract"]["cli_channel"]["settlement_plan"]["ordered_steps"][1] + assert resume["next_step"]["vision_authoring"] == original_writeback["vision_authoring"] + + def parsed_closeout(step): + command = step["semantic_closeout_command_template"].replace( + "", "single_surface").replace("", "outcome_progress") + return vars(build_parser().parse_args(shlex.split(command)[1:])) + + assert parsed_closeout(resume["next_step"]) == parsed_closeout(original_writeback) after_cli = poll["after"]["interaction_contract"]["cli_channel"] assert after_cli["settlement_resume_ref"] == "$.settlement_resume" assert after_cli["spend_after_validation"] is False From 3f1cb516e033cd9533eb3a6a7e8ce98581a8df5b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:19:18 +0800 Subject: [PATCH 24/33] Complete bilingual 2.3.0 release decisions, optional usage and community attribution Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/releases/v2.3.0.md | 198 +++++++++++++++++++------------- 1 file changed, 118 insertions(+), 80 deletions(-) diff --git a/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md index 1d899acfc5..17f254febf 100644 --- a/docs/product/releases/v2.3.0.md +++ b/docs/product/releases/v2.3.0.md @@ -1,5 +1,23 @@ # LoopX 2.3.0 +## Release Decision + +**Who should upgrade:** Operators using managed Goals, the Research Workspace, or a supported native host can use the clearer acceptance, source evidence, and request recovery. Windows desktop preview users should install the new stable desktop bundle manually. An existing deployment with a custom ACP endpoint named `antigravity` should rename that endpoint before upgrading. Users who do not need these workflows can stay on 2.2.0. + +**What this release solves:** Goal acceptance can use retained checks and evidence. The Workspace makes captured Turn evidence, report editions, and the original conversation easier to follow. An opted-in research Turn can evaluate the next target against matched measurements. + +**Breaking changes:** Yes. The built-in Antigravity executor reserves the id `antigravity`. Rename an existing custom ACP endpoint using that id. Existing canonical File and SQLite Goals retain their storage owner. Inherited Stride can apply its evidence-conditioned guidance. Use the Goal or machine setting to opt out explicitly. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7928](https://github.com/loopx-project/loopx/pull/7928) + +**How to verify:** Expect `loopx 2.3.0` and the expected installation owner in Doctor. Open a known Goal and inspect its acceptance record, then open the original source of one Research result. + +**Contributors:** Maintained by [@huangruiteng](https://github.com/huangruiteng). See the community contributions below. + +```bash +loopx --version +loopx doctor +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +``` + ## At a Glance Check what a Goal actually delivered, pick the next research step using comparable evidence, and find the source behind a result in the Workspace. @@ -30,44 +48,6 @@ loopx doctor - A native Host retry keeps the live guard's original capture and managed claim identity. If a saved capture lacks a verified binding, keep its existing claim receipt and obtain a fresh guard for the same Turn if preparation is still needed. [#8279](https://github.com/loopx-project/loopx/pull/8279) - SSH Goals use the compact activation prompt with inherited context delivery. Windows Doctor reads drive-qualified wheel records using the native path parser. -### Contributors - -- [@BigDataDZ](https://github.com/BigDataDZ) — Native host discovery, installer quoting, Windows path handling and Doctor identity diagnostics. [#7886](https://github.com/loopx-project/loopx/pull/7886) -- [@cocolord](https://github.com/cocolord) — CLI demand loading, native context and recovery fixtures, Issue-Fix redirect verification, and original-Turn closeout after automatic archival. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#8264](https://github.com/loopx-project/loopx/pull/8264) -- [@Duang777](https://github.com/Duang777) — Status retry journal and Lark Inbox recovery on damaged state. [#7693](https://github.com/loopx-project/loopx/pull/7693) -- [@gcl-coder](https://github.com/gcl-coder) — Windows settlement qualification, bound scheduler fallback and bounded Git discovery. [#7920](https://github.com/loopx-project/loopx/pull/7920) -- [@HaoooLee](https://github.com/HaoooLee) — Strict registry checks when repository tracking is unknown. [#8162](https://github.com/loopx-project/loopx/pull/8162) -- [@hhyykk](https://github.com/hhyykk) — Owner attention, Issue-Fix correction and original return, and TypeScript composition of Todo and quota reads. [#7860](https://github.com/loopx-project/loopx/pull/7860) -- [@JasonBuildAI](https://github.com/JasonBuildAI) — Host recovery encoding, owner review vocabulary and credential redaction in shared Lark sinks. [#8027](https://github.com/loopx-project/loopx/pull/8027) -- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — Native CLI capture fixtures and canonical receipt and handoff vocabularies. [#8182](https://github.com/loopx-project/loopx/pull/8182) -- [@keoyyo85](https://github.com/keoyyo85) — Quota settlement import isolation and architecture coverage. [#8054](https://github.com/loopx-project/loopx/pull/8054) -- [@LIHUA919](https://github.com/LIHUA919) — Rune source-verification pilot and native recovery and Antigravity admission readbacks. [#7875](https://github.com/loopx-project/loopx/pull/7875) -- [@NIU-123370](https://github.com/NIU-123370) — Bilingual RFC mirror checks, Anti-Entropy help and the release timeline. [#8128](https://github.com/loopx-project/loopx/pull/8128) -- [@songoow](https://github.com/songoow) — Independent Claude Code benchmark runner, expired-claim and Goal feedback recovery, and FrontierCS-2 compatibility. [#7082](https://github.com/loopx-project/loopx/pull/7082) -- [@steven-kid](https://github.com/steven-kid) — Captured Research Turn costs and ranges, readable evidence downloads, and keyboard access in desktop settings. [#7882](https://github.com/loopx-project/loopx/pull/7882) -- [@Weimr2627](https://github.com/Weimr2627) — Status compatibility exports and degraded Git registry fixtures. [#8160](https://github.com/loopx-project/loopx/pull/8160) -- [@xiaobaifly7](https://github.com/xiaobaifly7) — Status readback that distinguishes unavailable history from absent Goal status. [#7917](https://github.com/loopx-project/loopx/pull/7917) -- [@xyx2002OvO](https://github.com/xyx2002OvO) — Private runtime I/O and Windows long path support. [#7603](https://github.com/loopx-project/loopx/pull/7603) -- [@zxdvd](https://github.com/zxdvd) — Strict Codex Turn result schemas. [#7976](https://github.com/loopx-project/loopx/pull/7976) - -## Release Decision - -**Who should upgrade:** Operators using managed Goals, the Research Workspace, or a supported native host can use the clearer acceptance, source evidence, and request recovery. Windows desktop preview users should install the new stable desktop bundle manually. An existing deployment with a custom ACP endpoint named `antigravity` should rename that endpoint before upgrading. - -**What this release solves:** Goal acceptance can use retained checks and evidence. The Workspace makes captured Turn evidence, report editions, and the original conversation easier to follow. An opted-in research Turn can evaluate the next target against matched measurements. - -**Breaking changes:** Yes. The built-in Antigravity executor reserves the id `antigravity`. Rename an existing custom ACP endpoint using that id. Existing canonical File and SQLite Goals retain their storage owner. Inherited Stride can apply its evidence-conditioned guidance. Use the Goal or machine setting to opt out explicitly. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7928](https://github.com/loopx-project/loopx/pull/7928) - -**How to verify:** Expect `loopx 2.3.0` and the expected installation owner in Doctor. Open a known Goal and inspect its acceptance record, then open the original source of one Research result. - -**Contributors:** Maintained by [@huangruiteng](https://github.com/huangruiteng). See the community contributions below. - -```bash -loopx --version -loopx doctor -loopx --format json goal-acceptance inspect --goal-id "$goal_id" -``` - ## State Kernel & Control Plane - Goal owners can configure and verify checks against retained commitments with an explicit configuration and a revision-bound readback. Existing local Todo acceptance continues under its own declaration. [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) @@ -92,21 +72,24 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" - Benchmark maintainers can opt into an independent Claude Code runner, one declared settled-work handoff, or sampled local work probes. Campaign reports can retain both score histories and an explicit small terminal duration tolerance. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#8070](https://github.com/loopx-project/loopx/pull/8070), [#8089](https://github.com/loopx-project/loopx/pull/8089), [#8258](https://github.com/loopx-project/loopx/pull/8258), [#8263](https://github.com/loopx-project/loopx/pull/8263) - The Rune pilot compares source-cited context with direct search through the existing Issue-Fix planning reader. It is a source-checkout experiment. This release makes no benchmark-score or long-horizon cost improvement claim. [#7875](https://github.com/loopx-project/loopx/pull/7875) +- Read authored, source-cited case studies in their benchmark child Goals and return to the original research records. A new study can explicitly give every arm the same native child-task limit. [#8272](https://github.com/loopx-project/loopx/pull/8272), [#8229](https://github.com/loopx-project/loopx/pull/8229) + ## Community Contributors - [@BigDataDZ](https://github.com/BigDataDZ) — Native host discovery, installer quoting, Windows path handling and Doctor identity diagnostics. [#7886](https://github.com/loopx-project/loopx/pull/7886), [#7887](https://github.com/loopx-project/loopx/pull/7887), [#7888](https://github.com/loopx-project/loopx/pull/7888), [#8107](https://github.com/loopx-project/loopx/pull/8107), [#8108](https://github.com/loopx-project/loopx/pull/8108) -- [@cocolord](https://github.com/cocolord) — CLI demand loading, native context and recovery fixtures, Issue-Fix redirect verification, and original-Turn closeout after automatic archival. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@cocolord](https://github.com/cocolord) — CLI demand loading, native context and recovery fixtures, Issue-Fix redirect verification, and original-Turn closeout after automatic archival. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264), [#8246](https://github.com/loopx-project/loopx/pull/8246), [#8288](https://github.com/loopx-project/loopx/pull/8288) - [@Duang777](https://github.com/Duang777) — Status retry journal and Lark Inbox recovery on damaged state. [#7693](https://github.com/loopx-project/loopx/pull/7693), [#7979](https://github.com/loopx-project/loopx/pull/7979) -- [@gcl-coder](https://github.com/gcl-coder) — Windows settlement qualification, bound scheduler fallback and bounded Git discovery. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154) +- [@gcl-coder](https://github.com/gcl-coder) — Windows settlement qualification, bound scheduler fallback and bounded Git discovery. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154), [#8169](https://github.com/loopx-project/loopx/pull/8169) - [@HaoooLee](https://github.com/HaoooLee) — Strict registry checks when repository tracking is unknown. [#8162](https://github.com/loopx-project/loopx/pull/8162) - [@hhyykk](https://github.com/hhyykk) — Owner attention, Issue-Fix correction and original return, and TypeScript composition of Todo and quota reads. [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#7870](https://github.com/loopx-project/loopx/pull/7870), [#7877](https://github.com/loopx-project/loopx/pull/7877), [#7881](https://github.com/loopx-project/loopx/pull/7881), [#7892](https://github.com/loopx-project/loopx/pull/7892), [#7893](https://github.com/loopx-project/loopx/pull/7893), [#7894](https://github.com/loopx-project/loopx/pull/7894), [#7895](https://github.com/loopx-project/loopx/pull/7895), [#7900](https://github.com/loopx-project/loopx/pull/7900), [#7901](https://github.com/loopx-project/loopx/pull/7901), [#7904](https://github.com/loopx-project/loopx/pull/7904) - [@JasonBuildAI](https://github.com/JasonBuildAI) — Host recovery encoding, owner review vocabulary and credential redaction in shared Lark sinks. [#8027](https://github.com/loopx-project/loopx/pull/8027), [#8029](https://github.com/loopx-project/loopx/pull/8029), [#8031](https://github.com/loopx-project/loopx/pull/8031), [#8181](https://github.com/loopx-project/loopx/pull/8181) -- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — Native CLI capture fixtures and canonical receipt and handoff vocabularies. [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — Native CLI capture fixtures and canonical receipt, handoff and material-lifecycle outcome vocabularies. [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184), [#7398](https://github.com/loopx-project/loopx/pull/7398) +- [@Jim-jimu](https://github.com/Jim-jimu) — Full write-scope coverage checks in quota admission. [#8271](https://github.com/loopx-project/loopx/pull/8271) - [@keoyyo85](https://github.com/keoyyo85) — Quota settlement import isolation and architecture coverage. [#8054](https://github.com/loopx-project/loopx/pull/8054), [#8055](https://github.com/loopx-project/loopx/pull/8055) -- [@LIHUA919](https://github.com/LIHUA919) — Rune source-verification pilot and native recovery and Antigravity admission readbacks. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094) +- [@LIHUA919](https://github.com/LIHUA919) — Rune source-verification pilot and native recovery and Antigravity admission readbacks. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094), [#8256](https://github.com/loopx-project/loopx/pull/8256) - [@NIU-123370](https://github.com/NIU-123370) — Bilingual RFC mirror checks, Anti-Entropy help and the release timeline. [#8128](https://github.com/loopx-project/loopx/pull/8128), [#8129](https://github.com/loopx-project/loopx/pull/8129), [#8130](https://github.com/loopx-project/loopx/pull/8130) - [@songoow](https://github.com/songoow) — Independent Claude Code benchmark runner, expired-claim and Goal feedback recovery, and FrontierCS-2 compatibility. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7914](https://github.com/loopx-project/loopx/pull/7914), [#7916](https://github.com/loopx-project/loopx/pull/7916), [#7954](https://github.com/loopx-project/loopx/pull/7954), [#7958](https://github.com/loopx-project/loopx/pull/7958) -- [@steven-kid](https://github.com/steven-kid) — Captured Research Turn costs and ranges, readable evidence downloads, and keyboard access in desktop settings. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019) +- [@steven-kid](https://github.com/steven-kid) — Captured Research Turn costs and ranges, readable evidence downloads, and desktop settings search that preserves drafts and keyboard access. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019), [#8020](https://github.com/loopx-project/loopx/pull/8020) - [@Weimr2627](https://github.com/Weimr2627) — Status compatibility exports and degraded Git registry fixtures. [#8160](https://github.com/loopx-project/loopx/pull/8160), [#8163](https://github.com/loopx-project/loopx/pull/8163) - [@xiaobaifly7](https://github.com/xiaobaifly7) — Status readback that distinguishes unavailable history from absent Goal status. [#7917](https://github.com/loopx-project/loopx/pull/7917) - [@xyx2002OvO](https://github.com/xyx2002OvO) — Private runtime I/O and Windows long path support. [#7603](https://github.com/loopx-project/loopx/pull/7603) @@ -343,6 +326,39 @@ loopx issue-fix workflow-plan --goal-id "$goal_id" loopx start-goal --guided --project . --capability-route issue-fix --goal-text "$issue_request" ``` +### Native benchmark child tasks + +**Activation:** This is an explicit per-study option. Inspect the runner help first, then add `--native-child-limit 3` to every arm's separately authorized fresh invocation. It requires a non-game task with native feedback; do not modify a running study. + +**Validation:** Check each fresh profile's native child limit and the LoopX Goal spawn-policy readback. Restored work must match its sealed setting. Configuration acceptance alone does not prove useful delegation. + +**Disable / rollback:** Start a separate study without the flag. Close completed child sessions through the native host; changing an existing session is not a rollback. + +**Authority boundary:** The parent alone submits candidates. Children share the task's time, account, access and Stop. The option grants no extra evaluation opportunity or deeper delegation. Omission preserves the existing configuration and ambient values cannot activate this mode. + +**Docs:** [Versioned runtime guide](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/runtime/RUNTIME.md#configure-the-native-job) + +```bash +python -m benchmark.edgebench.run --help +``` + +### Authored research case studies + +**Activation:** Opt in per source-pack registration with `--case-study-json`. Inspect existing packs first. Register the case's pack under its own benchmark child Goal; a global switch or model execution is not needed. + +**Validation:** List the registered packs, then open the case in Research. Follow a cited line back to its exact saved source. Changed sources remain historical, and unavailable translations remain explicit. + +**Disable / rollback:** Return to Research records and analysis or select a previous pack. Omit the companion on future registrations. Editing a companion creates a new version; retain existing source and reading versions. + +**Authority boundary:** Reading runs no analysis or experiment. Registration verifies source bytes and citation ranges, not entailment or scientific validity. Goal parentage grants no additional access or execution authority. + +**Docs:** [Versioned case-study guide](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/trajectory-assessment.md#authored-case-studies) + +```bash +loopx benchmark trajectory-packs --goal-id "$case_goal" --format json +loopx benchmark trajectory-pack-register --goal-id "$case_goal" --input-json "$pack_input" --source-root "$source_root" --title "$case_title" --case-study-json "$case_study" +``` + ## Install / Update For a new PyPI environment, install the package, then install the workflow entry points for the host you use. @@ -366,40 +382,9 @@ loopx doctor ## 中文摘要 -在 Goal 里核对实际交付,在研究工作台对照同条件候选,并回到原始记录查看结果来源。已有安装先执行 `loopx update check`,由当前安装方式执行 `loopx update apply`。 - -- 到 Goal 的验收页查看承诺、检查和读回证据。局部 Todo 仍按自身交付结案。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) -- 给已有 Explore 证据的 Goal 启用 Yardstick,在同一目标和测量条件下对照候选。 [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261) -- 打开研究记录查看捕获的 Turn 范围、用量对照和可读证据报告,也可以从首页回看一版报告。 [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146) -- 在对话里编辑或取消未运行的消息,在支持的会话继续引导当前 Turn,最后回原对话核对答案。 [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8269](https://github.com/loopx-project/loopx/pull/8269) -- 复盘和恢复沿用实际分配的 Turn 与原捕获。过期 claim 会显示恢复动作,结算失败保留诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8267](https://github.com/loopx-project/loopx/pull/8267) -- 已完成的 Todo 自动归档后,仍可沿原 Turn 结算。 [#8264](https://github.com/loopx-project/loopx/pull/8264) -- 原生 Host 重试沿用实时准入的原捕获和托管领取身份。缺少可验证绑定的快照仍可读取,保留已有回执。仍需准备时,为同一 Turn 获取新的 guard。 [#8279](https://github.com/loopx-project/loopx/pull/8279) -- SSH Goal 在继承上下文设置时使用紧凑提示。Windows Doctor 能读回带盘符的 wheel 安装记录。 - -### 贡献者 - -- [@BigDataDZ](https://github.com/BigDataDZ) — 原生宿主发现、安装命令引用、Windows 长路径和 Doctor 身份诊断. [#7886](https://github.com/loopx-project/loopx/pull/7886) -- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证及 Todo 自动归档后的原 Turn 结算。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#8264](https://github.com/loopx-project/loopx/pull/8264) -- [@Duang777](https://github.com/Duang777) — 状态重试日志和飞书 Inbox 损坏状态处理. [#7693](https://github.com/loopx-project/loopx/pull/7693) -- [@gcl-coder](https://github.com/gcl-coder) — Windows 结算验证、调度回退绑定和 Git 发现时限. [#7920](https://github.com/loopx-project/loopx/pull/7920) -- [@HaoooLee](https://github.com/HaoooLee) — 跟踪状态未知时的严格 registry 检查. [#8162](https://github.com/loopx-project/loopx/pull/8162) -- [@hhyykk](https://github.com/hhyykk) — 负责人提醒、Issue-Fix 修订与原路返回、Todo 与 quota 读回的 TypeScript 合成. [#7860](https://github.com/loopx-project/loopx/pull/7860) -- [@JasonBuildAI](https://github.com/JasonBuildAI) — 宿主恢复编码、owner 评审术语和飞书共享投影凭据脱敏. [#8027](https://github.com/loopx-project/loopx/pull/8027) -- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — 原生 CLI 捕获夹具与回执、交接术语统一. [#8182](https://github.com/loopx-project/loopx/pull/8182) -- [@keoyyo85](https://github.com/keoyyo85) — 结算命令加载隔离和架构覆盖. [#8054](https://github.com/loopx-project/loopx/pull/8054) -- [@LIHUA919](https://github.com/LIHUA919) — Rune 源码核验试验、原生恢复和 Antigravity 准入读回. [#7875](https://github.com/loopx-project/loopx/pull/7875) -- [@NIU-123370](https://github.com/NIU-123370) — 双语 RFC 镜像检查、Anti-Entropy 帮助和版本时间线. [#8128](https://github.com/loopx-project/loopx/pull/8128) -- [@songoow](https://github.com/songoow) — 独立 Claude Code 基准 runner、过期 claim 与 Goal 反馈恢复、FrontierCS-2 兼容. [#7082](https://github.com/loopx-project/loopx/pull/7082) -- [@steven-kid](https://github.com/steven-kid) — 研究 Turn 成本与范围、可读证据下载和桌面设置键盘可用性. [#7882](https://github.com/loopx-project/loopx/pull/7882) -- [@Weimr2627](https://github.com/Weimr2627) — 状态兼容导出和 Git registry 降级夹具. [#8160](https://github.com/loopx-project/loopx/pull/8160) -- [@xiaobaifly7](https://github.com/xiaobaifly7) — 状态读回区分历史不可用与 Goal 状态缺失. [#7917](https://github.com/loopx-project/loopx/pull/7917) -- [@xyx2002OvO](https://github.com/xyx2002OvO) — 私有运行时 I/O 和 Windows 长路径支持. [#7603](https://github.com/loopx-project/loopx/pull/7603) -- [@zxdvd](https://github.com/zxdvd) — 严格的 Codex Turn 结果 schema. [#7976](https://github.com/loopx-project/loopx/pull/7976) - ### 升级决策 -**谁需要升级:**使用受管 Goal、研究工作台或原生宿主的用户。Windows 桌面预览版用户应手动安装新版稳定版桌面包。配置了同名自定义 ACP endpoint 的用户先将 `antigravity` 改名。 +**谁需要升级:**使用受管 Goal、研究工作台或原生宿主的用户。Windows 桌面预览版用户应手动安装新版稳定版桌面包。配置了同名自定义 ACP endpoint 的用户先将 `antigravity` 改名。 不需要这些工作流的用户可以继续使用 2.2.0。 **解决了什么:**在 Goal 核对承诺与证据。在研究结果中查看原 Turn、报告和下一步的可比测量。在原会话恢复和追踪请求。 @@ -409,6 +394,23 @@ loopx doctor **贡献者:**版本维护者为 [@huangruiteng](https://github.com/huangruiteng),具体社区贡献见下方。 +```bash +loopx --version +loopx doctor +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +``` + +在 Goal 里核对实际交付,在研究工作台对照同条件候选,并回到原始记录查看结果来源。已有安装先执行 `loopx update check`,由当前安装方式执行 `loopx update apply`。 + +- 到 Goal 的验收页查看承诺、检查和读回证据。局部 Todo 仍按自身交付结案。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) +- 给已有 Explore 证据的 Goal 启用 Yardstick,在同一目标和测量条件下对照候选。 [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261) +- 打开研究记录查看捕获的 Turn 范围、用量对照和可读证据报告,也可以从首页回看一版报告。 [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146) +- 在对话里编辑或取消未运行的消息,在支持的会话继续引导当前 Turn,最后回原对话核对答案。 [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8269](https://github.com/loopx-project/loopx/pull/8269) +- 复盘和恢复沿用实际分配的 Turn 与原捕获。过期 claim 会显示恢复动作,结算失败保留诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- 已完成的 Todo 自动归档后,仍可沿原 Turn 结算。 [#8264](https://github.com/loopx-project/loopx/pull/8264) +- 原生 Host 重试沿用实时准入的原捕获和托管领取身份。缺少可验证绑定的快照仍可读取,保留已有回执。仍需准备时,为同一 Turn 获取新的 guard。 [#8279](https://github.com/loopx-project/loopx/pull/8279) +- SSH Goal 在继承上下文设置时使用紧凑提示。Windows Doctor 能读回带盘符的 wheel 安装记录。 + ### 状态内核与控制面 - Goal 承诺可以配置检查并按 revision 核对结果,局部 Todo 仍按自己的验收声明完成。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) @@ -433,21 +435,24 @@ loopx doctor - 维护者可显式选择 Claude Code runner、一次结算后交接或本地采样探针。报告支持分数轨迹和小额时长容差。 [#7082](https://github.com/loopx-project/loopx/pull/7082), [#8070](https://github.com/loopx-project/loopx/pull/8070), [#8089](https://github.com/loopx-project/loopx/pull/8089), [#8258](https://github.com/loopx-project/loopx/pull/8258), [#8263](https://github.com/loopx-project/loopx/pull/8263) - Rune 是源码对照试验,经既有 Issue-Fix 路径读取经核验的引用。本版没有基准提分或完整成本改善结论。 [#7875](https://github.com/loopx-project/loopx/pull/7875) +- 在 benchmark child Goal 阅读带来源引用的案例,并返回原研究记录。新 study 可显式为每个 arm 设置相同的原生 child-task 上限。 [#8272](https://github.com/loopx-project/loopx/pull/8272), [#8229](https://github.com/loopx-project/loopx/pull/8229) + ### 社区贡献者 - [@BigDataDZ](https://github.com/BigDataDZ) — 原生宿主发现、安装命令引用、Windows 长路径和 Doctor 身份诊断. [#7886](https://github.com/loopx-project/loopx/pull/7886), [#7887](https://github.com/loopx-project/loopx/pull/7887), [#7888](https://github.com/loopx-project/loopx/pull/7888), [#8107](https://github.com/loopx-project/loopx/pull/8107), [#8108](https://github.com/loopx-project/loopx/pull/8108) -- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证及 Todo 自动归档后的原 Turn 结算。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证及 Todo 自动归档后的原 Turn 结算。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264), [#8246](https://github.com/loopx-project/loopx/pull/8246), [#8288](https://github.com/loopx-project/loopx/pull/8288) - [@Duang777](https://github.com/Duang777) — 状态重试日志和飞书 Inbox 损坏状态处理. [#7693](https://github.com/loopx-project/loopx/pull/7693), [#7979](https://github.com/loopx-project/loopx/pull/7979) -- [@gcl-coder](https://github.com/gcl-coder) — Windows 结算验证、调度回退绑定和 Git 发现时限. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154) +- [@gcl-coder](https://github.com/gcl-coder) — Windows 结算验证、调度回退绑定和 Git 发现时限. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154), [#8169](https://github.com/loopx-project/loopx/pull/8169) - [@HaoooLee](https://github.com/HaoooLee) — 跟踪状态未知时的严格 registry 检查. [#8162](https://github.com/loopx-project/loopx/pull/8162) - [@hhyykk](https://github.com/hhyykk) — 负责人提醒、Issue-Fix 修订与原路返回、Todo 与 quota 读回的 TypeScript 合成. [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#7870](https://github.com/loopx-project/loopx/pull/7870), [#7877](https://github.com/loopx-project/loopx/pull/7877), [#7881](https://github.com/loopx-project/loopx/pull/7881), [#7892](https://github.com/loopx-project/loopx/pull/7892), [#7893](https://github.com/loopx-project/loopx/pull/7893), [#7894](https://github.com/loopx-project/loopx/pull/7894), [#7895](https://github.com/loopx-project/loopx/pull/7895), [#7900](https://github.com/loopx-project/loopx/pull/7900), [#7901](https://github.com/loopx-project/loopx/pull/7901), [#7904](https://github.com/loopx-project/loopx/pull/7904) - [@JasonBuildAI](https://github.com/JasonBuildAI) — 宿主恢复编码、owner 评审术语和飞书共享投影凭据脱敏. [#8027](https://github.com/loopx-project/loopx/pull/8027), [#8029](https://github.com/loopx-project/loopx/pull/8029), [#8031](https://github.com/loopx-project/loopx/pull/8031), [#8181](https://github.com/loopx-project/loopx/pull/8181) -- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — 原生 CLI 捕获夹具与回执、交接术语统一. [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — 原生 CLI 捕获夹具与回执、交接和材料生命周期结果术语统一。 [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184), [#7398](https://github.com/loopx-project/loopx/pull/7398) +- [@Jim-jimu](https://github.com/Jim-jimu) — quota 准入中的完整 write-scope 覆盖检查。 [#8271](https://github.com/loopx-project/loopx/pull/8271) - [@keoyyo85](https://github.com/keoyyo85) — 结算命令加载隔离和架构覆盖. [#8054](https://github.com/loopx-project/loopx/pull/8054), [#8055](https://github.com/loopx-project/loopx/pull/8055) -- [@LIHUA919](https://github.com/LIHUA919) — Rune 源码核验试验、原生恢复和 Antigravity 准入读回. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094) +- [@LIHUA919](https://github.com/LIHUA919) — Rune 源码核验试验、原生恢复和 Antigravity 准入读回. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094), [#8256](https://github.com/loopx-project/loopx/pull/8256) - [@NIU-123370](https://github.com/NIU-123370) — 双语 RFC 镜像检查、Anti-Entropy 帮助和版本时间线. [#8128](https://github.com/loopx-project/loopx/pull/8128), [#8129](https://github.com/loopx-project/loopx/pull/8129), [#8130](https://github.com/loopx-project/loopx/pull/8130) - [@songoow](https://github.com/songoow) — 独立 Claude Code 基准 runner、过期 claim 与 Goal 反馈恢复、FrontierCS-2 兼容. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7914](https://github.com/loopx-project/loopx/pull/7914), [#7916](https://github.com/loopx-project/loopx/pull/7916), [#7954](https://github.com/loopx-project/loopx/pull/7954), [#7958](https://github.com/loopx-project/loopx/pull/7958) -- [@steven-kid](https://github.com/steven-kid) — 研究 Turn 成本与范围、可读证据下载和桌面设置键盘可用性. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019) +- [@steven-kid](https://github.com/steven-kid) — 研究 Turn 成本与范围、可读证据下载、保留草稿的桌面设置搜索与键盘可用性。 [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019), [#8020](https://github.com/loopx-project/loopx/pull/8020) - [@Weimr2627](https://github.com/Weimr2627) — 状态兼容导出和 Git registry 降级夹具. [#8160](https://github.com/loopx-project/loopx/pull/8160), [#8163](https://github.com/loopx-project/loopx/pull/8163) - [@xiaobaifly7](https://github.com/xiaobaifly7) — 状态读回区分历史不可用与 Goal 状态缺失. [#7917](https://github.com/loopx-project/loopx/pull/7917) - [@xyx2002OvO](https://github.com/xyx2002OvO) — 私有运行时 I/O 和 Windows 长路径支持. [#7603](https://github.com/loopx-project/loopx/pull/7603) @@ -684,6 +689,39 @@ loopx issue-fix workflow-plan --goal-id "$goal_id" loopx start-goal --guided --project . --capability-route issue-fix --goal-text "$issue_request" ``` +#### Native benchmark child tasks + +**启用:** 按新 study 显式选择。先查看 runner 帮助,再给每个 arm 的已授权新运行命令加入同一个 `--native-child-limit 3`。仅用于采用 native feedback 的非游戏任务,不修改运行中的 study。 + +**验证:** 逐臂核对新 profile 的原生 child limit 和 LoopX Goal spawn policy 读回。恢复工作必须匹配冻结配置;配置通过不代表模型实际完成了有用协作。 + +**停用 / 回退:** 另开不带该参数的 study。由原生宿主关闭已完成 child session;不通过改动继续中的会话回退。 + +**权限边界:** 仅 parent 提交候选。child 共用原任务的时间、账号、访问范围和 Stop,不增加评测机会或委派深度。省略参数保留既有配置,环境中的值不能自动启用本模式。 + +**文档:** [本版运行时指南](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/runtime/RUNTIME.md#configure-the-native-job) + +```bash +python -m benchmark.edgebench.run --help +``` + +#### Authored research case studies + +**启用:** 每次注册 source pack 时用 `--case-study-json` 显式附加阅读稿,先列出已有 pack。将该案例的 pack 注册到对应 benchmark child Goal,无需全局开关或模型执行。 + +**验证:** 列出已注册 pack,在 Research 打开案例,沿引用行回到原始来源。来源变化时保留历史提示,缺少翻译时明确显示原文。 + +**停用 / 回退:** 返回 Research records and analysis 或选择原 pack。后续注册省略阅读稿;修改阅读稿时创建新版本,保留既有来源与阅读版本。 + +**权限边界:** 阅读不运行分析或实验。注册检查来源字节和引用行范围,不认证论述蕴含关系或科学有效性;Goal 父子关系不增加访问和执行权限。 + +**文档:** [本版案例阅读指南](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/trajectory-assessment.md#authored-case-studies) + +```bash +loopx benchmark trajectory-packs --goal-id "$case_goal" --format json +loopx benchmark trajectory-pack-register --goal-id "$case_goal" --input-json "$pack_input" --source-root "$source_root" --title "$case_title" --case-study-json "$case_study" +``` + ### 发布验证 - Package version and tag: `2.3.0` / `v2.3.0`. From 92a5f2228ed82f618f9b1c4a7cd8079572d20592 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:29:26 +0800 Subject: [PATCH 25/33] fix(workspace): read complete evidence from original Task sources Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 1 + .../personal-workspace/context-drawer.tsx | 23 ++++++-- .../personal-workspace/task-request.tsx | 27 +++++---- .../task-inspector-return.mjs | 59 +++++++++++++++++-- loopx/presentation/goal_source_routing.py | 1 + .../presentation/test_linked_goal_sources.py | 48 ++++++++++++++- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 933f241741..662623992a 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -657,6 +657,7 @@ export async function readTodoRequest(goalId: string, todoId: string, signal: Ab const result = z.object({ ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(), status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(), + evidence: z.string().nullable().optional(), note: z.string().nullable().optional(), review_basis: todoUpdateSourceBasisSchema.optional(), source_access: z.object({namespace: z.string()}).optional(), }).parse(await requestJson(url, {signal})); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 06ba3826b1..141179183a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -45,7 +45,7 @@ import type { LarkGoalConnection } from "../../data/chat"; import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./i18n"; import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, hasGoalUsage } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; -import { TaskRequest } from "./task-request"; +import { TaskRequest, taskRequestIdentity, type TaskRequestRead } from "./task-request"; import { parseTodoResumeCondition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; @@ -145,6 +145,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const { locale, t } = useWorkspaceI18n(); const [correction, setCorrection] = useState(""); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); + const [taskRequestRead, setTaskRequestRead] = useState(null); const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle"); const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record"); const [runActions, setRunActions] = useState>({}); @@ -355,6 +356,15 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.item.goalId; const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace; const taskReadable = !readOnly || Boolean(taskNamespace); + const taskDetail = selection.kind === "todo" && taskRequestRead?.phase === "ready" + && taskRequestRead.identity === taskRequestIdentity(selection.item, taskReadable, taskNamespace) + ? taskRequestRead.detail : null; + // Missing fields retain compatibility with older/local request-only readers; + // an explicit null from the current source clears an outdated display hint. + const taskEvidence = taskDetail && "evidence" in taskDetail ? taskDetail.evidence + : selection.kind === "todo" ? selection.item.evidence : undefined; + const taskNote = taskDetail && "note" in taskDetail ? taskDetail.note + : selection.kind === "todo" ? selection.item.note : undefined; const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal") : selection.kind === "todo" ? selection.item.goalTitle : selection.kind === "run" ? selection.item.goalTitle @@ -684,7 +694,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ) : selection.item.detailMode ? (

{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}

- +
) : ( <> @@ -697,6 +707,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

@@ -788,13 +799,13 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {t("drawer.taskCompletionProofHint")} } : null} - {selection.item.evidence ?
+ {taskEvidence ?

{t("drawer.evidence")}

- +
: null} - {selection.item.note ?
+ {taskNote ?
{t("drawer.notes")} - +
: null} {todoCompleted ?
{t("drawer.taskCompletedTitle")}{t("drawer.taskCompletedNote")}
: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx index 6205c7fb0a..a99525808f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/task-request.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useRef, useState} from "react"; +import {useCallback, useEffect, useId, useRef, useState} from "react"; import {compactPythonWhitespace, compactPythonTextLength} from "../../../../../../loopx/control_plane/coordination/python_whitespace.ts"; import {readTodoRequest} from "../../data/chat"; import type {TodoUpdateSourceBasis} from "../../data/action-source-basis"; @@ -7,18 +7,25 @@ import {MarkdownText} from "./markdown"; import type {PersonalWorkspaceCallbacks, WorkspaceActionPreview, WorkspaceTodo} from "./personal-workspace-model"; // Per-open read/draft state, never a second Task or execution owner. -type RequestRead = {identity: string} & ( +export type TaskRequestRead = {identity: string} & ( | {phase: "loading" | "error"; detail?: never} | {phase: "ready"; detail: Awaited>} ); -export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; +export function taskRequestIdentity(todo: WorkspaceTodo, local: boolean, sourceNamespace?: string) { + return JSON.stringify([local, sourceNamespace, todo.goalId, todo.sourceTodoId, todo.requestText, + todo.status, todo.evidence, todo.note]); +} + +export function TaskRequest({todo, local, sourceNamespace, onSave, onRead}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; + onRead?: (read: TaskRequestRead) => void; onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {locale, t} = useWorkspaceI18n(); const countId = useId(); const sourceId = todo.sourceTodoId; - const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]); - const [read, setRead] = useState(null); + const identity = taskRequestIdentity(todo, local, sourceNamespace); + const [read, setRead] = useState(null); + const publishRead = useCallback((value: TaskRequestRead) => {setRead(value); onRead?.(value);}, [onRead]); const [retry, setRetry] = useState(0); const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); const [pending, setPending] = useState(null); @@ -32,10 +39,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works useEffect(() => { if (!local || !sourceId) return; const controller = new AbortController(); - setRead({identity, phase: "loading"}); + publishRead({identity, phase: "loading"}); void readTodoRequest(todo.goalId, sourceId, controller.signal, sourceNamespace).then(detail => { if (controller.signal.aborted) return; - setRead({identity, phase: "ready", detail}); + publishRead({identity, phase: "ready", detail}); if (rebaseRef.current && detail.review_basis) { const basis = detail.review_basis; setDraft(previous => previous ? {...previous, basis} : null); @@ -44,10 +51,10 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } rebaseRef.current = false; }).catch(() => { - if (!controller.signal.aborted) setRead({identity, phase: "error"}); + if (!controller.signal.aborted) publishRead({identity, phase: "error"}); }); return () => controller.abort(); - }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace]); + }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace, publishRead]); const current = local && read?.identity === identity ? read : null; const detail = current?.phase === "ready" ? current.detail : null; const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis @@ -68,7 +75,7 @@ export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: Works } const observed = await readTodoRequest(todo.goalId, sourceId, new AbortController().signal, sourceNamespace); if (!mounted.current) return; - setRead({identity, phase: "ready", detail: observed}); + publishRead({identity, phase: "ready", detail: observed}); if (observed.text !== text) throw new Error(t("drawer.requirementsReadbackChanged")); setDraft(null); setPending(null); setSaved(true); } catch (error) { diff --git a/examples/personal-workspace-browser/task-inspector-return.mjs b/examples/personal-workspace-browser/task-inspector-return.mjs index a6f22362c3..adb17f9d5a 100644 --- a/examples/personal-workspace-browser/task-inspector-return.mjs +++ b/examples/personal-workspace-browser/task-inspector-return.mjs @@ -14,12 +14,32 @@ export const taskInspectorReturnScenario = { const coverageEntries = []; const tail = " FINAL_ACCEPTANCE: every requirement is visible; stale or unavailable reads remain explicit."; const prefix = "[P0] Full queue follow-up. "; - const request = prefix + "A".repeat(988 - prefix.length - tail.length) + tail; + const request = prefix + "Read the original source, compare its accepted outcome with the retained reference, and preserve conditions and uncertainty. ".repeat(12).slice(0, 988 - prefix.length - tail.length) + tail; + const evidence = "Independent verification. The retained source contains the complete result and comparison conditions. " + + "The bounded list preview does not include the final reference, so opening this Task must read it from the same original source. " + + "Unicode evidence 证据🙂 remains intact. Retained baseline: reference-v2."; + const note = "Conditions and limitations. This synthetic example separates a reported result from independent confirmation. " + + "Missing measurements are still unknown; reading the Task does not launch an analysis or change its status. Attribution remains unknown."; for (const width of [1512, 390]) { + let metadata = {evidence, note}; const context = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width, height: 844 }, - beforeGoto(api) { + async beforeGoto(api, page) { api.todoRequestTexts.set(JSON.stringify(["progress-projection", "todo-progress-full"]), request); + api.transformStatus = payload => { + const goal = payload.attention_queue.items.find(item => item.goal_id === "progress-projection"); + for (const item of goal?.agent_todos?.items ?? []) { + if (item.todo_id === "todo-progress-full") Object.assign(item, {evidence: evidence.slice(0, 100), note: note.slice(0, 100)}); + if (item.todo_id === "todo-progress-current") item.evidence = "Current verification"; + } + }; + await page.route("**/api/chat/todo/detail?*", async route => { + const query = new URL(route.request().url()).searchParams; + if (query.get("todo_id") !== "todo-progress-full") return route.fallback(); + api.todoRequestReads.push({goalId: query.get("goal_id"), todoId: query.get("todo_id")}); + await route.fulfill({json: {ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", + text: request, status: "open", archive_state: "active", updated_at: null, ...metadata}}); + }); }, }); const { page, api } = context; @@ -78,10 +98,37 @@ export const taskInspectorReturnScenario = { await fullOpener.click(); await waitForFull(); assert.equal(await body.innerText(), request, "A cold read restores every one of the 988 source characters beyond the 500-character status projection"); + const evidenceCard = drawer.getByRole("region", {name: "证据", exact: true}); + const notes = drawer.locator('details[aria-label="备注"]'); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); + await notes.locator("summary").click(); + assert.equal(await notes.locator(".personal-md").innerText(), note); + assert.equal(api.todoRequestReads.filter(read => read.todoId === "todo-progress-full").length, 1, "Requirements, evidence and notes share one source read"); await page.screenshot({ path: resolve(outputDir, `task-request-full-${width}.png`), animations: "disabled" }); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); + // Older readers omit metadata; explicit null from the current source + // must instead clear an outdated bounded hint. + for (const fields of [{}, {evidence: null, note: null}]) { + metadata = fields; + await fullOpener.click(); + await waitForFull(); + if ("evidence" in fields) { + assert.equal(await evidenceCard.count(), 0); + assert.equal(await notes.count(), 0); + } else { + assert.match(await evidenceCard.locator(".personal-md").innerText(), /^Independent verification/); + assert.doesNotMatch(await evidenceCard.innerText(), /Retained baseline/); + await notes.locator("summary").click(); + assert.match(await notes.locator(".personal-md").innerText(), /^Conditions and limitations/); + assert.doesNotMatch(await notes.innerText(), /Attribution remains unknown/); + } + await page.keyboard.press("Escape"); + await drawer.waitFor({state: "hidden"}); + } + metadata = {evidence, note}; + // A failed or mismatched read must discard an earlier successful body, // preserve the received summary, and offer a real retry in the drawer. let nextRead = "failed"; @@ -93,7 +140,7 @@ export const taskInspectorReturnScenario = { nextRead = null; if (outcome === "late") { await new Promise(resolveWait => { releaseLate = resolveWait; }); - await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, status: "open", archive_state: "active", updated_at: null } }); + await route.fulfill({ json: { ok: true, goal_id: "progress-projection", todo_id: "todo-progress-full", text: request, evidence, note, status: "open", archive_state: "active", updated_at: null } }); lateFinished(); return; } @@ -108,8 +155,10 @@ export const taskInspectorReturnScenario = { await retry.waitFor(); assert.equal((await body.innerText()).length, 500); assert.ok(!(await body.innerText()).includes("FINAL_ACCEPTANCE"), "A previous successful body is never reused after a failed exact read"); + assert.doesNotMatch(await drawer.innerText(), /Retained baseline|Attribution remains unknown/); await retry.click(); await waitForFull(); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), evidence); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); } @@ -124,6 +173,8 @@ export const taskInspectorReturnScenario = { releaseLate(); await finished; assert.equal(await body.innerText(), "Current Todo", "A late response from the closed Task cannot overwrite the next selection"); + assert.equal(await evidenceCard.locator(".personal-md").innerText(), "Current verification"); + assert.equal(await notes.count(), 0, "A late source note cannot appear under the next Task"); await page.keyboard.press("Escape"); await drawer.waitFor({ state: "hidden" }); assert.ok(api.todoRequestReads.every(read => read.todoId), "A display-only legacy identity must never be sent as an authority id"); @@ -274,6 +325,6 @@ export const taskInspectorReturnScenario = { await exited; await rm(root, {recursive:true,force:true}); } - return { coverageEntries, note: "The 988-character source survives a bounded 500-character status; failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; + return { coverageEntries, note: "The 988-character request plus complete evidence/notes share one read beyond bounded status hints; absent fields preserve legacy compatibility and explicit null clears stale hints. Failed/mismatched reads, retry and late selection responses are explicit on packaged desktop/390px. Pointer/Escape/Enter/close/More retain the opener with reduced motion. Editing uses a disposable real File authority and Chat HTTP owner: one save, retained concurrent-conflict draft and lost successful response recovery without another apply. Workspace navigation/status is synthetic. File/SQLite hard-lease semantics are qualified separately by HTTP+CLI tests; no active Goal or model execution." }; }, }; diff --git a/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 179d47d187..55c7d7071b 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -107,6 +107,7 @@ def _todo_detail(self): return self._send_json({"ok": True, "goal_id": goal_id, "todo_id": todo_id, "text": item["text"], "status": item["status"], + "evidence": item.get("evidence"), "note": item.get("note"), "archive_state": item.get("archive_state", "active"), "updated_at": item.get("updated_at"), "source_access": row["source_access"]}) except (ValueError, TypeError, KeyError): diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index d568779ad5..8a7a6d64da 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -25,7 +25,8 @@ from loopx.control_plane.status.goal_source import GoalSource -def native_source(w, name='original', task_text='Check the original question against its evidence', done=False): +def native_source(w, name='original', task_text='Check the original question against its evidence', done=False, + task_evidence=None, task_note=None): from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority @@ -44,6 +45,10 @@ def native_source(w, name='original', task_text='Check the original question aga rows, _, _ = parse_todo_source(state.read_text()) for todo in rows['agent']: todo['schema_version'] = 'todo_item_v0' + if task_evidence is not None: + todo['evidence'] = task_evidence + if task_note is not None: + todo['note'] = task_note initialize_canonical_authority(runtime, 'solver-goal', build_todo_runtime_shadow_projection( goal_id='solver-goal', todos=rows['agent'], handoff_mode='soft_claim'), state_path=state, provider='sqlite') for goal in (('research-owner', 'other-owner') if name == 'original' else ()): @@ -78,6 +83,47 @@ def request(path, body=None, origin=None): return raw, request, {path: path.read_bytes() for path in (registry, state, log)} +@pytest.mark.parametrize('done', [False, True]) +def test_linked_task_details_keep_complete_evidence_and_notes(workspace, done): + evidence = 'Independent verification. ' + '证据🙂 ' * 180 + ' Retained baseline: reference-v2.' + note = 'Conditions and limitations. ' + '完整备注 ' * 180 + ' Attribution remains unknown.' + raw, request, before = native_source(workspace, done=done, task_evidence=evidence, task_note=note) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + query = {'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': namespace} + endpoint = '/api/chat/todo/detail?' + detail = request(endpoint + urlencode(query)) + assert detail['evidence'] == evidence and detail['note'] == note + assert detail['status'] == ('done' if done else 'open') + assert detail['source_access']['namespace'] == namespace + assert 'review_basis' not in detail + + for changed in ({'source_namespace': 'f' * 64}, {'todo_id': 'todo_missing'}): + with pytest.raises(HTTPError) as refused: + request(endpoint + urlencode({**query, **changed})) + error = json.load(refused.value) + assert not error['ok'] and not {'text', 'evidence', 'note'} & error.keys() + + source_registry = Path(raw['registry']) + source_registry.unlink() + with pytest.raises(HTTPError) as unavailable: + request(endpoint + urlencode(query)) + assert unavailable.value.code == 503 + assert 'Retained baseline' not in unavailable.value.read().decode() + source_registry.write_bytes(before[source_registry]) + assert request(endpoint + urlencode(query))['evidence'] == evidence + assert all(path.read_bytes() == data for path, data in before.items()) + assert not workspace['count'].exists() + + +def test_linked_task_details_preserve_absent_metadata(workspace): + raw, request, _ = native_source(workspace) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + detail = request('/api/chat/todo/detail?' + urlencode({ + 'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': GoalSource.parse(raw).namespace})) + assert detail['evidence'] is None and detail['note'] is None + + @pytest.mark.parametrize("separate_chat_store", [False, True]) def test_app_parent_directory_reads_original_tasks_history_and_explicit_analysis(workspace, separate_chat_store): w = workspace From 27ca9ba90a6ef2a51104c6f813d9fb6ee533a246 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:44:15 +0800 Subject: [PATCH 26/33] Preserve valid root object projections in captured replan readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/quota/turn_envelope.py | 15 +++++-- .../test_goal_state_context_transport.py | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 8e1318a32c..261af9ce82 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -62,14 +62,15 @@ def _apply_context_projection_changes( try: for change in result["changes"]: path = change["path"] - if not isinstance(path, list) or not path: - raise ValueError("projection path must be nonempty") + if not isinstance(path, list): + raise ValueError("projection path must be a list") + if not path and "remove" in change: + raise ValueError("projection root cannot be removed") parent: Any = projected for key in path[:-1]: parent = parent[key] - key = path[-1] if "remove" in change: - del parent[key] + del parent[path[-1]] continue if "from" in change: value: Any = source @@ -77,6 +78,12 @@ def _apply_context_projection_changes( value = value[part] else: value = change["value"] + if not path: + if not isinstance(value, dict): + raise ValueError("projection root must be an object") + projected = deepcopy(value) + continue + key = path[-1] if isinstance(parent, list) and key == len(parent): parent.append(deepcopy(value)) else: diff --git a/tests/control_plane/test_goal_state_context_transport.py b/tests/control_plane/test_goal_state_context_transport.py index 73850271e7..2e07d7d2a0 100644 --- a/tests/control_plane/test_goal_state_context_transport.py +++ b/tests/control_plane/test_goal_state_context_transport.py @@ -18,6 +18,49 @@ from tests.control_plane.codex_checkpoint_fixture import reference_metadata_oracle +def test_scalar_root_projection_keeps_the_complete_view_and_original_number(): + from loopx.control_plane.quota.turn_envelope import _apply_context_projection_changes + + source = {"counter": 9007199254740997} + view = {"ok": True, "goal_id": "example", "stage": "explore", "explore_basis": None} + actual = _apply_context_projection_changes(source, {"changes": [ + {"path": [], "value": view}, + {"path": ["counter"], "from": ["counter"]}, + ]}, base={}) + assert actual == {**view, "counter": 9007199254740997} + assert "counter" not in view + assert source == {"counter": 9007199254740997} + + +def test_root_source_copy_keeps_numeric_identity_without_aliasing(): + import math + + from loopx.control_plane.quota.turn_envelope import _apply_context_projection_changes + + source = {"facts": {"unsafe": 9007199254740997, "negative_zero": -0.0}} + actual = _apply_context_projection_changes(source, { + "changes": [{"path": [], "from": []}], + }, base={}) + assert actual == source and actual is not source + assert actual["facts"] is not source["facts"] + assert math.copysign(1, actual["facts"]["negative_zero"]) == -1 + actual["facts"]["unsafe"] = 0 + assert source["facts"]["unsafe"] == 9007199254740997 + + +@pytest.mark.parametrize("change", [ + {"path": [], "remove": True}, + {"path": [], "value": None}, + {"path": [], "value": []}, + {"path": [], "value": False}, +]) +def test_projection_root_must_remain_an_object(change): + from loopx.control_plane.quota.turn_envelope import _apply_context_projection_changes + + with pytest.raises(RuntimeError, match="Invalid TypeScript context projection changes"): + _apply_context_projection_changes({}, {"changes": [change]}, base={}) + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_goal_availability_transport_preserves_full_read_and_recovery(tmp_path, monkeypatch, provider): isolate_sqlite_runtime(tmp_path, monkeypatch) From 34af6a561c844a9b426bc87f881da1c1001549b3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:44:16 +0800 Subject: [PATCH 27/33] Resolve shared-topic ambiguity and return available accepted report content Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/evaluations/chat-intake.public.json | 1 + loopx/chat_agent.py | 2 ++ tests/test_chat_intake_evaluation.py | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/examples/evaluations/chat-intake.public.json b/examples/evaluations/chat-intake.public.json index cab5259ce5..ab346a7957 100644 --- a/examples/evaluations/chat-intake.public.json +++ b/examples/evaluations/chat-intake.public.json @@ -402,6 +402,7 @@ "status": "accepted", "covers": "Microsoft cash flow FY2023–2025 with annual-report citations", "content_read": true, + "content": "# report-v2 — Microsoft cash-flow comparison, FY2023–2025\n\nPublic synthetic test artifact; the following index units are fixture data, not Microsoft's actual financial figures.\n\n| Fixture year | Operating cash flow | Capital expenditure | Difference |\n| --- | ---: | ---: | ---: |\n| FY2023 | 100 | 40 | 60 |\n| FY2024 | 110 | 50 | 60 |\n| FY2025 | 120 | 60 | 60 |\n\nIn this synthetic comparison, both series rise and operating cash flow exceeds capital expenditure in every year. Sources in this fixture: annual-report-FY2023, annual-report-FY2024, annual-report-FY2025. This is the complete accepted fixture report, ready to return with its report-v2 reference.", "observed_at": "2026-09-01T12:05:00Z" } ], diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 8acfe64e8a..26576cb9aa 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -452,6 +452,7 @@ def _agent_item_text(message: dict[str, Any]) -> str: "Resolve the exact object and source; an identifier in another repository, an old waiting task or a closed-but-uncompleted object is not proof of the requested outcome. " "Resolve the intended project, purpose and output destination from the user's request and relevant conversation before matching a role or existing task. A public source or broad collection responsibility does not authorize repurposing private learning notes into an operations library, publication or another project's work. " "If current evidence shows the requested outcome is already satisfied, explain that result with its source and do not create work, delegate, propose a protected action or repeat the effect. " + "Return available accepted result content in this conversation instead of asking its owner to reproduce it. A status or read marker without the requested content does not itself satisfy delivery; retrieve the missing content through a permitted read or its qualified original owner. " "A saved source, read receipt or recorded proposal is not proof that the requested outcome works. Separate source duplication, applicable new information and independently verified completion; an unchanged source may still expose unfinished work, while changed bytes may add no useful information. " "A request for explanation, fact checking, comparison or judgment normally needs your analysis, not automatic assignment. " "Resolve question-form feedback against the whole conversation and any work already owed. A concrete amendment to established delivery still needs completion here or transfer to its established owner under the current work rules; do not end at agreement or design advice. Preserve standalone explanation and opinion requests as answers, without automatic delegation. " @@ -461,6 +462,7 @@ def _agent_item_text(message: dict[str, Any]) -> str: "Resolve ordinary shorthand against the requested subject, project, purpose and destination. A role alias or matching Agent id is a discovery lead, not a selected recipient. Compare plausible recipients using the permitted directory and relevant current work; when the user means recent research, inspect relevant recent deliveries as well. Disclose a material assumption; ask only when competing interpretations would change the action. Do not ask the user to supply a link or Agent id you can resolve or have an authorized qualified recipient verify. " "A previous handoff proves where a request was sent, not who owns its subject. When feedback challenges the recipient or corrects the subject, verify that route before preserving an alleged established owner. " "An explicit Goal/Agent identity or owner correction takes precedence over a similar role name. Preserve the full goal_id/agent_id pair. " + "Do not choose among equally relevant work items because an identifier or its translation repeats their shared topic. Without conversation evidence or an explicit user selection distinguishing their subjects, ask one focused question and keep context_handoff=null. " "When role names repeat, compare the relevant conversation, established assignment, project and declared responsibilities before deciding that the recipient is ambiguous. Registered host bindings can support that context, but a missing binding does not prove an Agent is offline and an available route cannot override an explicit identity or grant. " "A corrected handoff does not retract a delivered misroute. Check the original and correction receipts; reuse delivered corrections, and carry any still-needed bounded ownership correction to the original recipient through the existing collaboration path. Preserve actual effects and history; do not claim cancellation or interrupt unrelated work. " "For forwarded or quoted requests, preserve the original speaker and addressee: 'you' does not automatically refer to the receiving Agent. Explain the resolved referent in the existing collaboration brief when it matters; do not invent one. " diff --git a/tests/test_chat_intake_evaluation.py b/tests/test_chat_intake_evaluation.py index 1499c06294..5c694ebc91 100644 --- a/tests/test_chat_intake_evaluation.py +++ b/tests/test_chat_intake_evaluation.py @@ -54,6 +54,22 @@ def test_satisfied_outcome_needs_evidence_and_no_competing_effect(): assert not evaluation.score(case, {"message": "Done."})["passed"] +def test_accepted_report_fixture_supplies_the_content_it_claims_to_have_read(): + suite = json.loads((ROOT / "examples/evaluations/chat-intake.public.json").read_text()) + case = next(row for row in suite["cases"] if row["id"] == "already-accepted-report") + context = suite["contexts"][case["context_ref"]] + report = next(row for row in context["external_observations"] if row["ref"] == "report-v2") + assert report["content_read"] is True and report["status"] == "accepted" + assert isinstance(report["content"], str) and report["content"].strip() + assert "report-v2" in report["content"] + assert "synthetic" in report["content"] + assert evaluation.score(case, {"message": report["content"]})["passed"] + assert not evaluation.score(case, { + "message": report["content"], + "context_handoff": {"goal_id": "cashflow", "agent_id": "researcher"}, + })["passed"] + + def test_shared_intake_guidance_precedes_handoff_without_widening_runtime(): from loopx.chat_agent import CONVERSATION_INTENT_RESOLUTION_INSTRUCTION, _turn_prompt from loopx.chat_manager import manager_agent_objective From f79687f5166ed79561fcf7f38c7ea6d716914845 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:45:30 +0800 Subject: [PATCH 28/33] fix(chat): keep live work through transient progress storage pressure Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/canary/module_metric_baseline.json | 2 +- loopx/chat_runtime.py | 30 ++++-- tests/test_chat_agent.py | 112 +++++++++++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index cb12b2d3d3..6cdeb34237 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -39,7 +39,7 @@ "loopx/chat_runtime.py": { "any_count": 38, "dict_any_count": 0, - "lines": 2475 + "lines": 2485 }, "loopx/chat_server.py": { "any_count": 25, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index bb4724f066..a4f9c32984 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +import errno from contextlib import nullcontext from datetime import datetime, timedelta, timezone import hashlib @@ -191,16 +192,25 @@ def _checkpoint_locked(self, *, force: bool = False) -> None: changes = dict(self.progress) if not changes.get("first_event_at"): changes.pop("first_event_at", None) - self.store.update_turn( - self.session_id, - self.turn_id, - expected_statuses={"starting", "running"} if "status" in changes else None, - **changes, - ) - self.store.update_session( - self.session_id, - last_activity_at=str(changes["last_activity_at"]), - ) + try: + self.store.update_turn( + self.session_id, + self.turn_id, + expected_statuses={"starting", "running"} if "status" in changes else None, + **changes, + ) + self.store.update_session( + self.session_id, + last_activity_at=str(changes["last_activity_at"]), + ) + except OSError as exc: + if force or exc.errno != errno.ENOSPC: + raise + # An ancillary progress snapshot must not abandon a live provider. + # Keep it dirty for the next normal checkpoint, without a hot retry + # or another model invocation. Start/close still require persistence. + self.last_checkpoint_at = now + return self.metadata_dirty = False self.last_checkpoint_at = now diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index f530609cef..9601916a63 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -3,6 +3,7 @@ from loopx.capabilities.native_chat.codex_adapter import failed_request_readback, interrupted_result_readback import contextlib +import errno import io import json import os @@ -21,6 +22,117 @@ import pytest +@pytest.mark.parametrize("surface", ["turn", "session"]) +@pytest.mark.parametrize("failure", ["transient-space", "full-at-close", "permission", "stop", "identity-space"]) +def test_progress_storage_failure_does_not_replay_or_discard_work(tmp_path, monkeypatch, surface, failure): + from loopx.chat_store import ChatSessionStore + + store = ChatSessionStore(tmp_path / "runtime") + sid = store.create_session(goal_id="fixture", agent_id="codex", + adapter_kind="codex_app_server", upstream_thread_id="saved-thread")["session_id"] + first, _ = store.create_queued_turn(sid, client_turn_id="original", message="Original request.") + following, _ = store.create_queued_turn(sid, client_turn_id="following", message="Separate request.") + runtime = chat_runtime.ChatRuntimeController(store=store, codex_bin="unused") + target = store._turn_path(sid, first["turn_id"]) if surface == "turn" else store._session_path(sid) + replace, initialize = os.replace, chat_runtime._TurnEventBuffer.__init__ + pressure, calls, write_failures = [], [], [] + + def replace_with_pressure(source, destination): + if pressure and Path(destination) == target: + record = json.loads(Path(source).read_text()) + # Fail progress checkpoints, not the subsequent failure/claim release. + if ((surface == "session" and record.get("active_turn_id") == first["turn_id"] + and record.get("last_error_code") is None) + or (surface == "turn" and record.get("status") in ( + {"starting", "running"} if failure == "identity-space" else {"running"}))): + write_failures.append(Path(destination).name) + raise OSError(errno.EACCES if failure == "permission" else errno.ENOSPC, "Synthetic storage failure") + return replace(source, destination) + + def initialize_immediately(self, **kwargs): + initialize(self, **kwargs, metadata_checkpoint_interval_sec=0) + + class Adapter: + upstream_thread_id = "saved-thread" + + def capabilities(self): + return {} + + def healthcheck(self): + return True + + def interrupt_turn(self): + pass + + def close_session(self): + pass + + def start_turn(self, message, event_sink): + calls.append(message) + if failure == "identity-space" and len(calls) == 1: + pressure.append(True) + try: + event_sink("turn.started", {"upstream_turn_id": "native-" + str(len(calls))}) + finally: + pressure.clear() + if len(calls) == 1: + pressure.append(True) + try: + for step in range(3): + event_sink("agent.phase", {"phase": "working", "label": f"Step {step}"}) + if failure == "stop": + store.begin_turn_interrupt(sid, first["turn_id"], expected_statuses={"running"}) + finally: + if failure != "full-at-close": + pressure.clear() + return {"message": "Full original answer." if len(calls) == 1 else "Separate answer."} + + adapter = Adapter() + monkeypatch.setattr(os, "replace", replace_with_pressure) + monkeypatch.setattr(chat_runtime._TurnEventBuffer, "__init__", initialize_immediately) + monkeypatch.setattr(runtime, "_ensure_adapter", lambda *args, **kwargs: adapter) + try: + # Use the real filesystem, atomic rename, managed claim and runtime worker. + if failure == "stop": + assert store.claim_next_queued_turn(sid)["turn_id"] == first["turn_id"] + runtime._run_turn(session_id=sid, turn_id=first["turn_id"], + message=first["message"], attachments=[], adapter=adapter) + else: + runtime._drain_session_queue(session_id=sid, work_dir=tmp_path, objective="Research") + result = store.load_turn(sid, first["turn_id"]) + events = store.events_after(sid, first["turn_id"], None) + assert write_failures + if failure == "transient-space": + assert len(write_failures) == 3 + assert result["status"] == "completed" + assert result["response"]["message"] == "Full original answer." + assert result["error"] is None + assert len([e for e in events if e["kind"] == "agent.phase"]) == 3 + assert not any(e["kind"] == "turn.failed" for e in events) + independent = ChatSessionStore(tmp_path / "runtime", startup_maintenance=False) + assert independent.load_turn(sid, first["turn_id"])["response"] == result["response"] + assert [m["text"] for m in independent.messages(sid) if m["role"] == "agent"] == [ + "Full original answer.", "Separate answer."] + elif failure == "stop": + assert result["status"] == "interrupting" + assert result["response"] is None + assert not any(e["kind"] in {"turn.completed", "turn.failed"} for e in events) + else: + assert result["status"] == "failed" + assert result["response"] is None + assert result["error_code"] == "runtime_error" + assert "Synthetic storage failure" in result["error"] + if failure == "stop": + assert calls == ["Original request."] + assert store.load_turn(sid, following["turn_id"])["status"] == "queued" + else: + assert calls == ["Original request.", "Separate request."] + assert store.load_turn(sid, following["turn_id"])["status"] == "completed" + finally: + pressure.clear() + runtime.close() + + @pytest.mark.parametrize("failure_kind", ["agent", "timeout", "runtime"]) @pytest.mark.parametrize("control_unavailable", [False, True]) def test_failed_worker_preserves_original_error_and_drains_followup(tmp_path, monkeypatch, failure_kind, control_unavailable): From bda8d23dea9a8023bd9c6356838b36b35fcc5e70 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:49:10 +0800 Subject: [PATCH 29/33] chore(canary): record inherited private-conversation module size Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/canary/module_metric_baseline.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 6cdeb34237..8f16a7f934 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -83,6 +83,9 @@ "dict_any_count": 0, "lines": 2760 }, + "loopx/extensions/lark/private_conversations.py": { + "lines": 2012 + }, "loopx/heartbeat_prompt.py": { "any_count": 17, "dict_any_count": 0, From e71bf9c386dbbf05fa544e0a554505ff91848984 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:55:33 +0800 Subject: [PATCH 30/33] Keep capture selection navigation in its bounded reader owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota_capture.py | 69 +--------------------- loopx/cli_commands/quota_context_reader.py | 67 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 67 deletions(-) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index e907dd9e1f..08c6e48578 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -5,7 +5,6 @@ import hashlib import json import os -import shlex import socket import stat import subprocess @@ -29,6 +28,8 @@ native_context_page_budget, ) from .quota_context_reader import ( + _capture_selection_transport_template, + bind_capture_selection_transport as bind_capture_selection_transport, _saved_context_read_page, _context_page_output_enabled, _capture_read_reference, @@ -638,72 +639,6 @@ def fits(value: dict[str, object]) -> bool: ) from exc -def _capture_selection_transport_template(payload: dict[str, Any], args: argparse.Namespace, *, - capture_root: Path) -> str | None: - """Derive display argv without editing the captured decision.""" - interaction = payload.get("interaction_contract") - cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None - selection = cli.get("selection_command") if isinstance(cli, dict) else None - if not isinstance(selection, dict): - return None - presentation = {**payload, "interaction_contract": {**interaction, - "cli_channel": {**cli, "selection_command": dict(selection)}}} - bind_capture_selection_transport(presentation, args, capture_root=capture_root) - return presentation["interaction_contract"]["cli_channel"]["selection_command"].get("command_args_template") - - -def bind_capture_selection_transport(payload: dict[str, Any], args: argparse.Namespace, *, - capture_root: Path | None = None) -> None: - """Retain caller display/capture options in the existing selection command. - - Only transport argv is composed here; the typed quota owner still selects the - action and owns admission. Each selection invocation allocates a new capture. - """ - root = getattr(args, "decision_output_root", None) - if root is None and capture_root is None: - return - interaction = payload.get("interaction_contract") - cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None - selection = cli.get("selection_command") if isinstance(cli, dict) else None - if not isinstance(selection, dict): - return - template = selection.get("command_args_template") - if not isinstance(template, str): - return - words = shlex.split(template) - options: dict[str, str] = {} - # A saved guard already carries its capture root. Preserve it unless this - # caller explicitly selects another; otherwise use the validated cache. - if root is not None or "--decision-output-root" not in words: - selected_root = root if root is not None else capture_root - options["--decision-output-root"] = str(Path(selected_root).expanduser().absolute()) - if getattr(args, "decision_handoff_socket", None): - options["--decision-handoff-socket"] = args.decision_handoff_socket - # Selection is part of the same Turn, including a caller's explicit opt-out. - if getattr(args, "context_delivery", None) is not None: - options["--context-delivery"] = args.context_delivery - if getattr(args, "delta_delivery", None) is not None: - options["--delta-delivery"] = args.delta_delivery - if getattr(args, "codex_context_dir", None): - options["--codex-context-dir"] = args.codex_context_dir - if getattr(args, "codex_context_root", None): - options["--codex-context-root"] = args.codex_context_root - if getattr(args, "max_page_output_bytes", None) is not None: - options["--max-page-output-bytes"] = str(args.max_page_output_bytes) - replaced = set(options) - if replaced & {"--codex-context-dir", "--codex-context-root"}: - replaced.update(("--codex-context-dir", "--codex-context-root")) - for option in replaced: - while option in words: - index = words.index(option) - del words[index:index + 2] - for option, value in options.items(): - words.extend((option, value)) - if getattr(args, "turn_envelope", False) and "--turn-envelope" not in words: - words.append("--turn-envelope") - selection["command_args_template"] = shlex.join(words) - - def handoff_decision(directory: Path | None, socket_path: str | None, context_delivery_enabled: bool = False) -> tuple[dict[str, Any], str] | None: """Withhold the original output until the Host releases these exact bytes. diff --git a/loopx/cli_commands/quota_context_reader.py b/loopx/cli_commands/quota_context_reader.py index e7c7f7aafc..22247d21f5 100644 --- a/loopx/cli_commands/quota_context_reader.py +++ b/loopx/cli_commands/quota_context_reader.py @@ -29,6 +29,73 @@ ) +def _capture_selection_transport_template(payload: dict[str, Any], args: argparse.Namespace, *, + capture_root: Path) -> str | None: + """Derive display argv without editing the captured decision.""" + interaction = payload.get("interaction_contract") + cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None + selection = cli.get("selection_command") if isinstance(cli, dict) else None + if not isinstance(selection, dict): + return None + presentation = {**payload, "interaction_contract": {**interaction, + "cli_channel": {**cli, "selection_command": dict(selection)}}} + bind_capture_selection_transport(presentation, args, capture_root=capture_root) + return presentation["interaction_contract"]["cli_channel"]["selection_command"].get("command_args_template") + + +def bind_capture_selection_transport(payload: dict[str, Any], args: argparse.Namespace, *, + capture_root: Path | None = None) -> None: + """Retain caller display/capture options in the existing selection command. + + Only transport argv is composed here; the typed quota owner still selects the + action and owns admission. Each selection invocation allocates a new capture. + """ + root = getattr(args, "decision_output_root", None) + if root is None and capture_root is None: + return + interaction = payload.get("interaction_contract") + cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None + selection = cli.get("selection_command") if isinstance(cli, dict) else None + if not isinstance(selection, dict): + return + template = selection.get("command_args_template") + if not isinstance(template, str): + return + words = shlex.split(template) + options: dict[str, str] = {} + # A saved guard already carries its capture root. Preserve it unless this + # caller explicitly selects another; otherwise use the validated cache. + if root is not None or "--decision-output-root" not in words: + selected_root = root if root is not None else capture_root + options["--decision-output-root"] = str(Path(selected_root).expanduser().absolute()) + if getattr(args, "decision_handoff_socket", None): + options["--decision-handoff-socket"] = args.decision_handoff_socket + # Selection is part of the same Turn, including a caller's explicit opt-out. + if getattr(args, "context_delivery", None) is not None: + options["--context-delivery"] = args.context_delivery + if getattr(args, "delta_delivery", None) is not None: + options["--delta-delivery"] = args.delta_delivery + if getattr(args, "codex_context_dir", None): + options["--codex-context-dir"] = args.codex_context_dir + if getattr(args, "codex_context_root", None): + options["--codex-context-root"] = args.codex_context_root + if getattr(args, "max_page_output_bytes", None) is not None: + options["--max-page-output-bytes"] = str(args.max_page_output_bytes) + replaced = set(options) + if replaced & {"--codex-context-dir", "--codex-context-root"}: + replaced.update(("--codex-context-dir", "--codex-context-root")) + for option in replaced: + while option in words: + index = words.index(option) + del words[index:index + 2] + for option, value in options.items(): + words.extend((option, value)) + if getattr(args, "turn_envelope", False) and "--turn-envelope" not in words: + words.append("--turn-envelope") + selection["command_args_template"] = shlex.join(words) + + + def _capture_page(path: Path, raw: bytes, digest: str, *, offset: int, size: int, output_limit: int | None, instruction: bool, presentation: dict[str, Any] | None = None, From 4055e743c9d335f5ad19a1b358a90532938f22dc Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:55:34 +0800 Subject: [PATCH 31/33] Complete bilingual release recovery notes and historical attribution Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/releases/v2.3.0.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md index 17f254febf..54762ce07f 100644 --- a/docs/product/releases/v2.3.0.md +++ b/docs/product/releases/v2.3.0.md @@ -42,6 +42,8 @@ loopx doctor ### Replan Fixes +- Captured Turn recovery reads the complete original source, including small object projections. The Task inspector opens the full original evidence and notes. [#8211](https://github.com/loopx-project/loopx/pull/8211), [#8356](https://github.com/loopx-project/loopx/pull/8356), [#8361](https://github.com/loopx-project/loopx/pull/8361) +- Conversation intake asks for clarification when existing Goals are equally relevant. When an accepted report body is available, the response returns that content; a status marker alone still requires retrieval from its existing owner. [#8356](https://github.com/loopx-project/loopx/pull/8356) - A pre-bound heartbeat now keeps the actual Turn identity from its producer through admission and settlement. An expired execution claim surfaces its recovery action. [#8232](https://github.com/loopx-project/loopx/pull/8232), [#7912](https://github.com/loopx-project/loopx/pull/7912) - Current decision cues appear before supporting material. A staged read stays on its original capture across recovery, and composed settlement failures retain the concrete diagnostic. [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8267](https://github.com/loopx-project/loopx/pull/8267) - Finish the original Turn after its completed Todo moves to the archive. [#8264](https://github.com/loopx-project/loopx/pull/8264) @@ -95,6 +97,8 @@ loopx doctor - [@xyx2002OvO](https://github.com/xyx2002OvO) — Private runtime I/O and Windows long path support. [#7603](https://github.com/loopx-project/loopx/pull/7603) - [@zxdvd](https://github.com/zxdvd) — Strict Codex Turn result schemas. [#7976](https://github.com/loopx-project/loopx/pull/7976) +Historical contribution: [@JustinIan-AI](https://github.com/JustinIan-AI) introduced the opt-in Anti-Entropy code governance capability. The original merged PR metadata is unavailable; attribution is verified against the [contribution commit](https://github.com/loopx-project/loopx/commit/ac74f90c4778381b6fcedd5d76059522591c3f42). + ## Documentation & Compatibility - Rename a custom ACP endpoint using the reserved `antigravity` id before installing the new package. Keep the existing canonical File or SQLite data store and inspect Doctor before and after updating. Follow the versioned 2.x migration instructions for any remaining Markdown-only Goal. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#8130](https://github.com/loopx-project/loopx/pull/8130) @@ -413,6 +417,8 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" ### 状态内核与控制面 +- 捕获后的 Turn 恢复读取完整原始来源,并能读回小型对象投影;Task 检查面板可打开完整原始证据与备注。 [#8211](https://github.com/loopx-project/loopx/pull/8211)、[#8356](https://github.com/loopx-project/loopx/pull/8356)、[#8361](https://github.com/loopx-project/loopx/pull/8361) +- 会话请求同时匹配多个现有 Goal 时先澄清;已有已验收报告正文时直接返回内容,只有状态标记时仍由原负责人取回正文。 [#8356](https://github.com/loopx-project/loopx/pull/8356) - Goal 承诺可以配置检查并按 revision 核对结果,局部 Todo 仍按自己的验收声明完成。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) - 心跳预检、准入和结算沿用实际 Turn,恢复保持原捕获及错误诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8267](https://github.com/loopx-project/loopx/pull/8267) - 继承的 Stride 依据证据和工作形态提供指引。显式开启 Yardstick 后,满足同目标、同验收与同测量条件的不同候选才参与对照。 [#7928](https://github.com/loopx-project/loopx/pull/7928), [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261), [#8268](https://github.com/loopx-project/loopx/pull/8268) @@ -458,6 +464,8 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" - [@xyx2002OvO](https://github.com/xyx2002OvO) — 私有运行时 I/O 和 Windows 长路径支持. [#7603](https://github.com/loopx-project/loopx/pull/7603) - [@zxdvd](https://github.com/zxdvd) — 严格的 Codex Turn 结果 schema. [#7976](https://github.com/loopx-project/loopx/pull/7976) +历史贡献:[@JustinIan-AI](https://github.com/JustinIan-AI) 引入了显式启用的 Anti-Entropy 代码治理能力。原始合并 PR 元数据目前不可用,归属已依据 [贡献提交](https://github.com/loopx-project/loopx/commit/ac74f90c4778381b6fcedd5d76059522591c3f42) 核实。 + ### 文档与兼容性 - 升级前重命名同名自定义 `antigravity` ACP endpoint。保持原 canonical 存储,用 Doctor 确认升级前后的安装路径。仍有 Markdown-only Goal 时按 2.x 迁移指南处理。 [#7832](https://github.com/loopx-project/loopx/pull/7832), [#8130](https://github.com/loopx-project/loopx/pull/8130) From 561f7816ef7e1a39ec6a28508261fcee130adcaf Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:59:29 +0800 Subject: [PATCH 32/33] fix(chat): require durable events before deferring progress metadata Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/canary/module_metric_baseline.json | 2 +- loopx/chat_runtime.py | 3 +++ tests/test_chat_agent.py | 10 +++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 8f16a7f934..081b6f04cd 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -39,7 +39,7 @@ "loopx/chat_runtime.py": { "any_count": 38, "dict_any_count": 0, - "lines": 2485 + "lines": 2488 }, "loopx/chat_server.py": { "any_count": 25, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index a4f9c32984..b1b7de9510 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -207,6 +207,9 @@ def _checkpoint_locked(self, *, force: bool = False) -> None: if force or exc.errno != errno.ENOSPC: raise # An ancillary progress snapshot must not abandon a live provider. + # Continue only after its primary event journal is durable; a full + # journal must not accumulate an unbounded in-memory backlog. + self.store.flush_events(self.session_id, self.turn_id) # Keep it dirty for the next normal checkpoint, without a hot retry # or another model invocation. Start/close still require persistence. self.last_checkpoint_at = now diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index 9601916a63..92a13a9e5d 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -23,9 +23,10 @@ @pytest.mark.parametrize("surface", ["turn", "session"]) -@pytest.mark.parametrize("failure", ["transient-space", "full-at-close", "permission", "stop", "identity-space"]) +@pytest.mark.parametrize("failure", ["transient-space", "full-at-close", "permission", "stop", "identity-space", "journal-space"]) def test_progress_storage_failure_does_not_replay_or_discard_work(tmp_path, monkeypatch, surface, failure): from loopx.chat_store import ChatSessionStore + import loopx.chat_store as chat_store store = ChatSessionStore(tmp_path / "runtime") sid = store.create_session(goal_id="fixture", agent_id="codex", @@ -35,6 +36,7 @@ def test_progress_storage_failure_does_not_replay_or_discard_work(tmp_path, monk runtime = chat_runtime.ChatRuntimeController(store=store, codex_bin="unused") target = store._turn_path(sid, first["turn_id"]) if surface == "turn" else store._session_path(sid) replace, initialize = os.replace, chat_runtime._TurnEventBuffer.__init__ + append = chat_store._append_jsonl_rows pressure, calls, write_failures = [], [], [] def replace_with_pressure(source, destination): @@ -52,6 +54,11 @@ def replace_with_pressure(source, destination): def initialize_immediately(self, **kwargs): initialize(self, **kwargs, metadata_checkpoint_interval_sec=0) + def append_with_pressure(path, rows): + if failure == "journal-space" and pressure and path == store._event_path(sid, first["turn_id"]): + raise OSError(errno.ENOSPC, "Synthetic storage failure") + return append(path, rows) + class Adapter: upstream_thread_id = "saved-thread" @@ -89,6 +96,7 @@ def start_turn(self, message, event_sink): adapter = Adapter() monkeypatch.setattr(os, "replace", replace_with_pressure) + monkeypatch.setattr(chat_store, "_append_jsonl_rows", append_with_pressure) monkeypatch.setattr(chat_runtime._TurnEventBuffer, "__init__", initialize_immediately) monkeypatch.setattr(runtime, "_ensure_adapter", lambda *args, **kwargs: adapter) try: From 2941018e47588bddff5613335f26dc317282c93c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:40:15 +0800 Subject: [PATCH 33/33] fix(release): deliver current task operations in native first reads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/releases/v2.3.0.md | 4 +- loopx/canary/module_metric_baseline.json | 2 +- .../quota/progressive_context.ts | 11 ++- tests/control_plane/test_agent_preferences.py | 72 ++++++++++--------- .../test_codex_operation_launch.py | 26 ++++--- .../control_plane/test_context_first_read.py | 27 +++++++ .../test_heartbeat_command_authority.py | 8 +-- .../test_turn_launch_identity.py | 3 +- 8 files changed, 101 insertions(+), 52 deletions(-) diff --git a/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md index 54762ce07f..1e1e7d039e 100644 --- a/docs/product/releases/v2.3.0.md +++ b/docs/product/releases/v2.3.0.md @@ -42,7 +42,7 @@ loopx doctor ### Replan Fixes -- Captured Turn recovery reads the complete original source, including small object projections. The Task inspector opens the full original evidence and notes. [#8211](https://github.com/loopx-project/loopx/pull/8211), [#8356](https://github.com/loopx-project/loopx/pull/8356), [#8361](https://github.com/loopx-project/loopx/pull/8361) +- Captured Turn recovery reads the complete original source, including small object projections. Native first reads include the current task operation and its declared continuation. The Task inspector opens the full original evidence and notes. [#8211](https://github.com/loopx-project/loopx/pull/8211), [#8356](https://github.com/loopx-project/loopx/pull/8356), [#8361](https://github.com/loopx-project/loopx/pull/8361) - Conversation intake asks for clarification when existing Goals are equally relevant. When an accepted report body is available, the response returns that content; a status marker alone still requires retrieval from its existing owner. [#8356](https://github.com/loopx-project/loopx/pull/8356) - A pre-bound heartbeat now keeps the actual Turn identity from its producer through admission and settlement. An expired execution claim surfaces its recovery action. [#8232](https://github.com/loopx-project/loopx/pull/8232), [#7912](https://github.com/loopx-project/loopx/pull/7912) - Current decision cues appear before supporting material. A staged read stays on its original capture across recovery, and composed settlement failures retain the concrete diagnostic. [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8267](https://github.com/loopx-project/loopx/pull/8267) @@ -417,7 +417,7 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" ### 状态内核与控制面 -- 捕获后的 Turn 恢复读取完整原始来源,并能读回小型对象投影;Task 检查面板可打开完整原始证据与备注。 [#8211](https://github.com/loopx-project/loopx/pull/8211)、[#8356](https://github.com/loopx-project/loopx/pull/8356)、[#8361](https://github.com/loopx-project/loopx/pull/8361) +- 捕获后的 Turn 恢复读取完整原始来源,并能读回小型对象投影;原生首读提供当前任务操作及其声明的后续命令,Task 检查面板可打开完整原始证据与备注。 [#8211](https://github.com/loopx-project/loopx/pull/8211)、[#8356](https://github.com/loopx-project/loopx/pull/8356)、[#8361](https://github.com/loopx-project/loopx/pull/8361) - 会话请求同时匹配多个现有 Goal 时先澄清;已有已验收报告正文时直接返回内容,只有状态标记时仍由原负责人取回正文。 [#8356](https://github.com/loopx-project/loopx/pull/8356) - Goal 承诺可以配置检查并按 revision 核对结果,局部 Todo 仍按自己的验收声明完成。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) - 心跳预检、准入和结算沿用实际 Turn,恢复保持原捕获及错误诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8267](https://github.com/loopx-project/loopx/pull/8267) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 081b6f04cd..a5bbb1e91e 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -34,7 +34,7 @@ "dict_any_count": 0 }, "loopx/chat_agent.py": { - "lines": 2043 + "lines": 2045 }, "loopx/chat_runtime.py": { "any_count": 38, diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 6d971e43f0..eafe9814f0 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -547,7 +547,7 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly include("agent_lane_next_action", "next_step"); include("agent_lane_next_action", "next_action_basis"); include("interaction_contract", "user_channel"); - for (const field of ["primary_action", "delivery_allowed", "must_attempt", "quiet_noop_allowed"]) + for (const field of ["primary_action", "delivery_allowed", "must_attempt", "quiet_noop_allowed", "next_task_action"]) include("interaction_contract", "agent_channel", field); const turnBindingRequired = object(interaction.cli_channel)?.turn_binding_required === true; if (turnBindingRequired) @@ -691,7 +691,14 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly if (!turnBindingRequired) for (const field of ["execution_preparation", "next_cli_actions", "spend_allowed_now", "spend_after_validation", "spend_policy", "quota_spend_source", "delivery_workspace_causality", "vision_continuation_audit"]) - if (field !== "next_cli_actions" || !repeatsSettlement) include("interaction_contract", "cli_channel", field); + if (field !== "next_cli_actions" || !repeatsSettlement) { + const before = pointers.length; + include("interaction_contract", "cli_channel", field); + // A task-facing operation and its declared continuation belong in the + // same opening. Keep the existing exact settlement deduplication. + if (field === "next_cli_actions" && object(channel?.next_task_action) && pointers.length > before) + opening.push(pointers.pop()!); + } // Explicit selection clears next_cli_actions. Deliver the owner's discovery // and same-Turn binding recipe alongside its non-exhaustive suggestions. if (cli?.selection_required === true) { diff --git a/tests/control_plane/test_agent_preferences.py b/tests/control_plane/test_agent_preferences.py index 4ca7afffad..fb69b6de9a 100644 --- a/tests/control_plane/test_agent_preferences.py +++ b/tests/control_plane/test_agent_preferences.py @@ -1,18 +1,21 @@ """Production CLI -> durable store -> next-turn disclosure; no provider mocks.""" import json -import re from copy import deepcopy from pathlib import Path import pytest from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority, render_prompt -from loopx.control_plane.quota.turn_envelope import prepare_context_delivery, restore_context_delivery +from loopx.control_plane.quota.turn_envelope import prepare_context_delivery, restore_context_delivery, turn_plan_host_source, quota_action_signature_document, build_turn_envelope from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, _run_cli, _write_fixture, ) +def _plan_authority(plan): + return extract_turn_authority(turn_plan_host_source(plan)) + + def _assert_initial_preferences(payload, directory, expected): from loopx.cli_commands.quota_capture import native_context_capture_view from tests.control_plane.test_context_first_read import read_selection @@ -67,10 +70,10 @@ def call(action, *args): assert "semantic-preference agent" not in json.dumps(payload) assert "preferences" not in payload["interaction_contract"]["agent_channel"]["work_context"]["instruction"] assert not (runtime / "agent-preferences").exists() - rc, empty_plan = _run_cli(registry, runtime, "turn", "plan", *scope) + rc, empty_plan = _run_cli(registry, runtime, "turn", "plan", "--include-transaction-detail", *scope) assert rc == 0, empty_plan assert "semantic_preference.agent_context" not in json.dumps(empty_plan) - assert "semantic-preference agent" not in render_prompt(extract_turn_authority(empty_plan)) + assert "semantic-preference agent" not in render_prompt(_plan_authority(empty_plan)) args = ("--key", "review.collaboration", "--statement", "Ask designated-reviewer before merging.", "--source-ref", "owner-message-1", "--source-quote", "Please use the designated reviewer.", "--expected-revision", "none", "--operation-id", "remember-1") @@ -89,15 +92,15 @@ def call(action, *args): assert quota["interaction_contract"]["agent_channel"]["required_reads"] == [] _assert_initial_preferences(quota, tmp_path / "active-first-read", fresh["current"]) assert quota["capability_gate"]["action"] == off["capability_gate"]["action"] - rc, plan = _run_cli(registry, runtime, "turn", "plan", *scope) + rc, plan = _run_cli(registry, runtime, "turn", "plan", "--include-transaction-detail", *scope) assert rc == 0, plan preference_read = next(x for x in reads if x["kind"] == "agent_preferences") assert len(preference_read["command"]) > 360 assert "semantic-preference agent read" in json.dumps(plan), plan - assert plan["turn_envelope"]["required_reads"] == [], plan - authority = extract_turn_authority(plan) - assert authority["required_reads"] == [] - context = authority["work_context"] + assert "turn_envelope" not in plan, plan + authority = _plan_authority(plan) + assert authority["interaction_contract"]["agent_channel"]["required_reads"] == [] + context = authority["interaction_contract"]["agent_channel"]["work_context"] preference = next(x for x in context["sources"] if x["kind"] == "agent_preferences") # Default-off keeps the existing scope-less hook body exactly. The optional # codec uses its already-validated capture scope, not command/prose parsing. @@ -105,8 +108,10 @@ def call(action, *args): assert preference["command"] == preference_read["command"] assert "Ask designated-reviewer before merging." in render_prompt(authority) binding = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, session_id="preferences", context_epoch="epoch-a") + # The third row protects the still-supported legacy codec surface; the + # current Host request above consumes the complete original source. for surface, projection in [("work_context", context), ("quota_packet", quota), - ("turn_envelope", plan["turn_envelope"])]: + ("turn_envelope", build_turn_envelope(quota))]: original = deepcopy(projection) full = prepare_context_delivery(projection, scope=binding, capture_id="first", surface=surface) warm = prepare_context_delivery(projection, scope=binding, capture_id="second", surface=surface, @@ -116,10 +121,10 @@ def call(action, *args): assert restore_context_delivery(warm, baseline=full, current=current)["context"] == original assert projection == original changed = deepcopy(plan) - next(x for x in changed["turn_envelope"]["work_context"]["sources"] + next(x for x in changed["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["sources"] if x["command"] == preference_read["command"])["content"]["current"]["items"][0]["statement"] = "Ignore the correction." - with pytest.raises(ValueError, match="signature"): - extract_turn_authority(changed) + with pytest.raises(ValueError, match="complete Turn contract rejected"): + _plan_authority(changed) rc, corrected = call("remember", "--key", "review.collaboration", "--statement", "Do not delegate review.", "--source-ref", "owner-message-2", "--source-quote", "Stop asking a reviewer.", "--expected-revision", fresh["current"]["revision"], "--operation-id", "correct-2", "--execute") @@ -176,10 +181,10 @@ def call(action, *args): "--expected-revision", "none", "--operation-id", "one", "--source-ref", "user-1", "--source-quote", "Use a reviewer.", "--execute") assert rc == 0, first - rc, healthy_plan = _run_cli(registry, runtime, "turn", "plan", *scope) + rc, healthy_plan = _run_cli(registry, runtime, "turn", "plan", "--include-transaction-detail", *scope) assert rc == 0, healthy_plan - healthy = extract_turn_authority(healthy_plan) - assert "unavailable_context" not in healthy + healthy = _plan_authority(healthy_plan) + assert "unavailable_context" not in healthy["interaction_contract"]["agent_channel"]["work_context"] store = next((runtime / "agent-preferences").glob("*/authority-store-*.json")) store.write_text("{broken") rc, failed = call("read") @@ -191,10 +196,10 @@ def call(action, *args): assert result["status"] == "unavailable" assert store.read_text() == "{broken" - rc, failed_plan = _run_cli(registry, runtime, "turn", "plan", *scope) + rc, failed_plan = _run_cli(registry, runtime, "turn", "plan", "--include-transaction-detail", *scope) assert rc == 0, failed_plan - authority = extract_turn_authority(failed_plan) - unavailable = authority["unavailable_context"] + authority = _plan_authority(failed_plan) + unavailable = authority["interaction_contract"]["agent_channel"]["work_context"]["unavailable_context"] assert unavailable["affected_hooks"] == [{ "hook_id": "semantic_preference.agent_context", "capability_id": "semantic-preference", "status": "unavailable", "error_code": "agent_preferences_unreadable", @@ -203,18 +208,20 @@ def call(action, *args): assert unavailable["dependent_action_policy"] == "hold_until_fresh_context" assert unavailable["independent_work_policy"] == "preserve_existing_authority" assert authority["primary_action"] == healthy["primary_action"] - assert authority["write_scope"] == healthy["write_scope"] - assert authority["required_reads"] == [] + assert authority["goal_boundary"] == healthy["goal_boundary"] + assert authority["interaction_contract"]["agent_channel"]["required_reads"] == [] prompt = render_prompt(authority) - assert "discard cached context" in prompt and "Independent work may" in prompt + rendered = json.loads(prompt.split("Turn request:\n", 1)[1].split("\n\n", 1)[0]) + assert rendered["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["unavailable_context"] == unavailable + assert "work_context" in prompt and "independent work retains existing authority" in prompt assert "Use a reviewer." not in prompt and "{broken" not in prompt rc, quota_envelope = _run_cli(registry, runtime, "quota", "should-run", *scope, "--turn-envelope") assert rc == 0, quota_envelope assert quota_envelope["contract_capsule"]["unavailable_context"] == unavailable changed = deepcopy(failed_plan) - del changed["turn_envelope"]["contract_capsule"]["unavailable_context"] - with pytest.raises(ValueError, match="signature"): - extract_turn_authority(changed) + del changed["work_contract"]["interaction_contract"]["agent_channel"]["work_context"]["unavailable_context"] + with pytest.raises(ValueError, match="complete Turn contract rejected"): + _plan_authority(changed) @pytest.mark.parametrize("other_scope", ["goal", "agent"]) @@ -238,7 +245,7 @@ def test_shared_runtime_preserves_unconfigured_scope_projection(tmp_path, other_ def projections(): result = [] - for command in [("quota", "should-run"), ("turn", "plan")]: + for command in [("quota", "should-run"), ("turn", "plan", "--include-transaction-detail")]: rc, payload = _run_cli(registry, runtime, *command, *scope) assert rc == 0, payload result.append(payload) @@ -254,13 +261,12 @@ def projections(): after = projections() for old, new in zip(before, after): assert new.get("turn_start_capability_hook_dispatch") == old.get("turn_start_capability_hook_dispatch") - old_authority, new_authority = map(extract_turn_authority, (before[1], after[1])) - for authority in (old_authority, new_authority): - # The complete observation includes a fresh clock. Keep every signed - # action dimension; only the raw observation's digest may differ. - observed_digest = authority["action_signature"].pop("source_decision_hash") - assert re.fullmatch(r"sha256:[0-9a-f]{64}", observed_digest) - assert new_authority == old_authority + old_authority, new_authority = map(_plan_authority, (before[1], after[1])) + # The complete source retains each observation's clock and transaction. + # Compare all registered action dimensions through their canonical owner, + # and retain the exact current context rather than a historical Host alias. + assert quota_action_signature_document(new_authority) == quota_action_signature_document(old_authority) + assert new_authority["interaction_contract"]["agent_channel"]["work_context"] == old_authority["interaction_contract"]["agent_channel"]["work_context"] rc, empty = _run_cli(registry, runtime, "semantic-preference", "agent", "read", *scope) assert rc == 0 and empty["current"]["items"] == [] assert len(list((runtime / "agent-preferences").iterdir())) == 1 diff --git a/tests/control_plane/test_codex_operation_launch.py b/tests/control_plane/test_codex_operation_launch.py index 3e8acbf9ca..c0df971b3d 100644 --- a/tests/control_plane/test_codex_operation_launch.py +++ b/tests/control_plane/test_codex_operation_launch.py @@ -124,13 +124,13 @@ def stop_before_start(**kwargs): monkeypatch.setattr(CodexChatAgentSession, "start", stop_before_start) else: - original_prompt = codex_operation_host._prompt + original_input = codex_operation_host.prepare_codex_cli_input def stop_before_turn(request): stop_goal() - return original_prompt(request) + return original_input(request) - monkeypatch.setattr(codex_operation_host, "_prompt", stop_before_turn) + monkeypatch.setattr(codex_operation_host, "prepare_codex_cli_input", stop_before_turn) code, payload = _cli([*arguments, "--execute"]) assert len(stopped) == 1, payload assert (turns.read_bytes() if turns.exists() else None) == before_turns, payload @@ -246,9 +246,13 @@ def test_native_turn_dispatch_failure_drains_before_stop_writer_is_released( 1, )) arguments[arguments.index("--timeout-seconds") + 1] = "1" - original_prompt = codex_operation_host._prompt - monkeypatch.setattr(codex_operation_host, "_prompt", - lambda request: original_prompt(request) + "\n" + "x" * (8 * 1024 * 1024)) + original_input = codex_operation_host.prepare_codex_cli_input + + def oversized_input(request): + text, schema = original_input(request) + return text + "\n" + "x" * (8 * 1024 * 1024), schema + + monkeypatch.setattr(codex_operation_host, "prepare_codex_cli_input", oversized_input) sessions, drained_before_release = [], [] entered = threading.Event() original_start = CodexChatAgentSession.start @@ -314,10 +318,14 @@ def test_guarded_native_dispatch_preserves_large_unicode_input(tmp_path, monkeyp _, _, _, turns, arguments = _fixture(tmp_path, monkeypatch, "sqlite") observed = tmp_path / "accepted-native-input" monkeypatch.setenv("FAKE_OPERATION_PROMPT_FILE", str(observed)) - original_prompt = codex_operation_host._prompt + original_input = codex_operation_host.prepare_codex_cli_input supplement = "完整输入☃" * (1024 * 1024) - monkeypatch.setattr(codex_operation_host, "_prompt", - lambda request: original_prompt(request) + "\n" + supplement) + + def supplemented_input(request): + text, schema = original_input(request) + return text + "\n" + supplement, schema + + monkeypatch.setattr(codex_operation_host, "prepare_codex_cli_input", supplemented_input) code, payload = _cli([*arguments, "--execute"]) assert code == 0 and payload["effects"]["host_invoked"], payload assert turns.read_text().splitlines() == ["owned-app-server-thread"] diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 8c409607ec..915e4ceaef 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -2,6 +2,7 @@ from __future__ import annotations from argparse import Namespace +from copy import deepcopy import hashlib import json from pathlib import Path @@ -356,6 +357,32 @@ def invoke(command): assert not registry.exists() and not runtime.exists() + +@pytest.mark.parametrize("bound", [False, True]) +def test_first_read_delivers_current_task_operation_with_its_continuation(tmp_path, decision, bound): + action = {"operation": "read_private_source", "target_ref": "fixture/private-source.json", + "future_constraint": {"allowed": False, "measurement": 2**60 + 1}} + commands = ["loopx quota should-run --goal-id example --agent-id worker"] + decision["interaction_contract"]["agent_channel"]["next_task_action"] = action + decision["interaction_contract"]["cli_channel"].update( + turn_binding_required=bound, next_cli_actions=commands) + original = deepcopy(decision) + view = native_context_capture_view(decision, tmp_path) + _page, values = read_selection(view["first_read_command"]) + + def delivered(pointer): + root = max((key for key in values if pointer == key or pointer.startswith(key + "/")), key=len) + value = values[root] + for key in pointer[len(root):].split("/")[1:]: + value = value[key] + return value + + assert delivered("/interaction_contract/agent_channel/next_task_action") == action + assert delivered("/interaction_contract/cli_channel/next_cli_actions") == commands + assert json.loads(Path(view["recovery"]["path"]).read_bytes()) == original + assert decision == original + + def test_initial_read_coalesces_whole_contract_with_exact_values_and_unknown_obligations(tmp_path, decision): cli = decision["interaction_contract"]["cli_channel"] cli["settlement_plan"]["ordered_steps"] = [{ diff --git a/tests/control_plane/test_heartbeat_command_authority.py b/tests/control_plane/test_heartbeat_command_authority.py index 59777fac58..6487b890ad 100644 --- a/tests/control_plane/test_heartbeat_command_authority.py +++ b/tests/control_plane/test_heartbeat_command_authority.py @@ -7,7 +7,7 @@ import pytest import test_quota_settlement_cli as cli -from loopx.control_plane.quota.turn_envelope import build_turn_envelope +from loopx.control_plane.quota.turn_envelope import build_turn_envelope, turn_plan_host_source from loopx.control_plane.work_items.interaction_contract import build_interaction_contract from loopx.control_plane.turn_driver.host_candidate import extract_turn_authority @@ -104,10 +104,10 @@ def test_current_selection_and_settlement_override_goal_command(tmp_path, provid assert all(saved.read_bytes() == original for saved, original in captures) code, plan = cli._run_cli(registry, runtime, "turn", "plan", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, *binding, - "--turn-instance-id", "read-only-command-plan", cwd=project) + "--turn-instance-id", "read-only-command-plan", "--include-transaction-detail", cwd=project) assert code == 0, plan - assert cli.TODO_ID in extract_turn_authority(plan)["primary_action"] - assert COMMAND not in json.dumps(plan["turn_envelope"]["contract_capsule"]) + assert cli.TODO_ID in extract_turn_authority(turn_plan_host_source(plan))["primary_action"] + assert COMMAND not in extract_turn_authority(turn_plan_host_source(plan))["primary_action"] @pytest.mark.parametrize("mode", ["monitor_quiet_skip", "autonomous_replan", "agent_workspace_repair"]) diff --git a/tests/control_plane/test_turn_launch_identity.py b/tests/control_plane/test_turn_launch_identity.py index bba5195961..85485651da 100644 --- a/tests/control_plane/test_turn_launch_identity.py +++ b/tests/control_plane/test_turn_launch_identity.py @@ -9,6 +9,7 @@ from host_process_fixture import write_python_host_fixture from test_replan_successor_guard_reentry import AGENT, GOAL, _fixture +from loopx.control_plane.quota.turn_envelope import read_turn_plan_work def _host(root: Path, *, material: bool) -> Path: @@ -81,7 +82,7 @@ def test_native_cli_uses_original_binding_and_replays_without_new_effects( obligation_id = None subject = todo_id if not todo_id: - envelope = preview["turn_envelope"] + envelope = read_turn_plan_work(preview) obligation_id = envelope["replan_action_packet"]["obligation_id"] assert obligation_id.startswith("replan-") subject = f"autonomous_replan:{obligation_id}"