Skip to content

Commit c219d62

Browse files
authored
fix(quota): close receipt-bound monitor settlement (#3513)
* fix(quota): close receipt-bound monitor settlement Signed-off-by: huangruiteng <huangrt01@163.com> * test(quota): cover settled monitor replay Signed-off-by: huangruiteng <huangrt01@163.com> * chore(quota): align monitor settlement lint Signed-off-by: huangruiteng <huangrt01@163.com> * fix(quota): preserve typed monitor settlement phase Signed-off-by: huangruiteng <huangrt01@163.com> * test(quota): spend from accountable delivery worktree Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 7a243e0 commit c219d62

20 files changed

Lines changed: 633 additions & 21 deletions

‎loopx/cli_commands/quota.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"scheduler-ack",
108108
"scheduler-ack-current",
109109
"scheduler-fail-current",
110+
"spend-slot",
110111
}
111112
)
112113

@@ -747,6 +748,7 @@ def handle_quota_command(
747748
source=args.source,
748749
agent_id=args.agent_id,
749750
available_capabilities=args.available_capabilities,
751+
scheduler_execution_context=scheduler_context,
750752
operator_inbox_urgency_projector=operator_inbox_urgency_projector,
751753
todo_id=args.todo_id,
752754
turn_instance_id=heartbeat_turn_id,

‎loopx/cli_commands/turn.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,11 @@ def spend(*, effect_ref: str) -> dict[str, object]:
597597
agent_id=args.agent_id,
598598
workspace_path=delivery_workspace_path,
599599
available_capabilities=args.available_capabilities,
600+
scheduler_execution_context=(
601+
payload.get("scheduler_execution_context")
602+
if isinstance(payload.get("scheduler_execution_context"), dict)
603+
else None
604+
),
600605
operator_inbox_urgency_projector=operator_inbox_urgency_projector,
601606
effect_ref=effect_ref,
602607
)

‎loopx/control_plane/agents/agent_lane_recommendation.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Callable
55
from typing import Any
66

7+
from ..effect_program import ReceiptBoundMonitorPhase
78
from ..todos.contract import (
89
TODO_TASK_CLASS_ADVANCEMENT,
910
TODO_TASK_CLASS_MONITOR,
@@ -14,7 +15,6 @@
1415
from ..todos.summary_item import compact_todo_summary_item
1516
from ..work_items.primary_action import protocol_action_text
1617
from ..work_items.work_lane import (
17-
ReceiptBoundMonitorPhase,
1818
work_lane_contract_is_due_monitor_attempt,
1919
work_lane_contract_is_lark_inbox_reply_due,
2020
)
@@ -42,6 +42,7 @@ def build_receipt_bound_monitor_next_action(
4242
agent_todo_items: list[dict[str, Any]],
4343
available_capabilities: Any,
4444
receipt_bound_todo_id: str | None,
45+
receipt_bound_monitor_phase: ReceiptBoundMonitorPhase | None = None,
4546
) -> dict[str, Any] | None:
4647
"""Recover an exact receipt-bound monitor omitted by compact hot lanes.
4748
@@ -80,6 +81,11 @@ def build_receipt_bound_monitor_next_action(
8081
if not text:
8182
return None
8283
payload = compact_todo_summary_item(item, text=text)
84+
monitor_phase = receipt_bound_monitor_phase or (
85+
ReceiptBoundMonitorPhase.POLL_DUE
86+
if monitor_due
87+
else ReceiptBoundMonitorPhase.SETTLEMENT_PENDING
88+
)
8389
payload.update(
8490
{
8591
"schema_version": AGENT_LANE_NEXT_ACTION_SCHEMA_VERSION,
@@ -89,11 +95,7 @@ def build_receipt_bound_monitor_next_action(
8995
"confidence": "selected",
9096
"preserves_goal_next_action": True,
9197
"selection_binding": "heartbeat_receipt",
92-
"receipt_bound_monitor_phase": (
93-
ReceiptBoundMonitorPhase.POLL_DUE
94-
if monitor_due
95-
else ReceiptBoundMonitorPhase.SETTLEMENT_PENDING
96-
).value,
98+
"receipt_bound_monitor_phase": monitor_phase.value,
9799
}
98100
)
99101
if not agent_scope_item_claimed_by(item):

‎loopx/control_plane/effect_program.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,33 @@
1919
SETTLEMENT_RECEIPT_SCHEMA_VERSION = "quota_settlement_receipt_v1"
2020

2121

22+
class ReceiptBoundMonitorPhase(StrEnum):
23+
POLL_DUE = "poll_due"
24+
SETTLEMENT_PENDING = "settlement_pending"
25+
SETTLED = "settled"
26+
27+
28+
def receipt_bound_monitor_phase(
29+
*,
30+
poll_present: bool,
31+
material_change: bool,
32+
durable_writeback_present: bool,
33+
quota_spend_present: bool,
34+
) -> ReceiptBoundMonitorPhase | None:
35+
result = effect_runtime_result(
36+
"settlement.receipt_bound_monitor_phase",
37+
{
38+
"poll_present": poll_present,
39+
"material_change": material_change,
40+
"durable_writeback_present": durable_writeback_present,
41+
"quota_spend_present": quota_spend_present,
42+
},
43+
)
44+
if result is None:
45+
return None
46+
return ReceiptBoundMonitorPhase(str(result))
47+
48+
2249
@dataclass(frozen=True)
2350
class EffectRequest:
2451
kind: str

‎loopx/control_plane/effect_program.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,31 @@ export const SETTLEMENT_FAILURE_KINDS = [
9595
] as const;
9696
export type SettlementFailureKind = (typeof SETTLEMENT_FAILURE_KINDS)[number];
9797

98+
export const RECEIPT_BOUND_MONITOR_PHASES = [
99+
"poll_due",
100+
"settlement_pending",
101+
"settled",
102+
] as const;
103+
export type ReceiptBoundMonitorPhase =
104+
(typeof RECEIPT_BOUND_MONITOR_PHASES)[number];
105+
106+
export interface ReceiptBoundMonitorSettlementState {
107+
poll_present: boolean;
108+
material_change: boolean;
109+
durable_writeback_present: boolean;
110+
quota_spend_present: boolean;
111+
}
112+
113+
export function receiptBoundMonitorPhase(
114+
state: ReceiptBoundMonitorSettlementState,
115+
): ReceiptBoundMonitorPhase {
116+
if (!state.poll_present) return "poll_due";
117+
if (!state.material_change) return "settled";
118+
return state.durable_writeback_present && state.quota_spend_present
119+
? "settled"
120+
: "settlement_pending";
121+
}
122+
98123
export interface SettlementIdentityInput {
99124
goal_id: string;
100125
agent_id: string;

‎loopx/control_plane/effect_runtime_handlers.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
settlementIdentity,
1010
settlementIdentityPayload,
1111
settlementPlanPayload,
12+
receiptBoundMonitorPhase,
1213
settlementResultPayload,
1314
SETTLEMENT_FAILURE_KINDS,
1415
SETTLEMENT_STEP_KINDS,
@@ -352,6 +353,15 @@ export function createEffectRuntimeHandlers(
352353
requiredString(params.expected_effect_id, "expected_effect_id"),
353354
),
354355
],
356+
[
357+
"settlement.receipt_bound_monitor_phase",
358+
(params) => receiptBoundMonitorPhase({
359+
poll_present: params.poll_present === true,
360+
material_change: params.material_change === true,
361+
durable_writeback_present: params.durable_writeback_present === true,
362+
quota_spend_present: params.quota_spend_present === true,
363+
}),
364+
],
355365
[
356366
"settlement.receipt",
357367
(params) => settlementReceipt(

‎loopx/control_plane/quota/effect_program.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
SETTLEMENT_IDENTITY_SCHEMA_VERSION,
99
SETTLEMENT_PLAN_SCHEMA_VERSION,
1010
SETTLEMENT_RECEIPT_SCHEMA_VERSION,
11+
ReceiptBoundMonitorPhase,
1112
SettlementBindingKind,
1213
SettlementFailure,
1314
SettlementFailureKind,
@@ -17,13 +18,15 @@
1718
SettlementResult,
1819
SettlementStep,
1920
SettlementStepKind,
21+
receipt_bound_monitor_phase,
2022
settlement_result_payload,
2123
)
2224

2325
__all__ = [
2426
"SETTLEMENT_IDENTITY_SCHEMA_VERSION",
2527
"SETTLEMENT_PLAN_SCHEMA_VERSION",
2628
"SETTLEMENT_RECEIPT_SCHEMA_VERSION",
29+
"ReceiptBoundMonitorPhase",
2730
"SettlementBindingKind",
2831
"SettlementFailure",
2932
"SettlementFailureKind",
@@ -35,6 +38,7 @@
3538
"SettlementStepKind",
3639
"build_codex_app_settlement_plan",
3740
"build_turn_scoped_cli_settlement_plan",
41+
"receipt_bound_monitor_phase",
3842
"settlement_binding_args",
3943
"settlement_result_payload",
4044
"settlement_step_command",

‎loopx/control_plane/quota/live_decision.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from ...quota import build_quota_should_run
8+
from .settlement import receipt_bound_monitor_settlement_phase
89
from ..scheduler.execution_context import (
910
SchedulerExecutionContextResolution,
1011
resolve_scheduler_execution_context,
@@ -125,6 +126,13 @@ def build_live_quota_should_run_decision(
125126
**status_payload,
126127
"bounded_research_frontier": dict(frontier),
127128
}
129+
receipt_bound_monitor_phase = receipt_bound_monitor_settlement_phase(
130+
runtime_root,
131+
goal_id=goal_id,
132+
agent_id=agent_id,
133+
todo_id=receipt_bound_todo_id,
134+
turn_instance_id=turn_instance_id,
135+
)
128136
payload = build_quota_should_run(
129137
decision_status_payload,
130138
goal_id=goal_id,
@@ -136,6 +144,7 @@ def build_live_quota_should_run_decision(
136144
scheduler_execution_context=resolved_context,
137145
operator_inbox_urgency_projector=operator_inbox_urgency_projector,
138146
receipt_bound_todo_id=receipt_bound_todo_id,
147+
receipt_bound_monitor_phase=receipt_bound_monitor_phase,
139148
receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id,
140149
turn_instance_id=turn_instance_id,
141150
)

‎loopx/control_plane/quota/settlement.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@
2828
SettlementResult,
2929
SettlementStep,
3030
SettlementStepKind,
31+
ReceiptBoundMonitorPhase,
3132
build_codex_app_settlement_plan,
3233
build_turn_scoped_cli_settlement_plan,
3334
settlement_binding_args,
35+
receipt_bound_monitor_phase,
3436
settlement_result_payload,
3537
settlement_step_command,
3638
)
3739
from .heartbeat_receipt import find_heartbeat_receipt
40+
from .monitor_poll import find_quota_monitor_poll_turn
3841
from .settlement_workspace_causality import (
3942
delivery_workspace_causality_from_event_details,
4043
)
@@ -59,6 +62,7 @@
5962
"find_settlement_step_event",
6063
"find_settlement_writeback",
6164
"infer_persisted_heartbeat_settlement_identity",
65+
"receipt_bound_monitor_settlement_phase",
6266
"require_settlement_spend",
6367
"require_settlement_terminal_closeout",
6468
"require_settlement_writeback",
@@ -70,6 +74,83 @@
7074
]
7175

7276

77+
def receipt_bound_monitor_settlement_phase(
78+
runtime_root: Path,
79+
*,
80+
goal_id: str,
81+
agent_id: str | None,
82+
todo_id: str | None,
83+
turn_instance_id: str | None,
84+
) -> ReceiptBoundMonitorPhase | None:
85+
"""Resolve the typed phase for one receipt-bound monitor turn.
86+
87+
A missing matching poll remains explicitly ``poll_due`` even when mutable
88+
Todo scheduling metadata has moved into the future. An unchanged poll is
89+
terminal by itself. A material poll is terminal only after the exact
90+
heartbeat identity has both durable writeback and quota-spend receipts.
91+
``None`` is reserved for an invalid receipt identity or runtime failure.
92+
"""
93+
94+
normalized_agent_id = normalize_todo_claimed_by(agent_id)
95+
normalized_todo_id = normalize_todo_id(todo_id)
96+
try:
97+
normalized_turn_id = normalize_turn_instance_id(turn_instance_id)
98+
except ValueError:
99+
return None
100+
if not normalized_agent_id or not normalized_todo_id or not normalized_turn_id:
101+
return None
102+
poll = find_quota_monitor_poll_turn(
103+
runtime_root,
104+
goal_id=goal_id,
105+
agent_id=normalized_agent_id,
106+
turn_instance_id=normalized_turn_id,
107+
)
108+
if not isinstance(poll, Mapping):
109+
return receipt_bound_monitor_phase(
110+
poll_present=False,
111+
material_change=False,
112+
durable_writeback_present=False,
113+
quota_spend_present=False,
114+
)
115+
if normalize_todo_id(poll.get("todo_id")) != normalized_todo_id:
116+
return receipt_bound_monitor_phase(
117+
poll_present=False,
118+
material_change=False,
119+
durable_writeback_present=False,
120+
quota_spend_present=False,
121+
)
122+
material_change = poll.get("material_change") is True
123+
if not material_change:
124+
return receipt_bound_monitor_phase(
125+
poll_present=True,
126+
material_change=False,
127+
durable_writeback_present=False,
128+
quota_spend_present=False,
129+
)
130+
identity_result = resolve_heartbeat_settlement_identity(
131+
runtime_root,
132+
goal_id=goal_id,
133+
agent_id=normalized_agent_id,
134+
todo_id=normalized_todo_id,
135+
turn_instance_id=normalized_turn_id,
136+
)
137+
identity = identity_result.value
138+
durable_writeback_present = bool(
139+
identity is not None
140+
and require_settlement_writeback(runtime_root, identity).value is not None
141+
)
142+
quota_spend_present = bool(
143+
identity is not None
144+
and require_settlement_spend(runtime_root, identity).value is not None
145+
)
146+
return receipt_bound_monitor_phase(
147+
poll_present=True,
148+
material_change=True,
149+
durable_writeback_present=durable_writeback_present,
150+
quota_spend_present=quota_spend_present,
151+
)
152+
153+
73154
def resolve_settlement_delivery_workspace_causality(
74155
runtime_root: Path,
75156
identity: SettlementIdentity,

‎loopx/control_plane/quota/should_run.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
build_interaction_contract,
3737
build_protocol_action_packet,
3838
)
39+
from .effect_program import ReceiptBoundMonitorPhase
3940

4041
from .should_run_packet import (
4142
_QuotaDecisionRoute,
@@ -224,6 +225,7 @@ def build_quota_should_run(
224225
) = None,
225226
operator_inbox_urgency_projector: Callable[..., dict[str, Any]] | None = None,
226227
receipt_bound_todo_id: str | None = None,
228+
receipt_bound_monitor_phase: ReceiptBoundMonitorPhase | None = None,
227229
receipt_bound_replan_obligation_id: str | None = None,
228230
turn_instance_id: str | None = None,
229231
) -> dict[str, Any]:
@@ -287,6 +289,7 @@ def build_quota_should_run(
287289
item=item,
288290
health_items=health_items,
289291
receipt_bound_todo_id=receipt_bound_todo_id,
292+
receipt_bound_monitor_phase=receipt_bound_monitor_phase,
290293
receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id,
291294
)
292295
route = _resolve_quota_should_run_route(prepared)

0 commit comments

Comments
 (0)