diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 0b0c5c198f..0466e71284 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3809,3 +3809,38 @@ minimal dependency closure, M3/Standard/workspace/meta packaging and complete cost acceptance remain open. This does not upgrade an installed host, start a model service or establish effectiveness from protocol fixtures. Preserve all nine original requirements, the original-Goal regression and earlier failures. + +### 2026-11-13 — Successful work records a fresh checkpoint on the same route + +The generic Host result boundary previously required a fresh Vision to use +`material_replan` and `replan_required`. A successful task retaining its route +could only reuse an unchanged reason, even when it had new outcome evidence. +An explicit `path_delta_mode: unchanged` now accepts the existing Vision packet +with `path_delta.outcome: continue` or `no_change`, without an unchanged reason. +The existing TypeScript Vision, task, validation and settlement owners still +decide durable effects. Material replans, stop decisions, malformed packets and +conflicting declarations retain their refusal. Omitted modes retain legacy +inference; generic and Codex guidance describe the same boundary. + +One existing isolated inventory task used the current model's tools through a +stdio adapter and a Python 3.11 standalone core artifact on SQLite. Revised +inputs yielded quantities 22/10/5 and value 185.25, versus 172.25 previously. +Independent checks verified the 13.00 reconciliation, all six excluded rows, +reusable generator and preservation of all five original files. After the +normalized result was journaled, SIGKILL interrupted the driver and validator. +The original Turn resumed at validation without invoking the Host, completed +the Todo and spent once; completed replay added no Host, writeback or spend. +The initial invalid completion/replan reply and three resume setup refusals +remain failed attempts. There were two Host dispatches and one model work pass; +the rejected reply was not treated as confirmed work. + +Source executor/driver checks and Codex/dsh regression pass. Installed File and +SQLite checks retain legacy completion and qualify both fresh checkpoint +outcomes plus replay; the initial oversized inline test command failed its +existing bound, then the fixture used an ordinary script path. No command +budget, authority, default or historical receipt was changed. This qualifies +the touched success/checkpoint and recovery path, not full V1–V5/V10 or R1. +Complete context retention, minimal closure, progressive product control, +comparable complete costs, M3/Standard/workspace delivery and the original-Goal +regression remain open. All nine original requirements and frozen failures +remain intact; no installed host or model service was upgraded. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index 018725cfd1..3d0cad662b 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2970,3 +2970,30 @@ Core module 诊断核验实际导入的 distribution、记录中的 module,以 完整 R1/V1–V5/V10、真实模型恢复/采用、渐进产品控制、最小依赖闭包、 M3/Standard/workspace/meta 拆包与完整成本验收仍开放。本轮不升级已安装 Host、 启动模型服务或从协议夹具推断效果。原九项要求、原 Goal 回归及此前失败全部保留。 + +### 2026-11-13 — 成功工作在同一路线上记录新检查点 + +Generic Host result 边界此前要求新 Vision 使用 `material_replan` 与 +`replan_required`。即使已有新成果证据,保留路线的成功任务也只能复用 unchanged +reason。现在显式 `path_delta_mode: unchanged` 可携带既有 Vision packet, +其 `path_delta.outcome` 必须为 `continue` 或 `no_change`,且不能同时声明 +unchanged reason。既有 TypeScript Vision、任务、验证与结算 owner 仍决定持久 +效果;material replan、停止决定、非法 packet 与矛盾声明继续拒绝。省略 mode +保持旧推断,generic 与 Codex 指导说明同一边界。 + +一个既有隔离库存任务通过 stdio adapter 使用当前模型工具,以及 Python 3.11 +standalone core artifact 与 SQLite。更正输入得到数量 22/10/5、价值 185.25, +此前为 172.25。独立检查核验了 13.00 差额、全部六条排除记录、可复用生成器与 +五份原文件保持不变。Normalized result 写入 journal 后,SIGKILL 中断 driver +与 validator。原 Turn 从 validation 恢复,没有再次调用 Host,完成 Todo 并 +只结算一次;完成后的 replay 没有增加 Host、writeback 或 spend。 +首次非法 completion/replan reply 与三次 resume setup 拒绝保留为失败。 +实际有两次 Host dispatch、一次模型工作;被拒绝的 reply 未冒充已确认工作。 + +源码 executor/driver 检查与 Codex/dsh 回归通过。安装态 File、SQLite 检查保留 +legacy completion,并核验两种新 checkpoint outcome 与 replay;首次过长的 +inline 测试命令触发既有上限,夹具随后采用普通 script path。未改命令预算、 +权限、默认行为或历史 receipt。本轮只资格化所触及的成功/检查点与恢复路径, +不关闭完整 V1–V5/V10 或 R1。完整 context retention、最小闭包、渐进产品控制、 +可比完整成本、M3/Standard/workspace 交付与原 Goal 回归仍开放。原九项要求和 +冻结失败全部保留;没有升级已安装 Host 或模型服务。 diff --git a/docs/reference/protocols/loopx-turn-v0.md b/docs/reference/protocols/loopx-turn-v0.md index 0b50faf338..9925025e01 100644 --- a/docs/reference/protocols/loopx-turn-v0.md +++ b/docs/reference/protocols/loopx-turn-v0.md @@ -778,8 +778,13 @@ result unchanged: `path_delta_mode`, `agent_vision_json`, and `path_delta_mode: "material_replan"`, `result_kind: "replan_required"`, and a JSON-encoded `goal_vision_replan_contract_v0` packet whose `goal_path_delta_v0.outcome` is `replan`; it must not also claim an unchanged -reason. An unchanged path uses `path_delta_mode: "unchanged"`, no vision -packet, and a bounded `vision_unchanged_reason`. The Turn executor remains the +reason. An unchanged path uses `path_delta_mode: "unchanged"` with either a +bounded `vision_unchanged_reason` to reuse the current checkpoint, or a fresh +vision packet whose `goal_path_delta_v0.outcome` is `continue` or `no_change`. +The fresh packet carries evidence for progress or completion while retaining +the route; it must not also declare `vision_unchanged_reason`. A fresh packet +requires an explicit path mode: omitting it retains the legacy inference of +`material_replan`. The Turn executor remains the owner of packet parsing, bounds, and semantic validation. These declarations do not grant write or Goal authority, and legacy hosts that omit both path-delta fields retain their prior compatibility behavior. For material results, an diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index 875785699b..421d552f96 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -396,8 +396,8 @@ def _render_prompt(request: Mapping[str, Any], *, checked: Mapping[str, Any] | N "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.", "For those material results, preserve the actual delivery_batch_scale and delivery_outcome selected from the signed execution_profile's work and outcome requirements. Return work_evidence and validation_evidence as separate non-empty arrays of short public-safe strings; name the artifact or observed work in the first and the independent command or readback in the second. For legacy requests without execution_profile, use null when evidence is not supplied. For stop results, set both evidence fields to null.", - "For those material results, set path_delta_mode=material_replan only when this Turn changes a prior assumption, route, scope, acceptance rule, or stops prior work; then provide a complete bounded agent vision packet with goal_path_delta_v0 in agent_vision_json and leave vision_unchanged_reason empty.", - "For routine continuation, retry, successor creation, or no-change replanning, set path_delta_mode=unchanged, leave agent_vision_json empty, and provide vision_unchanged_reason.", + "For those material results, set path_delta_mode=material_replan and result_kind=replan_required only when this Turn changes a prior assumption, route, scope, acceptance rule, or stops prior work; then provide a complete bounded agent vision packet with goal_path_delta_v0 in agent_vision_json and leave vision_unchanged_reason empty.", + "For routine continuation, retry, successor creation, or no-change replanning, explicitly set path_delta_mode=unchanged. Reuse the current checkpoint with vision_unchanged_reason and empty agent_vision_json, or record new progress/completion evidence in a fresh agent_vision_json packet with path_delta.outcome continue or no_change and empty vision_unchanged_reason.", "When replan_action_packet requires a semantic delta, return progress_observation_json as a JSON-encoded typed_progress_observation_v0 with result_class, the observed surface_id/hypothesis_id/probe_kind or blocker/coverage fields, and opaque evidence_ids. Report only independently checked evidence; a plan summary or a vision change alone cannot substitute for the required observation. Otherwise leave this field empty.", "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.', diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 3ca076c661..a5e68b24ff 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -365,8 +365,16 @@ def _normalize_host_path_delta( errors.append("material_replan cannot also declare vision_unchanged_reason") elif path_delta_mode == "unchanged": if agent_vision is not None: - errors.append("unchanged path_delta_mode cannot include agent_vision_json") - if not unchanged_reason: + path_delta = agent_vision.get("path_delta") + if not isinstance(path_delta, dict): + errors.append("unchanged agent_vision_json requires goal_path_delta_v0") + elif path_delta.get("outcome") not in {"continue", "no_change"}: + errors.append( + "unchanged goal_path_delta_v0 outcome must be continue or no_change" + ) + if unchanged_reason: + errors.append("fresh agent_vision cannot also declare vision_unchanged_reason") + elif not unchanged_reason: errors.append("unchanged path_delta_mode requires vision_unchanged_reason") return path_delta_mode, agent_vision diff --git a/loopx/control_plane/turn_driver/host_candidate.py b/loopx/control_plane/turn_driver/host_candidate.py index 569256429f..25ee221143 100644 --- a/loopx/control_plane/turn_driver/host_candidate.py +++ b/loopx/control_plane/turn_driver/host_candidate.py @@ -441,8 +441,11 @@ def render_prompt(authority: Mapping[str, Any], *, allow_completion: bool = Fals "result_kind to replan_required, and agent_vision_json to a JSON-encoded " "bounded goal_vision_replan_contract_v0 packet whose path_delta is " "goal_path_delta_v0 with outcome replan; leave vision_unchanged_reason " - "empty. For unchanged paths, set path_delta_mode to unchanged, omit " - "agent_vision_json, and provide vision_unchanged_reason. These fields " + "empty. For unchanged paths, explicitly set path_delta_mode to unchanged. " + "Reuse the current checkpoint with vision_unchanged_reason and no " + "agent_vision_json, or record new progress/completion evidence in a fresh " + "agent_vision_json packet with path_delta.outcome continue or no_change " + "and no vision_unchanged_reason. These fields " "describe the result only; they grant no authority. For stop results " "(wait, user_action_required, iteration_failed), omit both " "path_delta_mode and agent_vision_json.\n" diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py index ba505ae37b..398b14d520 100644 --- a/tests/test_core_distribution.py +++ b/tests/test_core_distribution.py @@ -460,8 +460,29 @@ def _assert_committed(core_python, workspace, runtime, result): return result["resume_turn_key"] -def test_installed_core_executes_checks_and_replays_once(core_python, task): +@pytest.mark.parametrize("checkpoint", [None, "continue", "no_change"]) +def test_installed_core_executes_checks_and_replays_once(core_python, task, checkpoint): workspace, runtime, registry, arguments = task + if checkpoint is not None: + vision = { + "schema_version": "goal_vision_replan_contract_v0", + "state": "active", + "vision_patch": {"acceptance_summary": "Independently check squares and preserve exact replay."}, + "path_delta": { + "schema_version": "goal_path_delta_v0", "outcome": checkpoint, + "prior_assumption": "The requested squares still needed a result check.", + "observed_reality": "The requested artifact is available for independent validation.", + "retained": ["The original squares request and acceptance."], + "evidence_refs": ["artifact:squares.json"], + }, + } + host_index = arguments.index("--host-adapter-command-json") + 1 + host_script = workspace / "checkpoint-host.py" + host_script.write_text(HOST_SOURCE.replace( + "'vision_unchanged_reason':'The requested squares remain the objective.'", + f"'path_delta_mode':'unchanged','agent_vision_json':{json.dumps(vision)!r}", + )) + arguments[host_index] = json.dumps([core_python, "-I", str(host_script)]) history = json.loads(_run(core_python, ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "history", "trajectory-hygiene", "--goal-id", GOAL], workspace).stdout) assert history["ok"] is True @@ -476,6 +497,11 @@ def test_installed_core_executes_checks_and_replays_once(core_python, task): "--format", "json", "turn", "inspect-journal", "--goal-id", GOAL, "--agent-id", AGENT, "--turn-key", key], workspace).stdout) assert inspected["ok"] is True + if checkpoint is not None: + journal_path = runtime / "goals" / GOAL / "turns" / f"{key.removeprefix('sha256:')}.json" + journal = json.loads(journal_path.read_text()) + assert journal["host_result"]["result_kind"] == "validated_completion" + assert journal["host_result"]["agent_vision"]["path_delta"]["outcome"] == checkpoint @pytest.mark.skipif(os.name == "nt", reason="this qualification injects a POSIX SIGKILL") diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 2c98fa2858..eaf46eff06 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -741,6 +741,100 @@ def test_new_material_host_result_requires_qualifying_settlement(kind: str, outc assert validation["result"]["delivery_outcome"] == outcome +def _continued_vision(*, outcome: str = "continue") -> dict[str, object]: + return { + "schema_version": "goal_vision_replan_contract_v0", + "state": "active", + "vision_patch": { + "acceptance_summary": "Independently validate the fixture result and recovery.", + }, + "path_delta": { + "schema_version": "goal_path_delta_v0", + "outcome": outcome, + "prior_assumption": "The fixture result had not been independently checked.", + "observed_reality": "The fixture produced new checkable result evidence.", + "retained": ["Keep the original fixture acceptance and execution route."], + "evidence_refs": ["fixture:result-checkpoint"], + }, + } + + +@pytest.mark.parametrize("kind", ["validated_progress", "validated_completion"]) +@pytest.mark.parametrize("outcome", ["continue", "no_change"]) +@pytest.mark.parametrize("from_journal", [False, True]) +def test_unchanged_route_can_record_fresh_result_checkpoint( + kind: str, outcome: str, from_journal: bool, +) -> None: + plan = _plan() + result = _host_result(plan, kind=kind) + result.pop("vision_unchanged_reason") + result["path_delta_mode"] = "unchanged" + packet = _continued_vision(outcome=outcome) + result["agent_vision" if from_journal else "agent_vision_json"] = ( + packet if from_journal else json.dumps(packet) + ) + + validation = validate_loopx_turn_host_result( + plan, result, from_journal=from_journal, completion_lifecycle_configured=True, + ) + + assert validation["ok"] is True, validation["errors"] + assert validation["result"]["result_kind"] == kind + assert validation["result"]["agent_vision"]["path_delta"] == packet["path_delta"] + + +@pytest.mark.parametrize("outcome", ["replan", "wait", "ask_human", "stop"]) +@pytest.mark.parametrize("from_journal", [False, True]) +def test_unchanged_checkpoint_cannot_hide_route_or_stop_decisions( + outcome: str, from_journal: bool, +) -> None: + plan = _plan() + result = _host_result(plan) + result.pop("vision_unchanged_reason") + packet = _continued_vision(outcome=outcome) + result.update(path_delta_mode="unchanged") + result["agent_vision" if from_journal else "agent_vision_json"] = ( + packet if from_journal else json.dumps(packet) + ) + + validation = validate_loopx_turn_host_result(plan, result, from_journal=from_journal) + + assert validation["ok"] is False + assert "outcome must be continue or no_change" in " ".join(validation["errors"]) + + +@pytest.mark.parametrize("defect, expected_error", [ + ("unchanged_reason", "fresh agent_vision cannot also declare"), + ("missing_path_delta", "unchanged agent_vision_json requires goal_path_delta_v0"), + ("implicit_mode", "outcome must be replan"), + ("completion_replan", "requires result_kind replan_required"), +]) +def test_fresh_checkpoint_preserves_explicit_and_legacy_boundaries( + defect: str, expected_error: str, +) -> None: + plan = _plan() + result = _host_result(plan, kind="validated_completion") + packet = _continued_vision() + result["path_delta_mode"] = "unchanged" + if defect != "unchanged_reason": + result.pop("vision_unchanged_reason") + if defect == "missing_path_delta": + packet.pop("path_delta") + elif defect == "implicit_mode": + result.pop("path_delta_mode") + elif defect == "completion_replan": + result["path_delta_mode"] = "material_replan" + packet = _continued_vision(outcome="replan") + result["agent_vision_json"] = json.dumps(packet) + + validation = validate_loopx_turn_host_result( + plan, result, completion_lifecycle_configured=True, + ) + + assert validation["ok"] is False + assert expected_error in " ".join(validation["errors"]) + + @pytest.mark.parametrize("packet, expected_error", [ (None, "cached agent_vision must be a JSON object"), ([], "cached agent_vision must be a JSON object"), @@ -3458,8 +3552,9 @@ def invoke(*_args: object, **_kwargs: object) -> dict[str, object]: assert all(count == 0 for count in calls.values()) +@pytest.mark.parametrize("fresh_checkpoint", [False, True]) def test_run_once_recovers_saved_host_result_from_validation_without_host_retry( - tmp_path: Path, + tmp_path: Path, fresh_checkpoint: bool, ) -> None: plan = _plan() calls = {"host": 0, "validation": 0, "writeback": 0, "spend": 0, "scheduler": 0} @@ -3467,7 +3562,11 @@ def test_run_once_recovers_saved_host_result_from_validation_without_host_retry( def host(_request: dict[str, object]) -> dict[str, object]: calls["host"] += 1 - return _host_result(plan) + result = _host_result(plan) + if fresh_checkpoint: + result.pop("vision_unchanged_reason") + result.update(path_delta_mode="unchanged", agent_vision_json=json.dumps(_continued_vision())) + return result def interrupted_validation( _plan: dict[str, object], @@ -3506,6 +3605,12 @@ def interrupted_validation( assert inspected["recovery_decision"]["can_continue"] is True assert inspected["recovery_decision"]["resume_from"] == "validation" assert inspected["recovery_decision"]["reinvoke_host"] is False + if fresh_checkpoint: + saved = json.loads(turn_journal_path( + tmp_path / "runtime", goal_id="fixture-goal", + turn_key=str(transaction["turn_key"]), + ).read_text()) + assert saved["host_result"]["agent_vision"]["path_delta"]["outcome"] == "continue" recovered = run_loopx_turn_once( plan,