From d20b92fcebdff335b5389c3de5d2eb93a84ad7b5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:07:08 +0800 Subject: [PATCH] fix(replan): retain mixed vision review across work refinement Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../work_items/replan_semantics.ts | 19 ++++++---- .../test_quota_settlement_cli.py | 35 +++++++++++++++++-- .../control_plane_ts/replan_semantics.test.ts | 31 +++++++++++++++- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index ab12ab0509..e4279848a9 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -32,6 +32,10 @@ const VISION_TRIGGERS = new Set([ "vision_successor_required", "required_agent_vision_missing", ]); const ACCEPTANCE_HOLD_TRIGGERS = new Set(["goal_acceptance_stale", "goal_acceptance_unbound"]); +// These review kinds carry no evidence window, capability or lifecycle duty. +// A vision gap dominates the ordinary outcome gap's discharge policy, so the +// same scalar receipt can retain a mixed review after its work links change. +const SCALAR_REVIEW_KINDS = new Set(["goal_outcome_uncovered", "vision_acceptance_gap"]); const EXTERNAL_REVIEW_TRIGGERS = new Set(["external_progress_review_drift"]); export const FRESH_PATH_DISPOSITIONS = new Set(["continue", "no_change", "replan"]); const PROGRESS_CLI_ARGS = "--progress-result-class --progress-surface-id --progress-hypothesis-id --progress-probe-kind --progress-evidence-id "; @@ -286,13 +290,13 @@ function preserveReceiptBoundObligation(request: JsonObject): JsonObject { // writeback. The exact receipt retains its typed kind, never raw task prose // or an old decision context. Other duties keep their canonical owners. const review = object(request.receipt_review); - if (request.guard_scoped && review.kind === "goal_outcome_uncovered" && + if (request.guard_scoped && SCALAR_REVIEW_KINDS.has(String(review.kind)) && typeof review.stop_condition === "string" && review.stop_condition.trim() && review.stop_condition.length <= 240 && typeof request.agent_id === "string" && request.agent_id.trim()) { return {obligation: {schema_version: "autonomous_replan_obligation_v0", required: true, obligation_id: selected, agent_id: request.agent_id, stop_condition: review.stop_condition, - triggers: [{kind: "goal_outcome_uncovered"}], selection_binding: "heartbeat_receipt"}}; + triggers: [{kind: review.kind}], selection_binding: "heartbeat_receipt"}}; } throw new EffectRuntimeRequestError( "heartbeat receipt settlement identity conflicts with the current autonomous replan obligation", @@ -445,21 +449,22 @@ export function projectReplanSemantics(value: unknown): JsonObject { if (request.operation === "receipt_review_source") { const kinds = triggerKinds(obligation); const triggers = Array.isArray(obligation.triggers) ? obligation.triggers : []; - // Only this plain frontier review has a complete scalar receipt source. - // Mixed, restricted, lifecycle and capability obligations must not lose - // their source-specific evidence or discharge rules through this route. + // Plain vision/outcome reviews share a scalar receipt source. Other mixed, + // restricted, lifecycle and capability obligations must retain their + // source-specific evidence and discharge rules through their own owners. return {review_source: obligation.schema_version === "autonomous_replan_obligation_v0" && obligation.required === true && kinds.length > 0 && obligation.trigger_count === triggers.length && typeof request.agent_id === "string" && request.agent_id.trim() && obligation.agent_id === request.agent_id && - kinds.every(kind => kind === "goal_outcome_uncovered") && + kinds.every(kind => SCALAR_REVIEW_KINDS.has(kind)) && obligation.resolution_mode == null && obligation.capability_guard == null && strings(obligation.satisfying_semantic_outcomes).length === 0 && obligation.progress_baseline == null && obligation.progress_window == null && triggers.every(trigger => object(trigger).progress_baseline == null && object(trigger).progress_window == null) && typeof obligation.stop_condition === "string" && obligation.stop_condition.trim() && obligation.stop_condition.length <= 240 - ? {kind: "goal_outcome_uncovered", stop_condition: obligation.stop_condition} : null}; + ? {kind: kinds.includes("vision_acceptance_gap") ? "vision_acceptance_gap" : "goal_outcome_uncovered", + stop_condition: obligation.stop_condition} : null}; } if (request.operation === "transition_eligibility") { return {allowed: !requiresStrideDecision(obligation, request.stride_strategy)}; diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index f726c10b1b..d782d5531c 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -7307,8 +7307,9 @@ def test_runtime_capability_reentry_preserves_receipt_bound_autonomous_replan( @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("stride", [False, True]) +@pytest.mark.parametrize("vision_gap", [False, True]) def test_replan_reentry_after_outcome_work_refinement_keeps_original_turn( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, stride: bool, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, stride: bool, vision_gap: bool, ) -> None: """Refreshing cumulative requirements must not rebind an admitted review.""" if provider == "sqlite": @@ -7348,11 +7349,36 @@ def refine(document: dict[str, Any], operation: str) -> dict[str, Any]: "declare-outcome") call("todo", "complete", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--no-follow-up", "--evidence", "artifact:initial-delivery") + if vision_gap: + from loopx.control_plane.goals.goal_vision import ( + compact_goal_vision_packet, normalize_goal_vision_packet, + ) + + # Two independently owned gaps coexist before admission. Linking work + # removes only the outcome gap, not the owed evidence-based review. + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + index.parent.mkdir(parents=True, exist_ok=True) + source_json, source_md = index.parent / "source.json", index.parent / "source.md" + source_json.write_text('{"fixture":"independent-vision-gap"}\n') + source_md.write_text("# Independently review the original artifact\n") + vision = compact_goal_vision_packet(normalize_goal_vision_packet({ + "goal_id": GOAL_ID, "agent_id": AGENT_ID, "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "Recovery evidence remains unproven.", + "replan_trigger_summary": "Review the artifact before selecting further work.", + "advancement_policy": "repeat_until_closed"}, + }, goal_id=GOAL_ID, agent_id=AGENT_ID)) + with index.open("a") as stream: + stream.write(json.dumps({"classification": "validated_progress", "agent_id": AGENT_ID, + "generated_at": (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat(), + "agent_vision": vision, "json_path": str(source_json), "markdown_path": str(source_md)}) + "\n") turn = "turn-replan-outcome-refinement" guard_args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--turn-instance-id", turn) first = call(*guard_args) assert first["decision"] == "autonomous_replan_required", first + if vision_gap: + assert {row["kind"] for row in first["autonomous_replan_obligation"]["triggers"]} == { + "vision_acceptance_gap", "goal_outcome_uncovered"} identity = first["heartbeat_receipt"]["settlement_identity"] assert identity["binding_kind"] == "autonomous_replan" receipt = _heartbeat_receipt_events(runtime, turn)[0] @@ -7372,7 +7398,8 @@ def refine(document: dict[str, Any], operation: str) -> dict[str, Any]: if row.get("source") == "goal_acceptance") assert acceptance["goal_acceptance_contract"]["outcome_requirements"] == expected assert _heartbeat_receipt_count(runtime, turn) == 1 - assert receipt["details"]["semantic_replan_review_kind"] == "goal_outcome_uncovered" + assert receipt["details"]["semantic_replan_review_kind"] == ( + "vision_acceptance_gap" if vision_gap else "goal_outcome_uncovered") assert receipt["details"]["semantic_replan_review_stop_condition"] == first["autonomous_replan_obligation"]["stop_condition"] assert replay["autonomous_replan_obligation"]["stop_condition"] == first["autonomous_replan_obligation"]["stop_condition"] assert "semantic_replan_obligation" not in receipt["details"] @@ -7427,7 +7454,9 @@ def refine(document: dict[str, Any], operation: str) -> dict[str, Any]: empty = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--replan-obligation-id", identity["replan_obligation_id"], "--turn-instance-id", turn, "--classification", "bounded_replan_progress", "--delivery-batch-scale", "single_surface", - "--delivery-outcome", "outcome_progress", "--agent-vision-json", str(decision), + "--delivery-outcome", "outcome_progress", + *(("--vision-unchanged-reason", "The original artifact remains unproven.") if vision_gap + else ("--agent-vision-json", str(decision))), "--no-global-sync", "--suppress-external-sinks", expected_code=1) assert empty["appended"] is False assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index dc3f5b4369..cb1654ca8b 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -153,7 +153,7 @@ test("verified original writeback projects only its outstanding debit even after current_obligation: null, transition_candidates: []}), /malformed/); }); -test("only a plain outcome review has a scalar receipt source", () => { +test("only plain vision and outcome reviews have a scalar receipt source", () => { const source = {schema_version: "autonomous_replan_obligation_v0", required: true, obligation_id: "replan-1111111111111111", agent_id: "reviewer", trigger_count: 1, stop_condition: "Stop at owner-only decisions.", @@ -166,6 +166,9 @@ test("only a plain outcome review has a scalar receipt source", () => { {triggers: []}, {trigger_count: 2}, {trigger_count: undefined}, {triggers: [{kind: "periodic_review_due"}]}, {triggers: [{kind: "goal_outcome_uncovered"}, {kind: "external_progress_review_drift"}]}, + {trigger_count: 2, triggers: [{kind: "vision_acceptance_gap"}, {kind: "periodic_review_due"}]}, + {trigger_count: 2, triggers: [{kind: "vision_acceptance_gap"}, {kind: "external_progress_review_drift"}]}, + {trigger_count: 2, triggers: [{kind: "vision_acceptance_gap"}, {kind: "goal_acceptance_stale"}]}, {satisfying_semantic_outcomes: ["new_runnable_successor"]}, {progress_baseline: {}}, {progress_window: []}, {triggers: [{kind: "goal_outcome_uncovered", progress_baseline: {}}]}, @@ -177,6 +180,32 @@ test("only a plain outcome review has a scalar receipt source", () => { } }); +test("mixed vision/outcome recovery preserves the dominant policy without discharging review", () => { + for (const triggers of [[{kind: "vision_acceptance_gap"}], + [{kind: "goal_outcome_uncovered"}, {kind: "vision_acceptance_gap"}], + [{kind: "vision_acceptance_gap"}, {kind: "goal_outcome_uncovered"}]]) { + const source = {schema_version: "autonomous_replan_obligation_v0", required: true, + obligation_id: "replan-1111111111111111", agent_id: "reviewer", triggers, + trigger_count: triggers.length, stop_condition: "Stop at owner-only decisions."}; + const review = projectReplanSemantics({operation: "receipt_review_source", agent_id: "reviewer", + obligation: source}).review_source; + assert.deepEqual(review, {kind: "vision_acceptance_gap", stop_condition: source.stop_condition}); + const recovered = projectReplanSemantics({operation: "receipt_bound_obligation", guard_scoped: true, + replay_phase: "open", selected_obligation_id: source.obligation_id, current_obligation: null, + transition_candidates: [], agent_id: "reviewer", receipt_review: review}).obligation as JsonObject; + assert.equal(recovered.obligation_id, source.obligation_id); + assert.deepEqual(requiredSemanticOutcomes(recovered), requiredSemanticOutcomes(source)); + assert.equal(recovered.selection_binding, "heartbeat_receipt"); + assert.equal(recovered.replan_context, undefined); + assert.equal(recovered.settlement_action_packet, undefined); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: recovered}).accepted, false); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: recovered, + observation_delta: {delta_kinds: ["new_surface"]}}).accepted, false); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: recovered, + agent_vision: vision}).accepted, true); + } +}); + test("a recorded outcome review survives work-link refinement without cached context or discharge", () => { const id = "replan-1111111111111111"; const request = {operation: "receipt_bound_obligation", guard_scoped: true,