Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/reference/protocols/quota-replan-planning-selection-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 测试覆盖来源变化、新事实读回、
重新判断、有效写回及幂等单次扣额,不代表已安装宿主采用或完整统一契约迁移完成。
7 changes: 4 additions & 3 deletions loopx/cli_commands/project_lifecycle_refresh_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions loopx/control_plane/work_items/replan_semantics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions loopx/control_plane/work_items/semantic_replan_writeback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
Expand All @@ -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 {}),
}

Expand Down
48 changes: 43 additions & 5 deletions tests/control_plane/test_effective_turn_replan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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"],
Expand All @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions tests/control_plane_ts/replan_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
8 changes: 8 additions & 0 deletions tests/control_plane_ts/stride_policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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}});
Expand Down
Loading