From ed9153989aec852d46e345b02f7462c3faeafb3c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:42:35 +0800 Subject: [PATCH] fix(refresh): preview outcome facts before replan qualification Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/goal-outcome-refinement-v0.md | 13 ++ .../rfcs/goal-outcome-refinement-v0.zh-CN.md | 9 + .../project_lifecycle_refresh_state.py | 12 +- loopx/state_refresh.py | 12 ++ .../references/outcome-refinement.md | 16 ++ .../references/repair-patterns.md | 1 + .../test_outcome_replan_composition_cli.py | 166 ++++++++++++++++++ 7 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/control_plane/test_outcome_replan_composition_cli.py diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.md index 8142405c48..d5530da124 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.md @@ -296,6 +296,19 @@ review record. This is sequential composition: refinement commits before the normal writeback. If writeback fails, its returned refinement receipt identifies what committed; retry the identical operation rather than assuming rollback. +An enabled due Stride review also binds the delivered context, including these +outcome facts. For a changed milestone, refine first, re-enter the same Turn's +guard, read its current facts and author the complete decision before ordinary +writeback. Do not silently rebind a stale decision. Batched dry-run now uses the +acceptance owner's prospective contract, so it detects the same basis mismatch +as execution without committing a refinement, run or spend. Execution retains +its sequential contract and partial-effect receipt; recover an already applied +refinement by its original operation, then reassess against fresh context. +Ordinary batching, unchanged work and the exact-context gate remain unchanged. +Real File/SQLite CLI cases cover preview, stale refusal, fresh-context recovery, +retained requirements and single-debit replay. This is source qualification, +not installed adoption, general atomicity or a model-efficiency claim. + | Event | Default action | Existing mechanism | | --- | --- | --- | | Material direct-user deliverable/correction | Add to the matching outcome, retaining prior intent | Host context + native refinement or skill/CLI bridge | diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md index b99d0b185a..48678667a7 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md @@ -210,6 +210,15 @@ active 含义;显式非活动状态仍拒绝细化。 这里是顺序组合:先提交细化,再做普通写回。后续写回失败时,返回的细化回执 标明已提交部分;按原操作重试,不能假定全部回滚。 +启用的到期 Stride 审查还绑定已交付上下文,其中包括这些成果事实。节点有变化时, +先细化,再以原 Turn 身份重入 guard、读取当前事实,完整重作决定后普通写回; +不能悄悄将旧决定绑定到新上下文。批量 dry-run 现使用验收 owner 的预期契约, +在不提交细化、运行或扣额的前提下,识别与实际执行相同的基线不匹配。 +实际执行保持顺序组合和部分副作用回执;已应用细化按原操作恢复,再据新上下文 +重新判断。普通批量写回、无变化工作及精确上下文门槛保持不变。 +真实 File/SQLite CLI 覆盖预览、旧基线拒绝、新上下文恢复、要求保留与单次扣额 +重放。此为源码资格,不代表已安装采用、通用原子性或模型效率收益。 + | 节点 | 默认动作 | 联动机制 | | --- | --- | --- | | 直接用户消息带来重要成果/实质纠正 | 细化对应成果并保留旧意图 | 宿主上下文+原生细化或技能/CLI | diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 0c1091843c..b9fa2a0cff 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -479,6 +479,7 @@ def handle_refresh_state_command( goal_ref: dict[str, str] | None = None outcome_update = None outcome_update_result = None + goal_acceptance_preview = None try: if getattr(args, "settle_turn", False) and not ( args.agent_id and args.turn_instance_id and bool(args.todo_id) != bool(args.replan_obligation_id) @@ -569,9 +570,14 @@ def handle_refresh_state_command( raise ValueError("--resume-external-sinks requires the original --turn-instance-id") if outcome_update is not None: from ..control_plane.goals.acceptance import refine_goal_outcome, public_goal_acceptance - outcome_update_result = public_goal_acceptance(refine_goal_outcome( + refinement_result = refine_goal_outcome( registry_path=registry_path, runtime_root=args.runtime_root, goal_id=args.goal_id, - agent_id=args.agent_id, execute=not args.dry_run, **outcome_update), include_contract=False) + agent_id=args.agent_id, execute=not args.dry_run, **outcome_update) + outcome_update_result = public_goal_acceptance(refinement_result, include_contract=False) + if args.dry_run: + # Preview the same ordered composition as execution: the typed + # refinement owner supplies the prospective acceptance facts. + goal_acceptance_preview = refinement_result["goal_acceptance_contract"] payload = refresh_state_run( external_delivery={"suppress": bool(args.suppress_external_sinks), "resume_key": getattr(args, "resume_external_sinks", None)}, @@ -619,6 +625,8 @@ def handle_refresh_state_command( dry_run=bool(args.dry_run), sync_global=not bool(args.no_global_sync), goal_ref=goal_ref, + **({"goal_acceptance_preview": goal_acceptance_preview} + if goal_acceptance_preview is not None else {}), ) except Exception as exc: payload = { diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index a8e2f54290..ecfba0ec0d 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -740,10 +740,13 @@ def refresh_state_run( sync_global: bool = True, external_delivery: dict[str, Any] | None = None, goal_ref: dict[str, str] | None = None, + goal_acceptance_preview: dict[str, Any] | None = None, ) -> dict[str, Any]: from .control_plane.todos.provider_projection import recover_refresh_todo_projection safe_goal_id = validate_goal_id_path_segment(goal_id) + if goal_acceptance_preview is not None and not dry_run: + raise ValueError("prospective acceptance facts are valid only for dry-run planning") if next_action_basis and not next_action: raise ValueError("--next-action-basis requires --next-action") if checkpoint_read_context_id and not turn_instance_id: @@ -967,6 +970,15 @@ def refresh_state_run( state_text, planning_events, todo_fields = ( planning_source.state_text, planning_source.events, planning_source.todo_fields, ) + if goal_acceptance_preview is not None: + # Only the batched CLI dry-run supplies this owner-produced plan. + # Execution always reads committed canonical facts. In particular, + # do not qualify a due Stride decision against the pre-update basis. + if not isinstance(todo_fields, dict) or not isinstance(todo_fields.get("agent_todos"), dict): + raise ValueError("outcome preview requires a canonical planning snapshot") + todo_fields = {**todo_fields, "agent_todos": { + **todo_fields["agent_todos"], "goal_acceptance_contract": goal_acceptance_preview, + }} expected_write_state_text = state_text normalized_next_action = normalize_next_action_text(next_action) if next_action else None if normalized_next_action and registry_goal is None: diff --git a/skills/loopx-project/references/outcome-refinement.md b/skills/loopx-project/references/outcome-refinement.md index a0b18f5e00..4a3e4a2026 100644 --- a/skills/loopx-project/references/outcome-refinement.md +++ b/skills/loopx-project/references/outcome-refinement.md @@ -118,6 +118,14 @@ For changed requirements or reviews, add this envelope to the **existing** its ordinary routing, Turn, checkpoint and delivery arguments. This does not replace them. +An enabled due Stride review binds the delivered context, which includes outcome +facts. When this milestone changes those facts, apply the refinement first with +the standalone command below, then re-enter the guard with the same Turn identity +and read its fresh context before authoring the complete Vision decision. Omit +the already applied update from that ordinary writeback. No extra refinement is +needed when the facts are unchanged. Do not copy a new context ID into an old +decision without reviewing the changed facts. + The batched response's `goal_outcome_update` acknowledges the operation with its status and canonical revision; it does not echo the complete acceptance contract. The same receipt remains present if the later ordinary writeback fails. Retry the @@ -127,6 +135,14 @@ context; the compact receipt cannot substitute for reading that basis before a new refinement. Standalone `goal-acceptance refine` and conversation outcome tools retain their full readback. +Batching is sequential: refinement commits before ordinary writeback. A dry-run +uses the typed owner's prospective acceptance facts; `planned` commits nothing. +If execution returns `stride_review_basis_mismatch` with an `applied` or +`replayed` refinement receipt, preserve that effect and use the same-Turn fresh +context route above. Repeating the stale Vision alone cannot recover it. An +identical refinement operation recovers its receipt without applying it again; +never replace its operation ID to evade the existing receipt or CAS check. + Do not add an immediate inspection solely to acknowledge this receipt. Reuse the complete admitted context while current; inspect only when that basis is absent or stale. Reuse its revision when still current: diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 2accb8a146..478c28a13b 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `stride_inline_outcome_basis_ordering` | A batched milestone dry-run succeeds, but execution returns `stride_review_basis_mismatch` after an outcome refinement has committed. | Exact Turn/obligation/context, the returned `goal_outcome_update` status and operation, current acceptance and run/spend receipts; compare due Stride with ordinary batching. | Sequential refinement changes the context that the decision references; a preview using pre-update facts hides that change. | Feed the typed acceptance owner's prospective contract to dry-run planning. For a changed due-review milestone, refine before a fresh same-Turn guard, read the new facts and author the complete decision; unchanged work needs no update. On partial failure retain applied/replayed receipts and original IDs, reassess fresh context, then settle once. Never assume rollback, blindly replace a context token, lower the gate or replay work. Cover real File/SQLite preview/refusal/recovery, ordinary batching and single debit. | | `native_code_cell_source_normalization` | An optional native context adapter always falls back to full after an output-budget pragma, despite complete retained input. | The same live model call, native cell source, child identity and complete model-visible capture bytes; compare the executable source separately from native framing. | Native removes the documented first-line execution pragma before recording the cell, while the adapter compares that body with the unprocessed model call. | Normalize only that first-line JSON-object pragma and retain exact remaining source, live call/cell ordering and complete-byte verification. Cover unchanged framing, altered bodies, malformed/extra headers and stale or partial input, plus real native resume and recovery. Preserve automatic same-capture full fallback; do not add an admission or owner question. | | `native_history_output_budget` | A complete CLI or code-cell result becomes a clipped or missing page in the next model input despite a larger direct-result budget. | Compare the CLI result, completed native cell, actual inference input and effective model history-output policy at the same native version. | Direct-result and retained-history limits are separate; an unknown loopback model may use a smaller byte-based fallback than the intended host's token-based policy. | Keep the original low-budget failures. Qualify a distinct matched workload under the intended declared history budget without changing live host limits or frozen criteria. Size batches within both budgets, retain complete-byte checks and same-capture recovery, and never count incomplete trials as savings or runtime output as retention proof. | | `archive_verification_byte_epoch_gap` | Backup preview or execution accepts compressed input rewritten after preflight; restoring it before the final hash conceals the change. | External manifest and preflight archive identity, the digest/count of bytes actually consumed by gzip/tar, copy observations and destination publication. | Separate mutable-file reads supply the hash and parser facts; a final hash cannot prove which bytes were parsed. | Keep early wrong-digest refusal. Generate a bounded compressed-byte witness in the parser's actual input stream, compare it with preflight before audit, and compare copy witnesses before publication. Preserve capacity checks, inert status and no execution authority. Cover preview, execute and ABA restoration with real producer archives and filesystem fault injection. | diff --git a/tests/control_plane/test_outcome_replan_composition_cli.py b/tests/control_plane/test_outcome_replan_composition_cli.py new file mode 100644 index 0000000000..8608043f23 --- /dev/null +++ b/tests/control_plane/test_outcome_replan_composition_cli.py @@ -0,0 +1,166 @@ +"""Outcome review and due replan use the same ordered facts in preview/execute. + +The public CLI uses disposable canonical providers; no model or experiment is +needed to prove stale-context refusal, recovery and once-only settlement. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _bind_selected_replan_guard, + _initialize_fixture_authority, _projected_cli_args, _run_cli, + _spend_run_count, _write_fixture, +) + + +def _review_packet(control: dict | None) -> dict: + packet = { + "schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", + "vision_patch": { + "acceptance_summary": "Preserve the export content and validate its recovery.", + "advancement_policy": "repeat_until_closed", + }, + "path_delta": { + "outcome": "replan", "prior_assumption": "The export needs a complete validation slice.", + "observed_reality": "The current artifact still needs independent recovery evidence.", + "retained": ["Original content requirement and independent validation"], + "changed": ["Validate the artifact and its recovery together"], + "evidence_refs": ["artifact:export-validation"], + }, + } + if control is not None: + packet["stride_decision"] = { + "schema_version": "stride_decision_v2", "strategy_version": "evidence_conditioned_v1", + "reference_context_id": control["reference_context_id"], + "reference_obligation_id": control["reference_obligation_id"], + "basis": "uncertain_evidence", "action": "verify", + "axes": { + "work_slice": {"plan": "Validate the artifact and its recovery together.", + "reason": "One complete slice answers the remaining qualification gap."}, + "route_distance": {"plan": "Retain the current export implementation.", + "reason": "No result yet supports replacing it."}, + "validation_scope": {"plan": "Check valid, invalid and replayed exports.", + "reason": "Recovery evidence must retain the original content boundary."}, + }, + "next_probe": { + "scope": "Complete one export recovery validation.", + "minimum_evidence": "Valid output, refused invalid input and once-only replay.", + "prediction": "The current implementation preserves content across recovery.", + "adoption_criterion": "Retain only independently validated recovery behavior.", + "cost_bound": "One artifact and its related failure/replay cases.", + }, + } + return packet + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("stride", [False, True]) +def test_batched_review_previews_post_refinement_basis_and_recovers_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, stride: bool, +) -> None: + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + registered = json.loads(registry.read_text()) + registered["goals"][0].update(status="active") + registered["goals"][0]["quota"]["allowed_slots"] = 4 + registry.write_text(json.dumps(registered)) + _initialize_fixture_authority(project, runtime, provider=provider) + + def call(*args: str, expected_code: int = 0) -> dict: + code, result = _run_cli(registry, runtime, *args, cwd=project) + assert code == expected_code, result + return result + + def inspect() -> dict: + return call("goal-acceptance", "inspect", "--goal-id", GOAL_ID) + + call("configure-goal", "--goal-id", GOAL_ID, "--execution-replan-after-turns", "1", + *(["--execution-stride-strategy", "evidence_conditioned_v1"] if stride else []), "--execute") + original = {"outcome_id": "export", "owner_agent_id": AGENT_ID, + "objective": "Deliver a recoverable export", "todo_ids": [TODO_ID], + "requirements": [{"id": "content", "description": "Preserve the original content"}], + "source": {"ref": "message:export-request", "summary": "The owner requires preserved content"}} + declaration = tmp_path / "declaration.json" + declaration.write_text(json.dumps(original)) + call("goal-acceptance", "refine", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--document", str(declaration), "--operation-id", "declare-export", + "--expected-provider-revision", inspect()["provider_revision"], "--execute") + prior = "export-artifact-turn" + call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", prior) + written = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", prior, "--classification", "artifact_validated", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--delivery-boundary", "in_flight_continuation", "--no-global-sync", "--suppress-external-sinks") + call(*_projected_cli_args(written["settlement_owed"]["command"], turn_instance_id=prior)) + + due = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", TURN_ID) + assert "periodic_review_due" in {row["kind"] for row in due["autonomous_replan_obligation"]["triggers"]} + bound = _bind_selected_replan_guard(registry, runtime, project, TURN_ID, todo_id=TODO_ID) + control = bound["replan_action_packet"].get("stride_control") if stride else None + packet = _review_packet(control) + decision = tmp_path / "decision.json" + decision.write_text(json.dumps(packet)) + command = bound["interaction_contract"]["cli_channel"]["next_cli_actions"][0].replace( + "", str(decision)) + refresh = (*_projected_cli_args(command, turn_instance_id=TURN_ID), "--no-global-sync", "--suppress-external-sinks") + before = inspect() + update = tmp_path / "update.json" + review = {"id": "content", "conclusion": "uncertain", "evidence_refs": ["artifact:export-validation"], + "note": "The artifact retains content; independent recovery validation remains open"} + update.write_text(json.dumps({"operation_id": "export-milestone", + "expected_provider_revision": before["provider_revision"], + "refinement": {"outcome_id": "export", "review": [review]}})) + batch = (*refresh, "--goal-outcome-update-json", str(update)) + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + original_index = index.read_bytes() + preview = call(*batch, "--dry-run", expected_code=1 if stride else 0) + assert preview["goal_outcome_update"]["status"] == "planned" + if stride: + assert preview["replan_transition"]["reason_code"] == "stride_review_basis_mismatch" + assert inspect() == before and index.read_bytes() == original_index + assert _spend_run_count(runtime) == 1 + + # Execution keeps its sequential, non-atomic contract. A rejected review + # must retain the independently committed refinement receipt. + actual = call(*batch, expected_code=1 if stride else 0) + assert actual["goal_outcome_update"]["status"] == "applied" + after = inspect() + assert after["goal_acceptance_contract"]["outcome_requirements"]["revision"] == 2 + if stride: + assert actual["appended"] is False + assert actual["replan_transition"]["reason_code"] == "stride_review_basis_mismatch" + assert index.read_bytes() == original_index + fresh = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + assert fresh["heartbeat_receipt"]["settlement_identity"] == bound["heartbeat_receipt"]["settlement_identity"] + current = fresh["replan_action_packet"]["stride_control"] + assert current["reference_context_id"] != control["reference_context_id"] + # Read the current facts before authoring against this new basis. Merely + # replaying the stale packet still cannot settle the review. + context = fresh["interaction_contract"]["agent_channel"]["work_context"] + acceptance = next(row["content"] for row in context["sources"] if row.get("source") == "goal_acceptance") + assert acceptance["goal_acceptance_contract"] == after["goal_acceptance_contract"] + replay = call(*batch, expected_code=1) + assert replay["goal_outcome_update"]["status"] == "replayed" + assert inspect() == after and index.read_bytes() == original_index + decision.write_text(json.dumps(_review_packet(current))) + actual = call(*refresh) + assert "goal_outcome_update" not in actual + assert actual["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True + spent = call(*_projected_cli_args(actual["settlement_owed"]["command"], turn_instance_id=TURN_ID)) + assert spent["ok"] and _spend_run_count(runtime) == 2 + call(*_projected_cli_args(actual["settlement_owed"]["command"], turn_instance_id=TURN_ID)) + assert _spend_run_count(runtime) == 2 and inspect() == after + retained = after["goal_acceptance_contract"]["outcome_requirements"]["outcomes"][0] + assert retained["sources"] == [original["source"]] + assert retained["requirements"][0]["description"] == original["requirements"][0]["description"] + assert retained["requirements"][0]["agent_review"]["conclusion"] == "uncertain"