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/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..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 @@ -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 @@ -591,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 54feab7b88..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 @@ -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 未覆盖的源变化、必需来源缺失、不兼容消费者和结算响应丢失。 @@ -435,6 +446,26 @@ C1 首个 PR 必须覆盖上述两个生产组合调用点,并交付至少一 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。 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/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..abb6631278 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,15 +697,19 @@ 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", "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) @@ -784,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/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 52918e6f30..b2c75e58a5 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -1232,7 +1232,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..5d2a6d5143 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,24 @@ 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 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: + 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 +810,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 +913,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 +962,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..569256429f 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"] @@ -34,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, @@ -63,19 +82,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 +245,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 +279,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 @@ -222,6 +308,11 @@ def replan_prompt_instruction(authority: Mapping[str, Any]) -> str: ) +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. @@ -229,71 +320,103 @@ 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" + 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" + 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" + "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 = 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 " + "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 +574,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/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-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-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/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。 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/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/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] 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..463c3e3e2c --- /dev/null +++ b/tests/control_plane/test_complete_host_request.py @@ -0,0 +1,193 @@ +"""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 + + +@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, + 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..b2b8880f5e 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,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()) + # 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) @@ -86,6 +93,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 +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["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 @@ -181,6 +193,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..8fd7d50477 100644 --- a/tests/control_plane/test_turn_envelope_settlement.py +++ b/tests/control_plane/test_turn_envelope_settlement.py @@ -102,6 +102,7 @@ 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"] == "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"] diff --git a/tests/control_plane/test_turn_host_context_delivery.py b/tests/control_plane/test_turn_host_context_delivery.py index 973d1b8c6f..7b8435015a 100644 --- a/tests/control_plane/test_turn_host_context_delivery.py +++ b/tests/control_plane/test_turn_host_context_delivery.py @@ -3,15 +3,24 @@ from copy import deepcopy import json +import os +from pathlib import Path import shlex import subprocess import sys 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 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 +35,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) + # 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__)"], + 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 +56,29 @@ 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)["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") if not context: @@ -70,59 +102,81 @@ 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} + # 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 = 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) 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]) + # 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 + 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 +200,18 @@ 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 - plan = build_loopx_turn_plan(envelope, host="dsh", execution_mode="isolated-headless", + original = (capture / "decision.json").read_bytes() + source = json.loads(original) + 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 + 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=plan["work_contract"], 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 +223,45 @@ 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) + 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"] == 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"] + 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 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") 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..b7c21a5231 100644 --- a/tests/test_delegation_turn_context.py +++ b/tests/test_delegation_turn_context.py @@ -225,6 +225,7 @@ def run(*arguments): "read_complete": values[pointer.removesuffix("unfinished_delegations") + "read_complete"]} required_reads = values["/interaction_contract/agent_channel/required_reads"] else: + 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 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..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} @@ -262,6 +274,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 @@ -603,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"] 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.