From d92ab973b77dcb5fc486561cba02a720103d40ce Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:25:12 +0800 Subject: [PATCH] Keep quota replan contributions bound across reader publication Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../turn_driver/capability_context.ts | 19 +++++-- .../test_replan_progress_native_cli.py | 51 +++++++++++++++++++ .../turn_capability_context.test.ts | 16 ++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/turn_driver/capability_context.ts b/loopx/control_plane/turn_driver/capability_context.ts index 1417b8b121..94ad5aea8d 100644 --- a/loopx/control_plane/turn_driver/capability_context.ts +++ b/loopx/control_plane/turn_driver/capability_context.ts @@ -19,13 +19,15 @@ const RESERVED = new Set([ ]); const FIELD = /^[a-z][a-z0-9_]{2,95}$/; -function contextSource(value: unknown): JsonObject { +function contextSource(value: unknown, bindReaderLocation = false): JsonObject { const source = requireJsonObject(value, "context source"); - // Reuse the governing owner. Binding the original observation adds detail - // locations/measurements without changing the authorized decision. + // The replan reader generates commands that carry the capture's own hash. + // Bind the governing action and settlement rather than these read addresses. + const {replan_read_path: _readerPath, ...beforeReaderBinding} = source; return {goal_id: source.goal_id, agent_id: source.agent_id, governing: "heartbeat_receipt" in source - ? quotaActionSignatureDocument(source) : turnEnvelopeActionSignatureDocument(source)}; + ? quotaActionSignatureDocument(bindReaderLocation ? source : beforeReaderBinding) + : turnEnvelopeActionSignatureDocument(source)}; } function fields(value: unknown, label: string): JsonObject { @@ -82,6 +84,15 @@ export function projectTurnContextContribution(input: JsonObject): JsonObject { result: {context_fields: contribution.context_fields, instructions: contribution.instructions, result_fields: contribution.result_fields, readback}}); if (contribution.basis_digest !== normalized.basis_digest) { + const source = requireJsonObject(input.source, "context source"); + // An existing unversioned quota observation may already have been bound + // to its exact reader. Preserve that basis only on an exact legacy match. + if ("heartbeat_receipt" in source && !Object.hasOwn(source, "contract_binding") && + typeof source.replan_read_path === "string") { + const {basis_digest: _currentBasis, ...value} = normalized; + const legacyBasis = canonicalAuthoritySha256({source: contextSource(source, true), ...value}); + if (contribution.basis_digest === legacyBasis) return {...normalized, basis_digest: legacyBasis}; + } throw new Error("context source or contribution changed after registration"); } return normalized; diff --git a/tests/control_plane/test_replan_progress_native_cli.py b/tests/control_plane/test_replan_progress_native_cli.py index 6faa860cec..57a418a6f3 100644 --- a/tests/control_plane/test_replan_progress_native_cli.py +++ b/tests/control_plane/test_replan_progress_native_cli.py @@ -167,6 +167,57 @@ def test_progress_cli_reads_policy_with_context_delivery( restart_effect_runtime(wait_for_exit=True) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_captured_replan_preserves_scoped_memory_without_trusting_reader_location( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, provider: str, +) -> None: + from copy import deepcopy + + from loopx.cli import main + from loopx.capabilities.agent_turn_recall.runtime import run_configured_agent_turn_recall_fail_open + from loopx.capabilities.reward_memory.experiment import resolve_reward_memory_experiment + from loopx.control_plane.capability_hooks import project_turn_context + from tests.capabilities.test_agent_turn_recall import RecallProvider, active_record + from tests.control_plane.reward_memory_host_fixture import enable_live_memory + + tmp_path = tmp_path.resolve() + isolate_sqlite_runtime(tmp_path, monkeypatch) + fixture = make_progress_cli_fixture(tmp_path, provider, "advanced") + registry, runtime = fixture["registry"], fixture["runtime"] + enable_live_memory(registry) + _, config = resolve_reward_memory_experiment(registry_path=registry, goal_id=GOAL, agent_id=AGENT) + memory = RecallProvider(active_record(config, expires_at="2099-01-01T00:00:00+00:00")) + monkeypatch.setattr("loopx.capabilities.agent_turn_recall.run_configured_agent_turn_recall_fail_open", + lambda **kw: run_configured_agent_turn_recall_fail_open(**kw, provider=memory)) + monkeypatch.setenv("CODEX_THREAD_ID", "replan-memory-fixture") + monkeypatch.chdir(fixture["project"]) + capture, journal = tmp_path / "capture", tmp_path / "native" + journal.mkdir(mode=0o700) + try: + code = main(["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT, + "--todo-id", TODO, "--turn-instance-id", TURN, "--decision-output-dir", str(capture), + "--context-delivery", "on", "--delta-delivery", "auto", "--codex-context-dir", str(journal), + "--prepared-capture-reference", "--defer-context-presentation"]) + output = capsys.readouterr().out + assert code == 0, output + captured = (capture / "decision.json").read_bytes() + assert json.loads(output)["capture_sha256"] == hashlib.sha256(captured).hexdigest() + source = json.loads(captured) + assert source["replan_action_packet"]["decision"] == "replan_required" + assert source["reward_memory_recall"]["status"] == "applied" and memory.calls == 1 + projected = project_turn_context(source) + assert projected["reward_memory_recall"]["context"]["guidance"] + relocated = {**source, "replan_read_path": str(tmp_path / "other-location.json")} + assert project_turn_context(relocated) == projected + changed = deepcopy(relocated) + plan = changed["interaction_contract"]["cli_channel"]["settlement_plan"] + plan["ordered_steps"][1]["command_template"] += " --evidence substituted" + assert project_turn_context(changed) == {} + finally: + restart_effect_runtime(wait_for_exit=True) + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("context_args,navigation", [ ((), False), diff --git a/tests/control_plane_ts/turn_capability_context.test.ts b/tests/control_plane_ts/turn_capability_context.test.ts index 88fdfe7939..881f16f6cc 100644 --- a/tests/control_plane_ts/turn_capability_context.test.ts +++ b/tests/control_plane_ts/turn_capability_context.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {quotaActionSignatureDocument} from "../../loopx/control_plane/quota/turn_envelope.ts"; import {validateTurnContextRegistration, validateTurnContextContribution, projectTurnContextContribution} from "../../loopx/control_plane/turn_driver/capability_context.ts"; @@ -27,6 +29,20 @@ const quotaSource = {goal_id: "fixture-goal", agent_id: "fixture-agent", enabled: true, configured_for_agent: true, experiment_available: true, automatic_recall: true, automatic_ingest: false}}}}; +test("an existing quota contribution can replay with its exact legacy reader binding", () => { + const source = {...quotaSource, replan_read_path: "fixture/decision.json", + replan_action_packet: {obligation_id: "fixture-replan", decision: "replan_required"}}; + const current = validateTurnContextContribution({registration, source, result}); + const {basis_digest: currentBasis, ...value} = current; + const legacyBasis = canonicalAuthoritySha256({source: {goal_id: source.goal_id, agent_id: source.agent_id, + governing: quotaActionSignatureDocument(source)}, ...value}); + assert.notEqual(legacyBasis, currentBasis); + const contribution = {...value, basis_digest: legacyBasis}; + assert.deepEqual(projectTurnContextContribution({contribution, source, readback: result.readback}), contribution); + assert.throws(() => projectTurnContextContribution({contribution, readback: result.readback, + source: {...source, replan_read_path: "fixture/other.json"}})); +}); + test("native quota context survives location-only capture binding", () => { const contribution = validateTurnContextContribution({registration, source: quotaSource, result}); assert.deepEqual(projectTurnContextContribution({contribution, readback: result.readback,