From a668b4ce63bb7e8eb0efd2d5c30694c2fa80d2b7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:20:17 +0800 Subject: [PATCH] fix(control-plane): recover receipt-bound lifecycle reviews safely Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goals/checkpoint_context_io.py | 3 + .../goals/checkpoint_read_context.ts | 15 ++- loopx/control_plane/quota/refresh_recovery.ts | 15 +++ loopx/control_plane/quota/settlement_phase.ts | 12 ++ .../quota/settlement_readback.ts | 4 + .../work_items/replan_semantics.ts | 7 +- .../work_items/semantic_replan_writeback.py | 6 +- .../references/repair-patterns.md | 1 + .../test_declared_terminal_settlement_cli.py | 103 +++++++++++++++++- .../test_refresh_state_replan_gate.py | 18 +++ .../test_replan_semantics_transport.py | 5 +- .../checkpoint_read_context.test.ts | 19 ++++ .../control_plane_ts/refresh_recovery.test.ts | 17 +++ .../control_plane_ts/replan_semantics.test.ts | 6 +- 14 files changed, 218 insertions(+), 13 deletions(-) diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index d63a223209..0ffc13bbaa 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -160,6 +160,9 @@ def read_checkpoint_context( identity = readback.identity.value with _source_guard(root, goal_id, path): result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run, + writeback_committed=readback.writeback.failure is None, + quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None, + semantic_replan_guard=readback.semantic_replan_guard, read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], facts=_local_source_facts(root, registry_path, path, identity, goal_ref)) receipt = result.pop("receipt") diff --git a/loopx/control_plane/goals/checkpoint_read_context.ts b/loopx/control_plane/goals/checkpoint_read_context.ts index ebaaad1478..7455b5fd87 100644 --- a/loopx/control_plane/goals/checkpoint_read_context.ts +++ b/loopx/control_plane/goals/checkpoint_read_context.ts @@ -1,9 +1,11 @@ -/** Read basis for a missing-checkpoint supplement, not an execution/permission lease. +/** Read basis for a missing decision/qualification checkpoint, not an execution/permission lease. * The commit effect holds source/index claims and the real provider fence. */ import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentity} from "../effect_program.ts"; import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; +import {isUnqualifiedBlockedReplanWriteback} from "../quota/settlement_phase.ts"; const RECEIPT_SCHEMA = "checkpoint_read_context_v1"; // Exact presentation fields only. Unknown future fields remain part of the basis. @@ -88,7 +90,16 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject { if (request.phase === "read") { const prior = requireJsonObject(request.prior, "committed writeback"); const checkpoint = jsonObject(prior.vision_checkpoint); - if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { + const missingQualification = request.writeback_committed === true && request.quota_spend_committed === false && + typeof identity.replan_obligation_id === "string" && + jsonObject(request.semantic_replan_guard)?.selected_obligation_id === identity.replan_obligation_id && + isUnqualifiedBlockedReplanWriteback(prior, settlementIdentity({ + goal_id: requireNonEmptyString(identity.goal_id, "goal_id"), + agent_id: requireNonEmptyString(identity.agent_id, "agent_id"), + turn_instance_id: requireNonEmptyString(identity.turn_instance_id, "turn_instance_id"), + replan_obligation_id: identity.replan_obligation_id, + })); + if ((checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) && !missingQualification) { return rejected("checkpoint_context_not_missing"); } const projected = snapshot(request); diff --git a/loopx/control_plane/quota/refresh_recovery.ts b/loopx/control_plane/quota/refresh_recovery.ts index b78e6f27b1..39f50a7018 100644 --- a/loopx/control_plane/quota/refresh_recovery.ts +++ b/loopx/control_plane/quota/refresh_recovery.ts @@ -73,6 +73,7 @@ export function refreshRecovery( receiptPresent: boolean, workspaceRequirement: string | undefined, laterAgentVision: boolean, + missingBlockedReplanQualification = false, ): JsonObject { const wantsVision = request.vision !== null || Boolean(request.unchanged_reason); const digest = wantsVision ? createHash("sha256").update(canonical({ @@ -129,6 +130,20 @@ export function refreshRecovery( (changesMutation && mutationDigest !== jsonObject(prior.refresh_recovery)?.mutation_digest)) { return result("reject", "committed_writeback_payload_conflict"); } + // Older callers could commit a blocked replan without qualifying its exact + // admitted source. Re-run that owner through the existing additive checkpoint + // path, preserving the original payload/receipt and executing no mutations. + // The readback owner proves that this identity has no committed debit. + if (missingBlockedReplanQualification && wantsVision) { + const judgmentDigest = createHash("sha256").update(canonical({ + vision: request.vision, unchanged_reason: request.unchanged_reason, merge_patch: request.merge_patch, + })).digest("hex"); + if (judgmentDigest === priorDigest) { + if (laterAgentVision) return result("reject", "checkpoint_superseded_by_later_vision"); + if (changesMutation) return result("reject", "checkpoint_supplement_must_not_repeat_mutations"); + return result("supplement_checkpoint", "complete_missing_replan_qualification_on_original_turn"); + } + } if (wantsVision && digest !== priorDigest) { if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) { return result("reject", "committed_vision_decision_conflict"); diff --git a/loopx/control_plane/quota/settlement_phase.ts b/loopx/control_plane/quota/settlement_phase.ts index 4be61f77ba..62219cee66 100644 --- a/loopx/control_plane/quota/settlement_phase.ts +++ b/loopx/control_plane/quota/settlement_phase.ts @@ -60,6 +60,18 @@ export function isAcceptedBlockedReplanWriteback( }); } +/** A legacy observation may need qualification, never acceptance by inference. + * Callers separately prove its guard/writeback receipts and absence of debit. */ +export function isUnqualifiedBlockedReplanWriteback(value: unknown, identity: SettlementIdentity): boolean { + const run = jsonObject(value); + return identity.binding_kind === "autonomous_replan" && identity.replan_obligation_id !== null && + !!run && run.goal_id === identity.goal_id && run.agent_id === identity.agent_id && + run.turn_instance_id === identity.turn_instance_id && run.replan_obligation_id === identity.replan_obligation_id && + run.delivery_outcome === "outcome_gap" && + isTurnScopedSettlementOutcome(run.delivery_outcome, run.progress_observation, identity.replan_obligation_id) && + jsonObject(run.autonomous_replan_ack)?.recorded !== true; +} + /** Classify persisted Run facts for history selection, without granting a * settlement, debit or execution right. Reuse the settlement owner's exact * semantic qualification; a caller-supplied "administrative" flag is no proof. diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index d2c52cbb4b..4bae8b67b2 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -34,6 +34,7 @@ import { import { isBoundedBlockedRetry, isAcceptedBlockedReplanWriteback, + isUnqualifiedBlockedReplanWriteback, qualifySettlementHistory, isCommittedMonitorPollEffect, isAcceptedProgressWriteback, @@ -1321,6 +1322,9 @@ function readQuotaSettlementFromRequest( run.goal_id === identity.goal_id && run.agent_id === identity.agent_id && (jsonObject(run.agent_vision) !== null || jsonObject(run.vision_checkpoint)?.required === true) ), + writeback.failure === null && spendRun === null && spendEvent === null && + semanticReplanGuard.selected_obligation_id === identity.replan_obligation_id && + isUnqualifiedBlockedReplanWriteback(writebackRun, identity), ); // A non-material poll need not appear as findWriteback's advancement run. // Its exact committed observation still fences an incompatible unavailable diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 6ebf44aaaf..7446c40266 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -4,6 +4,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; import { visionAuthoringContract } from "../goals/vision_checkpoint.ts"; import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; +import { TODO_COMPLETION_RECOVERIES } from "../todos/completion_state.ts"; import {normalizeStrideDecision, normalizeStrideStrategy, STRIDE_DECISION_SCHEMA, strideControlContract, strideReviewMismatch} from "./stride_policy.ts"; import { projectProgressDelta } from "./progress_delta.ts"; @@ -141,7 +142,9 @@ function lifecycleSource(request: JsonObject): JsonObject { item.claimed_by === agent && item.last_actor_agent_id === agent && completed !== null && completed <= admitted && strings(item.successor_todo_ids).length === 0 && !item.superseded_by && - (item.no_followup !== true || (item.completion_recovery === "lifecycle_reentry_terminal_closeout" && + // Either canonical closeout path can finish after this review was + // admitted. The exact trigger/identity checks below still own reentry. + (item.no_followup !== true || (TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery) && updated !== null && updated >= admissionSecond)); }).sort((left, right) => { // Terminal reentry changes updated_at, never the original completed_at. @@ -154,7 +157,7 @@ function lifecycleSource(request: JsonObject): JsonObject { item.no_followup === true && item.completion_continuation === "no_followup" && typeof item.todo_id === "string" && !!item.todo_id && typeof item.completion_turn_key === "string" && !!item.completion_turn_key && - item.completion_recovery === "lifecycle_reentry_terminal_closeout"); + TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery)); return {items, settled}; } diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index 31ed43607f..7a5165230b 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -374,7 +374,11 @@ def qualify_replan_writeback( context.get("replan_transition_candidates", []) if isinstance(candidate.get("obligation"), dict) and candidate["obligation"].get("obligation_id") == guard_semantic_replan_obligation_id), None) - if guard_scoped and receipt_bound_replan_review is not None: + # Every selected replan retains its exact receipt-bound source. A missing + # reconstruction must not fall through as an unselected future obligation + # and commit an unqualified writeback. Plain outcome reviews additionally + # carry a scalar source; lifecycle/transition reviews need the same owner. + if guard_scoped and guard_semantic_replan_obligation_id: obligation = project_replan_semantics({ "operation": "receipt_bound_obligation", "guard_scoped": True, "selected_obligation_id": guard_semantic_replan_obligation_id, diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index d812ee520d..c11caad075 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -101,6 +101,7 @@ teaches a reusable control-plane lesson. | `normal_no_user_todo_misflagged` | `action_required=false` and user open count is zero, but the agent reports a state projection fault. | quota payload and user todo summary. | Rule over-applied the "todo not projected" fallback. | Allow "无用户待办/无需通知"; update heartbeat/prompt smoke if needed. | | `todo_no_followup_global_terminal_overreach` | A completed scoped Todo records `done + no_followup`, but `refresh-state --repair-delta-kind no_followup` becomes `replan_noop` because unrelated advancement or long-running monitor Todos remain open. | rejected repair-delta claim, complete Agent Todo projection, scoped Todo metadata, and the latest local no-follow-up completion. | The repair-delta validator reused Goal-level terminal-frontier completeness to validate a Todo-local closure. | Keep Goal `terminal_no_followup` strict, but validate repair-delta no-follow-up against the latest scoped completed Todo in a complete parser projection; reject other-agent, continuation-required, malformed, or truncated evidence and keep the emitted Todo evidence bounded. Cover the real CLI path with an open monitor regression. | | `terminal_settlement_ordering_gap` | Final Todo completion succeeds, then the matching refresh or spend is rejected because the Goal is already terminal; different hosts may expose different closeout orders or retry the material effect. | Typed settlement plan and effect identity, Todo completion event, accountable refresh and spend receipts, Turn journal phase prefix, and host task-body projection. | Final `no_followup` was modeled like ordinary pre-settlement Todo completion even though it mutates Goal terminal state and activates the strict no-more-spend guard. | Keep Goal terminal guards strict. Treat ordinary successor completion as Todo lifecycle work, but make final `no_followup` a conditional post-spend terminal closeout under the same effect identity. Require matching writeback and spend receipts at the mutation boundary, prove the closeout event itself carries `no_followup`, and journal it so closeout-only retry cannot repeat writeback or spend. Cover CLI, visible Goal, Codex App, Turn driver, adapter order, failure short-circuit, and replay. | +| `receipt_bound_lifecycle_source_gap` | A lifecycle review is admitted, then closing the original Todo through its settled Turn makes same-review reentry fail with an identity conflict; a blocked refresh may commit without a qualified replan ACK. | Exact original completion identity and closeout receipt, admitted review identity/time, canonical Todo readback, typed blocked observation and writeback/debit receipts. | Lifecycle reconstruction recognized only the completion-key reentry form; writeback could treat a missing selected source like an unrelated future obligation. | Reuse the canonical completion-recovery vocabulary for both legal paths and retain exact actor, identity and timestamp checks. Revalidate every selected replan through the shared owner before writeback. For an already committed unqualified blocked observation with no debit, permit only an exact additive checkpoint requalification; preserve the original run and all receipt history, reject payload changes or later superseding vision, and never infer acceptance or erase a committed debit. | | `host_settlement_source_projection_gap` | A visible Goal host packet requires `source=visible-goal` without an explicit Turn receipt, but the nested interaction contract tells the agent to spend as `heartbeat` and binds the currently selected Todo. The command can fail after Todo completion or misattribute interactive usage. | Host runtime profile, canonical host quota commands, interaction-contract CLI actions, selected Todo lifecycle order, and the failed spend preview. | The generic interaction projection hard-coded heartbeat accounting and selected-Todo binding instead of deriving the accounting route from the scheduler execution context. | Route spend actions from the typed runtime profile. Native Goal runtimes preserve `source=visible-goal` and host-level attribution; heartbeat automation keeps its Todo- and Turn-bound settlement plan. Cover the exact CLI actions for each native Goal profile and retain the heartbeat regression. | | `visible_goal_settlement_replan_attribution_gap` | An accountable visible-Goal delivery writeback moves the frontier into capability or control-plane repair; the following unbound spend is recorded against the new repair state with no Todo, Turn, effect, or delivery-workspace causality, and an exact retry cannot replay the committed effect. | Newest same-Agent accountable run, its complete nested settlement identity, top-level Todo/Turn fields, original heartbeat receipt, delivery-workspace causality, current quota frontier, spend classification, and retry run count. | The unbound visible-Goal path used the mutable current frontier as settlement authority and did not recover the completed delivery identity; commit classification also represented eligible, repair, and delivery semantics as overlapping booleans, allowing a later repair projection to overwrite attribution. | Allow unbound recovery only for visible-Goal spend from the newest fully typed same-Agent accountable run; require its top-level and nested identities to match the TypeScript-generated settlement payload and revalidate the original heartbeat receipt. Preserve Todo/Turn/effect/workspace causality, write the CLI receipt under the recovered Turn, and reduce commit semantics to one closed TypeScript disposition where proven delivery completion precedes later frontier repair. Cover missing/legacy identity, capability and control-plane replan, and exact one-run replay. | | `runtime_capability_user_gate_overreach` | A missing launcher/runtime capability such as `network` becomes `capability_gate.action=ask_owner`, `requires_user_action=true`, or a user notification even though no credential, protected access, or production authority is needed. | quota `capability_gate`, heartbeat `--available-capability` inputs, launcher/runtime observations, interaction contract user channel. | Capability availability and permission authority were collapsed into one owner-held class. | Keep runtime provisioning gaps in the agent repair lane, require truthful runtime observation before declaring availability, and reserve `ask_owner` for credentials or protected/production authority. Cover all-blocked and mixed-capability cases with capability-gate and monitor smokes. | diff --git a/tests/control_plane/test_declared_terminal_settlement_cli.py b/tests/control_plane/test_declared_terminal_settlement_cli.py index 4447582c27..437fb554fb 100644 --- a/tests/control_plane/test_declared_terminal_settlement_cli.py +++ b/tests/control_plane/test_declared_terminal_settlement_cli.py @@ -259,8 +259,10 @@ def test_original_lease_and_failed_validator_remain_required_before_accounting( @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("historical_terminals", [0, 1, 4]) +@pytest.mark.parametrize("closeout_source", ["turn", "lifecycle"]) def test_no_followup_transition_recovers_bound_review_through_writeback_and_single_debit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, historical_terminals: int, + closeout_source: str, ) -> None: project, _, runtime, _, run, lease, marker, _, _ = _fixture( tmp_path, monkeypatch, provider, historical_terminals=historical_terminals, @@ -292,9 +294,13 @@ def call(*args: str, expected: int = 0): identity = admitted["heartbeat_receipt"]["settlement_identity"] assert admitted["autonomous_replan_obligation"]["triggers"][0]["todo_id"] == cli.TODO_ID assert len(admitted["autonomous_replan_obligation"]["triggers"]) == 1 - call("todo", "complete", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, - "--agent-id", cli.AGENT_ID, "--completion-identity-key", done["completion_identity_key"], - "--no-follow-up") + if closeout_source == "turn": + terminal = call("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, + "--evidence", "Original declared source check passed.", "--no-follow-up") + else: + terminal = call("todo", "complete", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID, + "--agent-id", cli.AGENT_ID, "--completion-identity-key", done["completion_identity_key"], + "--no-follow-up") recovered = call(*guard) assert recovered["heartbeat_receipt"]["settlement_identity"] == identity assert recovered.get("selected_todo") is None @@ -329,7 +335,96 @@ def call(*args: str, expected: int = 0): assert call(*spend)["appended"] is False assert call(*guard)["effective_action"] == "heartbeat_settled_skip" after = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] - for field in ("completed_at", "completion_turn_key", "completion_receipt_id", "completion_validation_sha256"): + for field in ("completed_at", "completion_turn_key", "completion_validation_sha256"): assert before[field] == after[field] + # Same-Turn closeout owns its terminal receipt; lifecycle reentry retains + # the prior completion receipt. Neither path repeats the original check. + assert after["completion_receipt_id"] == ( + terminal["completion_receipt_id"] if closeout_source == "turn" else before["completion_receipt_id"] + ) assert marker.read_text() == "run\n" assert cli._spend_run_count(runtime) == 2 # Original delivery plus the independently admitted review. + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("legacy_unqualified", [False, True]) +def test_same_turn_terminal_recovery_accepts_a_real_blocker_without_another_debit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, + legacy_unqualified: bool, capsys: pytest.CaptureFixture[str], +) -> None: + _, workspace, runtime, registry, run, lease, marker, _, _ = _fixture(tmp_path, monkeypatch, provider) + + def call(*args: str): + code, payload = run(*args) + assert code == 0, json.dumps(payload, ensure_ascii=False, indent=2) + return payload + + binding = ("--agent-id", cli.AGENT_ID, "--todo-id", cli.TODO_ID, + "--turn-instance-id", cli.TURN_ID) + call("quota", "should-run", "--codex-app", "--goal-id", cli.GOAL_ID, *binding) + complete = ("todo", "complete", "--goal-id", cli.GOAL_ID, *binding, *lease, + "--evidence", "Original declared source check passed.") + original = call(*complete) + call("refresh-state", "--goal-id", cli.GOAL_ID, *binding, + "--classification", "original_delivery_validated", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--vision-state", "vision_closed", + "--vision-summary", "Original source deliverable validated.", + "--vision-acceptance", "Original source and declared check passed.", + "--no-global-sync", "--suppress-external-sinks") + call("quota", "spend-slot", "--goal-id", cli.GOAL_ID, *binding, + "--slots", "1", "--source", "heartbeat", "--execute") + turn = "turn-blocked-lifecycle-review" + guard = ("quota", "should-run", "--runtime-profile", "generic_cli", "--goal-id", cli.GOAL_ID, + "--agent-id", cli.AGENT_ID, "--turn-instance-id", turn) + admitted = call(*guard) + identity = admitted["heartbeat_receipt"]["settlement_identity"] + call(*complete, "--no-follow-up") + recovered = call(*guard) + assert recovered["heartbeat_receipt"]["settlement_identity"] == identity + replan = identity["replan_obligation_id"] + refresh = ("refresh-state", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--turn-instance-id", turn, "--replan-obligation-id", replan, + "--classification", "source_review_blocked", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_gap", "--delivery-boundary", "semantic_closeout", + "--progress-result-class", "blocked", "--progress-work-item-id", replan, + "--progress-blocker-id", "blocker:independent-source-unavailable", + "--progress-evidence-id", "evidence:independent-source-read-failed", + "--vision-unchanged-reason", "Original acceptance is retained; independent source is unavailable.", + "--no-global-sync", "--suppress-external-sinks") + legacy_path = None + legacy_bytes = None + if legacy_unqualified: + # Fault-inject the old qualifier omission through the real CLI. The + # synthetic provider, journal and original guard are never rewritten. + import loopx.state_refresh as state_refresh + from loopx.cli import main as cli_main + from loopx.control_plane.work_items.semantic_replan_writeback import RefreshReplanQualification + + with monkeypatch.context() as patch: + patch.chdir(workspace) + patch.setattr(state_refresh, "qualify_refresh_replan_writeback", lambda **kw: + RefreshReplanQualification(None, None, None, kw["classification"], kw["delivery_outcome"], False)) + code = cli_main(["--format", "json", "--registry", str(registry), + "--runtime-root", str(runtime), *refresh]) + unqualified = json.loads(capsys.readouterr().out) + assert code == 0, unqualified + assert unqualified["autonomous_replan_recorded"] is False + legacy_path = Path(unqualified["json_path"]) + legacy_bytes = legacy_path.read_bytes() + context = call("checkpoint-context", "--goal-id", cli.GOAL_ID, "--agent-id", cli.AGENT_ID, + "--turn-instance-id", turn, "--replan-obligation-id", replan) + refresh = (*refresh, "--checkpoint-read-context", context["read_context_id"]) + refreshed = call(*refresh) + assert refreshed["autonomous_replan_recorded"] is True + assert refreshed["settlement_progress"]["state"] == "settled" + assert refreshed["settlement_progress"]["closeout_kind"] == "typed_blocked_writeback_no_spend" + assert call(*guard)["effective_action"] == "heartbeat_settled_skip" + assert marker.read_text() == "run\n" + assert cli._spend_run_count(runtime) == 1 + after = call("todo", "list", "--goal-id", cli.GOAL_ID, "--todo-id", cli.TODO_ID)["todo"] + assert after["completion_turn_key"] == original["completion_identity_key"] + if legacy_path is not None: + assert legacy_path.read_bytes() == legacy_bytes + assert refreshed["refresh_recovery"]["decision"] == "supplement_checkpoint" + assert call(*refresh)["idempotent_replay"] is True + assert cli._spend_run_count(runtime) == 1 diff --git a/tests/control_plane/test_refresh_state_replan_gate.py b/tests/control_plane/test_refresh_state_replan_gate.py index 0f586b679a..7e75b1d2fb 100644 --- a/tests/control_plane/test_refresh_state_replan_gate.py +++ b/tests/control_plane/test_refresh_state_replan_gate.py @@ -11,6 +11,7 @@ from canonical_authority_fixture import initialize_canonical_source_fixture from loopx.cli import main as cli_main +from loopx.control_plane.effect_runtime import EffectRuntimeRejected from loopx.control_plane.status.autonomous_replan_projection import ( AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD, ) @@ -197,6 +198,23 @@ def test_turn_guard_keeps_its_exact_replan_target_strict() -> None: ) +@pytest.mark.parametrize("run_count", [0, AUTONOMOUS_REPLAN_PERIODIC_RUN_THRESHOLD]) +def test_selected_replan_cannot_write_when_its_source_cannot_be_reconstructed(run_count: int) -> None: + # Deferring an obligation not selected by an ordinary Turn is legal. Losing + # the exact source of an already selected replan must instead fail closed. + with pytest.raises(EffectRuntimeRejected, match="identity conflicts"): + enforce_open_replan_writeback( + newest_first_runs=_durable_runs(run_count), state_text=STATE_TEXT, + agent_id=AGENT_ID, goal_id=GOAL_ID, guard_scoped=True, + guard_semantic_replan_obligation_id="replan-1111111111111111", + progress_observation={ + "schema_version": "typed_progress_observation_v0", + "result_class": "blocked", "work_item_id": "replan-1111111111111111", + "blocker_id": "blocker:source-missing", "evidence_ids": ["evidence:source-read"], + }, + ) + + def _typed_repeat_runs() -> list[dict]: """Two consecutive identical blocked observations (external-wait stall). diff --git a/tests/control_plane/test_replan_semantics_transport.py b/tests/control_plane/test_replan_semantics_transport.py index 8089b057ca..aa137b4e11 100644 --- a/tests/control_plane/test_replan_semantics_transport.py +++ b/tests/control_plane/test_replan_semantics_transport.py @@ -20,7 +20,8 @@ def isolated_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @pytest.mark.parametrize("large", [False, True]) -def test_lifecycle_source_preserves_complete_input_and_output(isolated_runtime: Path, large: bool): +@pytest.mark.parametrize("recovery", ["same_turn_terminal_closeout", "lifecycle_reentry_terminal_closeout"]) +def test_lifecycle_source_preserves_complete_input_and_output(isolated_runtime: Path, large: bool, recovery: str): item = { "todo_id": "todo_accepted_source", "index": 1, "status": "done", "done": True, "task_class": "advancement_task", @@ -28,7 +29,7 @@ def test_lifecycle_source_preserves_complete_input_and_output(isolated_runtime: "completed_at": "2026-08-01T00:00:00Z", "updated_at": "2026-08-01T00:00:00Z", "completion_turn_key": "original-completion", "no_followup": True, "completion_continuation": "no_followup", - "completion_recovery": "lifecycle_reentry_terminal_closeout", + "completion_recovery": recovery, "note": "x" * (effect_runtime.MAX_REQUEST_BYTES + 128) if large else "Accepted source.", } params = {"operation": "lifecycle_source", "agent_id": "fixture-agent", diff --git a/tests/control_plane_ts/checkpoint_read_context.test.ts b/tests/control_plane_ts/checkpoint_read_context.test.ts index e496374c8f..da80428fd5 100644 --- a/tests/control_plane_ts/checkpoint_read_context.test.ts +++ b/tests/control_plane_ts/checkpoint_read_context.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import {evaluateCheckpointReadContext as evaluate} from "../../loopx/control_plane/goals/checkpoint_read_context.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {settlementIdentity} from "../../loopx/control_plane/effect_program.ts"; const identity = {goal_id: "goal", agent_id: "agent", todo_id: "task", turn_instance_id: "turn", effect_id: "effect"}; const facts = { @@ -30,6 +31,24 @@ test("read captures the exact task, transitive upstream results and goal accepta assert.equal(check(result.receipt).ok, true); }); +test("an unqualified blocked replan needs the exact committed source and no debit before reread", () => { + const bound = settlementIdentity({goal_id: "goal", agent_id: "agent", turn_instance_id: "turn", + replan_obligation_id: "replan-1111111111111111"}); + const prior = {goal_id: "goal", agent_id: "agent", turn_instance_id: "turn", + replan_obligation_id: bound.replan_obligation_id, delivery_outcome: "outcome_gap", + progress_observation: {schema_version: "typed_progress_observation_v0", result_class: "blocked", + work_item_id: bound.replan_obligation_id, blocker_id: "blocker:source", evidence_ids: ["evidence:read"]}, + vision_checkpoint: {decision: "unchanged_with_reason", satisfied: true}}; + const pending = {identity: bound, prior, writeback_committed: true, quota_spend_committed: false, + semantic_replan_guard: {selected_obligation_id: bound.replan_obligation_id}}; + assert.equal(read(pending).ok, true); + for (const changed of [{...pending, writeback_committed: false}, {...pending, quota_spend_committed: true}, + {...pending, semantic_replan_guard: {}}, {...pending, prior: {...prior, agent_id: "other-agent"}}, + {...pending, prior: {...prior, autonomous_replan_ack: {recorded: true}}}]) { + assert.equal(read(changed).error_code, "checkpoint_context_not_missing"); + } +}); + test("old receipts and identical content from a different provider lineage require reread", () => { const source = {...facts.source, store_identity: "file:first"}; const receipt = read({facts: {...facts, source}}).receipt as JsonObject; diff --git a/tests/control_plane_ts/refresh_recovery.test.ts b/tests/control_plane_ts/refresh_recovery.test.ts index 9d90e381a6..1b4e93357f 100644 --- a/tests/control_plane_ts/refresh_recovery.test.ts +++ b/tests/control_plane_ts/refresh_recovery.test.ts @@ -41,6 +41,23 @@ test("digest uses JSON structure, not property insertion order", () => { assert.equal(first.vision_request_digest, second.vision_request_digest); }); +test("a verified missing blocked-replan qualification permits only an exact additive checkpoint", () => { + const retry = {...request, delivery_outcome: "outcome_gap", unchanged_reason: "Original source is unavailable."}; + const original = {...prior, delivery_outcome: "outcome_gap", + vision_checkpoint: {...prior.vision_checkpoint, decision: "unchanged_with_reason", satisfied: true}, + refresh_recovery: refreshRecovery(retry, null, false, undefined, false)}; + assert.equal(refreshRecovery(retry, original, true, undefined, false).decision, "replay"); + assert.equal(refreshRecovery(retry, original, true, undefined, false, true).decision, "supplement_checkpoint"); + assert.equal(refreshRecovery({...retry, checkpoint_read_context_id: "fresh-read"}, + original, true, undefined, false, true).decision, "supplement_checkpoint"); + assert.equal(refreshRecovery(retry, original, true, undefined, true, true).decision, "reject"); + for (const changed of [{...retry, unchanged_reason: "A different judgment"}, + {...retry, delivery_outcome: "outcome_progress"}, {...retry, progress_observation: {result_class: "advanced"}}, + {...retry, mutation: {next_action: "Start unrelated work"}}]) { + assert.equal(refreshRecovery(changed, original, true, undefined, false, true).decision, "reject"); + } +}); + test("checkpoint replay is bound to the read receipt used for the committed judgment", () => { const supplement = {...request, unchanged_reason: "Still applicable", checkpoint_read_context_id: "read-A"}; const admitted = refreshRecovery(supplement, prior, true, "unknown", false); diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 7183df3877..7d5674dd28 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -557,13 +557,14 @@ test("only a bound original receipt can select the source-derived legacy obligat }); -test("canonical no-follow-up restores only its exact admitted review and still owes Vision", () => { +for (const recovery of ["same_turn_terminal_closeout", "lifecycle_reentry_terminal_closeout"]) { +test(`${recovery} restores only its exact admitted review and still owes Vision`, () => { const id = "replan-1111111111111111", agent = "synthetic-agent"; const row = {todo_id: "todo_original", text: "Verify independent source", status: "done", done: true, task_class: "advancement_task", claimed_by: agent, last_actor_agent_id: agent, completed_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-01T00:00:01Z", completion_turn_key: "original-completion", no_followup: true, completion_continuation: "no_followup", - completion_recovery: "lifecycle_reentry_terminal_closeout"}; + completion_recovery: recovery}; const original = {required: true, obligation_id: id, resolution_mode: "todo_lifecycle_settlement", triggers: [{kind: "completed_advancement_without_successor", todo_id: row.todo_id, completion_turn_key: row.completion_turn_key, agent_id: agent}]}; @@ -602,6 +603,7 @@ test("canonical no-follow-up restores only its exact admitted review and still o assert.throws(() => projectReplanSemantics({...request, selected_obligation_id: "replan-2222222222222222"}), /identity conflicts/); }); +} test("lifecycle reconstruction restores completion order before its trigger bound", () => { const agent = "synthetic-agent";