Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/architecture/rfcs/agent-loop-effect-interpreter-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,13 @@ Stop or narrow M7 when any kill criterion holds:
`work_lane_contract`, and `scheduler_hint`.
- focused tests and docs that pin the lens.

The quota closeout adapter now consumes receipt-derived settlement progress
from the TS readback instead of independently treating a spend run as settled.
Normal refresh, replay and spend responses share that projection; executable
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.

### What Is Missing

- A generic shared executor is deliberately absent. The current adapters share
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ M7 只有在至少产生一个下列最终 effect 时才有理由存在:
- around 语义编码在 `capability_gate`、`interaction_contract`、`work_lane_contract` 和 `scheduler_hint` 中。
- 聚焦测试和文档固定该视角。

配额收尾适配现在消费 TS readback 按回执归约的结算进度,不再独立把存在 spend run 当作已结算。正常刷新、重放和扣款响应共享该投影;可执行命令绑定原 Agent 和路由。补回执复用现有幂等 writer。这是有界的 M7.4 接入,没有增加共享 executor 或 authority store,也不证明 Todo 终态或 Goal 验收完成。

### 还缺什么

- 通用共享 executor 被有意保留为空。当前 adapter 共享 plan/receipt algebra,却拥有不同的执行边界,因此 M7.3 应以 no-follow-up 关闭,而不是用推测性 framework 填充。
Expand Down
11 changes: 11 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,17 @@ or every T3 consumer. Python retains codecs, IO and the documented legacy route
prose hint until its remaining writers emit explicit replan flags; no new
capability/provider or parallel business authority is introduced.

The quota admission/settlement consumer now resolves explicit Todo selection
from the complete unified Todo reader before display compaction. It removes the
direct Markdown candidate append while preserving the pre-promotion event
adapter. Post-promotion empty or unavailable authority cannot revive display
rows. Settlement progress is reduced from the existing TS receipt chain;
Python renders identity-complete commands and both JSON/Markdown readbacks.
The existing idempotent writer repairs a missing spend receipt without a second
debit. This closes the demonstrated T3 consumer gap, not D1–D3, provider
promotion, or the remaining Python transaction adapters. See the
[operating contract](../../quota-allocation.md#receipt-backed-settlement-progress).

**T4 — collect full-writer retirement after durability cutover.**

- The 2026-09-19 command audit retires two already-typed but unconsumed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,8 @@ PostgreSQL 读取使用同一个 repeatable-read snapshot,并发提交在下
历史。合法结果 schema、File/NoKV 持久字节、请求身份及版本算法保持兼容。这支持
T3/D1 reader,未完成全部 Todo writer、retention/compaction 或 promotion。

配额准入与结算消费者现在从统一 Todo reader 读取完整来源,在显示压缩前解析显式 Todo 选择。它删除直接追加 Markdown 候选的路径,保留 promote 前的事件适配;promote 后权威为空或不可读都不能复活展示行。结算进度由现有 TS 回执链归约,Python 负责完整身份命令及 JSON/Markdown 展示。现有幂等 writer 可补齐缺失的 spend 回执而不再次扣款。这关闭已复现的 T3 消费者缺口,不代表 D1–D3、provider promotion 或剩余 Python 事务适配已完成。操作语义见[结算进度契约](../../quota-allocation.md#receipt-backed-settlement-progress)。

**T4 — durable cutover 后兑现完整 writer 删除。**

- 2026-09-19 命令审计退役两条已经 typed、但没有实际消费者的执行面:
Expand Down
28 changes: 28 additions & 0 deletions docs/quota-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ an outer caller still owns repeated execution. A completed-Todo threshold, a
per-Turn path declaration, and a same-Todo continuation budget are different
controls.

### Receipt-backed settlement progress

Turn-scoped `refresh-state` and `quota spend-slot` expose
`settlement_progress` from the TypeScript receipt readback. The states are
`identity_required`, `writeback_required`, `writeback_receipt_required`,
`spend_required`, `spend_receipt_required`, and `settled`. A durable run without
its matching receipt is incomplete. `settled` certifies this writeback/spend
chain; Todo completion and Goal acceptance retain their separate checks.

After verified writeback, `settlement_owed.command` carries the original Goal,
Agent, Todo or replan obligation, Turn, registry/runtime route and spend source.
Execute it unchanged. In `spend_receipt_required`, the same idempotent spend
writer restores the receipt without another debit. Refresh and recovery never
spend automatically. JSON and normal/recovery Markdown expose the same step.
Rejected recovery reports observed progress without offering a spend command.
The raw Python refresh API returns `writeback_receipt_required` until its CLI
caller appends the refresh receipt and rereads the chain; it must not offer a
spend command before that point. Older guards without a persisted spend source
retain the existing heartbeat default.

## Minimal Contract

The compact status shape can start with a small object:
Expand Down Expand Up @@ -494,6 +514,14 @@ hard lane or returns a refreshed portfolio. No settlement plan is exposed before
reentry, and a previously bound receipt cannot be retargeted. `recommended_action`
retains the human-readable rejection or deferral guidance; the executable recovery
command lives in `next_cli_actions` and `agent_channel.primary_action`.
Fresh explicit selection reads the complete Todo source through the same reader
as `todo list`, before display or Agent-lane compaction. Before shared-authority
promotion, that reader retains Markdown plus the existing event overlay. After
promotion, it reads the selected canonical provider, including authoritative
empty results; missing/stale display and provider failure never authorize a
Markdown fallback. The guard does not append a second Markdown candidate list.
Historical receipt-bound recovery remains separate from new work admission.

A single-candidate response
keeps the direct execution path and does not add an extra selection round trip.

Expand Down
4 changes: 4 additions & 0 deletions loopx/cli_commands/project_lifecycle_refresh_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
GOAL_VISION_ADVANCEMENT_POLICY_CHOICES,
)
from ..control_plane.quota.settlement import (
attach_settlement_progress,
read_heartbeat_settlement,
settlement_result_payload,
)
Expand Down Expand Up @@ -618,6 +619,9 @@ def handle_refresh_state_command(
"exact settlement readback unexpectedly returned not-found"
)
settlement_result = settlement_readback.delivery
attach_settlement_progress(
payload, settlement_readback, registry_path=registry_path, runtime_root=runtime_root,
)
payload["settlement_result"] = settlement_result_payload(
settlement_result
)
Expand Down
18 changes: 18 additions & 0 deletions loopx/control_plane/quota/effect_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ def _quoted_turn_ref(turn_instance_id_ref: str) -> str:
return shlex.quote(turn_instance_id_ref)


def _settlement_actor_args(arguments: str, agent_id: str) -> str:
"""Identity owns the actor; optional host arguments cannot omit or retarget it."""
tokens = shlex.split(arguments)
actors = []
for index, token in enumerate(tokens):
if token == "--agent-id":
actors.append(tokens[index + 1] if index + 1 < len(tokens) else None)
elif token.startswith("--agent-id="):
actors.append(token.partition("=")[2])
if actors and actors != [agent_id]:
raise ValueError("settlement command actor must match its exact identity")
if actors:
return arguments if arguments[:1].isspace() else f" {arguments}"
return f"{arguments} --agent-id {shlex.quote(agent_id)}"


def build_codex_app_settlement_plan(
*,
goal_id: str,
Expand Down Expand Up @@ -116,6 +132,8 @@ def build_turn_scoped_cli_settlement_plan(
turn_instance_id=turn_instance_id,
replan_obligation_id=replan_obligation_id,
)
scoped_cli_args = _settlement_actor_args(scoped_cli_args, identity.agent_id)
lifecycle_actor_args = _settlement_actor_args(lifecycle_actor_args, identity.agent_id)
quoted_turn = _quoted_turn_ref(turn_instance_id)
binding_arg = (
f" --todo-id {shlex.quote(todo_id)}"
Expand Down
17 changes: 17 additions & 0 deletions loopx/control_plane/quota/live_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,23 @@ def build_live_quota_should_run_decision(
fresh_operator_inbox_read = _fresh_operator_inbox_read_required(
turn_start_hook_dispatch
)
if requested_action_todo_id and not receipt_bound_todo_id:
# Candidate discovery and admission use the same provider-first reader.
# Keep the complete snapshot internal; presentation is bounded later.
from ...todos import list_goal_todos

source = list_goal_todos(
registry_path=registry_path, runtime_root_arg=str(runtime_root), goal_id=goal_id,
)
todo_fields = {key: source[key] for key in ("user_todos", "agent_todos")}
queue = decision_status_payload.get("attention_queue") or {}
decision_status_payload["attention_queue"] = {
**queue,
"items": [
{**item, **todo_fields} if item.get("goal_id") == goal_id else item
for item in queue.get("items") or []
],
}
payload = build_quota_should_run(
decision_status_payload,
goal_id=goal_id,
Expand Down
74 changes: 3 additions & 71 deletions loopx/control_plane/quota/refresh_external_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,7 @@
from typing import Any

from ...rollout_event_log import append_rollout_event, build_rollout_event, rollout_event_log_path
from .effect_program import (
SettlementStepKind,
build_turn_scoped_cli_settlement_plan,
)
from .settlement import (
QuotaSettlementReadback,
settlement_result_payload,
settlement_step_command,
)

TURN_SETTLEMENT_OWED_SCHEMA_VERSION = "turn_settlement_owed_v0"


def turn_settlement_owed(
readback: QuotaSettlementReadback,
*,
command_prefix: str = "loopx",
) -> dict[str, Any] | None:
"""Name the settlement step a committed writeback still owes.

A turn-scoped writeback appends the accountable run, but the Turn is only
settled once the quota spend for the same effect identity exists. Reporting
the writeback as finished hid that debt until the next wake raised
``unsettled_host_turn_recovery``, so the owed step is named here together
with the exact command that pays it under the same identity.
"""

identity = readback.identity.value
if identity is None or readback.spend_run is not None:
return None
owed: dict[str, Any] = {
"schema_version": TURN_SETTLEMENT_OWED_SCHEMA_VERSION,
"kind": "quota_spend",
"effect_id": identity.effect_id,
"goal_id": identity.goal_id,
"agent_id": identity.agent_id,
"todo_id": identity.todo_id,
"replan_obligation_id": identity.replan_obligation_id,
"turn_instance_id": identity.turn_instance_id,
"reason": (
"the writeback is committed but this Turn still owes its quota spend; "
"spend once for the same settlement identity and do not treat the "
"writeback alone as closeout"
),
"recovery_does_not_spend": True,
}
if bool(identity.todo_id) == bool(identity.replan_obligation_id):
# A turn-scoped identity carries exactly one work item. Without it the
# debt is still owed, but no exact command can be offered.
return owed
plan = build_turn_scoped_cli_settlement_plan(
goal_id=identity.goal_id,
agent_id=identity.agent_id,
command_prefix=command_prefix,
todo_id=identity.todo_id,
replan_obligation_id=identity.replan_obligation_id,
scoped_cli_args="",
lifecycle_actor_args="",
turn_instance_id=identity.turn_instance_id,
)
command = settlement_step_command(plan.as_dict(), SettlementStepKind.QUOTA_SPEND)
if command:
owed["command"] = command
return owed
from .settlement import QuotaSettlementReadback, attach_settlement_progress, settlement_result_payload


def finish_external_delivery_refresh(
Expand All @@ -80,13 +17,6 @@ def finish_external_delivery_refresh(
raise RuntimeError("TypeScript refresh external delivery result missing or invalid")
payload["external_delivery"] = {k: v for k, v in plan.items() if k != "transition"}
payload["external_sink_delivery_authorized"] = plan["authorized"] is True
if payload.get("ok") and not dry_run:
owed = turn_settlement_owed(readback)
if owed is not None:
# The committed writeback is not a closeout: name the step and the
# exact command this Turn still owes so the caller cannot mistake
# one for the other.
payload["settlement_owed"] = owed
transition = plan.get("transition")
if payload.get("ok") and transition and not dry_run:
identity = readback.identity.value
Expand Down Expand Up @@ -131,6 +61,8 @@ def refresh_recovery_payload(
"refresh_recovery": recovery, "settlement_identity": identity.as_dict(),
"settlement_result": settlement_result_payload(readback.delivery),
}
if not dry_run:
attach_settlement_progress(payload, readback, registry_path=registry_path, runtime_root=runtime_root)
if decision == "reject":
payload["error"] = (
f"{recovery['reason']}: committed writeback is unchanged; "
Expand Down
59 changes: 59 additions & 0 deletions loopx/control_plane/quota/settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
import shlex
from typing import Any

from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result
Expand Down Expand Up @@ -119,6 +120,7 @@ def render_refresh_recovery_markdown(payload: dict[str, Any]) -> str | None:
f"- reason: `{recovery.get('reason')}`",
"- appended: `False` — original writeback preserved; no new delivery or spend.",
]
lines.extend(render_settlement_progress_markdown(payload))
checkpoint = payload.get("vision_checkpoint") or {}
if checkpoint:
lines.append(
Expand Down Expand Up @@ -152,6 +154,62 @@ class QuotaSettlementReadback:
replay_phase: ReceiptBoundReplayPhase | None
refresh_recovery: dict[str, Any] | None = None
external_delivery: dict[str, Any] | None = None
progress: dict[str, Any] | None = None


def attach_settlement_progress(
payload: dict[str, Any],
readback: QuotaSettlementReadback,
*,
registry_path: Path | None = None,
runtime_root: Path | None = None,
) -> None:
"""Render the TS-owned receipt progress without deriving a second settlement rule."""
progress = readback.progress
if not isinstance(progress, dict) or progress.get("schema_version") != "quota_settlement_progress_v0":
raise RuntimeError("TypeScript quota settlement progress missing or invalid")
payload["settlement_progress"] = dict(progress)
payload.pop("settlement_owed", None)
identity = readback.identity.value
if payload.get("ok") is not True or progress.get("next_step") != "quota_spend" or identity is None:
return
prefix = "loopx"
if registry_path is not None:
prefix += f" --registry {shlex.quote(str(registry_path))}"
if runtime_root is not None:
prefix += f" --runtime-root {shlex.quote(str(runtime_root))}"
plan = build_turn_scoped_cli_settlement_plan(
goal_id=identity.goal_id,
agent_id=identity.agent_id,
todo_id=identity.todo_id,
replan_obligation_id=identity.replan_obligation_id,
turn_instance_id=identity.turn_instance_id,
command_prefix=prefix,
scoped_cli_args="",
lifecycle_actor_args="",
quota_spend_source=progress["quota_spend_source"],
)
payload["settlement_owed"] = {
**identity.as_dict(), "schema_version": "turn_settlement_owed_v0",
"kind": "quota_spend", "recovery_does_not_spend": True,
"reason": (
"quota spend is committed but its receipt is missing; retry the same identity to repair it without another debit"
if progress["state"] == "spend_receipt_required" else
"writeback is verified; execute quota spend once for the same settlement identity"
),
"command": settlement_step_command(plan.as_dict(), SettlementStepKind.QUOTA_SPEND),
}


def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]:
progress = payload.get("settlement_progress")
if not isinstance(progress, dict):
return []
lines = [f"- settlement: `{progress.get('state')}`"]
owed = payload.get("settlement_owed")
if isinstance(owed, dict):
lines.extend([f"- settlement_owed: {owed['reason']}", "", "```sh", owed["command"], "```"])
return lines


__all__ = [
Expand Down Expand Up @@ -307,6 +365,7 @@ def read_heartbeat_settlement(
writeback_run=_optional_readback_record(payload.get("writeback_run")),
refresh_recovery=_optional_readback_record(payload.get("refresh_recovery")),
external_delivery=_optional_readback_record(payload.get("external_delivery")),
progress=_optional_readback_record(payload.get("progress")),
spend_run=_optional_readback_record(payload.get("spend_run")),
heartbeat_receipt=_optional_readback_record(payload.get("heartbeat_receipt")),
writeback_event=_optional_readback_record(payload.get("writeback_event")),
Expand Down
8 changes: 8 additions & 0 deletions loopx/control_plane/quota/settlement_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
upgrade_identityless_heartbeat_receipt,
)
from .settlement import (
attach_settlement_progress,
read_heartbeat_settlement,
settlement_result_payload,
)
Expand Down Expand Up @@ -374,6 +375,9 @@ def quota_rollout_details(
"quiet_noop_allowed": bool(agent_channel.get("quiet_noop_allowed")),
"closeout_required": closeout_required,
}
cli_channel = interaction.get("cli_channel")
if isinstance(cli_channel, Mapping) and cli_channel.get("quota_spend_source"):
details["quota_spend_source"] = cli_channel["quota_spend_source"]
retained_todo_id = normalize_todo_id(retained_selection.get("retained_todo_id"))
if retained_todo_id:
retained_bound = retained_disposition == "preserve_retained_todo"
Expand Down Expand Up @@ -416,6 +420,10 @@ def attach_spend_settlement_result(
)
if readback is None:
raise RuntimeError("exact settlement readback unexpectedly returned not-found")
attach_settlement_progress(
payload, readback, runtime_root=runtime_root,
registry_path=Path(str(payload["registry"])) if payload.get("registry") else None,
)
identity = readback.identity.value
if identity is None:
settlement_result = readback.identity
Expand Down
Loading
Loading