diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index 840b67f9df..362729b842 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -599,6 +599,33 @@ This closes the producer-to-detail transport gap only. Live models, packaged App/Lark, later authority checks and the remaining convergence matrix retain their independent acceptance; no Python projection is retired by this slice. +The shared Host result adapter now retains supplied text for the existing Turn +validator instead of truncating it into a passing candidate. Over-limit next +actions could lose a trailing approval constraint while still being accepted. +JSON booleans, numbers and containers also retain their types for rejection by +that validator; false/zero cannot disappear into sparse-field defaults. Legacy +null/omitted optional text retains its existing absence semantics. +Explicit delivery scale and outcome now retain their values and JSON types; +only omitted metadata uses the existing sparse defaults. Previously a declared +`surface_only`, unqualified `outcome_gap` or invalid value became +`outcome_progress`, bypassing the existing refusal and recording a quota spend. +The shared prompt uses the existing delivery vocabularies. Native File/SQLite +tests preserve valid scale history. New material results also reuse the existing +settlement rule before task validation or effect preparation: `surface_only` +and `outcome_gap` produce a typed `validation_failed` without a prepared +writeback. Previously vocabulary-valid but nonqualifying results reached +refresh-state, raised a generic CLI error and left an in-progress prepared +writeback that repeated the same refusal. The Host contract carries no typed +blocker observation; ordinary CLI blocked settlement is unchanged. Historical +cached results retain provider readback/recovery, including an unknown provider +observation followed by a committed receipt, without relaunching the Host or +repeating writeback. No settlement policy is added here. +File/SQLite native CLI and actual DSH subprocess qualification cover rejection +before task validation/writeback/spend/ACK, failed-result replay, and a corrected +fresh Turn settling once. Boundary-length valid results retain their behavior. +This is result-transport correctness; it does not qualify model adoption, undo +Host work already performed, or close the three convergence streams. + The first ordinary-CLI consolidation replaces an exactly identical `heartbeat_recommendation.blocked_priority_fallback` mirror with `blocked_priority_fallback_ref="$.blocked_priority_fallback"`. The complete root diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index de13ceb6f1..09cf650df5 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -412,6 +412,25 @@ fixture。只读规划与可选 quota capture 默认值不变,原生执行 Tur 这只关闭 producer 到 detail 的传输缺口。真实模型、打包 App/Lark、后续 authority 检查 及其余收敛矩阵仍独立验收,本切片不退役任何 Python 投影。 +共享 Host 结果适配器现在保留所提供文本,交给既有 Turn 校验器,而不通过截短使 +候选通过。超限后续行动原本可能丢失末尾审批约束却仍获接受。JSON 布尔值、数字和 +容器也保留原类型,交给同一校验器拒绝;false/零不能消失到 +稀疏字段默认值,旧 null/省略的可选文本仍按缺省处理。明确提供的交付规模与结果 +现在也保留原值及 JSON 类型,只有省略元数据时才沿用稀疏默认值。此前声明的 +`surface_only`、不满足条件的 `outcome_gap` 或非法值都会变成 `outcome_progress`, +绕过既有拒绝并记入 quota 扣费。共享 prompt 使用既有交付词汇。原生 File/SQLite +测试保留合法规模历史。新材料结果也在任务验证或效果准备前复用既有结算规则: +`surface_only` 与 `outcome_gap` 返回 typed `validation_failed`,不留下 prepared +写回。此前词汇合法但不满足结算条件的结果会进入 refresh-state,抛出通用 CLI +错误,留下反复遭遇同一拒绝的 in-progress prepared 写回。Host 合同不携带 typed +blocker observation;普通 CLI 的阻塞结算保持原行为。历史缓存结果仍走 provider +读回/恢复,覆盖 provider 返回 unknown 后读到 committed receipt,不重启 Host 或 +重复写回;此处没有新增结算策略。 +File/SQLite 原生 CLI +和实际 DSH 子进程验证任务校验、写回、扣费、ACK 前的拒绝、失败结果重放,以及 +修正后的新 Turn 只结算一次。边界长度的合法结果保持原行为。这只证明结果传输 +正确性,不证明模型采用、撤销 Host 已执行工作或关闭原三批收敛验收。 + 首个普通 CLI 整合将完全相同的 `heartbeat_recommendation.blocked_priority_fallback` 副本替换为 `blocked_priority_fallback_ref="$.blocked_priority_fallback"`。根字段的完整事实、 diff --git a/docs/reference/protocols/loopx-turn-v0.md b/docs/reference/protocols/loopx-turn-v0.md index dea27b7a7c..fe80dc7b4b 100644 --- a/docs/reference/protocols/loopx-turn-v0.md +++ b/docs/reference/protocols/loopx-turn-v0.md @@ -677,6 +677,52 @@ no-declaration result. The executor accepts those empty strings and rejects non-empty stop-result declarations. The shared material validation applies to generic CLI, Codex CLI, dsh, and optional-cloud host results. +Shared dsh/optional-cloud adapters preserve candidate text through the existing +Turn result limits. They must not truncate a supplied classification, summary, +next action, recommendation or unchanged-path reason to make it pass validation: +a trailing constraint can change the meaning. The executor rejects over-limit +text before task validation, durable writeback, quota spend or scheduler ACK. +Generated adapter diagnostics remain bounded. Existing transport/output limits +still apply, and a failed result does not undo work the Host already performed. +Candidate text fields retain their JSON types until validation: booleans, +numbers, arrays and objects are rejected, including false, zero and empty +containers. They must not become instructions through string coercion or +missing-field fallbacks. Legacy null/omitted optional text keeps its absence +semantics; sparse adapter candidates retain their established defaults. +Explicit `delivery_batch_scale` and `delivery_outcome` also pass through to +their existing validators; only omitted metadata uses the legacy +`single_surface` / `outcome_progress` defaults. The adapter must not replace a +declared result with progress or accept read-only scale aliases for new writes. +Valid vocabulary does not establish a qualifying settlement. For new material +Host results, the validator reuses the existing settlement rule before task +validation or effect preparation. `surface_only` and `outcome_gap` return +`validation_failed` at `host_result_contract`: this Host contract carries no +typed blocker observation. The failed journal retains no prepared writeback; +ordinary replay has no effects, and a corrected candidate needs a new Turn or +an explicitly admitted failed-result retry. Historical cached results keep +their provider readback and settlement recovery; a possibly committed effect +cannot be discarded by this new-candidate check. Ordinary CLI typed-blocker +settlement remains unchanged. Valid declared scales reach durable history +unchanged. + +共享 dsh/可选云端适配器保留候选结果的完整文本,交给现有 Turn 结果校验器判定上限; +不得截短分类、摘要、后续行动、建议或路径不变理由以使其通过,否则可能丢失末尾约束。 +超限结果在任务验证、持久写回、quota 扣费和 scheduler ACK 前被拒绝。适配器生成的 +诊断仍有界,原传输和输出上限保留;结果被拒绝不会撤销 Host 已经执行的工作。 +候选文本字段保留原 JSON 类型直到校验;布尔值、数字、数组和对象(含 false、零和 +空容器)会被拒绝,不得经字符串转换或缺字段兜底变成行动。旧 null/省略的可选 +文本继续按缺省处理,稀疏适配器候选保留既有默认值。 +明确提供的 `delivery_batch_scale` 与 `delivery_outcome` 也原样交给既有校验器; +只有省略元数据时才使用旧 `single_surface` / `outcome_progress` 默认值。适配器 +不得将所声明结果替换为进展,也不得让只读 scale 别名用于新写入。词汇合法不代表 +满足结算条件。新材料 Host 结果在任务校验或效果准备前复用既有结算规则: +`surface_only` 与 `outcome_gap` 返回 `host_result_contract` 阶段的 +`validation_failed`,因为当前 Host 合同不携带 typed blocker observation。 +失败 journal 不留下 prepared 写回;普通重放无效果,修正候选须通过新 Turn 或明确 +准入的失败结果重试。历史缓存结果继续走 provider 读回和结算恢复,不能由新增候选 +校验丢弃可能已经提交的效果。普通 CLI 的 typed blocker 结算保持原行为。合法声明 +的规模原样进入持久历史。 + The existing TypeScript Vision normalizer owns packet interpretation and emits the canonical `goal_vision_replan_contract_v0` shape. Its current compatibility behavior does not verify the submitted packet's top-level `schema_version`; diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index abc4c89c9f..26bcc6e1f5 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -24,7 +24,10 @@ ) from ..goals.goal_vision import normalize_goal_vision_packet from ..work_items.delivery_batch_scale import require_delivery_batch_scale -from ..work_items.delivery_outcome import require_delivery_outcome +from ..work_items.delivery_outcome import ( + explain_turn_scoped_settlement_gap, + require_delivery_outcome, +) from . import subagent_execution_topology as subagent from .command_validation import ( TaskValidator, @@ -218,7 +221,13 @@ def _bounded_public_text( required: bool, errors: list[str], ) -> str | None: - text = str(result.get(field) or "").strip() + value = result.get(field) + # Missing/null legacy optional text remains absent. Other JSON types must + # not acquire instruction meaning through Python string coercion. + if value is not None and not isinstance(value, str): + errors.append(f"{field} must be a string") + return None + text = (value or "").strip() if required and not text: errors.append(f"{field} is required") return None @@ -428,6 +437,16 @@ def validate_loopx_turn_host_result( normalized["delivery_outcome"] = require_delivery_outcome( result.get("delivery_outcome") ).value + if not from_journal: + # This Host contract carries no typed blocker observation. + # Refuse new candidates before preparing an impossible write. + # Saved results retain provider readback/recovery: an effect + # may already have committed under an older producer. + settlement_gap = explain_turn_scoped_settlement_gap( + normalized["delivery_outcome"], None, + ) + if settlement_gap: + errors.append(settlement_gap) except ValueError as exc: errors.append(str(exc)) diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index c5ba0ac51e..4887e2c154 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -12,6 +12,8 @@ from typing import Any from ..quota.turn_envelope import turn_envelope_action_signature_document +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 LOOPX_TURN_HOST_REQUEST_SCHEMA = "loopx_turn_host_request_v0" @@ -192,6 +194,8 @@ def render_prompt(authority: Mapping[str, Any]) -> str: "- summary: what changed or why stopped (<=400 chars)\n" "- recommended_action: the bounded follow-up recommendation (<=1200 chars)\n" "- next_action: the concrete next step (<=1200 chars)\n" + f"- delivery_batch_scale (material results): {' | '.join(DELIVERY_BATCH_SCALE_CHOICES)}\n" + f"- delivery_outcome (material results): {' | '.join(DELIVERY_OUTCOME_CHOICES)}\n" "- vision_unchanged_reason: why a material result's goal path is unchanged " "(<=240 chars)\n" "For material result kinds (validated_progress, repair_required, and " @@ -250,26 +254,22 @@ def parse_model_json(text: str) -> dict[str, Any] | None: return parsed if isinstance(parsed, dict) else None -def _complete_material_fields(result: dict[str, Any], kind: str) -> None: +def _complete_material_fields( + result: dict[str, Any], candidate: Mapping[str, Any], kind: str, +) -> None: """Fill the required delivery fields of a material host candidate.""" - result["delivery_batch_scale"] = "single_surface" - result["delivery_outcome"] = "outcome_progress" - # Material results require these bounded text fields; fill them from - # adjacent fields if the model returned a sparse block. - if not result.get("recommended_action"): - result["recommended_action"] = _bounded( - result.get("next_action") or result.get("classification") or kind, - limit=TEXT_LIMITS["recommended_action"], - ) - if not result.get("next_action"): - result["next_action"] = _bounded( - result.get("recommended_action"), - limit=TEXT_LIMITS["next_action"], - ) - if not result.get("classification"): - result["classification"] = _bounded( - kind, limit=TEXT_LIMITS["classification"] - ) + # Only omitted metadata gets the legacy defaults. Supplied values, including + # null and invalid JSON types, belong to the existing executor validators. + result["delivery_batch_scale"] = candidate.get("delivery_batch_scale", "single_surface") + result["delivery_outcome"] = candidate.get("delivery_outcome", "outcome_progress") + # Preserve sparse legacy candidates; the executor validates both supplied + # fields and these aliases against the same result contract. + if "recommended_action" not in result: + result["recommended_action"] = result.get("next_action") or result.get("classification") or kind + if "next_action" not in result: + result["next_action"] = result.get("recommended_action") + if "classification" not in result: + result["classification"] = kind def build_result( @@ -327,16 +327,19 @@ def build_result( "result_kind": kind, "completed_phases": list(COMPLETED_PHASES), } - for field, limit in TEXT_LIMITS.items(): + for field in TEXT_LIMITS: if field == "vision_unchanged_reason": continue value = candidate.get(field) - text = _bounded(value, limit=limit) - if text: + # The executor must see an over-limit candidate and reject it. Clipping + # can remove a trailing constraint and turn invalid input into a valid + # but semantically different next action or progress claim. + text = value.strip() if isinstance(value, str) else value + if text or (value is not None and not isinstance(value, str)): result[field] = text if kind in MATERIAL_KINDS: - _complete_material_fields(result, kind) + _complete_material_fields(result, candidate, kind) for field in ("path_delta_mode", "agent_vision_json"): if field in candidate: # Preserve the structured executor input verbatim. The executor @@ -345,10 +348,10 @@ def build_result( has_path_delta = "path_delta_mode" in candidate or "agent_vision_json" in candidate unchanged_reason = candidate.get("vision_unchanged_reason") - if unchanged_reason: - result["vision_unchanged_reason"] = _bounded( - unchanged_reason, limit=TEXT_LIMITS["vision_unchanged_reason"] - ) + if unchanged_reason is not None and not isinstance(unchanged_reason, str): + result["vision_unchanged_reason"] = unchanged_reason + elif unchanged_reason: + result["vision_unchanged_reason"] = unchanged_reason.strip() elif not has_path_delta: # Keep the legacy material-without-packet behavior for older hosts. result["vision_unchanged_reason"] = _bounded( diff --git a/tests/control_plane/test_host_result_semantics.py b/tests/control_plane/test_host_result_semantics.py new file mode 100644 index 0000000000..186d38ff2b --- /dev/null +++ b/tests/control_plane/test_host_result_semantics.py @@ -0,0 +1,114 @@ +"""A real DSH subprocess preserves the result that native settlement validates.""" +from __future__ import annotations + +import json +import sys + +import pytest + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.control_plane.test_turn_host_decision_capture import _fixture_module + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("candidate_fields,error", [ + ({"next_action": "Review the evidence. " * 65 + "Stop before publication; approval is still required."}, + "next_action exceeds 1200 characters"), + ({"next_action": {"action": "Continue", "constraint": "Stop before publication."}}, + "next_action must be a string"), + ({"next_action": False}, "next_action must be a string"), + ({"delivery_batch_scale": "single_segment"}, "legacy alias 'single_segment' is read-only"), + ({"delivery_outcome": False}, "delivery_outcome must be one of"), + ({"delivery_outcome": "surface_only"}, "cannot settle a Turn"), + ({"delivery_outcome": "outcome_gap"}, "blocked"), +], ids=["overlimit", "object", "false", "legacy-scale", "invalid-outcome", "surface-only", "untyped-gap"]) +def test_native_turn_rejects_invalid_candidate_before_writeback( + tmp_path, monkeypatch, provider, candidate_fields, error, +): + isolate_sqlite_runtime(tmp_path, monkeypatch) + f = _fixture_module() + project, runtime, workspace, registry = f._write_fixture(tmp_path, turn_count=2) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + code, listed = f._run_cli([*prefix, "todo", "list", "--goal-id", f.GOAL_ID]) + assert code == 0, listed + state = project / ".codex/goals" / f.GOAL_ID / "ACTIVE_GOAL_STATE.md" + initialize_canonical_authority(runtime, f.GOAL_ID, + build_todo_runtime_shadow_projection(goal_id=f.GOAL_ID, handoff_mode="soft_claim", todos=listed["todos"]), + state_path=state, provider=provider) + candidate = { + "result_kind": "validated_progress", "classification": "artifact_checked", + "summary": "The isolated artifact was checked.", + "recommended_action": "Continue the accepted task.", + "next_action": "Continue the accepted task.", + "vision_unchanged_reason": "The accepted Goal remains unchanged.", + **candidate_fields, + } + (workspace / "candidate.json").write_text(json.dumps(candidate)) + runner = tmp_path / "runner.py" + runner.write_text( + "import json\nfrom pathlib import Path\n" + "def run_dsh_turn(*, prompt, workspace, **kwargs):\n" + " root = Path(workspace)\n" + " with (root / 'invocations.txt').open('a') as out: out.write('host\\n')\n" + " (root / 'received.txt').write_text(prompt)\n" + " return (root / 'candidate.json').read_text()\n" + ) + validator = [sys.executable, "-c", + "import json,pathlib,sys; json.load(sys.stdin); " + "pathlib.Path('validator-ran').write_text('verified')"] + base = [*prefix, "turn", "run-once", "--goal-id", f.GOAL_ID, "--agent-id", f.AGENT_ID, + "--host", "generic-cli", "--execution-mode", "isolated-headless", "--project", str(workspace), + "--host-command-json", json.dumps([sys.executable, "-m", "loopx.dsh_goal_mode", + "--workspace", str(workspace), "--dsh-home", str(tmp_path / "dsh-home"), "--dsh-runner", str(runner)]), + "--validation-command-json", json.dumps(validator), "--scan-root", str(project), + "--no-global-sync", "--timeout-seconds", "30"] + code, failed = f._run_cli([*base, "--turn-instance-id", "invalid-result", "--execute"]) + assert code == 1 and not failed["ok"], failed + assert error in json.dumps(failed) + assert failed["schema_version"] == "loopx_turn_execution_v0" + assert failed["status"] == "failed" + assert failed["result_kind"] == "validation_failed" + assert failed["validation_stage"] == "host_result_contract" + assert failed["effects"] == { + "host_invoked": True, "state_written": False, + "quota_spent": False, "scheduler_acknowledged": False, + } + resume_key = failed["resume_turn_key"] + journal_path, = (runtime / "goals" / f.GOAL_ID / "turns").glob("*.json") + journal = json.loads(journal_path.read_text()) + assert journal["status"] == "failed" + assert journal["completed_phases"] == ["host_execute", "typed_result"] + assert not journal.get("effect_attempts") + assert "writeback" not in journal and "quota_spend" not in journal + assert not (workspace / "validator-ran").exists() + assert f._quota_spend_count(runtime) == 0 + code, current = f._run_cli([*prefix, "todo", "list", "--goal-id", f.GOAL_ID]) + assert code == 0 and current["todos"] == listed["todos"] + + # A correction is a fresh admitted Turn, not a replay of an invalid result. + candidate.update({ + "next_action": "Stop before publication; approval is still required.", + "delivery_batch_scale": "implementation", "delivery_outcome": "outcome_progress", + }) + (workspace / "candidate.json").write_text(json.dumps(candidate)) + code, repeated = f._run_cli([*base, "--resume-turn-key", resume_key, "--execute"]) + assert code == 1 and not repeated["ok"], repeated + assert repeated["status"] == "failed" and not any(repeated["effects"].values()) + assert f._quota_spend_count(runtime) == 0 + assert (workspace / "invocations.txt").read_text() == "host\n" + assert not (workspace / "validator-ran").exists() + code, recovered = f._run_cli([*base, "--turn-instance-id", "corrected-result", "--execute"]) + assert code == 0 and recovered["status"] == "committed", recovered + assert (workspace / "validator-ran").read_text() == "verified" + assert f._quota_spend_count(runtime) == 1 + rows = [json.loads(line) for line in + (runtime / "goals" / f.GOAL_ID / "runs/index.jsonl").read_text().splitlines()] + progress = next(row for row in rows if row.get("classification") == "artifact_checked") + assert progress["delivery_batch_scale"] == "implementation" + assert progress["delivery_outcome"] == "outcome_progress" + code, replay = f._run_cli([*base, "--resume-turn-key", recovered["resume_turn_key"], "--execute"]) + assert code == 0 and replay["ok"], replay + assert not any(replay["effects"].values()) + assert f._quota_spend_count(runtime) == 1 + assert (workspace / "invocations.txt").read_text() == "host\nhost\n" diff --git a/tests/test_dsh_goal_mode.py b/tests/test_dsh_goal_mode.py index 50215fd1aa..59e0ed2a03 100644 --- a/tests/test_dsh_goal_mode.py +++ b/tests/test_dsh_goal_mode.py @@ -459,6 +459,37 @@ def test_build_result_shapes_material_results_with_required_fields() -> None: assert "agent_vision_json" not in result +@pytest.mark.parametrize("kind", ["validated_progress", "repair_required", "replan_required"]) +@pytest.mark.parametrize("field,value,valid", [ + ("delivery_batch_scale", "test_only", True), + ("delivery_batch_scale", "multi_surface", True), + ("delivery_batch_scale", "implementation", True), + ("delivery_outcome", "surface_only", False), + ("delivery_outcome", "outcome_gap", False), + ("delivery_outcome", "primary_goal_outcome", True), + ("delivery_batch_scale", "single_segment", False), + ("delivery_batch_scale", "bounded_segment", False), + *[(field, value, False) + for field in ("delivery_batch_scale", "delivery_outcome") + for value in (None, False, 0, {}, [], "", "unknown")], +]) +def test_explicit_delivery_metadata_reaches_its_existing_validator(kind, field, value, valid): + request = _signed_request() + base = { + "result_kind": kind, "classification": "artifact_checked", + "next_action": "Continue the accepted task.", + "vision_unchanged_reason": "The accepted Goal remains unchanged.", + } + adapted = turn_host_adapter.build_result(request, {**base, field: value}) + assert type(adapted[field]) is type(value) and adapted[field] == value + direct = {**turn_host_adapter.build_result(request, base), field: value} + # Legal outcome names alone cannot settle a material Turn. The current + # Host result has no typed blocker observation, so outcome_gap is refused. + for result in (adapted, direct): + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert verdict["ok"] is valid, verdict["errors"] + + def _material_replan_candidate() -> tuple[dict[str, object], str]: vision = { "schema_version": "goal_vision_replan_contract_v0", @@ -632,20 +663,74 @@ def test_build_result_is_accepted_by_the_real_host_result_validator( assert verdict["ok"], verdict["errors"] -def test_build_result_bounds_every_free_text_field() -> None: +@pytest.mark.parametrize("field,limit", [ + ("classification", 120), ("summary", 400), ("next_action", 1_200), + ("recommended_action", 1_200), ("vision_unchanged_reason", 240), +]) +@pytest.mark.parametrize("kind", ["validated_progress", "wait"]) +@pytest.mark.parametrize("extra", [0, 1]) +def test_candidate_text_reaches_executor_limits_without_losing_its_tail( + field: str, limit: int, kind: str, extra: int, +) -> None: + # Independent limits from the Turn result contract. A trailing restriction + # must reach the validator, including on stop and legacy unchanged paths. + tail = "Stop before publication." + value = "验" * (limit + extra - len(tail)) + tail + candidate = { + "result_kind": kind, "classification": "implementation_refined", + "summary": "The isolated artifact was checked.", + "next_action": "Continue the accepted work.", + "recommended_action": "Read the next current decision.", + "vision_unchanged_reason": "The accepted Goal remains unchanged.", + field: value, + } + request = _signed_request() result = turn_host_adapter.build_result( - _signed_request(), - { - "result_kind": "user_action_required", - "classification": "x" * 500, - "summary": "y" * 900, - "next_action": "z" * 5000, - }, + request, candidate, ) - # Independent limits from the Turn result contract, not the adapter's table. - assert len(result["classification"]) <= 120 - assert len(result["summary"]) <= 400 - assert len(result["next_action"]) <= 1_200 + assert result[field] == value + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert verdict["ok"] is (extra == 0), verdict["errors"] + if extra: + assert f"{field} exceeds {limit} characters" in verdict["errors"] + + +@pytest.mark.parametrize("field", [ + "classification", "summary", "next_action", "recommended_action", "vision_unchanged_reason", +]) +@pytest.mark.parametrize("value", [True, False, 42, 0, 1.25, {}, [], {"action": "Continue"}, ["Continue"]]) +@pytest.mark.parametrize("kind", ["validated_progress", "wait"]) +def test_nontext_candidate_fields_reach_the_executor_without_coercion(field, value, kind): + request = _signed_request() + valid = { + "result_kind": kind, "classification": "artifact_checked", "summary": "Evidence checked.", + "next_action": "Continue the accepted task.", "recommended_action": "Read current requirements.", + "vision_unchanged_reason": "The accepted Goal is unchanged.", + } + adapted = turn_host_adapter.build_result(request, {**valid, field: value}) + assert type(adapted[field]) is type(value) and adapted[field] == value + # The generic host route must reject the same raw JSON type even without + # the shared DSH/Ark adapter. A transport cannot repair malformed meaning. + direct = {**turn_host_adapter.build_result(request, valid), field: value} + for result in (adapted, direct): + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert not verdict["ok"] + assert f"{field} must be a string" in verdict["errors"] + + +@pytest.mark.parametrize("field", [ + "classification", "summary", "next_action", "recommended_action", "vision_unchanged_reason", +]) +@pytest.mark.parametrize("kind", ["validated_progress", "wait"]) +def test_legacy_null_candidate_text_still_uses_the_existing_absent_field_behavior(field, kind): + request = _signed_request() + result = turn_host_adapter.build_result(request, { + "result_kind": kind, "classification": "artifact_checked", "summary": "Evidence checked.", + "next_action": "Continue the accepted task.", "recommended_action": "Read current requirements.", + "vision_unchanged_reason": "The accepted Goal is unchanged.", field: None, + }) + verdict = validate_loopx_turn_host_result(_turn_plan(request), result) + assert verdict["ok"], verdict["errors"] def test_adapter_runs_hermetically_through_the_module_entry() -> None: diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 0eed761ed1..c6fd88ce84 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -724,6 +724,23 @@ def test_host_result_requires_bounded_public_material_fields(field: str) -> None assert "unsupported host result fields" in " ".join(validation["errors"]) +@pytest.mark.parametrize("kind", [ + "validated_progress", "validated_completion", "repair_required", "replan_required", +]) +@pytest.mark.parametrize("outcome", ["surface_only", "outcome_gap"]) +def test_new_material_host_result_requires_qualifying_settlement(kind: str, outcome: str) -> None: + plan = _plan() + result = {**_host_result(plan), "result_kind": kind, "delivery_outcome": outcome} + + validation = validate_loopx_turn_host_result( + plan, result, completion_lifecycle_configured=True, + ) + + assert validation["ok"] is False + assert outcome in " ".join(validation["errors"]) + assert validation["result"]["delivery_outcome"] == outcome + + @pytest.mark.parametrize("packet, expected_error", [ (None, "cached agent_vision must be a JSON object"), ([], "cached agent_vision must be a JSON object"), @@ -1869,9 +1886,11 @@ def test_run_once_commits_once_and_replays_without_duplicate_effects( assert resumed["route"] == stored["plan"]["route"] +@pytest.mark.parametrize("saved_outcome", ["outcome_progress", "surface_only", "outcome_gap"]) def test_provider_can_commit_before_its_journal_checkpoint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + saved_outcome: str, ) -> None: plan = _plan() calls = {"writeback": 0, "spend": 0, "scheduler": 0} @@ -1927,6 +1946,33 @@ def fail_before_writeback_checkpoint( assert prepared["effect_ref"] in provider_records monkeypatch.setattr(turn_executor, "_write_journal", write_journal) + # Model metadata accepted by a historical producer. A new candidate gate + # cannot erase this unresolved provider commit or replace its saved result. + journal["host_result"]["delivery_outcome"] = saved_outcome + write_journal( + turn_journal_path( + tmp_path / "runtime", goal_id="fixture-goal", turn_key=journal["turn_key"], + ), + journal, + ) + unresolved = run_loopx_turn_once( + plan, + host_runner=lambda _request: pytest.fail("host must not run during recovery"), + project=tmp_path, + runtime_root=tmp_path / "runtime", + goal_id="fixture-goal", + timeout_seconds=5, + execute=True, + task_validator=_passing_validator, + writeback=writeback, + writeback_resolver=lambda _effect_ref: {"kind": "unknown", "reason": "provider unavailable"}, + spend=spend, + scheduler=scheduler, + ) + assert unresolved["result_kind"] == "writeback_failed" + assert unresolved["receipt"]["failed_phase"] == "durable_writeback" + assert _journal(tmp_path / "runtime")["effect_attempts"]["durable_writeback"] == prepared + assert calls == {"writeback": 1, "spend": 0, "scheduler": 0} resumed = run_loopx_turn_once( plan, host_runner=lambda _request: pytest.fail("host must not run during recovery"), @@ -1941,6 +1987,7 @@ def fail_before_writeback_checkpoint( "kind": "committed", "payload": provider_records[effect_ref], }, + retry_failed=True, spend=spend, scheduler=scheduler, ) @@ -1948,6 +1995,7 @@ def fail_before_writeback_checkpoint( assert resumed["status"] == "committed" assert calls == {"writeback": 1, "spend": 1, "scheduler": 1} assert "effect_attempts" not in _journal(tmp_path / "runtime") + assert _journal(tmp_path / "runtime")["host_result"]["delivery_outcome"] == saved_outcome def test_recreation_waits_for_admitted_provider_checkpoint(