From 3088b0427f544b6f50ebaa2434b2c5a7c4f1de08 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 17:38:05 +0800 Subject: [PATCH 1/7] Require stride decisions for due periodic reviews Signed-off-by: huangruiteng --- .../goals/goal_amendment_runtime.py | 12 +- .../goals/goal_frontier/__init__.py | 8 ++ .../goals/goal_frontier/ack_policy.py | 11 ++ .../control_plane/goals/vision_checkpoint.ts | 4 +- .../control_plane/quota/should_run_prepare.py | 5 + .../work_items/progress_observation.py | 21 +++- .../work_items/replan_semantics.ts | 55 +++++++-- .../work_items/semantic_replan_writeback.py | 8 +- .../control_plane/work_items/stride_policy.ts | 8 +- .../test_effective_turn_replan.py | 112 ++++++++++++++++++ .../test_goal_frontier_replan_rules.py | 12 ++ .../control_plane_ts/replan_semantics.test.ts | 11 +- tests/control_plane_ts/stride_policy.test.ts | 65 ++++++++++ 13 files changed, 304 insertions(+), 28 deletions(-) diff --git a/loopx/control_plane/goals/goal_amendment_runtime.py b/loopx/control_plane/goals/goal_amendment_runtime.py index ee4439c32a..9c70a86307 100644 --- a/loopx/control_plane/goals/goal_amendment_runtime.py +++ b/loopx/control_plane/goals/goal_amendment_runtime.py @@ -23,7 +23,8 @@ from ..work_items.autonomous_replan_obligation import ensure_replan_novelty_policy -def _replan_readback(derived: Mapping[str, Any], inventory: list[dict[str, Any]], view: Mapping[str, Any]) -> list[dict[str, Any]]: +def _replan_readback(derived: Mapping[str, Any], inventory: list[dict[str, Any]], view: Mapping[str, Any], + *, stride_strategy: str = "off") -> list[dict[str, Any]]: """Transport the existing replan owner's decisions, never create an ACK.""" if view.get("current_lifetime") is not True or not isinstance(view.get("intent"), dict): return [] @@ -39,9 +40,11 @@ def _replan_readback(derived: Mapping[str, Any], inventory: list[dict[str, Any]] obligation = obligations[item["obligation_id"]] for agent in item["bound_agent_ids"]: ack = replan_successor_transition_ack(None, agent_id=agent, - replan_obligation=obligation, agent_todo_items=view.get("work_items") or []) + replan_obligation=obligation, agent_todo_items=view.get("work_items") or [], + stride_strategy=stride_strategy) accepted = autonomous_replan_ack_satisfies_obligation(ack, - replan_obligation=obligation, acceptance_gaps=None) + replan_obligation=obligation, acceptance_gaps=None, + stride_strategy=stride_strategy) rows.append({"obligation_id": item["obligation_id"], "agent_id": agent, "state": "settled" if accepted else "pending", "acknowledgement": ack if accepted else None, @@ -105,7 +108,8 @@ def inspect() -> dict[str, Any]: timeout=CANONICAL_AUTHORITY_WRITE_TIMEOUT_SECONDS) if not isinstance(value, dict): raise TypeError("Goal amendment readback is invalid") - value["replan_observations"] = _replan_readback(derived, obligations, value) + value["replan_observations"] = _replan_readback(derived, obligations, value, + stride_strategy=(goal.get("execution_profile") or {}).get("stride_strategy", "off")) return value if operation in {"propose", "commit", "claim"}: diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 5d46e204ed..46d574a9aa 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1658,11 +1658,15 @@ def build_goal_frontier_projection_context_from_status( agent_id=agent_id, ) effective_replan_ack = latest_agent_replan_ack or projected_replan_ack + stride_strategy = ((project_asset or {}).get("execution_profile") or {}).get( + "stride_strategy", "off" + ) replan_transition_ack = replan_successor_transition_ack( agent_todo_summary, agent_id=agent_id, replan_obligation=replan_obligation, agent_todo_items=agent_todo_source_items, + stride_strategy=stride_strategy, ) # Keep the validated history-obligation transition distinct from any # successor frontier obligation derived below. Exact Turn settlement may @@ -1677,6 +1681,7 @@ def build_goal_frontier_projection_context_from_status( replan_obligation=replan_obligation, acceptance_gaps=source_acceptance_gaps, todo_succession_gap_open=todo_succession_gap_open, + stride_strategy=stride_strategy, ) and autonomous_replan_ack_matches_frontier( obligation_ack, @@ -1714,6 +1719,7 @@ def build_goal_frontier_projection_context_from_status( agent_id=agent_id, replan_obligation=frontier_replan_obligation, agent_todo_items=agent_todo_source_items, + stride_strategy=stride_strategy, ) frontier_obligation_ack = frontier_transition_ack or effective_replan_ack frontier_transition_candidate = {"obligation": frontier_replan_obligation, "ack": frontier_transition_ack} @@ -1729,6 +1735,7 @@ def build_goal_frontier_projection_context_from_status( source_ack = replan_successor_transition_ack( agent_todo_summary, agent_id=agent_id, replan_obligation=source, agent_todo_items=agent_todo_source_items, + stride_strategy=stride_strategy, ) frontier_transition_candidate = {"obligation": source, "ack": source_ack} @@ -1754,6 +1761,7 @@ def build_goal_frontier_projection_context_from_status( replan_obligation=frontier_replan_obligation, acceptance_gaps=source_acceptance_gaps, todo_succession_gap_open=todo_succession_gap_open, + stride_strategy=stride_strategy, ) and autonomous_replan_ack_matches_frontier( frontier_obligation_ack, diff --git a/loopx/control_plane/goals/goal_frontier/ack_policy.py b/loopx/control_plane/goals/goal_frontier/ack_policy.py index 63866f5f77..d012373f01 100644 --- a/loopx/control_plane/goals/goal_frontier/ack_policy.py +++ b/loopx/control_plane/goals/goal_frontier/ack_policy.py @@ -14,6 +14,7 @@ from ...work_items.progress_observation import ( replan_obligation_trigger_checkpoints, replan_obligation_trigger_kinds, + replan_successor_transition_allowed, required_semantic_outcomes, ) from ...work_items.autonomous_replan_obligation import ensure_replan_novelty_policy @@ -40,11 +41,16 @@ def autonomous_replan_ack_satisfies_obligation( replan_obligation: dict[str, Any] | None, acceptance_gaps: list[dict[str, Any]] | None, todo_succession_gap_open: bool = False, + stride_strategy: str = "off", ) -> bool: """Reject ACKs that miss the obligation's typed semantic outcome.""" if not autonomous_replan_ack_has_frontier_delta(ack): return False + if ack.get("source") == "todo_replan_successor_transition" and not replan_successor_transition_allowed( + replan_obligation or {}, stride_strategy=stride_strategy, + ): + return False semantic_delta = ack.get("semantic_delta") if isinstance(ack, dict) else {} obligation_id = str((replan_obligation or {}).get("obligation_id") or "").strip() acknowledged_obligation_id = str( @@ -75,6 +81,7 @@ def replan_successor_transition_ack( agent_id: str | None, replan_obligation: dict[str, Any] | None, agent_todo_items: Iterable[dict[str, Any]] | None = None, + stride_strategy: str = "off", ) -> dict[str, Any] | None: """Project an exact runnable-successor Todo as the replan receipt. @@ -151,6 +158,10 @@ def replan_successor_transition_ack( ) if successor_binding is None: return None + if not replan_successor_transition_allowed( + replan_obligation or {}, stride_strategy=stride_strategy, + ): + return None if trigger_checkpoints is None: trigger_checkpoints = replan_obligation_trigger_checkpoints(replan_obligation or {}) semantic_delta = { diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index 29ccc4886e..c0c4ca383c 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -641,9 +641,9 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta); Object.assign(fieldUsage, pathDeltaUsage); const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, boundedPublicText); - if (strideDecision && (!pathDelta || !["continue", "no_change", "replan"].includes(String(pathDelta.outcome)) || + if (strideDecision && (!pathDelta || !["continue", "no_change", "replan", "stop"].includes(String(pathDelta.outcome)) || !Array.isArray(pathDelta.evidence_refs) || !pathDelta.evidence_refs.length)) { - throw new EffectRuntimeRequestError("stride_decision requires a fresh evidence-linked path decision"); + throw new EffectRuntimeRequestError("stride_decision requires an evidence-linked path decision"); } Object.assign(fieldUsage, strideUsage); const [fallbackDeclarations, fallbackUsage] = normalizeFallbackDeclarations( diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 9acbcd98c0..7fb449997a 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -192,6 +192,7 @@ def _preserve_receipt_bound_replan_obligation( agent_id: str | None = None, replay_phase: ReceiptBoundReplayPhase | None = None, transition_candidates: list[dict[str, Any]] | None = None, + stride_strategy: str = "off", ) -> dict[str, Any] | None: preserved_replan_id = normalize_todo_replan_obligation_id( receipt_bound_replan_obligation_id @@ -206,6 +207,7 @@ def _preserve_receipt_bound_replan_obligation( "agent_id": agent_id, "replay_phase": replay_phase.value if replay_phase is not None else None, "transition_candidates": transition_candidates or [], + "stride_strategy": stride_strategy, }) except EffectRuntimeRejected as exc: if exc.diagnostic_code == "heartbeat_receipt_identity_conflict": @@ -858,6 +860,9 @@ def _prepare_quota_should_run_item( agent_id=agent_frontier_id, replay_phase=receipt_bound_replay_phase, transition_candidates=goal_frontier_context.get("replan_transition_candidates"), + stride_strategy=((project_asset or {}).get("execution_profile") or {}).get( + "stride_strategy", "off" + ), ) ) replan_scope = goal_frontier_context.get("replan_scope") or {} diff --git a/loopx/control_plane/work_items/progress_observation.py b/loopx/control_plane/work_items/progress_observation.py index 688886c65f..bb83a47f2a 100644 --- a/loopx/control_plane/work_items/progress_observation.py +++ b/loopx/control_plane/work_items/progress_observation.py @@ -345,16 +345,33 @@ def required_semantic_outcomes(obligation: Mapping[str, Any]) -> list[str]: return list(replan_writeback_requirements(obligation)["required_any_of"]) +def replan_successor_transition_allowed( + obligation: Mapping[str, Any], *, stride_strategy: str = "off", +) -> bool: + """Ask the typed owner whether a Todo transition can settle this review.""" + try: + result = effect_runtime_result("work_item.replan_semantics.project", { + "operation": "transition_eligibility", "obligation": dict(obligation), + "stride_strategy": stride_strategy, + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + if not isinstance(result, Mapping) or not isinstance(result.get("allowed"), bool): + raise RuntimeError("TypeScript transition eligibility shape mismatch") + return result["allowed"] + + def guarded_replan_transition_delta( *, guard_scoped: bool, selected_obligation_id: str | None, - transition_acks: list[dict[str, Any] | None], + transition_candidates: list[dict[str, Any]], stride_strategy: str = "off", ) -> dict[str, Any] | None: """Adapt revalidated canonical receipts to the TS-owned exact Turn gate.""" try: result = effect_runtime_result("work_item.replan_semantics.project", { "operation": "turn_transition", "guard_scoped": guard_scoped, "selected_obligation_id": selected_obligation_id, - "transition_acks": transition_acks, + "transition_candidates": transition_candidates, + "stride_strategy": stride_strategy, }) except EffectRuntimeRejected as exc: raise ValueError(str(exc)) from None diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 392637d0d7..66e6d850a0 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -66,13 +66,21 @@ function isExternalReview(obligation: JsonObject): boolean { return triggerKinds(obligation).some(kind => EXTERNAL_REVIEW_TRIGGERS.has(kind)); } +// A due cadence review is a decision checkpoint. Typed progress may still be +// its outcome, but cannot stand in for the review when the strategy is enabled. +function requiresPeriodicStrideDecision(obligation: JsonObject, fallbackStrategy: unknown = "off"): boolean { + const context = object(obligation.replan_context); + const strategy = Object.hasOwn(context, "stride_strategy") ? context.stride_strategy : fallbackStrategy; + return triggerKinds(obligation).includes("periodic_review_due") && normalizeStrideStrategy(strategy) !== "off"; +} + /** Route only a revalidated canonical transition to its original Turn guard. * A successor frontier duty stays with the next decision; this receipt cannot * discharge an unrelated duty or certify completion of the settlement Todo. */ function projectTurnTransition(request: JsonObject): JsonObject { - if (typeof request.guard_scoped !== "boolean" || !Array.isArray(request.transition_acks)) { - throw new EffectRuntimeRequestError("turn transition requires a scoped guard and transition receipts"); + if (typeof request.guard_scoped !== "boolean" || !Array.isArray(request.transition_candidates)) { + throw new EffectRuntimeRequestError("turn transition requires a scoped guard and source-bound transition candidates"); } const selected = optionalNonEmptyString(request.selected_obligation_id, "selected_obligation_id"); if (selected !== null && !/^replan-[a-f0-9]{16}$/.test(selected)) { @@ -80,8 +88,12 @@ function projectTurnTransition(request: JsonObject): JsonObject { } let delta: JsonObject | null = null; if (request.guard_scoped && selected !== null) { - for (const value of request.transition_acks) { - const ack = object(value); + for (const value of request.transition_candidates) { + const candidateSource = object(value); + const sourceObligation = object(candidateSource.obligation); + if (sourceObligation.obligation_id !== selected) continue; + if (requiresPeriodicStrideDecision(sourceObligation, request.stride_strategy)) continue; + const ack = object(candidateSource.ack); const candidate = object(ack.semantic_delta); if (ack.schema_version === "autonomous_replan_ack_v0" && ack.recorded === true && ack.source === "todo_replan_successor_transition" && @@ -189,7 +201,8 @@ function preserveReceiptBoundObligation(request: JsonObject): JsonObject { } if (obligation.obligation_id !== selected || !requiredSemanticOutcomes(obligation).includes("new_runnable_successor")) continue; const result = projectTurnTransition({guard_scoped: request.guard_scoped, - selected_obligation_id: selected, transition_acks: [candidate.ack]}); + selected_obligation_id: selected, transition_candidates: [candidate], + stride_strategy: request.stride_strategy}); if (result.semantic_delta !== null) { original = obligation; delta = result.semantic_delta as JsonObject; @@ -329,14 +342,28 @@ export function projectReplanSemantics(value: unknown): JsonObject { if (request.operation === "turn_transition") return projectTurnTransition(request); if (request.operation === "receipt_bound_obligation") return preserveReceiptBoundObligation(request); const obligation = requireJsonObject(request.obligation, "obligation"); + if (request.operation === "transition_eligibility") { + return {allowed: !requiresPeriodicStrideDecision(obligation, request.stride_strategy)}; + } const required = requiredSemanticOutcomes(obligation); const externalReview = isExternalReview(obligation); if (request.operation === "requirements") { const strideControl = strideControlContract(object(obligation.replan_context), obligation.obligation_id); + const writeback = writebackProjection(required, externalReview); + if (requiresPeriodicStrideDecision(obligation)) { + const contract = object(writeback.writeback_contract); + writeback.cli_semantic_args = VISION_CLI_ARGS; + writeback.writeback_contract = {...contract, + preferred_input: "evidence_linked_vision_path", + required_fields: [...new Set([...strings(contract.required_fields), "agent_vision.stride_decision"])], + path_outcomes: [...FRESH_PATH_DISPOSITIONS, "stop"], + rule: "This due periodic review must record a current evidence-linked stride_decision before any typed progress, successor, blocker or terminal outcome can settle it. " + + String(contract.rule ?? "")}; + } return {required_any_of: required, planning_guidance: [...REPLAN_PLANNING_GUIDANCE, ...stridePlanningGuidance(object(obligation.replan_context).stride_strategy)], ...(strideControl ? {stride_control: strideControl} : {}), - ...writebackProjection(required, externalReview)}; + ...writeback}; } if (request.operation !== "qualify") { throw new EffectRuntimeRequestError("replan semantics operation must be requirements or qualify"); @@ -350,10 +377,15 @@ export function projectReplanSemantics(value: unknown): JsonObject { const context = object(obligation.replan_context); const [strideDecision] = normalizeStrideDecision(vision.stride_decision); const structuredReview = normalizeStrideStrategy(context.stride_strategy) !== "off"; - const freshPath = Boolean(String(patch.acceptance_summary ?? "").trim() && - FRESH_PATH_DISPOSITIONS.has(String(path.outcome ?? "").trim()) && strings(path.evidence_refs).length); + const periodicStrideReview = requiresPeriodicStrideDecision(obligation); + const pathOutcome = String(path.outcome ?? "").trim(); + const evidenceLinkedPath = Boolean(String(patch.acceptance_summary ?? "").trim() && + (FRESH_PATH_DISPOSITIONS.has(pathOutcome) || pathOutcome === "stop") && strings(path.evidence_refs).length); + const freshPath = evidenceLinkedPath && FRESH_PATH_DISPOSITIONS.has(pathOutcome); const independentStride = strideDecision?.schema_version === STRIDE_DECISION_SCHEMA; - const missingStride = structuredReview && freshPath && (!strideDecision || !independentStride); + const missingStride = structuredReview && (freshPath || periodicStrideReview) && + (!strideDecision || !independentStride); + const missingStridePath = periodicStrideReview && !evidenceLinkedPath; const staleStride = !!strideDecision && structuredReview && strideDecision.reference_obligation_id !== obligation.obligation_id; const reviewMismatch = strideDecision && structuredReview ? strideReviewMismatch(strideDecision, context) : null; @@ -386,7 +418,8 @@ export function projectReplanSemantics(value: unknown): JsonObject { outcomes.push("fresh_vision_path_outcome"); } if (staleStride || missingStride || reviewMismatch) outcomes = outcomes.filter(outcome => outcome !== "fresh_vision_path_outcome"); - const satisfying = inconsistentTerminal ? [] : outcomes.filter(outcome => required.includes(outcome as SemanticOutcome)); + const satisfying = inconsistentTerminal || (periodicStrideReview && (missingStride || missingStridePath || staleStride || reviewMismatch)) + ? [] : outcomes.filter(outcome => required.includes(outcome as SemanticOutcome)); const replayRefused = (replayed || knownBlocker) && !satisfying.length && !inconsistentTerminal; const identityRefused = identityWithoutEvidence && !satisfying.length && !inconsistentTerminal; return { @@ -395,6 +428,7 @@ export function projectReplanSemantics(value: unknown): JsonObject { observation_fingerprint: observation.observation_fingerprint ?? null, reason: satisfying.length ? "writeback changes an outcome accepted by this obligation source" : missingStride ? "enabled stride review requires agent_vision.stride_decision with independent axes/reasons and minimum_evidence from the delivered control contract" + : missingStridePath ? "due stride review requires an evidence-linked Vision path, including a supported terminal stop" : staleStride ? "stride decision must reference the admitted replan obligation; reassess against its evidence" : reviewMismatch ? reviewMismatch : inconsistentTerminal ? "coverage-backed no-follow-up requires agent_vision.state=no_followup and path_delta.outcome=stop" @@ -404,6 +438,7 @@ export function projectReplanSemantics(value: unknown): JsonObject { : identityRefused ? "external progress review accepts a new surface, hypothesis or probe family only with evidence ids absent from the evaluated baseline and every claim in the obligation window" : "writeback does not satisfy this obligation's typed outcomes", ...(missingStride && !satisfying.length ? {reason_code: "stride_decision_required"} + : missingStridePath && !satisfying.length ? {reason_code: "stride_path_evidence_required"} : staleStride && !satisfying.length ? {reason_code: "stride_reference_obligation_mismatch"} : reviewMismatch && !satisfying.length ? {reason_code: "stride_review_basis_mismatch"} : inconsistentTerminal ? {reason_code: "no_followup_vision_path_inconsistent"} diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index cdb9236586..04ec484b2f 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -341,10 +341,10 @@ def qualify_replan_writeback( transition_delta = guarded_replan_transition_delta( guard_scoped=guard_scoped, selected_obligation_id=guard_semantic_replan_obligation_id, - transition_acks=[context.get("run_replan_transition_ack"), - context.get("replan_transition_ack"), - *(candidate.get("ack") for candidate in - context.get("replan_transition_candidates", []))], + transition_candidates=context.get("replan_transition_candidates", []), + stride_strategy=((registry_goal or {}).get("execution_profile") or {}).get( + "stride_strategy", "off" + ), ) if transition_delta is not None: # This closes only the selected Turn obligation. The freshly diff --git a/loopx/control_plane/work_items/stride_policy.ts b/loopx/control_plane/work_items/stride_policy.ts index 6e5952f2f7..334c629ed9 100644 --- a/loopx/control_plane/work_items/stride_policy.ts +++ b/loopx/control_plane/work_items/stride_policy.ts @@ -272,12 +272,14 @@ export function strideControlContract(context: JsonObject, obligationId: unknown previous_review_options: {adoption: decisionSchema.adoption_options, information_gain: decisionSchema.information_options, goal_gain: decisionSchema.gain_options}, explore_basis: context.explore_basis ?? null, explore_dispositions: decisionSchema.explore_dispositions, minimal_example: example, - attachment: "agent_vision.stride_decision", requires_structured_fresh_path: true, - rule: "Enabled fresh Vision path reviews require an explicit grow/switch/verify action and a concrete plan/reason for each axis. " + + attachment: "agent_vision.stride_decision", requires_evidence_linked_path: true, + required_on_periodic_review: true, + rule: "Every due periodic review with this strategy enabled must record a stride decision before settlement, including when a typed progress, successor, blocker or terminal outcome is also reported. " + + "The decision requires an evidence-linked Vision path (including a terminal stop when supported by evidence), an explicit grow/switch/verify action and a concrete plan/reason for each axis. " + "Grow expands a supported route; switch names the reference method and changed mechanism; verify resolves a named grow-or-switch question. Size every action for a coherent accepted deliverable or complete interpretable experiment, including ordinary engineering and maintenance. Grow changes useful completed scope, not authority or settlement boundaries. " + "The minimal example demonstrates syntax, not the recommended action for the current evidence. " + "When a previous decision is delivered, assess its adoption, information and goal gain in previous_review against its exact reference. " + "When Explore basis is delivered, assess applicable findings/alternatives in explore_review; missing or omitted evidence stays unknown. " + - "These are Agent claims and proposal-shape obligations, not execution authority or utility certification. Other source-specific exits remain unchanged.", + "These are Agent claims and proposal-shape obligations, not execution authority or utility certification. Other source-specific outcomes remain legal but cannot bypass a due stride review.", }; } diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index e28c8c1318..c72a51e82e 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -19,6 +19,8 @@ _configure_selectable_alternative, ALTERNATIVE_TODO_ID, ) +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.work_items.replan_history_codec import ( effective_turn_cadence_context, ) @@ -301,6 +303,116 @@ def periodic(): ) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_due_stride_review_survives_successor_transition_until_decision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + data = json.loads(registry.read_text()) + data["goals"][0]["quota"]["allowed_slots"] = 4 + registry.write_text(json.dumps(data)) + rc, listed = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID) + assert rc == 0, listed + initialize_canonical_authority(runtime, GOAL_ID, build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, todos=listed["todos"], handoff_mode="soft_claim", leases=[]), + state_path=project / data["goals"][0]["state_file"], 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 + + call("configure-goal", "--goal-id", GOAL_ID, "--execution-replan-after-turns", "2", + "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + for index in range(2): + turn = f"stride-cadence-{index}" + guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", turn, + "--scan-path", str(project)) + assert guard["selected_todo"]["todo_id"] == TODO_ID + call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", turn, + "--classification", "validated_progress", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--no-global-sync", "--suppress-external-sinks") + call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", turn, + "--slots", "1", "--source", "heartbeat", "--execute") + + guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--turn-instance-id", "stride-due-review", + "--scan-path", str(project)) + obligation = guard["autonomous_replan_obligation"] + assert "periodic_review_due" in {trigger["kind"] for trigger in obligation["triggers"]} + assert guard["replan_action_packet"]["stride_control"]["required_on_periodic_review"] is True + successor = call("todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--claimed-by", AGENT_ID, "--text", "Validate a new independent source", + "--task-class", "advancement_task", "--action-kind", "validate", + "--target-key", "independent-source", "--replan-obligation-id", obligation["obligation_id"]) + assert successor["todo_id"] + reread = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", "stride-due-review", + "--scan-path", str(project)) + assert reread["heartbeat_receipt"]["settlement_identity"]["replan_obligation_id"] == obligation["obligation_id"] + assert reread["autonomous_replan_obligation"]["obligation_id"] == obligation["obligation_id"] + assert reread["autonomous_replan_obligation"].get("resolution_mode") != "receipt_bound_replan_settlement" + assert reread["replan_action_packet"]["stride_control"]["required_on_periodic_review"] is True + rejected = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--replan-obligation-id", obligation["obligation_id"], + "--turn-instance-id", "stride-due-review", + "--classification", "typed_blocker_writeback", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_gap", "--progress-result-class", "blocked", + "--progress-blocker-id", "fixture:blocked-source", + "--progress-evidence-id", "fixture:blocked-source-evidence", + "--no-global-sync", "--suppress-external-sinks", expected_code=1) + assert rejected.get("appended") is not True + assert "stride_decision" in rejected["error"] + decision_path = tmp_path / "stride-review.json" + control = reread["replan_action_packet"]["stride_control"] + decision = { + "schema_version": "stride_decision_v2", "strategy_version": "evidence_conditioned_v1", + "reference_context_id": control["reference_context_id"], + "reference_obligation_id": obligation["obligation_id"], + "basis": "uncertain_evidence", "action": "verify", + "axes": { + "work_slice": {"plan": "Complete the independent source validation.", + "reason": "One complete check resolves the pending evidence question."}, + "route_distance": {"plan": "Retain the current delivery route while checking its source.", + "reason": "No result yet supports replacing the route."}, + "validation_scope": {"plan": "Check the source result and a contrary case.", + "reason": "Both cases distinguish a usable source from a false lead."}, + }, + "next_probe": {"scope": "One bounded independent source validation.", + "prediction": "The source supports the original acceptance.", + "adoption_criterion": "Adopt only if the checked source meets acceptance.", + "cost_bound": "One source and its contrary case before replanning.", + "minimum_evidence": "A checked result and a contrary-case result."}, + } + decision_path.write_text(json.dumps({ + "schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "The original delivery remains open while the new source is validated."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "replan", + "prior_assumption": "The first source was sufficient for the next validation.", + "observed_reality": "The reviewed evidence identifies another source to validate.", + "retained": ["Original acceptance and validation boundary"], + "changed": ["Validate the independent source before claiming completion"], + "evidence_refs": ["evidence:independent-source-review"]}, + "stride_decision": decision, + })) + accepted = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--replan-obligation-id", obligation["obligation_id"], + "--turn-instance-id", "stride-due-review", "--agent-vision-json", str(decision_path), + "--classification", "bounded_replan_progress", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks") + assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True + persisted = json.loads(Path(accepted["json_path"]).read_text()) + assert persisted["agent_vision"]["stride_decision"] == decision + + @pytest.mark.parametrize("invalid", [0, 7, True, 2.5, "2"]) def test_effective_cadence_rejects_invalid_units(invalid): from loopx.execution_profile import compact_execution_profile diff --git a/tests/control_plane/test_goal_frontier_replan_rules.py b/tests/control_plane/test_goal_frontier_replan_rules.py index fbfb4d6d8f..9c45dd469b 100644 --- a/tests/control_plane/test_goal_frontier_replan_rules.py +++ b/tests/control_plane/test_goal_frontier_replan_rules.py @@ -660,6 +660,18 @@ def test_exact_replan_successor_uses_authoritative_todo_source() -> None: "action_kind": "validate", "target_key": "experiment:selected-bounded-slice", } + due = {"obligation_id": obligation_id, "triggers": [{"kind": "periodic_review_due"}]} + assert replan_successor_transition_ack( + None, agent_id="current-agent", replan_obligation=due, + agent_todo_items=[successor], stride_strategy="evidence_conditioned_v1", + ) is None + assert not autonomous_replan_ack_satisfies_obligation( + ack, replan_obligation=due, acceptance_gaps=None, + stride_strategy="evidence_conditioned_v1", + ) + assert autonomous_replan_ack_satisfies_obligation( + ack, replan_obligation=due, acceptance_gaps=None, stride_strategy="off", + ) def test_long_chain_successor_transition_is_bound_to_current_frontier() -> None: diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 3f4132d3a1..0271830208 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -27,8 +27,10 @@ test("a revalidated successor receipt settles only its exact original Turn guard outcomes: ["new_runnable_successor"], satisfying_outcomes: ["new_runnable_successor"]}; const ack = {schema_version: "autonomous_replan_ack_v0", recorded: true, source: "todo_replan_successor_transition", semantic_delta: delta}; + const source = {obligation_id: id}; const request = {operation: "turn_transition", guard_scoped: true, - selected_obligation_id: id, transition_acks: [null, ack]}; + selected_obligation_id: id, transition_candidates: [{obligation: source, ack: null}, + {obligation: source, ack}]}; assert.deepEqual(projectReplanSemantics(request).semantic_delta, delta); for (const guard of [{guard_scoped: false}, {selected_obligation_id: null}, {selected_obligation_id: "replan-2222222222222222"}]) { @@ -38,10 +40,13 @@ test("a revalidated successor receipt settles only its exact original Turn guard {schema_version: "unknown"}, {semantic_delta: {...delta, accepted: false}}, {semantic_delta: {...delta, satisfying_outcomes: []}}, {semantic_delta: {...delta, successor_todo_id: null}}]) { - assert.equal(projectReplanSemantics({...request, transition_acks: [{...ack, ...invalid}]}).semantic_delta, null); + assert.equal(projectReplanSemantics({...request, transition_candidates: [ + {obligation: source, ack: {...ack, ...invalid}}]}).semantic_delta, null); } const next = {...ack, semantic_delta: {...delta, obligation_id: "replan-2222222222222222"}}; - assert.deepEqual(projectReplanSemantics({...request, transition_acks: [next, ack]}).semantic_delta, delta); + assert.deepEqual(projectReplanSemantics({...request, transition_candidates: [ + {obligation: source, ack: next}, {obligation: source, ack}]}).semantic_delta, delta); + assert.throws(() => projectReplanSemantics({...request, transition_candidates: undefined}), /source-bound/); assert.throws(() => projectReplanSemantics({...request, selected_obligation_id: "malformed"}), /malformed/); }); diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index c139083216..e44fa39d92 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -77,6 +77,71 @@ test("an accepted schema and vector do not authorize work or certify gain; stale assert.equal(projectReplanSemantics({operation: "qualify", obligation, stride_decision: proposal}).accepted, false); }); +test("every enabled due review records stride even when progress, a successor or a blocker is the exit", () => { + const obligation = {obligation_id: proposal.reference_obligation_id, + triggers: [{kind: "periodic_review_due"}], + replan_context: {stride_strategy: "evidence_conditioned_v1", context_id: contextId}}; + const authored = buildVisionCheckpoint(request).agent_vision; + const withoutDecision = {...authored, stride_decision: undefined}; + for (const outcome of ["new_surface", "new_runnable_successor", "new_concrete_blocker"]) { + const observation_delta = {delta_kinds: [outcome], evidence_novel: true}; + const refused = projectReplanSemantics({operation: "qualify", obligation, + observation_delta, agent_vision: withoutDecision}); + assert.equal(refused.accepted, false, outcome); + assert.equal(refused.reason_code, "stride_decision_required", outcome); + assert.equal(projectReplanSemantics({operation: "qualify", obligation, + observation_delta, agent_vision: authored}).accepted, true, outcome); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: {...obligation, + replan_context: {stride_strategy: "off"}}, observation_delta}).accepted, true, outcome); + } + const stale = projectReplanSemantics({operation: "qualify", obligation, + observation_delta: {delta_kinds: ["new_concrete_blocker"]}, + agent_vision: {...authored, stride_decision: {...proposal, + reference_obligation_id: "replan-" + "b".repeat(16)}}}); + assert.equal(stale.accepted, false); + assert.equal(stale.reason_code, "stride_reference_obligation_mismatch"); + const bareDecision = projectReplanSemantics({operation: "qualify", obligation, + observation_delta: {delta_kinds: ["new_concrete_blocker"]}, + agent_vision: {stride_decision: proposal}}); + assert.equal(bareDecision.accepted, false); + assert.equal(bareDecision.reason_code, "stride_path_evidence_required"); +}); + +test("terminal due review can record stride without manufacturing a continuing path", () => { + const terminal = buildVisionCheckpoint({...request, agent_vision_packet: {...packet, + state: "no_followup", path_delta: {...packet.path_delta, outcome: "stop"}}}).agent_vision; + const obligation = {obligation_id: proposal.reference_obligation_id, + triggers: [{kind: "periodic_review_due"}], + replan_context: {stride_strategy: "evidence_conditioned_v1", context_id: contextId}}; + const observation_delta = {delta_kinds: ["coverage_backed_no_followup"]}; + assert.equal(projectReplanSemantics({operation: "qualify", obligation, observation_delta, + agent_vision: {...terminal, stride_decision: undefined}}).accepted, false); + assert.equal(projectReplanSemantics({operation: "qualify", obligation, observation_delta, + agent_vision: terminal}).accepted, true); +}); + +test("a successor Todo transition cannot silently settle an enabled due review", () => { + const obligation = {obligation_id: proposal.reference_obligation_id, + triggers: [{kind: "periodic_review_due"}]}; + const semantic_delta = {schema_version: "replan_semantic_delta_v0", accepted: true, + obligation_id: proposal.reference_obligation_id, successor_todo_id: "todo_successor", + outcomes: ["new_runnable_successor"], satisfying_outcomes: ["new_runnable_successor"]}; + const ack = {schema_version: "autonomous_replan_ack_v0", recorded: true, + source: "todo_replan_successor_transition", semantic_delta}; + const candidate = {obligation, ack}; + assert.deepEqual(projectReplanSemantics({operation: "transition_eligibility", obligation, + stride_strategy: "evidence_conditioned_v1"}), {allowed: false}); + const transition = {operation: "turn_transition", guard_scoped: true, + selected_obligation_id: proposal.reference_obligation_id, + transition_candidates: [candidate]}; + assert.equal(projectReplanSemantics({...transition, stride_strategy: "evidence_conditioned_v1"}).semantic_delta, null); + assert.deepEqual(projectReplanSemantics({...transition, stride_strategy: "off"}).semantic_delta, semantic_delta); + const bound = projectReplanSemantics({operation: "receipt_bound_obligation", guard_scoped: true, + selected_obligation_id: proposal.reference_obligation_id, current_obligation: obligation, + transition_candidates: [candidate], replay_phase: "open", stride_strategy: "evidence_conditioned_v1"}); + assert.equal((bound.obligation as Record).resolution_mode, undefined); +}); + test("all three actions preserve independently planned complete work and validation", () => { const axes = {...proposal.axes, work_slice: {plan: "Finish the expanded engine and paired independent repetitions.", reason: "The complete experiment is needed to resolve uncertainty."}, From bf9200f0898e03e9b719894281efe11117ab6a87 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 17:38:25 +0800 Subject: [PATCH 2/7] Document mandatory due stride review settlement Signed-off-by: huangruiteng --- .../rfcs/hierarchical-agent-stride-control-v0.md | 5 +++++ .../hierarchical-agent-stride-control-v0.zh-CN.md | 3 +++ .../protocols/goal-vision-replan-contract-v0.md | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index b696df5554..b79a0268ea 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -643,6 +643,11 @@ projection/review semantics. Both historical v1 decision shapes retain read compatibility, but cannot discharge a fresh enabled review. Frozen workers retain their exact source. Pin implementation revisions for comparisons; do not reinterpret old decisions. The policy seam can evolve when evidence warrants it, without maintaining speculative variants. +For an enabled periodic review, every settlement path now requires a current, +source-bound stride decision. Typed progress, successor and terminal results +remain valid evidence/outcomes, while a bare Todo successor transition cannot +stand in for the review. This closes the decision-recording gap; it does not +establish that the proposed action was adopted or improved the Goal. Six effective work Turns remains the review cadence, not the size of a Todo, experiment or route change. Matched continuation must qualify actual adoption, target benefit and full cost before promotion or synergy claims. diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md index 525e4f3c4f..d0647756a9 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.zh-CN.md @@ -550,6 +550,9 @@ revision、考虑的 finding 与适用/暂缓等理由,不建新图或第二 普通投影/复盘;两种历史 v1 决策保留原义读取兼容,不能完成新的启用策略复盘; 新提案使用 v2 数据格式,仍只有同一个可选策略,冻结 worker 保持确切源码。比较固定 实现 revision,不重释旧决定;有证据后迭代同一组件,避免维护假设性的策略变体。 +启用策略的周期复盘到期后,任何结算路径都须留下绑定本次来源的 stride 决定。 +类型化进展、后续任务和终止结果仍可作为证据与结果,但单独的 Todo 后续转换不能 +代替复盘。这补齐决定记录闭环,不证明提案已被采用或提升了 Goal。 六个有效工作 Turn 仍是复盘周期,不是 Todo、实验或路线变化的大小。策略采用、 目标收益、完整成本和交互增益须通过匹配接续等实验验证后再作结论。 diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index bfcdafeba2..34d1cc3d90 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -484,8 +484,14 @@ changed evidence basis requires reassessment under the original Turn identity, not another quota debit. Disabled Explore adds no Explore assessment duty. The typed owner validates shape, references and declared provenance before the -fresh Vision path exit; other source-specific progress, successor and terminal -exits remain unchanged. All attachment text counts toward the existing +fresh Vision path exit. When an enabled Goal reaches a periodic review, every +accepted exit must also record a current `agent_vision.stride_decision`: a new +progress observation, runnable successor, blocker or coverage-backed terminal +result cannot silently replace that decision. The typed outcome remains legal +and retains its own evidence gate. A terminal review may attach the decision to +an evidence-linked `stop` path. A Todo successor transition alone cannot settle +the due review. Other replan sources retain their existing exit policies. +All attachment text counts toward the existing 1,800-character Vision budget. The live packet exposes field limits. Legacy recorded declarations and old explicit off-mode attachments retain transport compatibility; a newly enabled fresh-path proposal requires v2 action, @@ -512,6 +518,10 @@ revision 与具体 finding,说明采用、暂缓、无适用证据或读取不 按原 Turn 身份重新审视;不开额外执行权或配额。机器校验提案结构与引用,不能证明 模型判断正确或已提升收益。两种历史 v1 决策保留原义读取兼容,但不能完成新的 启用策略复盘;数据格式更新不增加可选策略版本。 +策略启用且周期复盘到期时,所有可接受出口都须记录绑定本次义务的 +`agent_vision.stride_decision`。新进展、可执行后续任务、阻塞或有覆盖证据的终止 +仍可作为结果,但不能代替复盘决定;单独创建后续 Todo 也不能结算到期义务。 +终止时可把决定附在有证据的 `stop` 路径上。其他 replan 来源沿用原有出口规则。 The same opt-in strategy covers ordinary engineering, organizing and maintenance. `grow` expands a coherent accepted deliverable, including related integration, From 6d74749517f9069889109e4e7d44ae72a631e6c1 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 18:02:18 +0800 Subject: [PATCH 3/7] Reuse frontier stride strategy in quota settlement Signed-off-by: huangruiteng --- loopx/control_plane/goals/goal_frontier/__init__.py | 1 + loopx/control_plane/quota/should_run_prepare.py | 4 +--- loopx/semantics/project_registry_io_manifest_v1.json | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 46d574a9aa..50cf1d6834 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1814,6 +1814,7 @@ def build_goal_frontier_projection_context_from_status( return { "schema_version": "goal_frontier_projection_context_v0", "replan_obligation": replan_obligation, + "stride_strategy": stride_strategy, "replan_scope": replan_scope, "goal_frontier_projection": goal_frontier_projection, "acceptance_gaps": acceptance_gaps, diff --git a/loopx/control_plane/quota/should_run_prepare.py b/loopx/control_plane/quota/should_run_prepare.py index 7fb449997a..89e43c0a20 100644 --- a/loopx/control_plane/quota/should_run_prepare.py +++ b/loopx/control_plane/quota/should_run_prepare.py @@ -860,9 +860,7 @@ def _prepare_quota_should_run_item( agent_id=agent_frontier_id, replay_phase=receipt_bound_replay_phase, transition_candidates=goal_frontier_context.get("replan_transition_candidates"), - stride_strategy=((project_asset or {}).get("execution_profile") or {}).get( - "stride_strategy", "off" - ), + stride_strategy=goal_frontier_context["stride_strategy"], ) ) replan_scope = goal_frontier_context.get("replan_scope") or {} diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index b41c7974e0..ddac5fa3ac 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1455,7 +1455,7 @@ }, { "site": "loopx/control_plane/goals/goal_amendment_runtime.py::.execute_goal_amendment::codec_read:load_project_registry#1", - "line": 64, + "line": 67, "column": 15, "kind": "codec_read", "api": "load_project_registry", @@ -1463,7 +1463,7 @@ }, { "site": "loopx/control_plane/goals/goal_amendment_runtime.py::.execute_goal_amendment::codec_transaction:source_session_registry_transaction#1", - "line": 74, + "line": 77, "column": 14, "kind": "codec_transaction", "api": "source_session_registry_transaction", From 9be0c2facb89e1eb33ca76eafe00ca49a8164b7e Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 18:22:39 +0800 Subject: [PATCH 4/7] Bind due stride decisions to review context Signed-off-by: huangruiteng --- loopx/control_plane/goals/vision_checkpoint.py | 2 ++ loopx/control_plane/goals/vision_checkpoint.ts | 8 +++++++- loopx/control_plane/host_adapter_settlement.py | 3 ++- .../control_plane/work_items/replan_semantics.ts | 6 +++++- loopx/state_refresh.py | 4 ++++ tests/control_plane/test_effective_turn_replan.py | 15 +++++++++++++-- tests/control_plane_ts/stride_policy.test.ts | 15 +++++++++++++-- 7 files changed, 46 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/goals/vision_checkpoint.py b/loopx/control_plane/goals/vision_checkpoint.py index 82c338ddb4..e9312bdc70 100644 --- a/loopx/control_plane/goals/vision_checkpoint.py +++ b/loopx/control_plane/goals/vision_checkpoint.py @@ -75,6 +75,7 @@ def prepare_vision_refresh( existing_agent_vision: dict[str, Any] | None, merge_patch: bool, require_path_delta_for_durable_change: bool, + allow_terminal_stride_path: bool = False, ) -> dict[str, Any]: """Run the TS-owned Vision preflight before semantic-replan qualification.""" @@ -92,6 +93,7 @@ def prepare_vision_refresh( "require_path_delta_for_durable_change": bool( require_path_delta_for_durable_change ), + "allow_terminal_stride_path": allow_terminal_stride_path, }, ) except EffectRuntimeRejected as exc: diff --git a/loopx/control_plane/goals/vision_checkpoint.ts b/loopx/control_plane/goals/vision_checkpoint.ts index c0c4ca383c..e9bf89e8de 100644 --- a/loopx/control_plane/goals/vision_checkpoint.ts +++ b/loopx/control_plane/goals/vision_checkpoint.ts @@ -203,6 +203,7 @@ interface VisionRefreshPrepareRequest { existing_agent_vision: JsonObject | null; merge_patch: boolean; require_path_delta_for_durable_change: boolean; + allow_terminal_stride_path: boolean; } interface VisionRefreshFinalizeRequest { @@ -556,6 +557,8 @@ function decodePrepareRequest(request: JsonObject): VisionRefreshPrepareRequest request.require_path_delta_for_durable_change, "require_path_delta_for_durable_change", ), + allow_terminal_stride_path: request.allow_terminal_stride_path === undefined ? false + : requiredBoolean(request.allow_terminal_stride_path, "allow_terminal_stride_path"), }; } @@ -641,7 +644,10 @@ function prepareVisionRefresh(request: VisionRefreshPrepareRequest): JsonObject const [pathDelta, pathDeltaUsage] = normalizeGoalPathDelta(updatePacket.path_delta); Object.assign(fieldUsage, pathDeltaUsage); const [strideDecision, strideUsage] = normalizeStrideDecision(updatePacket.stride_decision, boundedPublicText); - if (strideDecision && (!pathDelta || !["continue", "no_change", "replan", "stop"].includes(String(pathDelta.outcome)) || + const allowedStridePaths = request.allow_terminal_stride_path + ? ["continue", "no_change", "replan", "stop"] + : ["continue", "no_change", "replan"]; + if (strideDecision && (!pathDelta || !allowedStridePaths.includes(String(pathDelta.outcome)) || !Array.isArray(pathDelta.evidence_refs) || !pathDelta.evidence_refs.length)) { throw new EffectRuntimeRequestError("stride_decision requires an evidence-linked path decision"); } diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index df57273faf..17580d7266 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -129,7 +129,8 @@ def host_vision_request(request: HostTodoSettlementRequest, vision: dict | None, # This is syntax/budget preflight only: refresh-state still validates against # the real baseline and current replan/settlement state at writeback time. prepare_vision_refresh(vision, goal_id=request.goal_id, agent_id=request.agent_id, - existing_agent_vision=None, merge_patch=False, require_path_delta_for_durable_change=False) + existing_agent_vision=None, merge_patch=False, require_path_delta_for_durable_change=False, + allow_terminal_stride_path=True) with tempfile.TemporaryDirectory(prefix="loopx-host-vision-") as directory: path = Path(directory) / "vision.json" path.write_text(json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8") diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 66e6d850a0..262c014170 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -388,7 +388,11 @@ export function projectReplanSemantics(value: unknown): JsonObject { const missingStridePath = periodicStrideReview && !evidenceLinkedPath; const staleStride = !!strideDecision && structuredReview && strideDecision.reference_obligation_id !== obligation.obligation_id; - const reviewMismatch = strideDecision && structuredReview ? strideReviewMismatch(strideDecision, context) : null; + const reviewMismatch = strideDecision && structuredReview + ? periodicStrideReview && strideDecision.reference_context_id !== context.context_id + ? "Stride decision must reference the delivered due-review context." + : strideReviewMismatch(strideDecision, context) + : null; let outcomes = strings(observation.delta_kinds); if (outcomes.some(value => (CAPABILITY_OUTCOMES as readonly string[]).includes(value))) { throw new EffectRuntimeRequestError("capability evidence must be qualified by its owning capability, not generic progress"); diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index c28dc8574b..c4ca2c4dc3 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1031,6 +1031,10 @@ def refresh_state_run( existing_agent_vision=existing_agent_vision, merge_patch=merge_agent_vision_patch, require_path_delta_for_durable_change=autonomous_replan_recorded, + allow_terminal_stride_path=( + ((registry_goal or {}).get("execution_profile") or {}).get("stride_strategy") + == "evidence_conditioned_v1" + ), ) generated_at = now_local() # Next Action is now an actor/Todo-bound recommendation in the existing diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index c72a51e82e..191168ee9a 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -391,7 +391,7 @@ def call(*args: str, expected_code: int = 0) -> dict: "cost_bound": "One source and its contrary case before replanning.", "minimum_evidence": "A checked result and a contrary-case result."}, } - decision_path.write_text(json.dumps({ + vision_packet = { "schema_version": "goal_vision_replan_contract_v0", "state": "vision_patch_proposed", "vision_patch": {"acceptance_summary": "The original delivery remains open while the new source is validated."}, @@ -402,7 +402,18 @@ def call(*args: str, expected_code: int = 0) -> dict: "changed": ["Validate the independent source before claiming completion"], "evidence_refs": ["evidence:independent-source-review"]}, "stride_decision": decision, - })) + } + decision_path.write_text(json.dumps({**vision_packet, + "stride_decision": {**decision, "reference_context_id": "replan-context-" + "b" * 24}})) + foreign = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--replan-obligation-id", obligation["obligation_id"], + "--turn-instance-id", "stride-due-review", "--agent-vision-json", str(decision_path), + "--classification", "bounded_replan_progress", "--delivery-batch-scale", "implementation", + "--delivery-outcome", "outcome_progress", "--no-global-sync", "--suppress-external-sinks", + expected_code=1) + assert foreign.get("appended") is not True + assert "context" in foreign["error"].lower() + decision_path.write_text(json.dumps(vision_packet)) accepted = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review", "--agent-vision-json", str(decision_path), diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index e44fa39d92..23d0b67e65 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -100,6 +100,12 @@ test("every enabled due review records stride even when progress, a successor or reference_obligation_id: "replan-" + "b".repeat(16)}}}); assert.equal(stale.accepted, false); assert.equal(stale.reason_code, "stride_reference_obligation_mismatch"); + const foreignContext = projectReplanSemantics({operation: "qualify", obligation, + observation_delta: {delta_kinds: ["new_concrete_blocker"]}, + agent_vision: {...authored, stride_decision: {...proposal, + reference_context_id: "replan-context-" + "b".repeat(24)}}}); + assert.equal(foreignContext.accepted, false); + assert.equal(foreignContext.reason_code, "stride_review_basis_mismatch"); const bareDecision = projectReplanSemantics({operation: "qualify", obligation, observation_delta: {delta_kinds: ["new_concrete_blocker"]}, agent_vision: {stride_decision: proposal}}); @@ -108,8 +114,13 @@ test("every enabled due review records stride even when progress, a successor or }); test("terminal due review can record stride without manufacturing a continuing path", () => { - const terminal = buildVisionCheckpoint({...request, agent_vision_packet: {...packet, - state: "no_followup", path_delta: {...packet.path_delta, outcome: "stop"}}}).agent_vision; + const terminalPacket = {...packet, + state: "no_followup", path_delta: {...packet.path_delta, outcome: "stop"}}; + assert.throws(() => buildVisionCheckpoint({...request, agent_vision_packet: terminalPacket}), /stride_decision requires/); + assert.throws(() => buildVisionCheckpoint({...request, allow_terminal_stride_path: "true", + agent_vision_packet: terminalPacket}), /allow_terminal_stride_path/); + const terminal = buildVisionCheckpoint({...request, allow_terminal_stride_path: true, + agent_vision_packet: terminalPacket}).agent_vision; const obligation = {obligation_id: proposal.reference_obligation_id, triggers: [{kind: "periodic_review_due"}], replan_context: {stride_strategy: "evidence_conditioned_v1", context_id: contextId}}; From 0adc10e05816cb0b891ead7895347c26ef89591d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 18:22:47 +0800 Subject: [PATCH 5/7] Specify due stride context binding Signed-off-by: huangruiteng --- docs/reference/protocols/goal-vision-replan-contract-v0.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 34d1cc3d90..5313088ca8 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -488,7 +488,8 @@ fresh Vision path exit. When an enabled Goal reaches a periodic review, every accepted exit must also record a current `agent_vision.stride_decision`: a new progress observation, runnable successor, blocker or coverage-backed terminal result cannot silently replace that decision. The typed outcome remains legal -and retains its own evidence gate. A terminal review may attach the decision to +and retains its own evidence gate. The decision references both the due +obligation and its delivered review context. A terminal review may attach it to an evidence-linked `stop` path. A Todo successor transition alone cannot settle the due review. Other replan sources retain their existing exit policies. All attachment text counts toward the existing @@ -520,7 +521,8 @@ revision 与具体 finding,说明采用、暂缓、无适用证据或读取不 启用策略复盘;数据格式更新不增加可选策略版本。 策略启用且周期复盘到期时,所有可接受出口都须记录绑定本次义务的 `agent_vision.stride_decision`。新进展、可执行后续任务、阻塞或有覆盖证据的终止 -仍可作为结果,但不能代替复盘决定;单独创建后续 Todo 也不能结算到期义务。 +仍可作为结果,但不能代替复盘决定;决定须引用本次义务及交付的复盘上下文, +单独创建后续 Todo 也不能结算到期义务。 终止时可把决定附在有证据的 `stop` 路径上。其他 replan 来源沿用原有出口规则。 The same opt-in strategy covers ordinary engineering, organizing and maintenance. From ddda3d1d6a624c1de6a79ef97f786efda17e24db Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 18:52:05 +0800 Subject: [PATCH 6/7] Honor machine stride defaults during due writeback Signed-off-by: huangruiteng --- loopx/state_refresh.py | 11 +-- .../test_replan_successor_guard_reentry.py | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index c4ca2c4dc3..e77e763c21 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -1023,6 +1023,9 @@ def refresh_state_run( goal_id=safe_goal_id, agent_id=normalized_agent_id, ) + effective_replan_goal = resolve_todo_replan_cadence_goal( + registry_goal or {"id": safe_goal_id}, runtime_root, + ) if agent_vision_packet is not None: agent_vision = prepare_vision_refresh( agent_vision_packet, @@ -1032,7 +1035,7 @@ def refresh_state_run( merge_patch=merge_agent_vision_patch, require_path_delta_for_durable_change=autonomous_replan_recorded, allow_terminal_stride_path=( - ((registry_goal or {}).get("execution_profile") or {}).get("stride_strategy") + (effective_replan_goal.get("execution_profile") or {}).get("stride_strategy") == "evidence_conditioned_v1" ), ) @@ -1111,11 +1114,9 @@ def read_explore_context(read_goal_id: str, read_agent_id: str | None): state_text=state_text, goal_id=safe_goal_id, progress_observation=normalized_progress_observation, - registry_goal=registry_goal, + registry_goal=effective_replan_goal, effective_turn_cadence=effective_turn_cadence_context( - resolve_todo_replan_cadence_goal( - registry_goal or {"id": safe_goal_id}, runtime_root, - ), runtime_root, + effective_replan_goal, runtime_root, registry_path=registry_path, goal_ref=goal_ref, source_admission=source_admission, ), diff --git a/tests/control_plane/test_replan_successor_guard_reentry.py b/tests/control_plane/test_replan_successor_guard_reentry.py index 8480f7baa1..7b6317c5f3 100644 --- a/tests/control_plane/test_replan_successor_guard_reentry.py +++ b/tests/control_plane/test_replan_successor_guard_reentry.py @@ -13,6 +13,8 @@ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from test_replan_successor_durable_ack import AGENT, GOAL, history +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.machine_configuration.store import configure_machine_configuration from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.goals.goal_vision import compact_goal_vision_packet, normalize_goal_vision_packet from loopx.goal_mode_mcp import GoalModeMCPConfig, GoalModeMCPControlPlane @@ -98,6 +100,75 @@ def _add(call, obligation_id: str): "--operation-id", "periodic-source-successor", "--replan-obligation-id", obligation_id) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_machine_default_stride_due_allows_evidence_linked_terminal_review( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, +) -> None: + call, runtime, index = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) + assert "stride_strategy" not in json.loads((tmp_path / "registry.json").read_text())["goals"][0].get("execution_profile", {}) + owner = build_builtin_machine_configuration_registry() + configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "todo_replan_cadence": {"schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "completed_todos", "count": 1, + "stride_strategy": "evidence_conditioned_v1"}}} + preview = configure_machine_configuration( + runtime_root=runtime, registry=owner, configuration=configuration) + configure_machine_configuration( + runtime_root=runtime, registry=owner, configuration=configuration, + execute=True, expected_plan_revision=preview["plan_revision"]) + + turn = "machine-default-terminal-review" + guard = _guard(call, turn) + obligation = guard["autonomous_replan_obligation"] + assert "periodic_review_due" in {trigger["kind"] for trigger in obligation["triggers"]} + control = guard["replan_action_packet"]["stride_control"] + assert control["strategy_version"] == "evidence_conditioned_v1" + common = ("refresh-state", "--goal-id", GOAL, "--agent-id", AGENT, + "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", turn, + "--classification", "source_review", "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_gap", "--progress-result-class", "blocked", + "--progress-blocker-id", "fixture:unverified-source", + "--progress-evidence-id", "fixture:provenance-refusal", + "--no-global-sync", "--suppress-external-sinks") + before = index.read_bytes() + rejected = call(*common, expected_code=1) + assert "stride_decision" in rejected["error"] + assert index.read_bytes() == before + + decision = { + "schema_version": "stride_decision_v2", "strategy_version": "evidence_conditioned_v1", + "reference_context_id": control["reference_context_id"], + "reference_obligation_id": obligation["obligation_id"], + "basis": "uncertain_evidence", "action": "verify", + "axes": { + "work_slice": {"plan": "Complete the source provenance check.", + "reason": "The pending decision needs one independent result."}, + "route_distance": {"plan": "Retain the current method while checking provenance.", + "reason": "No result yet supports replacing the route."}, + "validation_scope": {"plan": "Check the source and a contrary case.", + "reason": "Both outcomes distinguish a valid source from a false lead."}}, + "next_probe": {"scope": "One complete provenance verification.", + "prediction": "Only checked source evidence supports continuation.", + "adoption_criterion": "Adopt only after the checked source meets acceptance.", + "cost_bound": "One source and its contrary case.", + "minimum_evidence": "A checked source result and contrary-case result."}} + packet = {"schema_version": "goal_vision_replan_contract_v0", + "state": "vision_patch_proposed", + "vision_patch": {"acceptance_summary": "The source outcome remains unproven."}, + "path_delta": {"schema_version": "goal_path_delta_v0", "outcome": "stop", + "prior_assumption": "The source might support the next decision.", + "observed_reality": "The source still lacks independent validation.", + "retained": ["Original source acceptance"], + "changed": ["Stop this route until the source is checked"], + "evidence_refs": ["evidence:source-provenance-review"]}, + "stride_decision": decision} + packet_path = tmp_path / "terminal-review.json" + packet_path.write_text(json.dumps(packet)) + accepted = call(*common, "--agent-vision-json", str(packet_path)) + assert accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True + assert json.loads(Path(accepted["json_path"]).read_text())["agent_vision"]["stride_decision"] == decision + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_mcp_replan_delivers_objective_and_evidence_guidance( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, From e36a734209646f87cd975f8a68afe4b622eee3b8 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 13 Oct 2026 19:02:31 +0800 Subject: [PATCH 7/7] Reject disabled stride host input before Todo effects Signed-off-by: huangruiteng --- .../control_plane/host_adapter_settlement.py | 7 +- loopx/goal_mode_mcp.py | 34 +++++++++- .../project_registry_io_manifest_v1.json | 8 +++ .../test_vision_checkpoint_runtime.py | 1 + tests/test_host_vision_recovery.py | 65 +++++++++++++++++++ 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index 17580d7266..c7d3270f09 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -116,7 +116,10 @@ def _request_payload( @contextmanager -def host_vision_request(request: HostTodoSettlementRequest, vision: dict | None, unchanged: str): +def host_vision_request( + request: HostTodoSettlementRequest, vision: dict | None, unchanged: str, + *, allow_terminal_stride_path: bool = False, +): """Materialize authored JSON for the existing CLI codec, never as public state.""" if vision is not None and unchanged: raise ValueError("choose a vision patch or an unchanged reason, not both") @@ -130,7 +133,7 @@ def host_vision_request(request: HostTodoSettlementRequest, vision: dict | None, # the real baseline and current replan/settlement state at writeback time. prepare_vision_refresh(vision, goal_id=request.goal_id, agent_id=request.agent_id, existing_agent_vision=None, merge_patch=False, require_path_delta_for_durable_change=False, - allow_terminal_stride_path=True) + allow_terminal_stride_path=allow_terminal_stride_path) with tempfile.TemporaryDirectory(prefix="loopx-host-vision-") as directory: path = Path(directory) / "vision.json" path.write_text(json.dumps(vision, ensure_ascii=False, allow_nan=False), encoding="utf-8") diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 3a201afc09..84bb00ad80 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -7,6 +7,7 @@ import sys from collections.abc import Callable, Mapping from dataclasses import dataclass +from pathlib import Path from typing import Annotated, Any try: # pydantic ships with the FastMCP extra; base installs have no pydantic. @@ -75,6 +76,33 @@ def goal_ref(self) -> dict[str, str] | None: "goal_instance_id": goal_instance_id, } + def _allow_terminal_stride_path(self, goal_id: str, vision: dict[str, Any] | None) -> bool: + """Use the same effective Goal cadence as formal writeback before Todo effects.""" + if not isinstance(vision, dict) or not isinstance(vision.get("path_delta"), dict): + return False + if vision["path_delta"].get("outcome") != "stop" or "stride_decision" not in vision: + return False + from .capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal + from .control_plane.projects.registry_codec import load_registry + from .paths import (default_registry_path, global_registry_path, + resolve_runtime_root, select_default_runtime_root) + from .registry import find_registry_goal + + selected = self.context()[1] + if selected == "@host-global": + registry_path = global_registry_path(select_default_runtime_root()) + else: + registry_path = Path(selected).expanduser() if selected else default_registry_path() + if not selected and not registry_path.exists(): + registry_path = global_registry_path(select_default_runtime_root()) + registry = load_registry(registry_path) + goal = find_registry_goal(registry, goal_id) + if goal is None: + raise ValueError(f"{goal_id}: registry does not contain the Goal for Vision preflight") + runtime_root = resolve_runtime_root(registry, registry_path=registry_path) + effective = resolve_todo_replan_cadence_goal(goal, runtime_root) + return (effective.get("execution_profile") or {}).get("stride_strategy") == "evidence_conditioned_v1" + def command_prefix(self) -> list[str]: executable = shutil.which("loopx") return [executable] if executable else [sys.executable, "-m", "loopx.cli"] @@ -267,7 +295,8 @@ def complete_task( no_follow_up=no_follow_up, goal_ref=self.goal_ref(), ) - with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: + with host_vision_request(request, agent_vision, vision_unchanged_reason, + allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: return settle_host_todo_completion(authored, run_cli=self.run_cli) def review_task_vision( @@ -290,7 +319,8 @@ def review_task_vision( checkpoint_read_context_id=read_context_id or None, goal_ref=self.goal_ref(), ) - with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: + with host_vision_request(request, agent_vision, vision_unchanged_reason, + allow_terminal_stride_path=self._allow_terminal_stride_path(goal_id, agent_vision)) as authored: return refresh_host_todo_vision(authored, run_cli=self.run_cli) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index ddac5fa3ac..a11874ed27 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2085,6 +2085,14 @@ "api": "load_registry", "classification": "codec_api" }, + { + "site": "loopx/goal_mode_mcp.py::.GoalModeMCPControlPlane._allow_terminal_stride_path::codec_read:load_registry#1", + "line": 98, + "column": 20, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" + }, { "site": "loopx/goal_portfolio.py::.build_goal_portfolio::codec_read:decode_registry_snapshot#1", "line": 359, diff --git a/tests/control_plane/test_vision_checkpoint_runtime.py b/tests/control_plane/test_vision_checkpoint_runtime.py index 14cae278e9..65e5caabb1 100644 --- a/tests/control_plane/test_vision_checkpoint_runtime.py +++ b/tests/control_plane/test_vision_checkpoint_runtime.py @@ -81,6 +81,7 @@ def call(method: str, params: dict[str, object]) -> dict[str, object]: "existing_agent_vision": None, "merge_patch": True, "require_path_delta_for_durable_change": True, + "allow_terminal_stride_path": False, }, } diff --git a/tests/test_host_vision_recovery.py b/tests/test_host_vision_recovery.py index 49aa8a1a2f..34195b8853 100644 --- a/tests/test_host_vision_recovery.py +++ b/tests/test_host_vision_recovery.py @@ -153,6 +153,71 @@ def test_bad_vision_is_rejected_before_todo_completion_and_can_be_corrected(tmp_ assert len(spends(runtime)) == 1 +@pytest.mark.parametrize("machine_enabled", [False, True]) +def test_terminal_stride_preflight_uses_effective_strategy_before_todo_effects( + tmp_path, monkeypatch, machine_enabled, +): + control, project, runtime = control_at(tmp_path) + monkeypatch.chdir(project) + if machine_enabled: + from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from loopx.capabilities.machine_configuration.store import configure_machine_configuration + + owner = build_builtin_machine_configuration_registry() + configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "todo_replan_cadence": {"schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "completed_todos", "count": 1, + "stride_strategy": "evidence_conditioned_v1"}}} + preview = configure_machine_configuration( + runtime_root=runtime, registry=owner, configuration=configuration) + configure_machine_configuration( + runtime_root=runtime, registry=owner, configuration=configuration, + execute=True, expected_plan_revision=preview["plan_revision"]) + authored = vision("vision_patch_proposed") + authored["path_delta"].update(outcome="stop", stopped=["Stop this route until provenance is checked."]) + authored["path_delta"].pop("retained") + authored["stride_decision"] = { + "schema_version": "stride_decision_v2", "strategy_version": "evidence_conditioned_v1", + "reference_context_id": "replan-context-" + "e" * 24, + "reference_obligation_id": "replan-" + "e" * 16, + "basis": "uncertain_evidence", "action": "verify", + "axes": {axis: {"plan": "Verify the bounded source outcome.", + "reason": "Acceptance remains unqualified."} + for axis in ("work_slice", "route_distance", "validation_scope")}, + "next_probe": {"scope": "One isolated local source check.", + "prediction": "The source check distinguishes the route.", + "adoption_criterion": "Adopt only a verified source outcome.", + "cost_bound": "One source and contrary case.", + "minimum_evidence": "Source result and contrary-case result."}} + state = project / "ACTIVE_GOAL_STATE.md" + before = state.read_bytes() + calls = [] + original = control.run_cli + + def recorded(args, **kwargs): + calls.append(args) + return original(args, **kwargs) + + monkeypatch.setattr(control, "run_cli", recorded) + if machine_enabled: + result = json.loads(control.complete_task("todo_reducer", fixture.AGENT, + "Synthetic acceptance", successor_todo_ids=["todo_cli"], agent_vision=authored)) + assert result["ok"] is True, result + assert len(spends(runtime)) == 1 + else: + with pytest.raises(ValueError, match="stride_decision requires an evidence-linked path decision"): + control.complete_task("todo_reducer", fixture.AGENT, "Synthetic acceptance", + successor_todo_ids=["todo_cli"], agent_vision=authored) + assert calls == [] + assert state.read_bytes() == before + assert not list(runtime.glob("goals/*/runs/index.jsonl")) + corrected = json.loads(control.complete_task("todo_reducer", fixture.AGENT, + "Synthetic acceptance", successor_todo_ids=["todo_cli"], + agent_vision=vision("vision_patch_proposed"))) + assert corrected["ok"] is True, corrected + assert len(spends(runtime)) == 1 + + @pytest.mark.parametrize("length", [240, 241]) def test_unchanged_reason_preflight_precedes_all_completion_effects(tmp_path, monkeypatch, length): control, project, runtime = control_at(tmp_path)