From 6ffbb44070996fadbf0d96056f465df6226055a6 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:28:56 +0800 Subject: [PATCH] fix(quota): preserve settlement contract in TurnEnvelope Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/agent-loop-effect-interpreter-v0.md | 10 ++ .../agent-loop-effect-interpreter-v0.zh-CN.md | 7 + docs/reference/protocols/turn-envelope-v0.md | 22 ++- loopx/control_plane/quota/turn_envelope.py | 1 + loopx/control_plane/quota/turn_envelope.ts | 11 +- .../testing/cli_output_differential.py | 6 + .../test_cli_output_differential.py | 18 ++- .../test_turn_envelope_settlement.py | 125 ++++++++++++++++++ 8 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/control_plane/test_turn_envelope_settlement.py diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md index b9f7c8b177..dfd73dda4c 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md @@ -419,6 +419,16 @@ commands bind the original actor and route. Receipt repair reuses the existing idempotent writer. This is a bounded M7.4 adoption with no shared executor or new authority store; it does not certify terminal Todo or Goal acceptance. +The R5 compact projection also retains the existing CLI settlement plan intact, +including its effect identity, ordered conditional steps and host handoff. Action +signature coverage v5 detects removal or mutation of that plan; packets without +one retain their historical coverage. Real CLI validation exercises premature +spend rejection and one original-Turn writeback/spend with idempotent replay. +This closes a projection omission, not the short-context rollout: same-Turn +cached detail delivery, normal/replan context selection and measured model +behavior remain unqualified. The 8 KiB target and delivery growth checks stay +unchanged. See [TurnEnvelope](../../reference/protocols/turn-envelope-v0.md). + ### What Is Missing - A generic shared executor is deliberately absent. The current adapters share diff --git a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md index 124452c023..d815e179ee 100644 --- a/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-loop-effect-interpreter-v0.zh-CN.md @@ -296,6 +296,13 @@ M7 只有在至少产生一个下列最终 effect 时才有理由存在: 配额收尾适配现在消费 TS readback 按回执归约的结算进度,不再独立把存在 spend run 当作已结算。正常刷新、重放和扣款响应共享该投影;可执行命令绑定原 Agent 和路由。补回执复用现有幂等 writer。这是有界的 M7.4 接入,没有增加共享 executor 或 authority store,也不证明 Todo 终态或 Goal 验收完成。 +R5 短包投影也完整保留已有 CLI 结算计划,包括 effect identity、带条件的步骤顺序和 +宿主交接。签名覆盖 v5 能检测计划被删除或修改;没有计划的输入保留原覆盖版本。 +真实 CLI 验证覆盖提前扣额度被拒绝、原 Turn 写回和结算一次,以及幂等重放。 +这补上了投影缺口;同 Turn 缓存详情、普通轮与 replan 上下文选择、模型行为收益 +尚未验收,8 KiB 目标和交付时增长检查保持不变。见 +[TurnEnvelope](../../reference/protocols/turn-envelope-v0.md)。 + ### 还缺什么 - 通用共享 executor 被有意保留为空。当前 adapter 共享 plan/receipt algebra,却拥有不同的执行边界,因此 M7.3 应以 no-follow-up 关闭,而不是用推测性 framework 填充。 diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index dd7823ad92..25d745acb3 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -8,7 +8,7 @@ payload. Preview it explicitly: ```bash -loopx quota should-run --goal-id --agent-id --turn-envelope +loopx --format json quota should-run --goal-id --agent-id --turn-envelope ``` The envelope flag selects a projection of the full decision. The original v0 @@ -43,13 +43,31 @@ Action-signature coverage is versioned independently from the envelope schema. `turn_envelope_action_dimensions_v1` additionally covers a blocking user gate's `response_plan`; `turn_envelope_action_dimensions_v2` additionally signs `action.action_portfolio`; `turn_envelope_action_dimensions_v3` additionally -signs `action.planning_horizon`. Base/head qualification accepts a declared +signs `action.planning_horizon`; v4 additionally signs capability `agent_context`; +`turn_envelope_action_dimensions_v5` additionally preserves the canonical +`writeback.settlement_plan`. Base/head qualification accepts a declared coverage migration as a review signal. The bounded, JSON-only v2 and v3 migration budgets apply only to their named schema transitions; ordinary growth limits resume once the new version is the baseline. A digest change without a supported coverage migration, or a projection above its one-version budget, still fails closed. +For a decision carrying a settlement plan, the opt-in envelope now transports +that plan intact: effect identity, ordered steps, command conditions, expected +receipts and the host-owned handoff. It also keeps the corresponding next CLI +commands untruncated. This repairs the earlier preview that could report matching +action hashes while omitting the settlement plan. The plan remains owned by the +shared settlement algebra; the envelope neither rebuilds it nor grants authority +to execute an unadmitted step. Packets without a plan keep their previous coverage +and do not acquire one from a historical heartbeat receipt. Stored v0–v4 signatures +are not rewritten. The v5 migration is an explicit semantic review signal and +has **no additional size allowance**; overflow still requires the existing budget +analysis. Default full quota output and settlement rules are unchanged. + +中文:短包现在完整保留已有结算计划及执行命令,签名 v5 覆盖结算身份、步骤顺序、 +条件和宿主边界;没有计划的输入不会凭空获得结算权限。旧签名保留,默认完整输出 +不变,大小预算不放宽。此修复是短上下文实验的前置条件,尚不证明模型收益。 + `quota_planning_horizon_v0` remains advisory even when carried by the envelope. Its `selection_contract` points back to `selected_todo` and `action_portfolio`, and `horizon_changes_selection=false`. Effect Program transports this diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 213c0bde17..1517da84f1 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -22,6 +22,7 @@ ACTION_SIGNATURE_COVERAGE_V2 = "turn_envelope_action_dimensions_v2" ACTION_SIGNATURE_COVERAGE_V3 = "turn_envelope_action_dimensions_v3" ACTION_SIGNATURE_COVERAGE_V4 = "turn_envelope_action_dimensions_v4" +ACTION_SIGNATURE_COVERAGE_V5 = "turn_envelope_action_dimensions_v5" ACTION_SIGNATURE_COVERAGE = ACTION_SIGNATURE_COVERAGE_V0 PLANNING_HORIZON_DETAIL_REFS_REF = "$.detail_ref" diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 5b8573077a..1a51925305 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -20,6 +20,7 @@ export const ACTION_SIGNATURE_COVERAGE_V1 = "turn_envelope_action_dimensions_v1" export const ACTION_SIGNATURE_COVERAGE_V2 = "turn_envelope_action_dimensions_v2"; export const ACTION_SIGNATURE_COVERAGE_V3 = "turn_envelope_action_dimensions_v3"; export const ACTION_SIGNATURE_COVERAGE_V4 = "turn_envelope_action_dimensions_v4"; +export const ACTION_SIGNATURE_COVERAGE_V5 = "turn_envelope_action_dimensions_v5"; export const ACTION_SIGNATURE_COVERAGE = ACTION_SIGNATURE_COVERAGE_V0; const EXECUTABLE_CLI_ARGS_MAX_ITEMS = 64; @@ -640,6 +641,8 @@ function actionProjection(payload: JsonObject, protocolActionFields: JsonObject) if (nextCliActions.length === 0 && Array.isArray(cliChannel.next_cli_actions)) { nextCliActions = [...cliChannel.next_cli_actions].map(pythonString); } + const settlementPlan = object(cliChannel.settlement_plan); + const hasSettlementPlan = Object.keys(settlementPlan).length > 0; let preserveBoundReplanCommands = replanSettlementOnly; if (replanPacket && !replanSettlementOnly) { const writebackContract = object(object(payload.replan_action_packet).writeback_contract); @@ -675,10 +678,15 @@ function actionProjection(payload: JsonObject, protocolActionFields: JsonObject) } else { // Original-Turn identities often follow an absolute runtime path. Cutting // a closeout command into display text can erase its binding or execute flag. - writeback.next_cli_actions = preserveBoundReplanCommands + writeback.next_cli_actions = hasSettlementPlan + ? nextCliActions.map(command => scalarString(command, "settlement command")) + : preserveBoundReplanCommands ? nextCliActions.slice(0, 5).map(command => scalarString(command, "bound replan closeout command")) : textList(nextCliActions, 5, 420); } + // Transport the canonical plan intact. Reconstructing it from command previews + // loses the effect identity, conditional closeout and host/agent boundary. + if (hasSettlementPlan) writeback.settlement_plan = settlementPlan; for (const field of ["replan_settlement_contract", "delivery_workspace_causality"]) { const value = object(cliChannel[field]); if (Object.keys(value).length > 0) writeback[field] = value; @@ -763,6 +771,7 @@ function turnActionProjection(payload: JsonObject, protocolActionFields: JsonObj } function signatureCoverage(envelope: JsonObject, responsePlanValue: unknown): string { + if (Object.keys(object(object(envelope.writeback).settlement_plan)).length > 0) return ACTION_SIGNATURE_COVERAGE_V5; if (Object.keys(object(envelope.agent_context)).length > 0) return ACTION_SIGNATURE_COVERAGE_V4; const action = object(envelope.action); if (Object.keys(object(action.planning_horizon)).length > 0) return ACTION_SIGNATURE_COVERAGE_V3; diff --git a/loopx/control_plane/testing/cli_output_differential.py b/loopx/control_plane/testing/cli_output_differential.py index 2c248d2a88..a1ec21a3e8 100644 --- a/loopx/control_plane/testing/cli_output_differential.py +++ b/loopx/control_plane/testing/cli_output_differential.py @@ -12,6 +12,7 @@ ACTION_SIGNATURE_COVERAGE_V2, ACTION_SIGNATURE_COVERAGE_V3, ACTION_SIGNATURE_COVERAGE_V4, + ACTION_SIGNATURE_COVERAGE_V5, ) @@ -402,6 +403,11 @@ def _action_signature_migration( (ACTION_SIGNATURE_COVERAGE_V1, ACTION_SIGNATURE_COVERAGE_V4), (ACTION_SIGNATURE_COVERAGE_V2, ACTION_SIGNATURE_COVERAGE_V4), (ACTION_SIGNATURE_COVERAGE_V3, ACTION_SIGNATURE_COVERAGE_V4), + (ACTION_SIGNATURE_COVERAGE_V0, ACTION_SIGNATURE_COVERAGE_V5), + (ACTION_SIGNATURE_COVERAGE_V1, ACTION_SIGNATURE_COVERAGE_V5), + (ACTION_SIGNATURE_COVERAGE_V2, ACTION_SIGNATURE_COVERAGE_V5), + (ACTION_SIGNATURE_COVERAGE_V3, ACTION_SIGNATURE_COVERAGE_V5), + (ACTION_SIGNATURE_COVERAGE_V4, ACTION_SIGNATURE_COVERAGE_V5), } if not ( isinstance(base_coverages, list) diff --git a/tests/control_plane/test_cli_output_differential.py b/tests/control_plane/test_cli_output_differential.py index 09c6464263..11a10eabb5 100644 --- a/tests/control_plane/test_cli_output_differential.py +++ b/tests/control_plane/test_cli_output_differential.py @@ -701,7 +701,7 @@ def test_unknown_action_portfolio_schema_migration_fails_closed() -> None: def test_unknown_action_signature_coverage_migration_fails_closed() -> None: candidate = _row( action_signature_sha256="unknown-semantic-signature", - action_signature_coverages=["turn_envelope_action_dimensions_v5"], + action_signature_coverages=["turn_envelope_action_dimensions_v999"], ) result = compare_cli_output_receipts(_receipt(_row()), _receipt(candidate)) @@ -1212,3 +1212,19 @@ def test_only_retired_evidence_command_with_real_replacement_is_allowed(): assert not compare_cli_output_receipts(_receipt(base), _receipt())['ok'] assert not compare_cli_output_receipts(_receipt(base), _receipt({**replacement, 'json_shape_paths': []}))['ok'] assert not compare_cli_output_receipts(_receipt({**base, 'row_id': 'surface/status/small/json'}), _receipt(replacement))['ok'] + + +@pytest.mark.parametrize("previous", range(5)) +def test_settlement_v5_migration_does_not_waive_output_growth(previous): + base = _row(action_signature_coverages=[f"turn_envelope_action_dimensions_v{previous}"]) + candidate = {**base, "action_signature_sha256": "settlement-signature", + "action_signature_coverages": ["turn_envelope_action_dimensions_v5"]} + result = compare_cli_output_receipts(_receipt(base), _receipt(candidate)) + assert result["ok"] and result["review_required"] + assert not compare_cli_output_receipts( + _receipt(base), _receipt({**candidate, "chars": 50_000}), + )["ok"] + assert not compare_cli_output_receipts(_receipt(candidate), _receipt(base))["ok"] + assert not compare_cli_output_receipts( + _receipt(candidate), _receipt({**candidate, "action_signature_sha256": "lost-identity"}), + )["ok"] diff --git a/tests/control_plane/test_turn_envelope_settlement.py b/tests/control_plane/test_turn_envelope_settlement.py new file mode 100644 index 0000000000..927d2e91d4 --- /dev/null +++ b/tests/control_plane/test_turn_envelope_settlement.py @@ -0,0 +1,125 @@ +"""Settlement remains executable and signed through the opt-in compact view.""" +from copy import deepcopy + +import pytest + +from loopx.control_plane.quota.turn_envelope import ( + build_turn_envelope, + quota_action_signature_document, + turn_envelope_action_signature_document, +) +from tests.test_turn_envelope import _full_decision +from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, + _configure_read_only_todo, _run_cli, _run_generated_cli, + _spend_run_count, _write_fixture, +) + + +def _plan(): + # Independent wire example, not generated by the projection under test. + return { + "schema_version": "quota_settlement_plan_v1", + "identity": { + "schema_version": "quota_settlement_identity_v0", + "goal_id": "fixture-goal", "agent_id": "codex-fixture", + "todo_id": "todo_fixture0001", "turn_instance_id": "turn-original", + "effect_id": "fixture-goal:codex-fixture:todo_fixture0001:turn-original", + }, + "ordered_steps": [ + {"kind": "validation", "owner": "agent", "command_condition": "todo_deliverable_complete"}, + {"kind": "durable_writeback", "owner": "agent", "precondition": "validation succeeded"}, + {"kind": "quota_spend", "owner": "agent", "expected_receipt": "quota_spend_receipt"}, + {"kind": "terminal_closeout", "owner": "agent", "conditional": True}, + ], + "host_handoff": {"owner": "host", "kind": "scheduler_handoff", "inside_agent_settlement": False}, + } + + +def test_plan_and_long_commands_survive_without_mutating_cached_packet(): + source = _full_decision() + cli = source["interaction_contract"]["cli_channel"] + cli["settlement_plan"] = _plan() + command = "loopx --runtime-root /" + "nested/" * 80 + " quota spend-slot --turn-instance-id turn-original --execute" + cli["next_cli_actions"] = [command] + cli["settlement_plan"]["ordered_steps"][2]["command_template"] = command + before = deepcopy(source) + envelope = build_turn_envelope(source) + assert source == before + assert envelope["writeback"]["settlement_plan"] == before["interaction_contract"]["cli_channel"]["settlement_plan"] + assert envelope["writeback"]["next_cli_actions"] == [command] + assert envelope["action_signature"]["coverage"] == "turn_envelope_action_dimensions_v5" + assert quota_action_signature_document(source) == turn_envelope_action_signature_document(envelope) + + +@pytest.mark.parametrize("mutation", ["identity", "order", "condition", "receipt", "handoff", "remove"]) +def test_signature_rejects_settlement_semantic_loss(mutation): + source = _full_decision() + source["interaction_contract"]["cli_channel"]["settlement_plan"] = _plan() + envelope = build_turn_envelope(source) + plan = envelope["writeback"]["settlement_plan"] + if mutation == "identity": + plan["identity"]["effect_id"] = "another-turn" + elif mutation == "order": + plan["ordered_steps"].reverse() + elif mutation == "condition": + plan["ordered_steps"][-1]["conditional"] = False + elif mutation == "receipt": + plan["ordered_steps"][2]["expected_receipt"] = "validation_receipt" + elif mutation == "handoff": + plan["host_handoff"]["inside_agent_settlement"] = True + else: + del envelope["writeback"]["settlement_plan"] + assert quota_action_signature_document(source) != turn_envelope_action_signature_document(envelope) + + +def test_absent_plan_is_not_invented_for_historical_or_unbound_packets(): + source = _full_decision() + before = build_turn_envelope(source) + assert "settlement_plan" not in before["writeback"] + assert before["action_signature"]["coverage"] == "turn_envelope_action_dimensions_v0" + source["interaction_contract"]["cli_channel"].update( + selection_required=True, next_cli_actions=[], spend_after_validation=False, + ) + envelope = build_turn_envelope(source) + assert "settlement_plan" not in envelope["writeback"] + assert envelope["writeback"]["spend_after_validation"] is False + + +def test_real_cli_envelope_settles_original_turn_once(tmp_path): + project, runtime, registry = _write_fixture(tmp_path) + _configure_read_only_todo(project) + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--todo-id", TODO_ID, + "--turn-instance-id", TURN_ID, "--scan-path", str(project)) + rc, full = _run_cli(registry, runtime, *args) + assert rc == 0, full + rc, envelope = _run_cli(registry, runtime, *args, "--turn-envelope") + assert rc == 0, envelope + plan = envelope["writeback"]["settlement_plan"] + assert plan == full["interaction_contract"]["cli_channel"]["settlement_plan"] + assert plan["identity"] == full["heartbeat_receipt"]["settlement_identity"] + assert [step["kind"] for step in plan["ordered_steps"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + assert plan["ordered_steps"][-1]["conditional"] is True + assert plan["host_handoff"]["inside_agent_settlement"] is False + spend_command = plan["ordered_steps"][2]["command_template"].replace("loopx ", "loopx --format json ", 1) + rc, premature = _run_generated_cli(spend_command, registry_path=registry) + assert rc != 0, premature + assert _spend_run_count(runtime) == 0 + writeback = plan["ordered_steps"][1]["command_template"].replace("loopx ", "loopx --format json ", 1) + writeback = writeback.replace("", "validated_progress").replace( + "", "single_surface").replace("", "outcome_progress") + rc, refreshed = _run_generated_cli( + writeback + " --delivery-boundary in_flight_continuation --no-global-sync --suppress-external-sinks", + registry_path=registry, + ) + assert rc == 0, refreshed + for replay in (False, True): + rc, spent = _run_generated_cli(spend_command, registry_path=registry) + assert rc == 0, spent + assert spent["settlement_result"]["ok"] is True + if replay: + assert spent["idempotent_replay"] is True + assert _spend_run_count(runtime) == 1