diff --git a/docs/reference/protocols/quota-replan-planning-selection-v0.md b/docs/reference/protocols/quota-replan-planning-selection-v0.md index 1196766dd1..3e68341bd2 100644 --- a/docs/reference/protocols/quota-replan-planning-selection-v0.md +++ b/docs/reference/protocols/quota-replan-planning-selection-v0.md @@ -82,3 +82,41 @@ qualification does not establish installation or original-consumer adoption. 确切身份冲突、缺少语义增量的拒绝,以及写回后的单次扣额。必须用已安装 CLI 的隔离合成场景证明旧的提前绑定失败,才将源码验证视为修复。源码/PR 验收 不代表已经安装或原请求方已经采用。 + +## Review context changes before writeback / 写回前的评审上下文变化 + +An enabled stride review can become stale between delivery and writeback. +For a context-ID mismatch within the same obligation, the semantic writeback +owner keeps `stride_review_basis_mismatch` and returns +`host_action=read_current_replan_context` with +`replan_transition.context_recovery`: the received ID, complete current +`replan_context`, current `stride_control`, and ordered recovery guidance. +There is no automatic acceptance or reference replacement. Read the returned +Goal acceptance, evidence and followthrough, perform applicable source reads, +and reassess the decision before retrying the original writeback with its same +Goal/Agent/Turn/obligation and settlement identity. The rejection emits no +unbound retry command; it commits no writeback or quota spend. Another relevant +change can reject the retry again. Other validation failures retain their +existing repair route, and stride-off behavior is unchanged. + +启用 stride 的评审可能在投递与写回之间过期。同一 obligation 内发生 context ID +不匹配时,语义写回 owner 保留 `stride_review_basis_mismatch`,给出 +`host_action=read_current_replan_context`,并返回 `replan_transition.context_recovery`:收到的 ID、完整的当前 `replan_context`、 +当前 `stride_control` 与按序恢复指引。不会自动接受提案或替换引用。消费者先读 +返回的 Goal 验收、证据和后续执行事实,完成适用的来源读取并重新判断,再使用 +原 Goal/Agent/Turn/obligation 和结算身份重试原写回。拒绝响应不再给出丢失身份的 +通用重试命令,不写入进展或扣额;相关事实再次变化仍可再次拒绝。其他校验失败 +保留原恢复路径,stride 关闭时的行为不变。 + +This does not classify another agent's outcome as irrelevant. Shared acceptance +and requirement-status changes remain in the review basis until an explicit +dependency-scoping contract qualifies narrower invalidation. A successful +same-Turn recovery is not evidence of fewer model tokens or better outcomes. +The File/SQLite CLI test covers a source change, readback of the changed facts, +reassessment, accepted writeback and an idempotent single spend; it does not +qualify installed-host adoption or the complete unified-contract migration. + +这不把其他 Agent 的 outcome 自动判为无关。共享验收和要求状态的变化仍属于评审 +依据;只有明确的依赖范围契约通过验收,才能缩小失效范围。同 Turn 恢复成功不 +证明模型 token 减少或效果改善。File/SQLite CLI 测试覆盖来源变化、新事实读回、 +重新判断、有效写回及幂等单次扣额,不代表已安装宿主采用或完整统一契约迁移完成。 diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 0c1091843c..d7fe8b5c05 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -640,9 +640,10 @@ def handle_refresh_state_command( runtime_root=args.runtime_root, ) payload["replan_transition"] = transition - payload["error"] += " Required transition: " + "; ".join( - transition["next_cli_actions"] - ) + if transition["next_cli_actions"]: + payload["error"] += " Required transition: " + "; ".join( + transition["next_cli_actions"] + ) if outcome_update_result is not None: # This native effect precedes the ordinary writeback. Retain its receipt # even if later settlement fails; replay the identical operation once. diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 31dd18a568..b95b0b1817 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -455,9 +455,11 @@ export function projectReplanSemantics(value: unknown): JsonObject { const missingStridePath = requiredStrideReview && !evidenceLinkedPath; const staleStride = !!strideDecision && (structuredReview || selectedComparison) && strideDecision.reference_obligation_id !== obligation.obligation_id; + const reviewContextChanged = !!strideDecision && (structuredReview || selectedComparison) + && (requiredStrideReview || selectedComparison) && strideDecision.reference_context_id !== context.context_id; const reviewMismatch = strideDecision && (structuredReview || selectedComparison) - ? (requiredStrideReview || selectedComparison) && strideDecision.reference_context_id !== context.context_id - ? "Stride decision must reference the delivered due-review context." + ? reviewContextChanged + ? "Stride decision references a different review context. Read the returned context_recovery before retrying the original writeback." : strideReviewMismatch(strideDecision, context, request.stride_review_evidence, vision.todo_delta) : null; let outcomes = strings(observation.delta_kinds); @@ -498,6 +500,21 @@ export function projectReplanSemantics(value: unknown): JsonObject { schema_version: "replan_semantic_delta_v0", accepted: satisfying.length > 0, outcomes, satisfying_outcomes: satisfying, required_any_of: required, observation_fingerprint: observation.observation_fingerprint ?? null, + // The write-time owner already resolved this complete basis. Return it on + // a same-obligation mismatch instead of asking the consumer to guess a new + // guard, change its Turn/Todo binding, or retry an unchanged proposal. + ...(reviewContextChanged && !staleStride && !missingStride && !missingStridePath && !satisfying.length ? { + context_recovery: { + received_context_id: strideDecision!.reference_context_id, + replan_context: structuredClone(context), + stride_control: strideControlContract(context, obligation.obligation_id), + instruction: "Read the complete returned replan_context and stride_control, including Goal acceptance, evidence, followthrough and applicable source reads. " + + "Reassess the decision against those facts before replacing its reference_context_id; copying the new id alone is not a review. " + + "Retry the original writeback with the same Goal, Agent, Turn and replan obligation and its original settlement identity. " + + "This response grants no new execution or spend authority and commits no writeback. Do not start another Turn or blindly rerun admission. " + + "If the basis changes again, the writer will reject again and return the then-current context.", + }, + } : {}), 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" diff --git a/loopx/control_plane/work_items/semantic_replan_writeback.py b/loopx/control_plane/work_items/semantic_replan_writeback.py index 0ad5399eb7..8ea408326c 100644 --- a/loopx/control_plane/work_items/semantic_replan_writeback.py +++ b/loopx/control_plane/work_items/semantic_replan_writeback.py @@ -131,12 +131,18 @@ def project_replan_writeback_rejection( capability_guard = obligation.get("capability_guard") if isinstance(capability_guard, Mapping) and rejection.semantic_delta.get("readback_actions"): next_cli_actions = rejection.semantic_delta["readback_actions"] + context_recovery = rejection.semantic_delta.get("context_recovery") + if isinstance(context_recovery, Mapping): + # TS owns the mismatch classification and complete review basis. Reading + # that basis precedes retry; the generic unbound writeback command would + # merely repeat the rejection and can lose the original Turn identity. + next_cli_actions = [] return { "schema_version": REPLAN_WRITEBACK_REJECTION_SCHEMA_VERSION, "required": True, "host_action": ( - "settle_todo_lifecycle" - if lifecycle_reentry is not None + "read_current_replan_context" if isinstance(context_recovery, Mapping) + else "settle_todo_lifecycle" if lifecycle_reentry is not None else "read_current_capability_evidence" if capability_guard else "write_typed_semantic_delta" ), @@ -147,6 +153,7 @@ def project_replan_writeback_rejection( "reason_code": rejection.semantic_delta.get("reason_code"), "triggers": triggers, "next_cli_actions": next_cli_actions, + **({"context_recovery": dict(context_recovery)} if isinstance(context_recovery, Mapping) else {}), **({"capability_guard": dict(capability_guard)} if isinstance(capability_guard, Mapping) else {}), } diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index b0c72e7fea..47a322f4f2 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -331,8 +331,9 @@ def periodic(): @pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("changed_context", [False, True]) def test_due_stride_review_survives_successor_transition_until_decision( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, changed_context: bool, ) -> None: if provider == "sqlite": isolate_sqlite_runtime(tmp_path, monkeypatch) @@ -443,8 +444,19 @@ def call(*args: str, expected_code: int = 0) -> dict: "stride_decision": decision, "todo_delta": [f"create:{successor['todo_id']}", "create:compare two methods"], } + # A real source revision after delivery must still invalidate the old basis. + # Recovery supplies the complete write-time facts, without another guard. + if changed_context: + state_path = project / json.loads(registry.read_text())["goals"][0]["state_file"] + state_path.write_text(state_path.read_text().replace( + "Settle one standard Codex App delivery.", + "Settle one standard delivery and verify the newly required source.")) + from tests.control_plane.test_quota_settlement_cli import _heartbeat_receipt_count, _spend_run_count + receipt_count = _heartbeat_receipt_count(runtime, "stride-due-review") + spend_count = _spend_run_count(runtime) + rejected_reference = decision["reference_context_id"] if changed_context else "replan-context-" + "b" * 24 decision_path.write_text(json.dumps({**vision_packet, - "stride_decision": {**decision, "reference_context_id": "replan-context-" + "b" * 24}})) + "stride_decision": {**decision, "reference_context_id": rejected_reference}})) 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), @@ -453,6 +465,28 @@ def call(*args: str, expected_code: int = 0) -> dict: expected_code=1) assert foreign.get("appended") is not True assert "context" in foreign["error"].lower() + transition = foreign["replan_transition"] + assert transition["reason_code"] == "stride_review_basis_mismatch" + assert transition["host_action"] == "read_current_replan_context" + assert transition["next_cli_actions"] == [] # No unbound repeat of the rejected write. + recovery = transition["context_recovery"] + current = recovery["replan_context"] + assert recovery["received_context_id"] == rejected_reference + assert current["obligation_id"] == obligation["obligation_id"] + assert current["agent_id"] == AGENT_ID and current["goal_id"] == GOAL_ID + assert current["evidence"] + assert current["coverage_ledger"] == [] # This fixture has no typed progress observations. + assert current["delivery_receipt"]["context_id"] == current["context_id"] + assert recovery["stride_control"]["reference_context_id"] == current["context_id"] + if changed_context: + assert current["context_id"] != control["reference_context_id"] + assert "newly required source" in current["core_goal"]["objective"] + decision["axes"]["validation_scope"]["plan"] = "Verify the newly required source and a contrary case." + else: + assert current["context_id"] == control["reference_context_id"] + assert _heartbeat_receipt_count(runtime, "stride-due-review") == receipt_count + assert _spend_run_count(runtime) == spend_count + decision["reference_context_id"] = current["context_id"] 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"], @@ -464,9 +498,13 @@ def call(*args: str, expected_code: int = 0) -> dict: assert persisted["agent_vision"]["stride_decision"] == decision assert persisted["settlement_identity"]["schema_version"] == "quota_settlement_identity_v1" assert persisted["replan_obligation_id"] == obligation["obligation_id"] - call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review", - "--slots", "1", "--source", "heartbeat", "--execute") + assert persisted["settlement_identity"] == reread["heartbeat_receipt"]["settlement_identity"] + for _ in range(2): + call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review", + "--slots", "1", "--source", "heartbeat", "--execute") + assert _spend_run_count(runtime) == spend_count + 1 + assert _heartbeat_receipt_count(runtime, "stride-due-review") == receipt_count # Continue through real authority/history, rather than injecting a crafted # context. The next periodic review must see the accepted autonomous decision. diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts index 5f4365e590..b80daf6356 100644 --- a/tests/control_plane_ts/replan_context.test.ts +++ b/tests/control_plane_ts/replan_context.test.ts @@ -649,3 +649,18 @@ test("blocked and unchanged observations never count as advanced work; caps pres assert.throws(() => validateReplanContext({...context, decision_followthrough: {...follow, observations: [{...follow.observations[0], result_class: "invented"}]}}), /followthrough/); }); + + +test("shared acceptance stays in the review basis when another outcome owner changes", () => { + const acceptance = {outcome_requirements: {outcomes: [{outcome_id: "shared-result", + owner_agent_id: "agent-b", requirements: [{id: "verified", planning_satisfied: true}]}]}}; + const original = projectReplanContext({...request, stride_strategy: "evidence_conditioned_v1", + goal_facts: {registry_objective: "Deliver the shared result", acceptance_contract: acceptance}}); + const changed = structuredClone(acceptance); + changed.outcome_requirements.outcomes[0].requirements[0].planning_satisfied = false; + const current = projectReplanContext({...request, stride_strategy: "evidence_conditioned_v1", + goal_facts: {registry_objective: "Deliver the shared result", acceptance_contract: changed}}); + assert.notEqual(original.context_id, current.context_id); + assert.deepEqual((current.core_goal as JsonObject).acceptance_contract, changed); + assert.equal((original.core_goal as JsonObject).objective, (current.core_goal as JsonObject).objective); +}); diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index f63ca865aa..19227e131a 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -196,6 +196,14 @@ for (const kind of decisionReviewTriggers) test(`${kind} records stride even whe reference_context_id: "replan-context-" + "b".repeat(24)}}}); assert.equal(foreignContext.accepted, false); assert.equal(foreignContext.reason_code, "stride_review_basis_mismatch"); + const recovery = foreignContext.context_recovery as Record; + assert.deepEqual(recovery.replan_context, obligation.replan_context); + assert.equal(recovery.stride_control.reference_context_id, contextId); + assert.equal(recovery.received_context_id, "replan-context-" + "b".repeat(24)); + assert.equal(stale.context_recovery, undefined); + // Returning the current facts must not alias or mutate the authority input. + recovery.replan_context.context_id = "changed-by-consumer"; + assert.equal(obligation.replan_context.context_id, contextId); const bareDecision = projectReplanSemantics({operation: "qualify", obligation, observation_delta: {delta_kinds: ["new_concrete_blocker"]}, agent_vision: {stride_decision: proposal}});