From 8d1af4673eab1b941c4d9b97c94c2486f8952dd3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:32:58 +0800 Subject: [PATCH 1/2] fix: retain autonomous replan decisions in stride followthrough Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../work_items/replan_context.ts | 38 +++++++++-- .../work_items/replan_context_codec.py | 4 +- .../test_effective_turn_replan.py | 68 ++++++++++++++++++- tests/control_plane_ts/replan_context.test.ts | 58 +++++++++++++++- 4 files changed, 157 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index 42b090892e..17c61ceefe 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -1,6 +1,7 @@ /** Bounded evidence for the existing replan boundary; no settlement authority. */ import {createHash} from "node:crypto"; -import type {JsonObject} from "../effect_program.ts"; +import {settlementIdentityPayload, SETTLEMENT_IDENTITY_SCHEMA_VERSION, + SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION, type JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; @@ -104,16 +105,33 @@ function coreGoal(value: unknown): JsonObject { objective_missing: objective === null, acceptance_contract: acceptance}; } +/** Qualify recorded bindings using the settlement owner, without settling work. + * Keep historical Todo evidence eligible under its existing read contract. + */ +function decisionBindingMatches(row: JsonObject, source: JsonObject, delta: JsonObject | null): boolean { + const identity = objectOrNull(source.settlement_identity); + if (!identity || identity.turn_instance_id !== source.turn_instance_id || + typeof identity.turn_instance_id !== "string" || !ID.test(identity.turn_instance_id)) return false; + if (identity.schema_version === SETTLEMENT_IDENTITY_SCHEMA_VERSION) { + return ["goal_id", "agent_id", "todo_id"].every(key => identity[key] === row[key]); + } + if (identity.schema_version !== SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION || + typeof row.goal_id !== "string" || typeof row.agent_id !== "string" || + [row.todo_id, source.todo_id, identity.todo_id].some(value => value != null && value !== "") || + ["goal_id", "agent_id"].some(key => source[key] !== row[key]) || + typeof source.replan_obligation_id !== "string" || !ID.test(source.replan_obligation_id) || + delta?.obligation_id !== source.replan_obligation_id) return false; + const expected = settlementIdentityPayload({goal_id: row.goal_id, agent_id: row.agent_id, + turn_instance_id: identity.turn_instance_id, replan_obligation_id: source.replan_obligation_id}); + return Object.entries(expected).every(([key, value]) => identity[key] === value); +} + function acceptedDecisionSource(row: JsonObject): boolean { const source = objectOrNull(row._source_facts); - const identity = objectOrNull(source?.settlement_identity); const vision = objectOrNull(source?.agent_vision); const ack = objectOrNull(source?.autonomous_replan_ack); const delta = objectOrNull(ack?.semantic_delta); - return !!source && identity?.schema_version === "quota_settlement_identity_v0" && - ["goal_id", "agent_id", "todo_id"].every(key => identity[key] === row[key]) && - identity.turn_instance_id === source.turn_instance_id && - typeof identity.turn_instance_id === "string" && ID.test(identity.turn_instance_id) && + return !!source && decisionBindingMatches(row, source, delta) && !objectOrNull(source.quota_spend_commit) && vision?.schema_version === "goal_vision_replan_contract_v0" && vision.agent_id === row.agent_id && (vision.goal_id == null || vision.goal_id === row.goal_id) && @@ -137,8 +155,10 @@ function recordedDecision(row: JsonObject): JsonObject | null { let stride: JsonObject | null; try { stride = normalizeRecordedStrideDecision(objectOrNull(source.agent_vision)?.stride_decision); } catch { return null; } + const autonomous = objectOrNull(source.settlement_identity)?.schema_version === SCOPED_SETTLEMENT_IDENTITY_SCHEMA_VERSION; const decision = {history_evidence_ref: "replan-evidence-" + digest(publicEvidenceRow(row)), - generated_at: row.generated_at, todo_id: row.todo_id, turn_instance_id: source.turn_instance_id, + generated_at: row.generated_at, todo_id: autonomous ? null : row.todo_id, turn_instance_id: source.turn_instance_id, + ...(autonomous ? {replan_obligation_id: source.replan_obligation_id} : {}), goal_ref: ref ? {goal_id: ref.goal_id, goal_instance_id: ref.goal_instance_id} : null, path_delta: path, ...(stride ? {stride_decision: stride} : {})}; // Existing history refs keep their identity. This derived ref additionally @@ -235,6 +255,10 @@ export function validateReplanContext(value: unknown): JsonObject { identifier(previous.evidence_ref, "previous_decision.evidence_ref"); identifier(previous.history_evidence_ref, "previous_decision.history_evidence_ref"); identifier(previous.turn_instance_id, "previous_decision.turn_instance_id"); + if (previous.replan_obligation_id !== undefined) { + identifier(previous.replan_obligation_id, "previous_decision.replan_obligation_id"); + if (previous.todo_id !== null) throw new EffectRuntimeRequestError("previous decision cannot bind both Todo and autonomous replan"); + } normalizeGoalPathDelta(previous.path_delta); normalizeRecordedStrideDecision(previous.stride_decision); } diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py index 6293e3708f..38839fb3e2 100644 --- a/loopx/control_plane/work_items/replan_context_codec.py +++ b/loopx/control_plane/work_items/replan_context_codec.py @@ -91,8 +91,10 @@ def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, age source["goal_ref"] = run.get("goal_ref") source["agent_vision"] = run.get("agent_vision") source["autonomous_replan_ack"] = run.get("autonomous_replan_ack") + source["replan_obligation_id"] = run.get("replan_obligation_id") for key, fields in (("settlement_identity", ( - "schema_version", "goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id")), + "schema_version", "goal_id", "agent_id", "todo_id", "turn_instance_id", "effect_id", + *(("binding_kind", "binding_id", "replan_obligation_id") if include_decisions else ()))), ("quota_spend_commit", ("schema_version", "effect_id"))): value = run.get(key) source[key] = {field: value.get(field) for field in fields} if isinstance(value, Mapping) else None diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index 13c41ee379..a804b54737 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -311,7 +311,7 @@ def test_due_stride_review_survives_successor_transition_until_decision( 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 + data["goals"][0]["quota"]["allowed_slots"] = 8 registry.write_text(json.dumps(data)) rc, listed = _run_cli(registry, runtime, "todo", "list", "--goal-id", GOAL_ID) assert rc == 0, listed @@ -321,7 +321,7 @@ def test_due_stride_review_survives_successor_transition_until_decision( def call(*args: str, expected_code: int = 0) -> dict: code, result = _run_cli(registry, runtime, *args, cwd=project) - assert code == expected_code, result + assert code == expected_code, json.dumps(result, indent=2) return result call("configure-goal", "--goal-id", GOAL_ID, "--execution-replan-after-turns", "2", @@ -438,6 +438,70 @@ def call(*args: str, expected_code: int = 0) -> dict: 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 + 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") + + # Continue through real authority/history, rather than injecting a crafted + # context. The next periodic review must see the accepted autonomous decision. + for index in range(2): + turn = f"stride-followthrough-{index}" + call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, "--turn-instance-id", turn, + "--scan-path", str(project)) + 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") + next_guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, + "--turn-instance-id", "stride-next-review", "--scan-path", str(project)) + next_obligation = next_guard["autonomous_replan_obligation"] + next_successor = call("todo", "add", "--goal-id", GOAL_ID, "--role", "agent", + "--claimed-by", AGENT_ID, "--text", "Resolve the remaining source uncertainty", + "--task-class", "advancement_task", "--action-kind", "validate", + "--target-key", "source-uncertainty", "--replan-obligation-id", next_obligation["obligation_id"]) + next_guard = call("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", next_successor["todo_id"], + "--turn-instance-id", "stride-next-review", "--scan-path", str(project)) + next_obligation = next_guard["autonomous_replan_obligation"] + next_control = next_guard["replan_action_packet"]["stride_control"] + follow = next_obligation["replan_context"]["decision_followthrough"] + previous = follow["previous_decision"] + assert previous["todo_id"] is None + assert previous["replan_obligation_id"] == obligation["obligation_id"] + assert previous["stride_decision"] == decision + assert follow["execution_and_goal_gain"] == "unknown" + assert next_control["previous_decision_ref"] == previous["evidence_ref"] + resolved = call("history", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--evidence-ref", previous["evidence_ref"]) + assert resolved["evidence"]["stride_decision"] == decision + next_decision = {**decision, "reference_context_id": next_control["reference_context_id"], + "reference_obligation_id": next_obligation["obligation_id"]} + refresh_args = ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--replan-obligation-id", next_obligation["obligation_id"], "--turn-instance-id", "stride-next-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") + decision_path.write_text(json.dumps({**vision_packet, "stride_decision": next_decision})) + missing_review = call(*refresh_args, expected_code=1) + assert missing_review.get("appended") is not True + assert "previous" in missing_review["error"].lower() + next_decision["previous_review"] = {"reference_decision_ref": previous["evidence_ref"], + "adoption": "unknown", "information_gain": "unknown", "goal_gain": "unknown", + "evidence_refs": [previous["history_evidence_ref"]], + "lesson": "The decision is recorded; subsequent work does not yet prove target gain."} + decision_path.write_text(json.dumps({**vision_packet, "stride_decision": next_decision})) + next_accepted = call(*refresh_args) + assert next_accepted["autonomous_replan_ack"]["semantic_delta"]["accepted"] is True + replay = call(*refresh_args) + assert replay.get("appended") is not True @pytest.mark.parametrize("invalid", [0, 7, True, 2.5, "2"]) diff --git a/tests/control_plane_ts/replan_context.test.ts b/tests/control_plane_ts/replan_context.test.ts index 7b344a0897..574f6bc37c 100644 --- a/tests/control_plane_ts/replan_context.test.ts +++ b/tests/control_plane_ts/replan_context.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import {createHash} from "node:crypto"; import {projectReplanContext, validateReplanContext} from "../../loopx/control_plane/work_items/replan_context.ts"; -import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; const progress = {schema_version: "typed_progress_observation_v0", result_class: "unchanged", surface_id: "surface-a", work_item_id: "todo-a", fingerprint: "same-work"}; @@ -130,6 +130,62 @@ function subsequent(position: number): JsonObject { const strideRequest = {...request, stride_strategy: "evidence_conditioned_v1"}; +function autonomousReviewed(position = 0, obligation = "replan-first"): JsonObject { + const legacy = reviewed(position); + const source = legacy._source_facts as JsonObject; + const ack = source.autonomous_replan_ack as JsonObject; + const identity = settlementIdentityPayload({goal_id: "goal-a", agent_id: "agent-a", + turn_instance_id: String(source.turn_instance_id), replan_obligation_id: obligation}); + return {...legacy, todo_id: undefined, _source_facts: {...source, todo_id: null, + replan_obligation_id: obligation, settlement_identity: identity, + autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, obligation_id: obligation}}}}; +} + +test("autonomous decisions return through their own binding and resolve without a fabricated Todo", () => { + const latest = autonomousReviewed(2); + const rows = [reviewed(), latest, subsequent(3)]; + const context = projectReplanContext({...strideRequest, rows}); + const follow = context.decision_followthrough as JsonObject; + assert.equal(follow.previous_decision.turn_instance_id, "turn-3"); + assert.equal(follow.previous_decision.replan_obligation_id, "replan-first"); + assert.equal(follow.previous_decision.todo_id, null); + assert.equal(follow.recorded_progress_count, 1); + assert.equal(follow.execution_and_goal_gain, "unknown"); + assert.deepEqual(validateReplanContext(context), context); + const resolve = {...request, operation: "resolve", evidence_ref: follow.previous_decision.evidence_ref, rows}; + assert.deepEqual(projectReplanContext(resolve).evidence, follow.previous_decision); + assert.throws(() => projectReplanContext({...resolve, rows: [autonomousReviewed(2, "replan-other")]}), /unavailable/); + const ordinary = projectReplanContext({...request, rows}); + assert.deepEqual(projectReplanContext({...request, rows, stride_strategy: "off"}), ordinary); + assert.equal(ordinary.decision_followthrough, undefined); +}); + +test("autonomous followthrough rejects conflicting identities, bindings and acknowledgements", () => { + const valid = autonomousReviewed(); + const source = valid._source_facts as JsonObject; + const identity = source.settlement_identity as JsonObject; + const ack = source.autonomous_replan_ack as JsonObject; + const corruptions = [ + ...["schema_version", "goal_id", "agent_id", "turn_instance_id", "effect_id", "binding_kind", + "binding_id", "replan_obligation_id"].map(key => ({settlement_identity: {...identity, [key]: "wrong"}})), + {settlement_identity: {...identity, todo_id: "todo-conflict"}}, + {todo_id: "todo-conflict"}, {goal_id: "wrong"}, {agent_id: "wrong"}, {turn_instance_id: "wrong"}, + {replan_obligation_id: null}, {replan_obligation_id: "wrong"}, + {autonomous_replan_ack: {...ack, recorded: false}}, + {autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, obligation_id: "wrong"}}}, + {autonomous_replan_ack: {...ack, semantic_delta: {...ack.semantic_delta, accepted: false}}}, + {quota_spend_commit: {schema_version: "quota_spend_commit_receipt_v0", effect_id: identity.effect_id + "#quota_spend"}}, + ]; + for (const patch of corruptions) { + assert.equal(projectReplanContext({...strideRequest, + rows: [{...valid, _source_facts: {...source, ...patch}}]}).decision_followthrough, undefined, JSON.stringify(patch)); + } + assert.equal(projectReplanContext({...strideRequest, rows: [{...valid, todo_id: "todo-conflict"}]}).decision_followthrough, undefined); + const corruptPath = autonomousReviewed(2); + corruptPath._source_facts.agent_vision.path_delta = {outcome: "replan"}; + assert.equal(projectReplanContext({...strideRequest, rows: [reviewed(), corruptPath]}).decision_followthrough, undefined); +}); + test("a prior accepted path returns with attributable later observations, never a utility verdict", () => { const decision = reviewed(); const later = subsequent(1); From 88157e454ce6fd8c2afa94ba21e469892643d11c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:32:58 +0800 Subject: [PATCH 2/2] docs: clarify autonomous replan feedback qualification Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/hierarchical-agent-stride-control-v0.md | 10 ++++++++++ .../rfcs/hierarchical-agent-stride-control-v0.zh-CN.md | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md index 9e21ceb006..e854205d2c 100644 --- a/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md +++ b/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md @@ -619,6 +619,16 @@ with invented evidence. The field never qualifies execution, scientific novelty, utility, effective-Turn counts or acceptance; these remain independent checks. No new scheduler, selection rule, quota debit or per-result settlement is added. +The history reader covers both legacy Todo-bound decisions and autonomous +replan decisions. For an autonomous decision, the recorded Goal, Agent, Turn, +obligation, settlement identity and accepted acknowledgement must agree; the +derived decision reference includes that obligation instead of inventing a +Todo. The existing settlement owner defines the binding, while the Python +codec only transports its facts. Enabled reviews therefore receive the prior +decision through the same `previous_review` requirement. Feature-off context +and legacy decision references remain unchanged. This repairs feedback +delivery; execution quality and goal benefit still require separate evidence. + #### An iterative strategy and engineering boundary The first strategy is **model-guided, evidence-conditioned work planning**. 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 7c9b4c1e56..4372198c74 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 @@ -532,6 +532,13 @@ replan 复查这个向量,不成为新的权限 owner。在授权目标内改 替代证据。此字段不资格化执行、科学创新、收益、有效 Turn 计数或验收,后者仍 独立验证;不增加 scheduler、选择规则、quota 消耗或逐结果结算。 +历史读取同时覆盖旧 Todo 绑定决策和自主 replan 决策。自主决策的 Goal、Agent、 +Turn、义务、settlement identity 与已接受的回执必须一致;派生决策引用包含该 +义务,不虚构 Todo。绑定由现有 settlement owner 定义,Python codec 只传输 +事实。启用策略后,上一决策进入同一个 `previous_review` 要求;关闭策略的 +context 和历史决策引用保持兼容。这里修复的是反馈送达,执行质量和目标收益 +仍需独立证据。 + #### 可迭代策略与工程边界 第一版策略是**模型依据证据规划完整工作**:显式选择 grow(扩大有支持的路线)、