From 2d82f938e36de250bc842d1208efb9ad8353c4bd Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 30 Sep 2026 19:28:19 +0800 Subject: [PATCH 1/3] fix(quota): fence settlement by exact GoalRef Signed-off-by: duanjialing.777 --- ...nstance-identity-and-orphan-recovery-v0.md | 22 + ...e-identity-and-orphan-recovery-v0.zh-CN.md | 20 + loopx/capabilities/multi_subagent/cli.py | 20 + .../multi_subagent/native_child_receipts.py | 156 +++++- loopx/cli_commands/agent_context.py | 28 ++ .../project_lifecycle_refresh_state.py | 39 ++ loopx/cli_commands/quota.py | 63 ++- loopx/cli_commands/quota_action_selection.py | 7 + loopx/cli_commands/quota_monitor_poll.py | 5 + loopx/cli_commands/quota_registration.py | 1 + loopx/cli_commands/quota_reward_memory.py | 3 + .../cli_commands/quota_scheduler_followup.py | 5 + loopx/cli_commands/todo.py | 46 +- .../cli_commands/todo_argument_validation.py | 9 + loopx/cli_commands/todo_event.py | 9 +- loopx/cli_commands/todo_registration.py | 1 + loopx/cli_commands/turn.py | 22 + loopx/cli_commands/turn_decision.py | 4 + loopx/cli_rollout.py | 3 + loopx/control_plane/effect_program.ts | 1 + .../control_plane/goals/checkpoint_commit.ts | 101 +++- .../goals/checkpoint_context_io.py | 73 ++- .../goals/first_party_host_admission.py | 8 +- .../control_plane/host_adapter_settlement.py | 3 + .../quota/accounting_admission.py | 113 +++++ .../quota/accounting_artifact_transaction.ts | 140 +++++- loopx/control_plane/quota/effect_program.py | 52 +- .../control_plane/quota/heartbeat_receipt.py | 29 ++ loopx/control_plane/quota/live_decision.py | 8 +- loopx/control_plane/quota/monitor_poll.py | 52 +- .../quota/monitor_poll_commit.ts | 68 ++- .../quota/refresh_external_delivery.py | 28 +- loopx/control_plane/quota/settlement.py | 86 +++- loopx/control_plane/quota/settlement_cli.py | 13 +- loopx/control_plane/quota/settlement_plan.ts | 24 +- .../quota/settlement_readback.ts | 90 +++- loopx/control_plane/quota/should_run.py | 6 + .../control_plane/quota/should_run_packet.py | 3 + loopx/control_plane/quota/slot_accounting.py | 12 + loopx/control_plane/quota/source_admission.ts | 387 +++++++++++++++ loopx/control_plane/quota/spend_commit.py | 109 +++-- loopx/control_plane/quota/spend_commit.ts | 117 +++-- .../quota/unsettled_host_turn.py | 55 ++- .../quota/unsettled_host_turn_recovery.ts | 54 +- loopx/control_plane/quota/void_commit.py | 41 +- loopx/control_plane/quota/void_commit.ts | 171 ++++--- loopx/control_plane/status/collection.py | 2 + loopx/control_plane/turn_driver/executor.py | 5 + .../turn_driver/host_todo_completion.ts | 48 +- .../control_plane/turn_driver/transaction.py | 2 + .../work_items/accountable_settlement.py | 6 + .../work_items/action_selection_contract.py | 15 + .../work_items/interaction_contract.py | 35 +- loopx/goal_mode_mcp.py | 17 +- loopx/quota.py | 63 +++ loopx/rollout_event_log.py | 28 +- .../goal_instance_binding_inventory_v1.json | 12 +- .../project_registry_io_manifest_v1.json | 42 +- loopx/state_refresh.py | 44 +- loopx/status.py | 20 + .../test_goal_instance_binding_inventory.py | 1 + .../test_native_child_receipts.py | 90 +++- tests/control_plane/checkpoint_process.py | 5 +- .../test_checkpoint_provider_fence.py | 141 ++++++ .../test_quota_rolling_window_projection.py | 36 ++ .../test_quota_spend_commit_runtime.py | 96 +++- .../test_quota_void_commit_runtime.py | 153 +++++- .../test_refresh_external_delivery.py | 40 ++ .../host_todo_completion.test.ts | 24 + .../quota_settlement_readback.test.ts | 344 ++++++++++++- .../quota_spend_commit.test.ts | 462 ++++++++++++++++++ .../quota_void_commit.test.ts | 169 ++++++- .../unsettled_host_turn_recovery.test.ts | 113 ++++- 73 files changed, 3970 insertions(+), 350 deletions(-) create mode 100644 loopx/control_plane/quota/accounting_admission.py create mode 100644 loopx/control_plane/quota/source_admission.ts diff --git a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md index 23b84bf36..ae368bc48 100644 --- a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md +++ b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.md @@ -840,6 +840,28 @@ promotion retain their own acceptance. No new paid cohort or soak is authorized. drain, unsupported/warm binary coverage, or any other M3 row. `execution_authority: false` and the overall activation hold remain. +### 2026-09-30: M3 quota settlement owner candidate + +- **Baseline:** `3ec049e13`. +- **Proposed:** Bind source-profile quota spend, replay, receipt repair, void, + settlement readback, and rolling-window accounting to the caller-captured + exact GoalRef. Python hands the ordered run-index and Goal-lifecycle lock + witnesses to the TypeScript accounting owner. TypeScript validates and claims + both witnesses, reuses `decideFirstPartyHostRuntime(require_current)`, and + keeps the owner fence through receipt and artifact commit. +- **Evidence:** TypeScript and Python integration tests publish same-alias Goal + B after Goal A capture and prove that stale A writes nothing. They also cover + B-only spend and void, cross-instance replay and prepared-receipt repair + rejection, exact settlement readback, and per-instance rolling-window + accounting. +- **Compatibility:** Non-source spend, replay, void, and readback requests omit + GoalRef and source admission. Their persisted records, receipts, response + payloads, and lock behavior retain the legacy shape. +- **Remaining hold:** This qualifies only the `quota_settlement` inventory row. + Unsupported and warm binaries, downstream external-effect drain, and every + other unqualified M3 owner remain blocked. `execution_authority: false` and + the overall activation hold remain unchanged. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | diff --git a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md index 8ccb2d1c9..be0a604aa 100644 --- a/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-instance-identity-and-orphan-recovery-v0.zh-CN.md @@ -761,6 +761,26 @@ service adoption、D1–D3 provider promotion 保留各自验收。不授权付 binary 或其他 M3 行已完成。`execution_authority: false` 和总 activation hold 保持不变。 +### 2026-09-30:M3 quota settlement owner 候选 + +- **基线:** `3ec049e13`。 +- **候选实现:** Source profile 的 quota spend、replay、receipt repair、void、 + settlement readback 与 rolling-window accounting 均绑定调用方预先捕获的精确 + GoalRef。Python 按顺序把 run-index 与 Goal lifecycle lock witness 交接给 + TypeScript accounting owner。TypeScript 校验并 claim 两个 witness,复用 + `decideFirstPartyHostRuntime(require_current)`,并保持 owner fence,直到 receipt + 与 artifact commit 完成。 +- **证据:** TypeScript 与 Python 集成测试在 Goal A 捕获后发布同名 Goal B, + 证明迟到的 A 不产生任何写入。测试还覆盖 B 独立 spend/void、跨实例 replay + 和 prepared receipt repair 拒绝、精确 settlement readback,以及按实例隔离的 + rolling-window accounting。 +- **兼容性:** 非 source 的 spend、replay、void 与 readback 请求不携带 GoalRef + 或 source admission;其持久化 record、receipt、响应 payload 和锁行为保持 + legacy 形态。 +- **剩余 hold:** 本切片只资格化 `quota_settlement` inventory 行。不支持及常驻 + binary、downstream external-effect drain 和其他未资格化 M3 owner 继续受阻。 + `execution_authority: false` 和总 activation hold 保持不变。 + ## 附录 B:决策日志 | 日期 | 决策 | Owner/批准 | 替代方案 | 变更的规范章节 | diff --git a/loopx/capabilities/multi_subagent/cli.py b/loopx/capabilities/multi_subagent/cli.py index a9fcf0f2e..96cf8d52e 100644 --- a/loopx/capabilities/multi_subagent/cli.py +++ b/loopx/capabilities/multi_subagent/cli.py @@ -2,7 +2,13 @@ from __future__ import annotations +import argparse + from ...agent_registry import load_goal_from_registry, registered_agent_ids_for_goal +from ...control_plane.goals.first_party_host_admission import ( + capture_first_party_host_goal_ref, +) +from ...control_plane.goals.source_session_registry_state import exact_goal_ref from ...orchestration import compact_orchestration_policy from .native_child_receipts import load_native_child_activity, record_native_child @@ -16,6 +22,7 @@ def register_native_child_commands(subparsers, add_format): parser.add_argument("--goal-id", required=True) parser.add_argument("--agent-id", required=True) parser.add_argument("--turn-instance-id", required=True) + parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) parser.add_argument("--operation-id", help="Stable identity for one host-native child operation.") parser.add_argument("--stage", choices=("decision", "result", "review")) parser.add_argument("--operation", choices=("spawn", "followup", "skip")) @@ -31,6 +38,17 @@ def handle_native_child_command(args, registry_path, runtime_root, print_payload if args.command != "native-child": return None try: + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + goal_ref = ( + exact_goal_ref(args.goal_id, goal_instance_id) + if goal_instance_id + else capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=args.goal_id, + ) + ) goal = load_goal_from_registry(registry_path, args.goal_id) if goal is None or args.agent_id not in registered_agent_ids_for_goal(goal): raise ValueError("coordinator is not registered for this Goal") @@ -50,6 +68,7 @@ def handle_native_child_command(args, registry_path, runtime_root, print_payload payload = {"ok": True, "native_child_activity": load_native_child_activity( runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=args.turn_instance_id, configured_limit=configured_limit, + registry_path=registry_path, goal_ref=goal_ref, )} else: if not args.operation_id or not args.stage or not args.outcome: @@ -62,6 +81,7 @@ def handle_native_child_command(args, registry_path, runtime_root, print_payload entrypoint_id=args.entrypoint_id, reason_code=args.reason_code, evidence_ref=args.evidence_ref, validation_ref=args.validation_ref, execute=args.execute, + registry_path=registry_path, goal_ref=goal_ref, ) except (OSError, ValueError, KeyError, RuntimeError) as exc: payload = {"ok": False, "error": str(exc)} diff --git a/loopx/capabilities/multi_subagent/native_child_receipts.py b/loopx/capabilities/multi_subagent/native_child_receipts.py index ead59dcd0..1044b845b 100644 --- a/loopx/capabilities/multi_subagent/native_child_receipts.py +++ b/loopx/capabilities/multi_subagent/native_child_receipts.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any +from ...control_plane.quota.accounting_admission import quota_accounting_admission from ...control_plane.quota.settlement import read_heartbeat_settlement from ...control_plane.runtime.public_safety import validate_public_safe_value from ...rollout_event_log import ( @@ -64,20 +65,33 @@ def _details(event: Mapping[str, Any]) -> dict[str, Any]: def _events_for_turn( events: Sequence[Mapping[str, Any]], *, goal_id: str, agent_id: str, turn_instance_id: str, + goal_ref: Mapping[str, Any] | None = None, ) -> list[dict[str, Any]]: return [dict(event) for event in events if event.get("event_kind") in EVENT_KINDS.values() and event.get("goal_id") == goal_id and event.get("agent_id") == agent_id - and event.get("run_id") == turn_instance_id] + and event.get("run_id") == turn_instance_id + and ( + event.get("goal_ref") == dict(goal_ref) + if goal_ref is not None + else "goal_ref" not in event + )] def native_child_activity( events: Sequence[Mapping[str, Any]], *, goal_id: str, agent_id: str, turn_instance_id: str, configured_limit: int, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """One read model for CLI, agent-context and product projections.""" - rows = _events_for_turn(events, goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id) + rows = _events_for_turn( + events, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_instance_id, + goal_ref=goal_ref, + ) operations: dict[str, dict[str, Any]] = {} for event in rows: details = _details(event) @@ -134,32 +148,80 @@ def native_child_activity( def load_native_child_activity( runtime_root: Path, *, goal_id: str, agent_id: str, turn_instance_id: str, configured_limit: int, + goal_ref: Mapping[str, Any] | None = None, + registry_path: Path | None = None, ) -> dict[str, Any]: - source = iter_rollout_events(rollout_event_log_path(runtime_root, goal_id)) - events = [event for event in source - if event.get("event_kind") in EVENT_KINDS.values() - and event.get("agent_id") == agent_id - and event.get("run_id") == turn_instance_id] - return native_child_activity( - events, goal_id=goal_id, agent_id=agent_id, - turn_instance_id=turn_instance_id, configured_limit=configured_limit, - ) + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="native-child-read", + lock_legacy_index=False, + ) as source_admission: + if source_admission is not None: + readback = read_heartbeat_settlement( + runtime_root, + goal_id=goal_id, + agent_id=agent_id, + todo_id=None, + turn_instance_id=turn_instance_id, + resolve_original_binding=True, + registry_path=registry_path, + goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=True, + ) + if readback is None or readback.identity.value is None: + raise ValueError( + "native child read requires an admitted, settlement-bound Turn guard" + ) + source = iter_rollout_events( + rollout_event_log_path(runtime_root, goal_id) + ) + events = [ + event + for event in source + if event.get("event_kind") in EVENT_KINDS.values() + and event.get("agent_id") == agent_id + and event.get("run_id") == turn_instance_id + and ( + event.get("goal_ref") == dict(goal_ref) + if goal_ref is not None + else "goal_ref" not in event + ) + ] + return native_child_activity( + events, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_instance_id, + configured_limit=configured_limit, + goal_ref=goal_ref, + ) def latest_native_child_activity( events: Sequence[Mapping[str, Any]], *, goal_id: str, configured_limit: int, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: """Expose only the latest reported Turn in existing Goal status surfaces.""" observations = [event for event in events if event.get("goal_id") == goal_id and event.get("event_kind") in EVENT_KINDS.values() - and event.get("agent_id") and event.get("run_id")] + and event.get("agent_id") and event.get("run_id") + and ( + event.get("goal_ref") == dict(goal_ref) + if goal_ref is not None + else "goal_ref" not in event + )] if not observations: return None latest = max(observations, key=lambda event: str(event.get("recorded_at") or "")) return native_child_activity( events, goal_id=goal_id, agent_id=str(latest["agent_id"]), turn_instance_id=str(latest["run_id"]), configured_limit=configured_limit, + goal_ref=goal_ref, ) @@ -212,13 +274,16 @@ def _normalized_fields( raise ValueError("stage must be decision, result or review") -def record_native_child( +def _record_native_child( *, runtime_root: Path, goal_id: str, agent_id: str, turn_instance_id: str, operation_id: str, configured_limit: int, stage: str, outcome: str, operation: str | None = None, entrypoint_id: str | None = None, reason_code: str | None = None, evidence_ref: str | None = None, validation_ref: str | None = None, execute: bool = False, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Preview or append a typed report; never launch a child or spend quota.""" goal_id = _id(goal_id, field="goal_id") @@ -233,7 +298,13 @@ def record_native_child( ) log_path = rollout_event_log_path(runtime_root, goal_id) events = load_rollout_events(log_path) - prior = _events_for_turn(events, goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id) + prior = _events_for_turn( + events, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_instance_id, + goal_ref=goal_ref, + ) existing = next((event for event in prior if event.get("case_id") == operation_id and event.get("event_kind") == EVENT_KINDS[stage]), None) @@ -244,6 +315,9 @@ def report_admission() -> Mapping[str, Any]: readback = read_heartbeat_settlement( runtime_root, goal_id=goal_id, agent_id=agent_id, todo_id=None, turn_instance_id=turn_instance_id, resolve_original_binding=True, + registry_path=registry_path, goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None, ) if readback is None or readback.identity.value is None or readback.identity.failure is not None: reason = (readback.identity.failure.reason @@ -261,7 +335,8 @@ def report_admission() -> Mapping[str, Any]: def validate_transition(observed: Sequence[Mapping[str, Any]]) -> None: admission = report_admission() current = _events_for_turn(observed, goal_id=goal_id, agent_id=agent_id, - turn_instance_id=turn_instance_id) + turn_instance_id=turn_instance_id, + goal_ref=goal_ref) decisions = {str(item.get("case_id")): item for item in current if item.get("event_kind") == EVENT_KINDS["decision"]} if stage == "decision": @@ -293,12 +368,20 @@ def validate_transition(observed: Sequence[Mapping[str, Any]]) -> None: goal_id=goal_id, event_kind=EVENT_KINDS[stage], agent_id=agent_id, run_id=turn_instance_id, case_id=operation_id, status=fields["outcome"], details=fields, recorded_at=(existing or {}).get("recorded_at"), + goal_ref=goal_ref, ) appended = False if execute: stored, appended = append_rollout_event_once( log_path, event, - identity_fields=("goal_id", "event_kind", "agent_id", "run_id", "case_id"), + identity_fields=( + "goal_id", + "event_kind", + "agent_id", + "run_id", + "case_id", + *(("goal_ref",) if goal_ref is not None else ()), + ), precondition=lambda: validate_transition(load_rollout_events(log_path)), ) if _details(stored) != fields: @@ -318,5 +401,46 @@ def validate_transition(observed: Sequence[Mapping[str, Any]]) -> None: events if execute or existing else [*events, event], goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, configured_limit=configured_limit, + goal_ref=goal_ref, ), } + + +def record_native_child( + *, runtime_root: Path, goal_id: str, agent_id: str, + turn_instance_id: str, operation_id: str, configured_limit: int, + stage: str, outcome: str, operation: str | None = None, + entrypoint_id: str | None = None, reason_code: str | None = None, + evidence_ref: str | None = None, validation_ref: str | None = None, + execute: bool = False, registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Preview or append one report under its exact quota owner.""" + + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="native-child-report", + lock_legacy_index=False, + ) as source_admission: + return _record_native_child( + runtime_root=runtime_root, + goal_id=goal_id, + agent_id=agent_id, + turn_instance_id=turn_instance_id, + operation_id=operation_id, + configured_limit=configured_limit, + stage=stage, + outcome=outcome, + operation=operation, + entrypoint_id=entrypoint_id, + reason_code=reason_code, + evidence_ref=evidence_ref, + validation_ref=validation_ref, + execute=execute, + registry_path=registry_path, + goal_ref=goal_ref, + source_admission=source_admission, + ) diff --git a/loopx/cli_commands/agent_context.py b/loopx/cli_commands/agent_context.py index c93a39487..ae4ad5af9 100644 --- a/loopx/cli_commands/agent_context.py +++ b/loopx/cli_commands/agent_context.py @@ -1,8 +1,14 @@ """Read-only lifecycle context for hosts whose native tools bypass LoopX Turn.""" +import argparse + from ..agent_registry import load_goal_from_registry, registered_agent_ids_for_goal from ..capabilities.multi_subagent.native_child_receipts import load_native_child_activity from ..control_plane.agent_context import project_goal_agent_context +from ..control_plane.goals.first_party_host_admission import ( + capture_first_party_host_goal_ref, +) +from ..control_plane.goals.source_session_registry_state import exact_goal_ref from ..orchestration import compact_orchestration_policy @@ -37,9 +43,29 @@ def register_agent_context(subparsers, add_format): "--turn-instance-id", help="Read durable native child activity for this exact admitted Turn.", ) + parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) def handle_agent_context(args, registry_path, runtime_root, print_payload, output_format): + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + try: + goal_ref = ( + exact_goal_ref(args.goal_id, goal_instance_id) + if goal_instance_id + else capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=args.goal_id, + ) + ) + except (OSError, ValueError, RuntimeError) as exc: + print_payload( + {"ok": False, "error": str(exc)}, + output_format(args), + render_agent_context, + ) + return 1 goal = load_goal_from_registry(registry_path, args.goal_id) if goal is None or args.agent_id not in registered_agent_ids_for_goal(goal): print_payload( @@ -118,6 +144,8 @@ def handle_agent_context(args, registry_path, runtime_root, print_payload, outpu runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=args.turn_instance_id, configured_limit=int(orchestration["max_children"]), + registry_path=registry_path, + goal_ref=goal_ref, ) observations["native_child_activity"] = native_activity context = project_goal_agent_context( diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index b316dc034..b5026fbe7 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -24,6 +24,10 @@ from ..control_plane.goals.goal_vision_policy import ( GOAL_VISION_ADVANCEMENT_POLICY_CHOICES, ) +from ..control_plane.goals.first_party_host_admission import ( + capture_first_party_host_goal_ref, +) +from ..control_plane.goals.source_session_registry_state import exact_goal_ref from ..control_plane.quota.settlement import ( attach_settlement_progress, read_heartbeat_settlement, @@ -84,6 +88,7 @@ def register_refresh_state_command( binding = context_parser.add_mutually_exclusive_group(required=True) binding.add_argument("--todo-id") binding.add_argument("--replan-obligation-id") + context_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) context_parser.add_argument("--project") context_parser.add_argument("--state-file") context_parser.add_argument("--dependency-todo-id", action="append", default=[], @@ -176,6 +181,7 @@ def register_refresh_state_command( "value on retries." ), ) + refresh_state_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) refresh_state_parser.add_argument("--completion-todo-id", help=argparse.SUPPRESS) refresh_state_parser.add_argument("--completion-turn-key", help=argparse.SUPPRESS) refresh_state_parser.add_argument( @@ -389,6 +395,17 @@ def handle_refresh_state_command( if args.command == "checkpoint-context": from ..control_plane.goals.checkpoint_context_io import read_checkpoint_context, render_checkpoint_context try: + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + goal_ref = ( + exact_goal_ref(args.goal_id, goal_instance_id) + if goal_instance_id + else capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=args.goal_id, + ) + ) payload = read_checkpoint_context( registry_path=registry_path, runtime_root_override=args.runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, @@ -396,6 +413,7 @@ def handle_refresh_state_command( project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, dependency_todo_ids=args.dependency_todo_id, + goal_ref=goal_ref, ) except Exception as exc: payload = {"ok": False, "error": str(exc), @@ -409,6 +427,7 @@ def handle_refresh_state_command( agent_vision_packet: dict[str, object] | None = None progress_observation: dict[str, object] | None = None merge_agent_vision_patch = False + goal_ref: dict[str, str] | None = None try: inline_vision_packet = inline_agent_vision_packet(args) if args.agent_vision_json and inline_vision_packet: @@ -444,6 +463,20 @@ def handle_refresh_state_command( if not isinstance(loaded_usage, dict): raise ValueError("--usage-json must be a JSON object") usage_measurement = loaded_usage + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + if goal_instance_id and not getattr(args, "turn_instance_id", None): + raise ValueError("--goal-instance-id requires --turn-instance-id") + if getattr(args, "turn_instance_id", None): + goal_ref = ( + exact_goal_ref(args.goal_id, goal_instance_id) + if goal_instance_id + else capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=args.goal_id, + ) + ) except Exception as exc: payload = { "ok": False, @@ -505,6 +538,7 @@ def handle_refresh_state_command( ), dry_run=bool(args.dry_run), sync_global=not bool(args.no_global_sync), + goal_ref=goal_ref, ) except Exception as exc: payload = { @@ -581,6 +615,7 @@ def handle_refresh_state_command( registry_path=registry_path, runtime_root_arg=args.runtime_root, event_kind="refresh_state", + goal_ref=goal_ref, agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), run_id=getattr(args, "turn_instance_id", None), @@ -626,6 +661,7 @@ def handle_refresh_state_command( else [] ), "run_id", + *(["goal_ref"] if goal_ref is not None else []), ] if getattr(args, "turn_instance_id", None) else None @@ -648,6 +684,8 @@ def handle_refresh_state_command( replan_obligation_id=getattr( args, "replan_obligation_id", None ), + registry_path=registry_path, + goal_ref=goal_ref, ) if settlement_readback is None: raise RuntimeError( @@ -656,6 +694,7 @@ def handle_refresh_state_command( settlement_result = settlement_readback.delivery attach_settlement_progress( payload, settlement_readback, registry_path=registry_path, runtime_root=runtime_root, + goal_ref=goal_ref, ) payload["settlement_result"] = settlement_result_payload( settlement_result diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index b9799d2ab..dbd71906b 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -18,6 +18,10 @@ ) from ..control_plane.effect_runtime import EffectRuntimeRejected from ..control_plane.capability_hooks import InteractionProjectionHookRegistration +from ..control_plane.goals.first_party_host_admission import ( + capture_first_party_host_goal_ref, +) +from ..control_plane.goals.source_session_registry_state import exact_goal_ref from ..control_plane.quota.cli_projection import ( compact_quota_monitor_poll_cli_payload, compact_quota_plan_cli_payload, @@ -106,6 +110,35 @@ RolloutEventAppender = Callable[..., dict[str, object]] +def _quota_goal_ref( + args: argparse.Namespace, + *, + registry_path: Path, +) -> dict[str, str] | None: + goal_id = str(getattr(args, "goal_id", None) or "").strip() + if not goal_id: + return None + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + if goal_instance_id: + return exact_goal_ref(goal_id, goal_instance_id) + if args.quota_command not in { + "should-run", + "monitor-poll", + "scheduler-ack", + "scheduler-ack-current", + "scheduler-fail-current", + "spend-slot", + "void-slot", + }: + return None + return capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=goal_id, + ) + + def _effective_spend_turn_instance_id( payload: Mapping[str, object], *, @@ -161,6 +194,7 @@ def _record_automatic_heartbeat_stall( interaction_projection_hooks: tuple[InteractionProjectionHookRegistration, ...], action_selection: RequestedQuotaActionSelection, cache_metadata: object, + goal_ref: Mapping[str, object] | None, ) -> tuple[dict[str, object], dict[str, object], object, str]: """Commit and reproject the automatic no-spend heartbeat observation.""" @@ -172,6 +206,7 @@ def _record_automatic_heartbeat_stall( goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=turn_id, + goal_ref=goal_ref, ) if ( payload.get("effective_action") @@ -188,6 +223,7 @@ def _record_automatic_heartbeat_stall( agent_id=args.agent_id, available_capabilities=args.available_capabilities, turn_instance_id=turn_id, + goal_ref=goal_ref, scheduler_execution_context=context.scheduler_context, operator_inbox_urgency_projector=context.operator_inbox_urgency_projector, bounded_research_frontier_projector=project_live_explore_composition_frontier, @@ -231,6 +267,7 @@ def _record_automatic_heartbeat_stall( ), turn_instance_id=turn_id, interaction_projection_hooks=interaction_projection_hooks, + goal_ref=goal_ref, ) rebuilt["heartbeat_stall_writeback"] = { "turn_instance_id": turn_id, @@ -367,7 +404,9 @@ def handle_quota_command( heartbeat_stall_observation = "not_evaluated" detail_sections: frozenset[str] = frozenset() context: QuotaCommandContext | None = None + goal_ref: dict[str, str] | None = None try: + goal_ref = _quota_goal_ref(args, registry_path=registry_path) turn_start_hook_dispatch, turn_start_mutated = _dispatch_quota_turn_start_hooks( args, registry_path=registry_path, @@ -420,6 +459,7 @@ def handle_quota_command( args, runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, + goal_ref=goal_ref, ) heartbeat_receipt_existing = action_selection.receipt payload = build_live_quota_should_run_decision( @@ -454,6 +494,7 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, interaction_projection_hooks=interaction_projection_hooks, turn_start_hook_dispatch=turn_start_hook_dispatch, + goal_ref=goal_ref, ) _attach_turn_start_hook_dispatch(payload, turn_start_hook_dispatch) action_selection_preflight = ( @@ -463,6 +504,7 @@ def handle_quota_command( registry_path=registry_path, context=context, selection=action_selection, + goal_ref=goal_ref, ) ) action_selection_preflight_failed = action_selection_preflight.rejected @@ -490,6 +532,7 @@ def handle_quota_command( runtime_root=runtime_root, turn_instance_id=heartbeat_turn_id, existing=heartbeat_receipt_existing, + goal_ref=goal_ref, ) else: ( @@ -507,6 +550,7 @@ def handle_quota_command( interaction_projection_hooks=interaction_projection_hooks, action_selection=action_selection, cache_metadata=cache_metadata, + goal_ref=goal_ref, ) heartbeat_receipt_ready = True elif args.quota_command == "monitor-poll": @@ -518,6 +562,7 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, scheduler_execution_context=scheduler_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, + goal_ref=goal_ref, monitor_poll_recorder=record_quota_monitor_poll, status_reloader=lambda: collect_status( registry_path=registry_path, @@ -541,6 +586,7 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, scheduler_context=scheduler_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, + goal_ref=goal_ref, ) elif args.quota_command == "spend-slot": payload = spend_quota_slot( @@ -556,6 +602,8 @@ def handle_quota_command( todo_id=args.todo_id, turn_instance_id=heartbeat_turn_id, replan_obligation_id=args.replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) elif args.quota_command == "void-slot": payload = void_quota_slot( @@ -567,6 +615,8 @@ def handle_quota_command( reason_summary=args.reason_summary, agent_id=args.agent_id, operator_inbox_urgency_projector=operator_inbox_urgency_projector, + registry_path=registry_path, + goal_ref=goal_ref, ) else: payload = build_quota_plan(status_payload, mode=args.quota_command) @@ -672,6 +722,7 @@ def handle_quota_command( registry_path=registry_path, runtime_root_arg=runtime_root_arg, event_kind="quota_should_run", + goal_ref=goal_ref, agent_id=args.agent_id, run_id=heartbeat_turn_id, status=str( @@ -685,13 +736,20 @@ def handle_quota_command( ), details=rollout_details, allow_failed=True, - idempotency_fields=["goal_id", "event_kind", "agent_id", "run_id"], + idempotency_fields=[ + "goal_id", + "event_kind", + "agent_id", + "run_id", + *(["goal_ref"] if goal_ref is not None else []), + ], ) receipt = find_heartbeat_receipt( runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=heartbeat_turn_id, + goal_ref=goal_ref, ) if receipt: rollout_event_value = payload.get("rollout_event") @@ -731,6 +789,7 @@ def handle_quota_command( registry_path=registry_path, runtime_root_arg=runtime_root_arg, event_kind=QUOTA_EVENT_KINDS[args.quota_command], + goal_ref=goal_ref, agent_id=args.agent_id, todo_id=rollout_todo_id, run_id=( @@ -761,6 +820,7 @@ def handle_quota_command( todo_id=rollout_todo_id, turn_instance_id=spend_turn_instance_id, replan_obligation_id=rollout_replan_obligation_id, + goal_ref=goal_ref, ) attach_reward_memory_ingest_after_spend( payload, @@ -772,6 +832,7 @@ def handle_quota_command( todo_id=rollout_todo_id, turn_instance_id=spend_turn_instance_id, replan_obligation_id=rollout_replan_obligation_id, + goal_ref=goal_ref, ) attach_reward_memory_recall_after_should_run( payload, diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index f0e5690ea..749737e23 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -82,6 +82,7 @@ def load_requested_quota_action_selection( *, runtime_root: Path, turn_instance_id: str | None, + goal_ref: Mapping[str, object] | None = None, ) -> RequestedQuotaActionSelection: requested_todo_id = _requested_quota_action_todo_id(args) if not turn_instance_id: @@ -98,6 +99,7 @@ def load_requested_quota_action_selection( goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) if not existing: return RequestedQuotaActionSelection( @@ -234,6 +236,7 @@ def reconcile_requested_quota_action_selection( registry_path: Path, context: QuotaCommandContext, selection: RequestedQuotaActionSelection, + goal_ref: Mapping[str, object] | None = None, ) -> ActionSelectionPreflightResult: recovery = _requested_quota_action_selection_preflight( payload, @@ -265,6 +268,7 @@ def reconcile_requested_quota_action_selection( scheduler_args=render_scheduler_execution_args( scheduler_execution_context=context.scheduler_context ), + goal_ref=goal_ref, ) receipt, receipt_status, receipt_appended = _retain_deferred_action_selection( payload, @@ -272,6 +276,7 @@ def reconcile_requested_quota_action_selection( runtime_root=context.runtime_root, turn_instance_id=context.heartbeat_turn_id, selection=selection, + goal_ref=goal_ref, ) return ActionSelectionPreflightResult( rejected=True, @@ -304,6 +309,7 @@ def _retain_deferred_action_selection( runtime_root: Path, turn_instance_id: str | None, selection: RequestedQuotaActionSelection, + goal_ref: Mapping[str, object] | None, ) -> tuple[dict[str, object] | None, str, bool]: """Append a deferred explicit choice without granting settlement authority.""" @@ -323,5 +329,6 @@ def _retain_deferred_action_selection( turn_instance_id=turn_instance_id, todo_id=selection.requested_todo_id, reason=str(qualification.get("reason") or "current_delivery_gate"), + goal_ref=goal_ref, ) return retained, "selection_retained" if appended else "replayed", appended diff --git a/loopx/cli_commands/quota_monitor_poll.py b/loopx/cli_commands/quota_monitor_poll.py index 838776bd5..95bd8639d 100644 --- a/loopx/cli_commands/quota_monitor_poll.py +++ b/loopx/cli_commands/quota_monitor_poll.py @@ -22,6 +22,7 @@ def _receipt_bound_monitor_todo_id( *, runtime_root: Path, turn_instance_id: str | None, + goal_ref: Mapping[str, object] | None, ) -> str | None: if not turn_instance_id: return None @@ -30,6 +31,7 @@ def _receipt_bound_monitor_todo_id( goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) if receipt is None: raise HeartbeatReceiptIdentityConflictError( @@ -57,6 +59,7 @@ def record_quota_monitor_poll_for_cli( operator_inbox_urgency_projector: Callable[..., dict[str, object]], status_reloader: Callable[[], dict[str, object]], monitor_poll_recorder: Callable[..., dict[str, object]], + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object]: """Execute the monitor-poll CLI request with receipt-bound settlement identity.""" return monitor_poll_recorder( @@ -87,10 +90,12 @@ def record_quota_monitor_poll_for_cli( task_lease_expected_version=getattr(args, "task_lease_expected_version", None), use_current_task_lease=bool(getattr(args, "use_current_task_lease", False)), turn_instance_id=turn_instance_id, + goal_ref=goal_ref, receipt_bound_todo_id=_receipt_bound_monitor_todo_id( args, runtime_root=runtime_root, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ), scheduler_execution_context=scheduler_execution_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index 5a51aa206..d3c379057 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -188,6 +188,7 @@ def register_quota_command( "refresh-state, spend, and retries." ), ) + quota_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) quota_parser.add_argument( "--scheduler-host-facts-chunk", dest="scheduler_host_facts_chunks", diff --git a/loopx/cli_commands/quota_reward_memory.py b/loopx/cli_commands/quota_reward_memory.py index 61179d8f0..c448a7de5 100644 --- a/loopx/cli_commands/quota_reward_memory.py +++ b/loopx/cli_commands/quota_reward_memory.py @@ -76,6 +76,7 @@ def attach_reward_memory_ingest_after_spend( todo_id: str | None, turn_instance_id: str, replan_obligation_id: str | None, + goal_ref: Mapping[str, Any] | None = None, ) -> None: if not execute or payload.get("ok") is not True: return @@ -86,6 +87,8 @@ def attach_reward_memory_ingest_after_spend( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) identity = readback.identity.value if readback else None writeback_event = readback.writeback_event if readback else None diff --git a/loopx/cli_commands/quota_scheduler_followup.py b/loopx/cli_commands/quota_scheduler_followup.py index 0e9fbef4e..fe39744c3 100644 --- a/loopx/cli_commands/quota_scheduler_followup.py +++ b/loopx/cli_commands/quota_scheduler_followup.py @@ -42,6 +42,7 @@ def _build_scheduler_followup_decision( | SchedulerExecutionContextResolution | None, operator_inbox_urgency_projector: Callable[..., dict[str, object]], + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object]: """Rebuild a scheduler follow-up from the originating receipt-bound Turn.""" @@ -74,6 +75,7 @@ def _build_scheduler_followup_decision( interaction_projection_hooks=( repository_delivery_interaction_hook(repo_path=Path.cwd()), ), + goal_ref=goal_ref, ) @@ -88,6 +90,7 @@ def build_scheduler_followup_payload( | SchedulerExecutionContextResolution | None, operator_inbox_urgency_projector: Callable[..., dict[str, object]], + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object]: """Execute one scheduler ACK/failure command against its live decision.""" @@ -97,6 +100,7 @@ def build_scheduler_followup_payload( goal_id=args.goal_id, agent_id=args.agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) if turn_instance_id else None @@ -167,6 +171,7 @@ def build_scheduler_followup_payload( ), scheduler_context=scheduler_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, + goal_ref=goal_ref, ) receipt_todo_id = ( heartbeat_receipt_settlement_todo_id(heartbeat_receipt) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index f08c84cc1..34fe27cbb 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -39,6 +39,10 @@ build_task_planning_packet, render_task_planning_packet, ) +from ..control_plane.goals.first_party_host_admission import ( + capture_first_party_host_goal_ref, +) +from ..control_plane.goals.source_session_registry_state import exact_goal_ref from ..todos import ( add_goal_todo, archive_completed_todos, @@ -87,13 +91,16 @@ def _read_todo_turn_settlement( - args: argparse.Namespace, *, runtime_root: Path, + args: argparse.Namespace, *, registry_path: Path, runtime_root: Path, + goal_ref: dict[str, str] | None, ) -> QuotaSettlementReadback: """Transport the original lifecycle tuple to the TS identity owner.""" readback = read_heartbeat_settlement( runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, turn_instance_id=args.turn_instance_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: raise RuntimeError("exact settlement readback unexpectedly returned not-found") @@ -124,6 +131,7 @@ def _completion_settlement_error( def _completion_settlement_plan( identity: SettlementIdentity, *, args: argparse.Namespace, registry_path: Path, runtime_root: Path, + goal_ref: dict[str, str] | None, ) -> dict[str, object]: """Render the native plan with the original route and supplied lease facts.""" actor_args = "" @@ -145,6 +153,7 @@ def _completion_settlement_plan( goal_id=identity.goal_id, agent_id=identity.agent_id, todo_id=identity.todo_id, turn_instance_id=identity.turn_instance_id, command_prefix=prefix, scoped_cli_args="", lifecycle_actor_args=actor_args, writeback_path_args=path_args, + goal_ref=goal_ref, ).as_dict() @@ -274,6 +283,7 @@ def handle_todo_command( renderer = _render_todo_receipt elif args.todo_command == "result-read": renderer = itemgetter("text") + goal_ref: dict[str, str] | None = None try: if args.todo_command is None: raise ValueError( @@ -283,6 +293,18 @@ def handle_todo_command( ) validate_shared_todo_options(args) validate_capability_gap_options(args) + if getattr(args, "turn_instance_id", None): + goal_instance_id = str( + getattr(args, "goal_instance_id", None) or "" + ).strip() + goal_ref = ( + exact_goal_ref(args.goal_id, goal_instance_id) + if goal_instance_id + else capture_first_party_host_goal_ref( + registry_path=registry_path, + goal_id=args.goal_id, + ) + ) if args.todo_command == "plan": validate_todo_plan_options(args) payload = build_task_planning_packet( @@ -512,7 +534,10 @@ def handle_todo_command( if getattr(args, "turn_instance_id", None): runtime_root = resolve_runtime_root(load_registry(registry_path), runtime_root_arg) settlement_readback = _read_todo_turn_settlement( - args, runtime_root=runtime_root, + args, + registry_path=registry_path, + runtime_root=runtime_root, + goal_ref=goal_ref, ) settlement_result = settlement_readback.identity assert settlement_result.value is not None @@ -558,7 +583,11 @@ def handle_todo_command( settlement_result ), "settlement_plan": _completion_settlement_plan( - identity, args=args, registry_path=registry_path, runtime_root=runtime_root, + identity, + args=args, + registry_path=registry_path, + runtime_root=runtime_root, + goal_ref=goal_ref, ), "error": completion_error, } @@ -631,7 +660,12 @@ def handle_todo_command( validate_todo_supersede_options(args) supersede_readback = ( _read_todo_turn_settlement( - args, runtime_root=resolve_runtime_root(load_registry(registry_path), runtime_root_arg), + args, + registry_path=registry_path, + runtime_root=resolve_runtime_root( + load_registry(registry_path), runtime_root_arg + ), + goal_ref=goal_ref, ) if args.turn_instance_id else None ) payload = supersede_goal_todo( @@ -676,6 +710,8 @@ def handle_todo_command( ) else: raise ValueError("unsupported todo command") + if goal_ref is not None: + payload["goal_ref"] = dict(goal_ref) except Exception as exc: payload = todo_error_payload(args, exc) append_todo_rollout_event( @@ -702,6 +738,8 @@ def handle_todo_command( agent_id=args.agent_id, todo_id=args.todo_id, turn_instance_id=getattr(args, "turn_instance_id", None), + registry_path=registry_path, + goal_ref=goal_ref, ) if settlement_readback is None: raise RuntimeError("exact settlement readback unexpectedly returned not-found") diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index ae274b5e7..e20124a83 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -16,6 +16,7 @@ ("--update-operation-id", "update_operation_id"), ("--update-expected-provider-revision", "update_expected_provider_revision"), ("--turn-instance-id", "turn_instance_id"), + ("--goal-instance-id", "goal_instance_id"), ("--completion-identity-key", "completion_identity_key"), ("--replan-obligation-id", "replan_obligation_id"), ("--status", "status"), @@ -541,6 +542,14 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: raise ValueError( "--turn-instance-id is supported only by todo complete/supersede settlement" ) + if getattr(args, "goal_instance_id", None) and ( + args.todo_command not in {"complete", "supersede"} + or not getattr(args, "turn_instance_id", None) + ): + raise ValueError( + "--goal-instance-id is supported only by turn-scoped todo " + "complete/supersede settlement" + ) if getattr(args, "update_operation_id", None) is not None and args.todo_command != "update": raise ValueError("--update-operation-id is supported only by todo update") if getattr(args, "update_expected_provider_revision", None) is not None and args.todo_command != "update": diff --git a/loopx/cli_commands/todo_event.py b/loopx/cli_commands/todo_event.py index ea5edcc17..558e14727 100644 --- a/loopx/cli_commands/todo_event.py +++ b/loopx/cli_commands/todo_event.py @@ -1,7 +1,7 @@ from __future__ import annotations import argparse -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from ..control_plane.coordination.local_authority import ( @@ -87,11 +87,17 @@ def append_todo_rollout_event( or (payload.get("idempotent_replay") and not turn_instance_id) ): return + goal_ref = ( + payload.get("goal_ref") + if isinstance(payload.get("goal_ref"), Mapping) + else None + ) append_cli_rollout_event( payload, registry_path=registry_path, runtime_root_arg=runtime_root_arg, event_kind=TODO_EVENT_KINDS.get(args.todo_command, "todo_update"), + goal_ref=goal_ref, agent_id=args.agent_id or args.claimed_by, todo_id=args.todo_id or str(payload.get("todo_id") or "").strip() or None, run_id=turn_instance_id, @@ -138,6 +144,7 @@ def append_todo_rollout_event( "agent_id", "todo_id", "run_id", + *(["goal_ref"] if goal_ref is not None else []), *(["status"] if terminal_closeout else []), ] if turn_instance_id diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 15c27e553..e4c6b588c 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -85,6 +85,7 @@ def register_todo_command( "turn-scoped quota guard and reuse it on retries." ), ) + todo_parser.add_argument("--goal-instance-id", help=argparse.SUPPRESS) todo_parser.add_argument( "--completion-identity-key", help=( diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 0cd7c3e24..4eeb54d9a 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -169,6 +169,7 @@ def handle_turn_command( runtime_root=runtime_root, runtime_root_arg=runtime_root_arg, turn_start_hook_dispatch=turn_start_hook_dispatch, + goal_ref=goal_ref, ) operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector scheduler_context = decision_owner.scheduler_execution_context @@ -294,6 +295,12 @@ def handle_turn_command( strict_goal_admission = goal_admission if goal_admission.enabled else None if strict_goal_admission is not None: strict_goal_admission.require_current() + resumed_goal_ref = payload.get("goal_ref") + goal_ref = ( + dict(resumed_goal_ref) + if isinstance(resumed_goal_ref, Mapping) + else None + ) if payload.get("route", {}).get("kind") == "capability_action_required": # The normal host transaction forbids Core mutations. A # capability may prepare artifacts and require authored input; @@ -419,6 +426,7 @@ def append_settlement_event( registry_path=registry_path, runtime_root_arg=runtime_root_arg, event_kind=event_kind, + goal_ref=goal_ref, agent_id=settlement_identity.agent_id, todo_id=settlement_identity.todo_id, run_id=settlement_identity.turn_instance_id, @@ -434,6 +442,7 @@ def append_settlement_event( "agent_id", "todo_id", "run_id", + *(("goal_ref",) if goal_ref is not None else ()), *(("status",) if event_kind == "todo_complete" else ()), ], ) @@ -519,6 +528,7 @@ def writeback( completion_turn_key=completion_turn_key, dry_run=False, sync_global=not bool(args.no_global_sync), + goal_ref=goal_ref, ) if refresh.get("ok") and ( refresh.get("appended") @@ -700,6 +710,8 @@ def spend(*, effect_ref: str) -> dict[str, object]: ), operator_inbox_urgency_projector=operator_inbox_urgency_projector, effect_ref=effect_ref, + registry_path=registry_path, + goal_ref=goal_ref, ) if spent.get("ok") and ( spent.get("appended") @@ -713,6 +725,8 @@ def spend(*, effect_ref: str) -> dict[str, object]: todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, replan_obligation_id=settlement_identity.replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: raise RuntimeError( @@ -807,6 +821,8 @@ def writeback_resolver(effect_ref: str) -> dict[str, object]: todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, replan_obligation_id=settlement_identity.replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: raise RuntimeError( @@ -851,6 +867,8 @@ def spend_resolver(effect_ref: str) -> dict[str, object]: todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, replan_obligation_id=settlement_identity.replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: raise RuntimeError( @@ -899,6 +917,8 @@ def terminal_closeout_resolver(effect_ref: str) -> dict[str, object]: todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, replan_obligation_id=settlement_identity.replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: raise RuntimeError( @@ -959,6 +979,7 @@ def scheduler(_spend_payload: dict[str, object]) -> dict[str, object]: bounded_research_frontier_projector=( project_live_explore_composition_frontier ), + goal_ref=goal_ref, ) hint = ( latest.get("scheduler_hint") @@ -1056,6 +1077,7 @@ def on_managed_start_admitted() -> None: settlement_identity, semantic_replan_guard_scoped=replan_guard_scoped, semantic_replan_obligation_id=replan_obligation_id, + goal_ref=goal_ref, ) managed_cadence = managed_cadence_start( diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index 8db3b1f10..f51a81992 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -87,6 +87,7 @@ def _build_turn_decision( scheduler_execution_context: Mapping[str, Any], operator_inbox_urgency_projector: Callable[..., dict[str, Any]], turn_start_hook_dispatch: Mapping[str, Any] | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> Callable[..., dict[str, Any]]: """Return the ``build_turn_decision`` every Turn owner resolves through. @@ -117,6 +118,7 @@ def build_turn_decision( ), requested_action_todo_id=requested_action_todo_id, turn_start_hook_dispatch=dict(turn_start_hook_dispatch or {}), + goal_ref=goal_ref, interaction_projection_hooks=( periodic_report_pending_intent_interaction_hook( registry_path=registry_path, @@ -194,6 +196,7 @@ def build_fresh_turn_decision_owner( runtime_root: Path, runtime_root_arg: str | None, turn_start_hook_dispatch: Mapping[str, Any] | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> FreshTurnDecisionOwner: """Read the live status and derive the shared decision inputs from it. @@ -230,6 +233,7 @@ def build_fresh_turn_decision_owner( scheduler_execution_context=scheduler_execution_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, turn_start_hook_dispatch=turn_start_hook_dispatch, + goal_ref=goal_ref, ), requested_todo_id=getattr(args, "todo_id", None), ) diff --git a/loopx/cli_rollout.py b/loopx/cli_rollout.py index b6714b5c7..54cbdbd82 100644 --- a/loopx/cli_rollout.py +++ b/loopx/cli_rollout.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from pathlib import Path from .history import load_registry @@ -18,6 +19,7 @@ def append_cli_rollout_event( registry_path: Path, runtime_root_arg: str | None, event_kind: str, + goal_ref: Mapping[str, object] | None = None, agent_id: str | None = None, todo_id: str | None = None, case_id: str | None = None, @@ -52,6 +54,7 @@ def append_cli_rollout_event( event = build_rollout_event( goal_id=goal_id, event_kind=event_kind, + goal_ref=goal_ref, agent_id=agent_id or str(payload.get("agent_id") or "").strip() or None, todo_id=todo_id or str(payload.get("todo_id") or "").strip() or None, case_id=case_id, diff --git a/loopx/control_plane/effect_program.ts b/loopx/control_plane/effect_program.ts index 181ae4c47..3f92ad39c 100644 --- a/loopx/control_plane/effect_program.ts +++ b/loopx/control_plane/effect_program.ts @@ -181,6 +181,7 @@ export interface SettlementStep { export interface SettlementPlan { identity: SettlementIdentity; steps: readonly SettlementStep[]; + goal_ref?: JsonObject; } export type SettlementBindGate = diff --git a/loopx/control_plane/goals/checkpoint_commit.ts b/loopx/control_plane/goals/checkpoint_commit.ts index 42292cc78..b70fbf4fb 100644 --- a/loopx/control_plane/goals/checkpoint_commit.ts +++ b/loopx/control_plane/goals/checkpoint_commit.ts @@ -13,7 +13,17 @@ import {requireLocalAuthorityRuntimeRoot} from "../coordination/local_authority_ import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {legacyCoordinationTodoLockPath} from "../coordination/legacy_writer_lock_paths.ts"; import {shadowMaintenanceLockPath, requireShadowPrimaryWriteAllowed} from "../coordination/shadow_management.ts"; -import {readQuotaSettlement, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA} from "../quota/settlement_readback.ts"; +import { + QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + readAdmittedQuotaSettlementFromSnapshot, + readQuotaSettlementFromSnapshot, + readQuotaSettlementSnapshot, +} from "../quota/settlement_readback.ts"; +import { + parseQuotaAccountingOwner, + quotaAccountingOwnerLocks, + requireCurrentQuotaAccountingOwner, +} from "../quota/source_admission.ts"; import {evaluateCheckpointReadContext} from "./checkpoint_read_context.ts"; import {withCheckpointAuthority} from "./checkpoint_authority.ts"; import {goalPathSegment} from "../rollout_receipt_log.ts"; @@ -112,16 +122,29 @@ export async function commitCheckpoint(value: unknown): Promise { const statePath = resolve(requireNonEmptyString(request.state_file, "state_file")); const targets = [indexPath, shadowMaintenanceLockPath(root, identity.goal_id), legacyCoordinationTodoLockPath(root, identity.goal_id), statePath].map(path => resolve(path)); + const owner = parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot: root, + goalId: identity.goal_id, + }); const retry = requireJsonObject(request.refresh_retry, "refresh retry"); const receiptPath = join(root, "goals", identity.goal_id, "checkpoint-contexts", `${createHash("sha256").update(identity.effect_id).digest("hex")}.json`); async function admission(): Promise { indexBytes(indexPath); - return await readQuotaSettlement({schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + const value = {schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, runtime_root: root, 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, - infer_turn_instance_id: false, allow_unbound_binding: false, refresh_retry: retry}); + infer_turn_instance_id: false, allow_unbound_binding: false, refresh_retry: retry, + ...(owner.kind === "exact_source" + ? {goal_ref: request.goal_ref, source_admission: request.source_admission} + : {})}; + const snapshot = await readQuotaSettlementSnapshot(root, identity.goal_id); + return owner.kind === "exact_source" + ? readAdmittedQuotaSettlementFromSnapshot(value, snapshot) + : readQuotaSettlementFromSnapshot(value, snapshot); } function replay(readback: JsonObject): JsonObject | null { const recovery = requireJsonObject(readback.refresh_recovery, "refresh recovery"); @@ -134,33 +157,85 @@ export async function commitCheckpoint(value: unknown): Promise { return committedArtifacts(prior, runsDir); } - const claims: {target: string; token: string; claim: FileMutationLockClaim}[] = []; + const claims: { + target: string; + token: string; + claim: FileMutationLockClaim; + borrowed: boolean; + }[] = []; let adopted = false; + let sourceClaimed = false; try { if (!Array.isArray(request.locks) || request.locks.length !== targets.length) { throw new EffectRuntimeRequestError("checkpoint requires the complete internal lock handoff"); } - for (const [i, value] of request.locks.entries()) { + const checkpointWitnesses = request.locks.map((value, i) => { const witness = requireJsonObject(value, "lock witness"); const token = requireNonEmptyString(witness.token, "lock token"); if (resolve(String(witness.target)) !== targets[i]) throw new EffectRuntimeRequestError("checkpoint lock target mismatch"); - const claim = await claimFileMutationLock(targets[i], token); + return { + target: targets[i], + pid: witness.pid, + token, + }; + }); + const sourceLocks = quotaAccountingOwnerLocks(owner); + if ( + owner.kind === "exact_source" + && ( + sourceLocks[0].target !== checkpointWitnesses[0].target + || sourceLocks[0].pid !== checkpointWitnesses[0].pid + || sourceLocks[0].token !== checkpointWitnesses[0].token + ) + ) { + throw new EffectRuntimeRequestError( + "checkpoint index handoff does not match quota source admission", + ); + } + const handoff = owner.kind === "exact_source" + ? [ + ...sourceLocks.map((witness) => ({...witness, borrowed: true})), + ...checkpointWitnesses.slice(1).map((witness) => ({ + ...witness, + borrowed: false, + })), + ] + : checkpointWitnesses.map((witness) => ({...witness, borrowed: false})); + for (const [i, witness] of handoff.entries()) { + const claim = await claimFileMutationLock(witness.target, witness.token); if (!claim) break; - claims.push({target: targets[i], token, claim}); - const owner = await mutationLockOwner(targets[i]); - if (owner?.token !== token || owner.pid !== witness.pid) break; - if (i === targets.length - 1) adopted = true; + claims.push({ + target: witness.target, + token: witness.token, + claim, + borrowed: witness.borrowed, + }); + const current = await mutationLockOwner(witness.target); + if (current?.token !== witness.token || current.pid !== witness.pid) break; + if (owner.kind === "exact_source" && i === sourceLocks.length - 1) { + sourceClaimed = true; + } + if (i === handoff.length - 1) adopted = true; } if (!adopted) { // A runtime retry can arrive after a successful save released the locks. // It may only read an exact committed result, never reuse the old handoff. - for (const entry of claims.splice(0).reverse()) await releaseFileMutationLockClaim(entry.claim); + if (sourceClaimed) { + requireCurrentQuotaAccountingOwner(owner); + const result = replay(await admission()); + if (result) return result; + unknown("checkpoint lock handoff expired"); + } + for (const entry of claims.splice(0).reverse()) { + await releaseFileMutationLockClaim(entry.claim); + } return await withFileMutationLock(indexPath, async () => { const result = replay(await admission()); if (result) return result; unknown("checkpoint lock handoff expired"); }); } + requireCurrentQuotaAccountingOwner(owner); const readback = await admission(); const repeated = replay(readback); if (repeated) return repeated; @@ -210,7 +285,9 @@ export async function commitCheckpoint(value: unknown): Promise { // The effect owns the end of the handed-off critical section. Releasing the // markers here also handles a caller that timed out but is still alive. for (const entry of claims.reverse()) { - if (adopted) await releaseFileMutationLock(entry.target, entry.token, entry.claim, true); + if (adopted && !entry.borrowed) { + await releaseFileMutationLock(entry.target, entry.token, entry.claim, true); + } else await releaseFileMutationLockClaim(entry.claim); } } diff --git a/loopx/control_plane/goals/checkpoint_context_io.py b/loopx/control_plane/goals/checkpoint_context_io.py index 96dc33c83..8eed8f9cd 100644 --- a/loopx/control_plane/goals/checkpoint_context_io.py +++ b/loopx/control_plane/goals/checkpoint_context_io.py @@ -1,6 +1,7 @@ """Source/receipt I/O only; checkpoint_read_context.ts owns decision semantics.""" from __future__ import annotations +from collections.abc import Mapping from contextlib import ExitStack, contextmanager import hashlib import json @@ -8,7 +9,7 @@ from typing import Any, Iterator from uuid import uuid4 -from ...file_lock import exclusive_cross_runtime_file_lock, exclusive_run_index_lock, cross_runtime_lock_witness +from ...file_lock import cross_runtime_lock_witness, exclusive_cross_runtime_file_lock from ...history import load_index, load_registry from ...paths import resolve_runtime_root from ...registry import atomic_write_json @@ -16,6 +17,7 @@ from ..coordination.legacy_writer_fence import legacy_coordination_todo_lock_path from ..coordination.shadow_management import shadow_maintenance_lock_target, require_shadow_primary_write_allowed from ..effect_runtime import effect_runtime_result, EffectRuntimeRejected +from ..quota.accounting_admission import quota_accounting_admission from ..quota.settlement import SettlementIdentity, read_heartbeat_settlement from ..todos.active_state_todo_parser import parse_todo_source from ..todos.machine_region import find_todo_source_regions @@ -89,6 +91,7 @@ def _source_guard(root: Path, goal_id: str, state_file: Path) -> Iterator[None]: def _local_source_facts( root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: text = state_file.read_text(encoding="utf-8") metadata, body = split_state_frontmatter(text) @@ -99,6 +102,15 @@ def _local_source_facts( active, archived, _ = parse_todo_source(text) todos = [*active["user"], *active["agent"], *archived] runs, _ = load_index(root / "goals" / identity.goal_id / "runs" / "index.jsonl") + runs = [ + run + for run in runs + if ( + run.get("goal_ref") == dict(goal_ref) + if goal_ref is not None + else "goal_ref" not in run + ) + ] newest = [run for _, run in sorted(enumerate(runs), key=lambda pair: (str(pair[1].get("generated_at") or ""), pair[0]), reverse=True)] return { @@ -109,12 +121,24 @@ def _local_source_facts( } -def _source_facts(root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity) -> dict[str, Any]: +def _source_facts( + root: Path, + registry_path: Path, + state_file: Path, + identity: SettlementIdentity, + goal_ref: Mapping[str, Any] | None = None, +) -> dict[str, Any]: # The typed owner derives Todo and complete acceptance from one head and # fails closed after cutover. Local parsed Markdown cannot override it. return _checkpoint_effect("goal.checkpoint_read_context.source", { "runtime_root": str(root.resolve()), "goal_id": identity.goal_id, - "facts": _local_source_facts(root, registry_path, state_file, identity), + "facts": _local_source_facts( + root, + registry_path, + state_file, + identity, + goal_ref, + ), }) @@ -123,6 +147,7 @@ def read_checkpoint_context( agent_id: str, todo_id: str | None, turn_instance_id: str, replan_obligation_id: str | None = None, project: Path | None = None, state_file: Path | None = None, dependency_todo_ids: list[str] | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: # Local import avoids a cycle with refresh-state's persistence adapter. from ...state_refresh import resolve_goal_state, registered_agents_for_goal @@ -134,17 +159,28 @@ def read_checkpoint_context( project_override=project, state_file_override=state_file) if agent_id not in registered_agents_for_goal(goal): raise ValueError("checkpoint-context requires a registered Agent") - with exclusive_run_index_lock(root / "goals" / goal_id / "runs" / "index.jsonl", operation="checkpoint-context"): + with quota_accounting_admission( + runtime_root=root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="checkpoint-context", + handoff_legacy_index=True, + ) as source_admission: require_complete_checkpoint_index(root / "goals" / goal_id / "runs" / "index.jsonl") readback = read_heartbeat_settlement(root, goal_id=goal_id, agent_id=agent_id, - todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id) + todo_id=todo_id, turn_instance_id=turn_instance_id, + replan_obligation_id=replan_obligation_id, + registry_path=registry_path, goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None) if readback is None or readback.identity.value is None or readback.writeback_run is None: raise ValueError("checkpoint-context requires the original committed Turn writeback") identity = readback.identity.value with _source_guard(root, goal_id, path): result = _evaluate(phase="read", identity=identity.as_dict(), prior=readback.writeback_run, read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [], - facts=_source_facts(root, registry_path, path, identity)) + facts=_source_facts(root, registry_path, path, identity, goal_ref)) receipt = result.pop("receipt") atomic_write_json(_receipt_path(root, identity), receipt) return {**result, "read_context_id": receipt["read_context_id"], "settlement_identity": identity.as_dict(), @@ -158,6 +194,7 @@ def read_checkpoint_context( def checkpoint_commit_guard( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, read_context_id: str | None, + goal_ref: Mapping[str, Any] | None = None, ) -> Iterator[dict[str, Any]]: """Capture/preview under source locks. The native save repeats the check under the real provider fence; this preliminary check is not the commit.""" @@ -167,13 +204,21 @@ def checkpoint_commit_guard( except FileNotFoundError: receipt = None result = _evaluate(phase="check", identity=identity.as_dict(), read_context_id=read_context_id, - receipt=receipt, facts=_source_facts(runtime_root, registry_path, state_file, identity)) + receipt=receipt, facts=_source_facts( + runtime_root, + registry_path, + state_file, + identity, + goal_ref, + )) yield result def commit_checkpoint_run( *, runtime_root: Path, registry_path: Path, state_file: Path, identity: SettlementIdentity, refresh_retry: dict[str, Any], record: dict[str, Any], index_record: dict[str, Any], markdown: str, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Handoff the held locks and parsed bytes to one native save operation.""" root = runtime_root.resolve() @@ -185,8 +230,20 @@ def commit_checkpoint_run( "locks": [cross_runtime_lock_witness(target) for target in targets], "state_sha256": hashlib.sha256(state_file.read_bytes()).hexdigest(), "index_sha256": hashlib.sha256(index.read_bytes()).hexdigest(), - "facts": _local_source_facts(root, registry_path, state_file, identity), + "facts": _local_source_facts( + root, + registry_path, + state_file, + identity, + goal_ref, + ), "refresh_retry": refresh_retry, "record": record, "index_record": index_record, "markdown": markdown, + **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), + **( + {"source_admission": dict(source_admission)} + if source_admission is not None + else {} + ), }) if not isinstance(result, dict) or not isinstance(result.get("ok"), bool): raise RuntimeError("invalid typed checkpoint commit result") diff --git a/loopx/control_plane/goals/first_party_host_admission.py b/loopx/control_plane/goals/first_party_host_admission.py index ae315c30b..dc05f8943 100644 --- a/loopx/control_plane/goals/first_party_host_admission.py +++ b/loopx/control_plane/goals/first_party_host_admission.py @@ -33,7 +33,7 @@ def __init__(self, code: str) -> None: self.code = code -def _source_authority(registry_path: Path, goal_id: str) -> dict[str, Any]: +def source_goal_authority(registry_path: Path, goal_id: str) -> dict[str, Any]: if not registry_path.is_file(): return {"kind": "unavailable", "reason": "registry_missing"} try: @@ -79,7 +79,7 @@ def capture_first_party_host_goal_ref( guard_path(requested_registry, goal_id), operation="first_party_host_goal_capture", ): - authority = _source_authority(requested_registry, goal_id) + authority = source_goal_authority(requested_registry, goal_id) if authority.get("kind") != "present": raise FirstPartyHostRuntimeRejected( "goal_not_registered" @@ -149,7 +149,7 @@ def _decision( ), "operation": operation, "planned_goal_ref": self.planned_goal_ref, - "authority": _source_authority( + "authority": source_goal_authority( self.registry_path, self.goal_id, ), @@ -220,7 +220,7 @@ def source_journal_admission( "profile_id": SOURCE_SESSION_PROFILE_ID, "registry_path": str(self.registry_path), "planned_goal_ref": self.planned_goal_ref, - "authority": _source_authority( + "authority": source_goal_authority( self.registry_path, self.goal_id, ), diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index ac450d5f5..df57273fa 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -62,6 +62,7 @@ class HostTodoSettlementRequest: vision_path: str | None = None vision_unchanged_reason: str | None = None checkpoint_read_context_id: str | None = None + goal_ref: Mapping[str, str] | None = None class HostCliRunner(Protocol): @@ -102,6 +103,8 @@ def _request_payload( } if provider_outcomes is not None: payload["provider_outcomes"] = provider_outcomes + if request.goal_ref is not None: + payload["goal_ref"] = dict(request.goal_ref) if request.vision_path or request.vision_unchanged_reason or phase in {"vision_refresh", "vision_context"}: payload.update( schema_version="loopx_host_todo_completion_transaction_v1", diff --git a/loopx/control_plane/quota/accounting_admission.py b/loopx/control_plane/quota/accounting_admission.py new file mode 100644 index 000000000..3ea97a9ee --- /dev/null +++ b/loopx/control_plane/quota/accounting_admission.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from ...file_lock import ( + cross_runtime_lock_witness, + exclusive_cross_runtime_file_lock, + exclusive_file_lock, + exclusive_run_index_lock, +) +from ..projects.registry_codec import SOURCE_SESSION_PROFILE_ID + + +QUOTA_SOURCE_ADMISSION_SCHEMA = "loopx_quota_source_admission_v0" + + +def _planned_goal_ref( + value: Mapping[str, Any] | None, + *, + goal_id: str, +) -> dict[str, str] | None: + if value is None: + return None + from ..goals.source_session_registry_state import exact_goal_ref + + planned = exact_goal_ref( + str(value.get("goal_id") or ""), + str(value.get("goal_instance_id") or ""), + ) + if planned["goal_id"] != goal_id: + raise ValueError("quota GoalRef does not match goal_id") + return planned + + +def _uses_source_profile( + registry_path: Path | None, + goal_id: str, +) -> bool: + if registry_path is None or not registry_path.is_file(): + return False + from ..goals.first_party_host_admission import source_goal_authority + + return source_goal_authority(registry_path, goal_id).get("kind") in { + "present", + "absent", + } + + +@contextmanager +def quota_accounting_admission( + *, + runtime_root: Path, + registry_path: Path | None, + goal_id: str, + goal_ref: Mapping[str, Any] | None, + operation: str, + lock_legacy_index: bool = True, + handoff_legacy_index: bool = False, +) -> Iterator[dict[str, Any] | None]: + """Hold quota's index/source locks until the TypeScript owner adopts them.""" + + root = runtime_root.expanduser().resolve() + registry = registry_path.expanduser().resolve() if registry_path else None + planned = _planned_goal_ref(goal_ref, goal_id=goal_id) + source_profile = _uses_source_profile(registry, goal_id) + if not source_profile: + if planned is not None: + raise ValueError("quota GoalRef requires a source-session registry") + if not lock_legacy_index: + yield None + return + index_path = root / "goals" / goal_id / "runs" / "index.jsonl" + lock = ( + exclusive_run_index_lock + if handoff_legacy_index + else exclusive_file_lock + ) + with lock(index_path, operation=operation): + yield None + return + if planned is None or registry is None: + raise ValueError("source-session quota commit requires an exact GoalRef") + + from ..goals.first_party_host_admission import source_goal_authority + from ..goals.source_session_registry_state import guard_path + + index_path = root / "goals" / goal_id / "runs" / "index.jsonl" + source_target = guard_path(registry, goal_id) + with exclusive_run_index_lock(index_path, operation=operation): + with exclusive_cross_runtime_file_lock( + source_target, + operation=operation, + ): + yield { + "schema_version": QUOTA_SOURCE_ADMISSION_SCHEMA, + "profile_id": SOURCE_SESSION_PROFILE_ID, + "registry_path": str(registry), + "planned_goal_ref": planned, + "authority": source_goal_authority(registry, goal_id), + "locks": [ + { + "role": "run_index", + **cross_runtime_lock_witness(index_path), + }, + { + "role": "source_guard", + **cross_runtime_lock_witness(source_target), + }, + ], + } diff --git a/loopx/control_plane/quota/accounting_artifact_transaction.ts b/loopx/control_plane/quota/accounting_artifact_transaction.ts index 70a0fde02..576deb432 100644 --- a/loopx/control_plane/quota/accounting_artifact_transaction.ts +++ b/loopx/control_plane/quota/accounting_artifact_transaction.ts @@ -10,6 +10,7 @@ import { atomicWriteText, withFileMutationLock, } from "../effect_runtime_io.ts"; +import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { jsonObject, optionalNonEmptyString as optionalString, @@ -65,12 +66,17 @@ export interface QuotaAccountingArtifactReceipt extends JsonObject { index_record: JsonObject; markdown: string; payload: JsonObject; + goal_ref?: JsonObject; } export type QuotaAccountingEffectResolution = | { kind: "absent" } | { kind: "matched"; record: JsonObject } - | { kind: "conflict"; reason: string }; + | { + kind: "conflict"; + reason: string; + reasonCode: "effect_id_conflict" | "goal_instance_conflict"; + }; export interface QuotaAccountingArtifactPrepareContext { jsonPath: string; @@ -101,6 +107,8 @@ export interface QuotaAccountingArtifactCommitRequest { effectId: string; requestDigest: string; expectedIndexDigest: string | null; + goalRef?: JsonObject; + indexLockHeld?: boolean; prepare: ( context: QuotaAccountingArtifactPrepareContext, ) => @@ -117,7 +125,10 @@ export type QuotaAccountingArtifactCommitOutcome = | { status: "conflict"; reason: string; - reasonCode: "effect_id_conflict" | "index_digest_conflict"; + reasonCode: + | "effect_id_conflict" + | "goal_instance_conflict" + | "index_digest_conflict"; indexDigest: string | null; } | { @@ -156,6 +167,42 @@ function sha256Bytes(value: Uint8Array): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } +function exactGoalRef(value: unknown, label: string): JsonObject | null { + if (value === undefined) return null; + const parsed = parseExactGoalRef(value); + if (parsed.kind === "invalid") { + throw new EffectRuntimeRequestError( + `${label} must be an exact GoalRef`, + "malformed_transaction_receipt", + ); + } + return { + goal_id: parsed.value.goalId.value, + goal_instance_id: parsed.value.goalInstanceId.value, + }; +} + +function sameGoalRef(left: JsonObject | null, right: JsonObject | null): boolean { + return left?.goal_id === right?.goal_id + && left?.goal_instance_id === right?.goal_instance_id; +} + +function ownerConflict( + existing: JsonObject | null, + requested: JsonObject | null, + label: string, +): string | null { + if (existing === null && requested === null) return null; + if (sameGoalRef(existing, requested)) return null; + if (existing === null) { + return `${label} belongs to a legacy Goal alias and cannot authorize an exact Goal instance`; + } + if (requested === null) { + return `${label} belongs to an exact Goal instance and cannot be consumed without GoalRef admission`; + } + return `${label} belongs to a different Goal instance`; +} + function runStem(generatedAt: string): string { const stem = generatedAt.replace(/[^0-9A-Za-z-]+/g, "-").replace(/^-+|-+$/g, ""); if (!stem) { @@ -384,6 +431,7 @@ function resolveEffectIdentity( contract: QuotaAccountingArtifactContract, record: JsonObject, expectedEffectId: string, + expectedGoalRef: JsonObject | null, ): QuotaAccountingEffectResolution { const rawMetadata = record[contract.metadataField]; const recordEffect = effectIdentityValue(record.effect_ref); @@ -395,6 +443,7 @@ function resolveEffectIdentity( return { kind: "conflict", reason: `${contract.label} index row has malformed effect metadata`, + reasonCode: "effect_id_conflict", }; } const metadataEffect = effectIdentityValue(metadata?.effect_id); @@ -409,6 +458,7 @@ function resolveEffectIdentity( return { kind: "conflict", reason: `${contract.label} index row has malformed effect identity`, + reasonCode: "effect_id_conflict", }; } if ( @@ -419,6 +469,19 @@ function resolveEffectIdentity( return { kind: "conflict", reason: `${contract.label} index row has conflicting effect identities`, + reasonCode: "effect_id_conflict", + }; + } + const goalConflict = ownerConflict( + exactGoalRef(record.goal_ref, `${contract.label} index row goal_ref`), + expectedGoalRef, + `${contract.label} effect identity`, + ); + if (goalConflict) { + return { + kind: "conflict", + reason: goalConflict, + reasonCode: "goal_instance_conflict", }; } return { kind: "matched", record }; @@ -428,11 +491,17 @@ export function resolveQuotaAccountingEffect( kind: QuotaAccountingArtifactKind, records: readonly JsonObject[], effectId: string, + goalRef: JsonObject | null = null, ): QuotaAccountingEffectResolution { const contract = contractFor(kind); for (const record of [...records].reverse()) { if (record.classification !== contract.classification) continue; - const resolution = resolveEffectIdentity(contract, record, effectId); + const resolution = resolveEffectIdentity( + contract, + record, + effectId, + goalRef, + ); if (resolution.kind !== "absent") return resolution; } return { kind: "absent" }; @@ -443,6 +512,8 @@ export async function lookupQuotaAccountingReplay( indexPath: string, effectId: string, readOnly: boolean, + goalRef: JsonObject | null = null, + indexLockHeld = false, ): Promise<{ resolution: QuotaAccountingEffectResolution; indexDigest: string | null; @@ -454,11 +525,14 @@ export async function lookupQuotaAccountingReplay( kind, parseQuotaAccountingIndex(content), effectId, + goalRef, ), indexDigest: await quotaAccountingIndexDigest(indexPath), }; }; - return readOnly ? await lookup() : await withFileMutationLock(indexPath, lookup); + return readOnly || indexLockHeld + ? await lookup() + : await withFileMutationLock(indexPath, lookup); } function receiptObject( @@ -481,6 +555,10 @@ function receiptObject( receipt.expected_index_bytes, "receipt.expected_index_bytes", ); + const goalRef = exactGoalRef( + receipt.goal_ref, + "transaction receipt goal_ref", + ); if (expectedIndexBytes < 0) { throw new EffectRuntimeRequestError( "receipt.expected_index_bytes cannot be negative", @@ -503,6 +581,7 @@ function receiptObject( index_record: requiredObject(receipt.index_record, "receipt.index_record"), markdown: requiredString(receipt.markdown, "receipt.markdown"), payload: requiredObject(receipt.payload, "receipt.payload"), + ...(goalRef === null ? {} : { goal_ref: goalRef }), }; } @@ -573,6 +652,28 @@ function validateReceiptPaths( receipt.index_record[contract.metadataField], `receipt.index_record.${contract.metadataField}`, ); + const receiptGoalRef = receipt.goal_ref ?? null; + const quotaEvent = requiredObject( + receipt.record.quota_event, + "receipt.record.quota_event", + ); + for (const [label, value] of [ + ["record", receipt.record.goal_ref], + ["quota event", quotaEvent.goal_ref], + ["index record", receipt.index_record.goal_ref], + ["payload", receipt.payload.goal_ref], + ] as const) { + const projected = exactGoalRef( + value, + `receipt ${label} goal_ref`, + ); + if (!sameGoalRef(projected, receiptGoalRef)) { + throw new EffectRuntimeRequestError( + `${contract.label} transaction receipt ${label} GoalRef does not match its owner`, + "malformed_transaction_receipt", + ); + } + } for (const [label, projection, expected] of [ ["record classification", receipt.record.classification, contract.classification], ["index classification", receipt.index_record.classification, contract.classification], @@ -740,11 +841,12 @@ async function ensureReceiptArtifacts( kind, index.records, receipt.effect_id, + receipt.goal_ref ?? null, ); if (matchResolution.kind === "conflict") { throw new EffectRuntimeRequestError( matchResolution.reason, - "effect_id_conflict", + matchResolution.reasonCode, ); } const match = matchResolution.kind === "matched" @@ -794,11 +896,28 @@ export async function commitQuotaAccountingArtifactTransaction( ): Promise { const contract = contractFor(request.kind); const indexPath = join(request.runsDir, "index.jsonl"); - return await withFileMutationLock(indexPath, async () => { + const requestedGoalRef = exactGoalRef( + request.goalRef, + `${contract.label} request goal_ref`, + ); + const commit = async (): Promise => { await rejectSymlinkPath(indexPath, `${contract.label} run index`); const receiptPath = transactionPath(contract, request.runsDir, request.effectId); const existingReceipt = await readReceipt(contract, receiptPath, request.runsDir); if (existingReceipt) { + const conflict = ownerConflict( + existingReceipt.goal_ref ?? null, + requestedGoalRef, + `${contract.label} transaction`, + ); + if (conflict) { + return { + status: "conflict", + reason: conflict, + reasonCode: "goal_instance_conflict", + indexDigest: await quotaAccountingIndexDigest(indexPath), + }; + } if ( existingReceipt.effect_id !== request.effectId || existingReceipt.request_digest !== request.requestDigest @@ -849,12 +968,13 @@ export async function commitQuotaAccountingArtifactTransaction( request.kind, currentRecords, request.effectId, + requestedGoalRef, ); if (duplicateResolution.kind === "conflict") { return { status: "conflict", reason: duplicateResolution.reason, - reasonCode: "effect_id_conflict", + reasonCode: duplicateResolution.reasonCode, indexDigest: currentDigest, }; } @@ -902,6 +1022,7 @@ export async function commitQuotaAccountingArtifactTransaction( index_record: preparation.indexRecord, markdown: preparation.markdown, payload: preparation.payload, + ...(requestedGoalRef === null ? {} : { goal_ref: requestedGoalRef }), } satisfies QuotaAccountingArtifactReceipt; validateReceiptPaths(contract, request.runsDir, prepared); await atomicWriteJson(receiptPath, prepared); @@ -916,5 +1037,8 @@ export async function commitQuotaAccountingArtifactTransaction( receipt: committedReceipt, indexDigest: await quotaAccountingIndexDigest(indexPath), }; - }); + }; + return request.indexLockHeld + ? await commit() + : await withFileMutationLock(indexPath, commit); } diff --git a/loopx/control_plane/quota/effect_program.py b/loopx/control_plane/quota/effect_program.py index 316acc3bc..0c65ee785 100644 --- a/loopx/control_plane/quota/effect_program.py +++ b/loopx/control_plane/quota/effect_program.py @@ -76,6 +76,24 @@ def _settlement_actor_args(arguments: str, agent_id: str) -> str: return f"{arguments} --agent-id {shlex.quote(agent_id)}" +def _settlement_goal_ref( + goal_ref: Mapping[str, object] | None, + *, + goal_id: str, +) -> dict[str, str] | None: + if goal_ref is None: + return None + from ..goals.source_session_registry_state import exact_goal_ref + + normalized = exact_goal_ref( + str(goal_ref.get("goal_id") or ""), + str(goal_ref.get("goal_instance_id") or ""), + ) + if normalized["goal_id"] != goal_id: + raise ValueError("settlement GoalRef does not match goal_id") + return normalized + + def build_codex_app_settlement_plan( *, goal_id: str, @@ -89,6 +107,7 @@ def build_codex_app_settlement_plan( writeback_path_args: str = "", delivery_boundary: str | None = None, quota_spend_source: str = "heartbeat", + goal_ref: Mapping[str, object] | None = None, ) -> SettlementPlan: return build_turn_scoped_cli_settlement_plan( goal_id=goal_id, @@ -102,6 +121,7 @@ def build_codex_app_settlement_plan( writeback_path_args=writeback_path_args, delivery_boundary=delivery_boundary, quota_spend_source=quota_spend_source, + goal_ref=goal_ref, ) @@ -118,6 +138,7 @@ def build_turn_scoped_cli_settlement_plan( writeback_path_args: str = "", delivery_boundary: str | None = None, quota_spend_source: str = "heartbeat", + goal_ref: Mapping[str, object] | None = None, ) -> SettlementPlan: if bool(todo_id) == bool(replan_obligation_id): raise ValueError( @@ -138,6 +159,7 @@ def build_turn_scoped_cli_settlement_plan( ) scoped_cli_args = _settlement_actor_args(scoped_cli_args, identity.agent_id) lifecycle_actor_args = _settlement_actor_args(lifecycle_actor_args, identity.agent_id) + normalized_goal_ref = _settlement_goal_ref(goal_ref, goal_id=identity.goal_id) quoted_turn = _quoted_turn_ref(turn_instance_id) binding_arg = ( f" --todo-id {shlex.quote(todo_id)}" @@ -145,6 +167,11 @@ def build_turn_scoped_cli_settlement_plan( else f" --replan-obligation-id {shlex.quote(str(replan_obligation_id))}" ) turn_arg = f" --turn-instance-id {quoted_turn}" + goal_ref_arg = ( + f" --goal-instance-id {shlex.quote(normalized_goal_ref['goal_instance_id'])}" + if normalized_goal_ref is not None + else "" + ) boundary_arg = ( " --delivery-boundary in_flight_continuation" if delivery_boundary == "in_flight_continuation" @@ -153,22 +180,23 @@ def build_turn_scoped_cli_settlement_plan( cli_prefix = command_prefix.strip() or "loopx" ordinary_completion = ( f"{cli_prefix} todo complete --goal-id {shlex.quote(goal_id)}{binding_arg}" - f"{lifecycle_actor_args}{turn_arg} --evidence ''" + f"{lifecycle_actor_args}{turn_arg}{goal_ref_arg} --evidence ''" ) terminal_closeout = ordinary_completion + " --no-follow-up" writeback = ( f"{cli_prefix} refresh-state --goal-id {shlex.quote(goal_id)} " "--classification --delivery-batch-scale " - f"--delivery-outcome {boundary_arg}{binding_arg}{turn_arg}" + f"--delivery-outcome {boundary_arg}{binding_arg}{turn_arg}{goal_ref_arg}" f"{scoped_cli_args}{writeback_path_args}" ) spend = ( f"{cli_prefix} quota spend-slot --goal-id {shlex.quote(goal_id)} --slots 1 " - f"--source {quota_spend_source} --execute{binding_arg}{turn_arg}" + f"--source {quota_spend_source} --execute{binding_arg}{turn_arg}{goal_ref_arg}" f"{scoped_cli_args}" ) payload = effect_runtime_result("settlement.turn_scoped_cli_plan", { "identity": identity.as_dict(), "delivery_boundary": delivery_boundary, + **({"goal_ref": normalized_goal_ref} if normalized_goal_ref is not None else {}), "command_templates": { "todo_completion": ordinary_completion, "durable_writeback": writeback, "quota_spend": spend, "terminal_closeout": terminal_closeout, @@ -219,4 +247,20 @@ def settlement_binding_args(plan: Mapping[str, Any] | None) -> str: if todo_id else f" --replan-obligation-id {shlex.quote(replan_obligation_id)}" ) - return binding_arg + (f" --turn-instance-id {_quoted_turn_ref(turn_instance_id)}") + goal_ref = plan.get("goal_ref") + goal_ref_arg = "" + if isinstance(goal_ref, Mapping): + normalized_goal_ref = _settlement_goal_ref( + goal_ref, + goal_id=str(identity.get("goal_id") or ""), + ) + assert normalized_goal_ref is not None + goal_ref_arg = ( + f" --goal-instance-id " + f"{shlex.quote(normalized_goal_ref['goal_instance_id'])}" + ) + return ( + binding_arg + + f" --turn-instance-id {_quoted_turn_ref(turn_instance_id)}" + + goal_ref_arg + ) diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index f02fec2e1..bd8fad289 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -27,7 +27,20 @@ def _heartbeat_receipt_events( goal_id: str, agent_id: str, turn_instance_id: str, + goal_ref: Mapping[str, object] | None, ) -> list[dict[str, object]]: + from ..goals.source_session_registry_state import exact_goal_ref + + expected_goal_ref = ( + exact_goal_ref( + str(goal_ref.get("goal_id") or ""), + str(goal_ref.get("goal_instance_id") or ""), + ) + if goal_ref is not None + else None + ) + if expected_goal_ref is not None and expected_goal_ref["goal_id"] != goal_id: + raise ValueError("heartbeat receipt GoalRef does not match goal_id") return [ event for event in events @@ -35,6 +48,11 @@ def _heartbeat_receipt_events( and str(event.get("goal_id") or "") == goal_id and str(event.get("agent_id") or "") == agent_id and str(event.get("run_id") or "") == turn_instance_id + and ( + event.get("goal_ref") == expected_goal_ref + if expected_goal_ref is not None + else "goal_ref" not in event + ) ] @@ -154,6 +172,7 @@ def find_heartbeat_receipt( goal_id: str, agent_id: str, turn_instance_id: str, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object] | None: events = load_rollout_events(rollout_event_log_path(runtime_root, goal_id)) return _effective_heartbeat_receipt( @@ -162,6 +181,7 @@ def find_heartbeat_receipt( goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) ) @@ -172,6 +192,7 @@ def ensure_turn_heartbeat_settlement_receipt( *, semantic_replan_guard_scoped: bool, semantic_replan_obligation_id: str | None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, object]: """Idempotently bind a Turn-created quota guard to its settlement identity. @@ -199,6 +220,7 @@ def ensure_turn_heartbeat_settlement_receipt( goal_id=identity.goal_id, agent_id=identity.agent_id, turn_instance_id=identity.turn_instance_id, + goal_ref=goal_ref, ) effective = _effective_heartbeat_receipt(matching) expected = ( @@ -261,6 +283,7 @@ def ensure_turn_heartbeat_settlement_receipt( receipt = build_rollout_event( goal_id=identity.goal_id, event_kind="quota_should_run", + goal_ref=goal_ref, agent_id=identity.agent_id, todo_id=identity.todo_id, run_id=identity.turn_instance_id, @@ -282,6 +305,7 @@ def retain_pending_heartbeat_action_selection( turn_instance_id: str, todo_id: str, reason: str, + goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], bool]: """Append an identity-less revision retaining one explicit Todo choice. @@ -306,6 +330,7 @@ def retain_pending_heartbeat_action_selection( goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) effective = _effective_heartbeat_receipt(matching) if effective is None: @@ -341,6 +366,7 @@ def retain_pending_heartbeat_action_selection( retained = build_rollout_event( goal_id=goal_id, event_kind="quota_should_run", + goal_ref=goal_ref, agent_id=agent_id, run_id=turn_instance_id, status="action_selection_deferred", @@ -368,6 +394,7 @@ def upgrade_identityless_heartbeat_receipt( status: str, summary: str, details: Mapping[str, object], + goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], bool]: """Append one settlement-bound receipt after an identity-less same-turn guard. @@ -414,6 +441,7 @@ def upgrade_identityless_heartbeat_receipt( goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, + goal_ref=goal_ref, ) effective = _effective_heartbeat_receipt(matching) if effective is None: @@ -467,6 +495,7 @@ def upgrade_identityless_heartbeat_receipt( corrected = build_rollout_event( goal_id=goal_id, event_kind="quota_should_run", + goal_ref=goal_ref, agent_id=agent_id, todo_id=normalized_todo_id or None, run_id=turn_instance_id, diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index dc96b233e..caa6a222d 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -491,6 +491,7 @@ def build_live_quota_should_run_decision( interaction_projection_hooks: Sequence[InteractionProjectionHookRegistration] | None = None, turn_start_hook_dispatch: Mapping[str, Any] | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, Any]: """Build one live CLI decision while keeping host observation injectable.""" resolved_context = resolve_scheduler_execution_context(scheduler_execution_context) @@ -538,6 +539,8 @@ def build_live_quota_should_run_decision( todo_id=receipt_bound_todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=receipt_bound_replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) receipt_bound_monitor_phase = ( settlement_readback.monitor_phase if settlement_readback else None @@ -609,6 +612,7 @@ def build_live_quota_should_run_decision( receipt_bound_replan_guard_scoped=receipt_bound_replan_guard_scoped, turn_instance_id=turn_instance_id, runtime_root=runtime_root, + goal_ref=goal_ref, ) _apply_retained_action_selection_reentry( payload, @@ -662,7 +666,8 @@ def build_live_quota_should_run_decision( ) if original_replan_settlement and settlement_readback is not None: attach_settlement_progress(payload, settlement_readback, - registry_path=registry_path, runtime_root=runtime_root) + registry_path=registry_path, runtime_root=runtime_root, + goal_ref=goal_ref) if receipt_bound_replay_phase is not ReceiptBoundReplayPhase.SETTLED and not original_replan_settlement: apply_unsettled_host_turn_recovery_if_required( payload, @@ -673,6 +678,7 @@ def build_live_quota_should_run_decision( current_turn_instance_id=turn_instance_id, available_capabilities=available_capabilities, scheduler_execution_context=resolved_context, + goal_ref=goal_ref, ) if hook_dispatch["failures"]: payload["capability_hook_dispatch"] = { diff --git a/loopx/control_plane/quota/monitor_poll.py b/loopx/control_plane/quota/monitor_poll.py index 1dcc90730..2ee4a3a58 100644 --- a/loopx/control_plane/quota/monitor_poll.py +++ b/loopx/control_plane/quota/monitor_poll.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Any -from ...file_lock import LockAcquisitionPolicy, exclusive_file_lock from ...turn_identity import normalize_turn_instance_id from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..runtime.time import now_local_iso @@ -27,6 +26,7 @@ ) from ..todos.todo_semantics import todo_item_task_class from .decision_summary import compact_quota_decision, quota_decision_agent_id +from .accounting_admission import quota_accounting_admission from .spend_sources import DEFAULT_SLOT_SPEND_SOURCE QUOTA_MONITOR_POLL_CLASSIFICATION = "quota_monitor_poll" @@ -296,6 +296,8 @@ def _request( observation: dict[str, Any], provider_receipt: Mapping[str, Any] | None = None, status_reload_warning: Mapping[str, Any] | None = None, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, ) -> dict[str, Any]: return { "schema_version": ("loopx_quota_monitor_poll_commit_request_v1" if observation.get("lease_proof") is not None @@ -319,6 +321,12 @@ def _request( if status_reload_warning is not None else None ), + **({"goal_ref": dict(goal_ref)} if goal_ref is not None else {}), + **( + {"source_admission": dict(source_admission)} + if source_admission is not None + else {} + ), } @@ -397,6 +405,7 @@ def _find_monitor_poll_turn( turn_instance_id: str, todo_id: str | None = None, target_key: str | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None normalized_target_key = str(target_key or "").strip() or None @@ -416,6 +425,11 @@ def _find_monitor_poll_turn( and str(row.get("goal_id") or "") == goal_id and str(row.get("agent_id") or "") == agent_id and str(row.get("turn_instance_id") or "") == turn_instance_id + and ( + row.get("goal_ref") == dict(goal_ref) + if goal_ref is not None + else "goal_ref" not in row + ) and ( normalized_todo_id is None or normalize_todo_id(row.get("todo_id")) == normalized_todo_id @@ -438,6 +452,7 @@ def find_quota_monitor_poll_turn( turn_instance_id: str, todo_id: str | None = None, target_key: str | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: """Return the latest matching monitor observation for one heartbeat turn.""" @@ -451,6 +466,7 @@ def find_quota_monitor_poll_turn( turn_instance_id=normalized_turn_id, todo_id=todo_id, target_key=target_key, + goal_ref=goal_ref, ) @@ -471,6 +487,7 @@ def _monitor_poll_effect_id( turn_instance_id: str | None, todo_id: str | None, target_key: str | None, + goal_ref: Mapping[str, Any] | None, ) -> str: if not turn_instance_id: return f"quota-monitor-poll:{goal_id}:{uuid.uuid4().hex}" @@ -485,6 +502,7 @@ def _monitor_poll_effect_id( turn_instance_id=turn_instance_id, todo_id=todo_id, target_key=None if todo_id else target_key, + goal_ref=goal_ref, ) existing_effect_id = _persisted_monitor_effect_id(existing) if existing_effect_id: @@ -717,6 +735,7 @@ def record_quota_monitor_poll_for_decision( turn_instance_id: str | None = None, _index_lock_held: bool = False, status_reloader: Callable[[], dict[str, Any]] | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: del render_markdown normalized_turn_id = normalize_turn_instance_id(turn_instance_id) @@ -736,6 +755,7 @@ def record_quota_monitor_poll_for_decision( turn_instance_id=normalized_turn_id, todo_id=safe_todo_id, target_key=safe_target_key, + goal_ref=goal_ref, ) if use_current_task_lease: from .monitor_poll_lease_transport import current_monitor_lease_proof @@ -829,7 +849,9 @@ def failure( ) return payload - def transact() -> tuple[dict[str, Any], dict[str, Any]]: + def transact( + source_admission: Mapping[str, Any] | None, + ) -> tuple[dict[str, Any], dict[str, Any]]: common = { "effect_id": effect_id, "runtime_root": runtime_root, @@ -841,6 +863,8 @@ def transact() -> tuple[dict[str, Any], dict[str, Any]]: "turn_instance_id": normalized_turn_id, "decision": decision, "observation": observation, + "goal_ref": goal_ref, + "source_admission": source_admission, } after_status = deepcopy(status_payload) provider_needed = bool(safe_todo_id or safe_target_key) @@ -887,16 +911,22 @@ def transact() -> tuple[dict[str, Any], dict[str, Any]]: return native, after_status try: - if execute and not _index_lock_held: - with exclusive_file_lock( - index_path, - policy=LockAcquisitionPolicy.MONITOR, - agent_id=decision_agent_id or agent_id, - operation="quota_monitor_poll_index", - ): - native, after_status = transact() + if _index_lock_held: + if goal_ref is not None: + raise ValueError( + "exact GoalRef monitor poll requires quota source admission" + ) + native, after_status = transact(None) else: - native, after_status = transact() + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="quota_monitor_poll_index", + lock_legacy_index=execute, + ) as source_admission: + native, after_status = transact(source_admission) except ValueError as exc: payload = failure( str(exc), diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index ef6ad2f53..7fa2a7c36 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -8,7 +8,17 @@ import { basename, dirname, join, resolve } from "node:path"; import { monitorSuccessorIntent, monitorSuccessorRoute } from "../scheduler/monitor_successor.ts"; import { normalizeTodoCapabilities } from "../todos/work_requirements.ts"; import { parseProjectionDelivery } from "../todos/projection_delivery.ts"; -import { readQuotaSettlement, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA } from "./settlement_readback.ts"; +import { + QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + readAdmittedQuotaSettlementFromSnapshot, + readQuotaSettlementSnapshot, +} from "./settlement_readback.ts"; +import { + parseQuotaAccountingOwner, + quotaGoalRef, + withQuotaAccountingOwner, + type QuotaAccountingOwner, +} from "./source_admission.ts"; import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; @@ -131,6 +141,7 @@ interface MonitorRequest { observation: MonitorObservation; provider_receipt: JsonObject | null; status_reload_warning: JsonObject | null; + owner: QuotaAccountingOwner; } interface MonitorProviderPlan extends JsonObject { @@ -467,6 +478,12 @@ function requestObject(value: unknown): MonitorRequest { observation, provider_receipt: jsonObject(request.provider_receipt), status_reload_warning: jsonObject(request.status_reload_warning), + owner: parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot: runtimeRoot ?? "", + goalId, + }), }; } @@ -548,13 +565,23 @@ interface Admission { async function readAuxiliarySettlement(request: MonitorRequest): Promise { if (!request.runtime_root || !request.turn_instance_id || !request.observation.actor_agent_id || !request.observation.settlement_todo_id) return null; - return await readQuotaSettlement({ + const snapshot = await readQuotaSettlementSnapshot( + request.runtime_root, + request.goal_id, + ); + return readAdmittedQuotaSettlementFromSnapshot({ schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, runtime_root: request.runtime_root, goal_id: request.goal_id, agent_id: request.observation.actor_agent_id, todo_id: request.observation.settlement_todo_id, turn_instance_id: request.turn_instance_id, replan_obligation_id: null, infer_turn_instance_id: false, allow_unbound_binding: false, - }); + ...(quotaGoalRef(request.owner) === null ? {} : { + goal_ref: quotaGoalRef(request.owner), + source_admission: request.owner.kind === "exact_source" + ? request.owner.admission + : undefined, + }), + }, snapshot); } async function auxiliaryMonitorAllowed( @@ -840,6 +867,11 @@ function buildRecord(request: MonitorRequest, allowed: Admission): JsonObject { monitor_target: target, monitor_event: event, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) { + record.goal_ref = goalRef; + event.goal_ref = goalRef; + } if (request.decision.agent_id) { record.agent_id = request.decision.agent_id; event.agent_id = request.decision.agent_id; @@ -870,6 +902,7 @@ function requestDigest(request: MonitorRequest): string { // projected decision during a retry, while the logical observation remains // the same effect. Mutable phase/CAS/provider fields are fenced separately. const observation: JsonObject = { ...request.observation }; + const goalRef = quotaGoalRef(request.owner); if (!observation.settlement_todo_id) delete observation.settlement_todo_id; return sha256(pythonJson({ schema_version: request.schema_version, @@ -878,6 +911,7 @@ function requestDigest(request: MonitorRequest): string { goal_id: request.goal_id, source: request.source, turn_instance_id: request.turn_instance_id, + ...(goalRef === null ? {} : { goal_ref: goalRef }), observation, })); } @@ -1450,6 +1484,8 @@ function indexRecordFor( request_digest: fingerprint, }, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) indexRecord.goal_ref = goalRef; for (const [field, value] of Object.entries({ agent_id: record.agent_id, turn_instance_id: record.turn_instance_id, @@ -1576,6 +1612,8 @@ async function payloadFor( : `${request.execute ? "appended" : "dry-run preview"} monitor poll event: ` + `${request.goal_id} effective_action=${request.decision.effective_action}`, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) payload.goal_ref = goalRef; if (request.turn_instance_id) { payload.turn_instance_id = request.turn_instance_id; payload.replayed = options.replayed; @@ -2018,10 +2056,10 @@ function validateNoEffect(receipt: JsonObject | null, plan: MonitorProviderPlan) } } -export async function evaluateQuotaMonitorPollCommit( - value: unknown, +async function evaluateQuotaMonitorPollRequest( + request: MonitorRequest, + indexLockHeld: boolean, ): Promise { - const request = requestObject(value); const fingerprint = requestDigest(request); if (request.phase === "provider_rejected" && !request.execute) { throw new EffectRuntimeRequestError("provider rejection recovery requires execute"); @@ -2117,7 +2155,7 @@ export async function evaluateQuotaMonitorPollCommit( } const runsDir = join(request.runtime_root, "goals", request.goal_id, "runs"); const indexPath = join(runsDir, "index.jsonl"); - return await withFileMutationLock(indexPath, async () => { + const commit = async (): Promise => { const receiptPath = transactionPath(runsDir, request.effect_id); const existing = await readReceipt(receiptPath); if (existing) { @@ -2393,5 +2431,19 @@ export async function evaluateQuotaMonitorPollCommit( null, indexRecord, ); - }); + }; + return indexLockHeld + ? await commit() + : await withFileMutationLock(indexPath, commit); +} + +export async function evaluateQuotaMonitorPollCommit( + value: unknown, +): Promise { + const request = requestObject(value); + return await withQuotaAccountingOwner( + request.owner, + async (indexLockHeld) => + await evaluateQuotaMonitorPollRequest(request, indexLockHeld), + ); } diff --git a/loopx/control_plane/quota/refresh_external_delivery.py b/loopx/control_plane/quota/refresh_external_delivery.py index 69cae8c31..bf9ecadbc 100644 --- a/loopx/control_plane/quota/refresh_external_delivery.py +++ b/loopx/control_plane/quota/refresh_external_delivery.py @@ -1,4 +1,5 @@ """Persist the typed resume decision inside the existing refresh serialization lock.""" +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -9,6 +10,7 @@ def finish_external_delivery_refresh( payload: dict[str, Any], readback: QuotaSettlementReadback | None, runtime_root: Path, *, dry_run: bool, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: if readback is None: return payload @@ -29,6 +31,7 @@ def finish_external_delivery_refresh( todo_id=identity.todo_id, run_id=identity.turn_instance_id, event_kind="refresh_external_delivery", status=transition["state"], summary="Refresh external delivery preference recorded.", details=transition, + goal_ref=goal_ref, ), ) plan["transition"] = None # The planned journal effect was committed once. @@ -38,6 +41,7 @@ def finish_external_delivery_refresh( def refresh_recovery_payload( readback: QuotaSettlementReadback, *, registry_path: Path, runtime_root: Path, goal_id: str, dry_run: bool, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: recovery = readback.refresh_recovery identity = readback.identity.value @@ -50,7 +54,13 @@ def refresh_recovery_payload( # Record a requested pause before a new writeback can commit. If later # validation fails, retaining the pause is conservative and retryable. if (plan.get("transition") or {}).get("state") == "paused": - finish_external_delivery_refresh({"ok": True}, readback, runtime_root, dry_run=dry_run) + finish_external_delivery_refresh( + {"ok": True}, + readback, + runtime_root, + dry_run=dry_run, + goal_ref=goal_ref, + ) return None payload = { **(readback.writeback_run or {}), "ok": decision != "reject" and not delivery_error, @@ -62,7 +72,13 @@ def refresh_recovery_payload( "settlement_result": settlement_result_payload(readback.delivery), } if not dry_run: - attach_settlement_progress(payload, readback, registry_path=registry_path, runtime_root=runtime_root) + attach_settlement_progress( + payload, + readback, + registry_path=registry_path, + runtime_root=runtime_root, + goal_ref=goal_ref, + ) if decision == "reject": payload["error"] = ( f"{recovery['reason']}: committed writeback is unchanged; " @@ -81,4 +97,10 @@ def refresh_recovery_payload( f"--resume-external-sinks {key} instead of --suppress-external-sinks. " if key else "") + "Existing provider permissions still apply; do not repeat business mutations or spend." ) - return finish_external_delivery_refresh(payload, readback, runtime_root, dry_run=dry_run) + return finish_external_delivery_refresh( + payload, + readback, + runtime_root, + dry_run=dry_run, + goal_ref=goal_ref, + ) diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index f5317506c..60e59a4a1 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -8,6 +8,7 @@ from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..settlement_driver import decode_settlement_result +from .accounting_admission import quota_accounting_admission from .effect_program import ( SETTLEMENT_IDENTITY_SCHEMA_VERSION, SETTLEMENT_PLAN_SCHEMA_VERSION, @@ -172,6 +173,7 @@ def attach_settlement_progress( *, registry_path: Path | None = None, runtime_root: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> None: """Render the TS-owned receipt progress without deriving a second settlement rule.""" progress = readback.progress @@ -197,6 +199,7 @@ def attach_settlement_progress( scoped_cli_args="", lifecycle_actor_args="", quota_spend_source=progress["quota_spend_source"], + goal_ref=goal_ref, ) payload["settlement_owed"] = { **identity.as_dict(), "schema_version": "turn_settlement_owed_v0", @@ -311,32 +314,69 @@ def read_heartbeat_settlement( allow_unbound_binding: bool = False, resolve_original_binding: bool = False, refresh_retry: dict[str, Any] | None = None, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, + borrow_source_admission: bool = False, ) -> QuotaSettlementReadback | None: """Read one complete heartbeat settlement through the TS domain owner.""" - try: - payload = effect_runtime_result( - "quota.settlement.read", - { - "schema_version": QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, - "runtime_root": str(runtime_root.expanduser()), - "goal_id": goal_id, - "agent_id": agent_id, - "todo_id": todo_id, - "turn_instance_id": turn_instance_id, - "replan_obligation_id": replan_obligation_id, - "infer_turn_instance_id": infer_turn_instance_id, - "allow_unbound_binding": allow_unbound_binding, - **({"resolve_original_binding": True} if resolve_original_binding else {}), - **( - {"refresh_retry": refresh_retry} - if refresh_retry is not None - else {} - ), - }, - ) - except EffectRuntimeRejected as exc: - raise ValueError(str(exc)) from None + def read(admission: Mapping[str, Any] | None) -> Any: + try: + return effect_runtime_result( + "quota.settlement.read", + { + "schema_version": QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, + "runtime_root": str(runtime_root.expanduser()), + "goal_id": goal_id, + "agent_id": agent_id, + "todo_id": todo_id, + "turn_instance_id": turn_instance_id, + "replan_obligation_id": replan_obligation_id, + "infer_turn_instance_id": infer_turn_instance_id, + "allow_unbound_binding": allow_unbound_binding, + **( + {"resolve_original_binding": True} + if resolve_original_binding + else {} + ), + **( + {"refresh_retry": refresh_retry} + if refresh_retry is not None + else {} + ), + **( + {"goal_ref": dict(goal_ref)} + if goal_ref is not None + else {} + ), + **( + {"source_admission": dict(admission)} + if admission is not None + else {} + ), + **( + {"borrow_source_admission": True} + if borrow_source_admission + else {} + ), + }, + ) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + + if source_admission is not None: + payload = read(source_admission) + else: + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="quota-settlement-read", + lock_legacy_index=False, + ) as admission: + payload = read(admission) if not isinstance(payload, Mapping) or ( payload.get("schema_version") != QUOTA_SETTLEMENT_READBACK_RESULT_SCHEMA diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index 2096d20ca..c0e7addfd 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -38,6 +38,7 @@ def reconcile_existing_heartbeat_receipt( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str]: """Bind an identity-less same-turn receipt without changing a bound receipt.""" @@ -130,6 +131,7 @@ def reconcile_existing_heartbeat_receipt( ), summary=f"heartbeat quota receipt upgraded for turn={turn_instance_id}", details=rollout_details, + goal_ref=goal_ref, ) if upgraded: receipt_status = "upgraded" @@ -149,6 +151,7 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root: Path, turn_instance_id: str, existing: dict[str, object], + goal_ref: Mapping[str, object] | None = None, ) -> tuple[dict[str, object], str, bool, str, bool]: """Reconcile an existing receipt and report whether the turn is receipt-ready.""" @@ -158,6 +161,7 @@ def reconcile_existing_heartbeat_receipt_for_turn( runtime_root=runtime_root, turn_instance_id=turn_instance_id, existing=existing, + goal_ref=goal_ref, ) return receipt, status, appended, stall_observation, True @@ -409,7 +413,11 @@ def attach_spend_settlement_result( todo_id: str | None, turn_instance_id: str, replan_obligation_id: str | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> None: + registry_path = ( + Path(str(payload["registry"])) if payload.get("registry") else None + ) readback = read_heartbeat_settlement( runtime_root, goal_id=goal_id, @@ -417,12 +425,15 @@ def attach_spend_settlement_result( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) 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, + registry_path=registry_path, + goal_ref=goal_ref, ) identity = readback.identity.value if identity is None: diff --git a/loopx/control_plane/quota/settlement_plan.ts b/loopx/control_plane/quota/settlement_plan.ts index 37d7547ee..ac98fab6c 100644 --- a/loopx/control_plane/quota/settlement_plan.ts +++ b/loopx/control_plane/quota/settlement_plan.ts @@ -4,6 +4,7 @@ import { settlementIdentity, type JsonObject, type SettlementIdentityInput, type SettlementPlan, type SettlementStep, } from "../effect_program.ts"; +import {parseExactGoalRef} from "../goals/goal_instance_identity.ts"; import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan { @@ -13,6 +14,18 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan const commands = requireJsonObject(params.command_templates, "command_templates"); const writeback = requireNonEmptyString(commands.durable_writeback, "durable_writeback"); const spend = requireNonEmptyString(commands.quota_spend, "quota_spend"); + const parsedGoalRef = params.goal_ref === undefined + ? null + : parseExactGoalRef(params.goal_ref); + if ( + parsedGoalRef !== null + && ( + parsedGoalRef.kind === "invalid" + || parsedGoalRef.value.goalId.value !== identity.goal_id + ) + ) { + throw new Error("settlement plan GoalRef is invalid or does not match identity"); + } const inFlight = params.delivery_boundary === "in_flight_continuation"; const validation: SettlementStep = { kind: "validation", owner: "agent", idempotency_key_ref: "$.identity.effect_id", @@ -52,5 +65,14 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan command_template: requireNonEmptyString(commands.terminal_closeout, "terminal_closeout"), conditional: true, }); - return {identity, steps}; + return { + identity, + steps, + ...(parsedGoalRef === null ? {} : { + goal_ref: { + goal_id: parsedGoalRef.value.goalId.value, + goal_instance_id: parsedGoalRef.value.goalInstanceId.value, + }, + }), + }; } diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 8c2dbaab2..3164a6563 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -56,6 +56,13 @@ import { import { refreshExternalDelivery } from "./refresh_external_delivery.ts"; import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait } from "./blocked_wait.ts"; import {nativeChildReportAdmission} from "../capabilities/native_child_admission.ts"; +import { + parseQuotaAccountingOwner, + quotaOwnerOwnsProjection, + withBorrowedQuotaAccountingOwner, + withQuotaAccountingOwner, + type QuotaAccountingOwner, +} from "./source_admission.ts"; export const QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA = "loopx_quota_settlement_readback_request_v0"; @@ -76,7 +83,9 @@ interface ReadbackRequest { infer_turn_instance_id: boolean; allow_unbound_binding: boolean; resolve_original_binding: boolean; + borrow_source_admission: boolean; refresh_retry: RefreshRetryRequest | null; + owner: QuotaAccountingOwner; } export interface QuotaSettlementReadbackSnapshot { @@ -177,6 +186,26 @@ function decodeRequest(value: unknown): ReadbackRequest { if (request.resolve_original_binding === true && request.infer_turn_instance_id) { throw new EffectRuntimeRequestError("original binding requires an explicit Turn identity"); } + if ( + request.borrow_source_admission !== undefined + && typeof request.borrow_source_admission !== "boolean" + ) { + throw new EffectRuntimeRequestError( + "borrow_source_admission must be a boolean", + ); + } + const owner = parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot, + goalId, + }); + if (request.borrow_source_admission === true && owner.kind !== "exact_source") { + throw new EffectRuntimeRequestError( + "borrow_source_admission requires exact source admission", + "quota_source_admission_invalid", + ); + } return { runtime_root: runtimeRoot, goal_id: goalId, @@ -193,7 +222,9 @@ function decodeRequest(value: unknown): ReadbackRequest { infer_turn_instance_id: request.infer_turn_instance_id, allow_unbound_binding: request.allow_unbound_binding, resolve_original_binding: request.resolve_original_binding === true, + borrow_source_admission: request.borrow_source_admission === true, refresh_retry: decodeRefreshRetry(request.refresh_retry), + owner, }; } @@ -334,6 +365,7 @@ function indexedRuns( export function committedMonitorPollFromSnapshot( snapshot: QuotaSettlementReadbackSnapshot, identity: Pick, + owner: QuotaAccountingOwner = {kind: "alias"}, ): JsonObject | null { if (snapshot.goalId !== identity.goal_id) { throw new EffectRuntimeRequestError("monitor poll snapshot scope mismatch"); @@ -342,6 +374,7 @@ export function committedMonitorPollFromSnapshot( turnKey(identity.goal_id, identity.agent_id, identity.turn_instance_id)!, ) ?? []; return [...runs].reverse().find((run) => + quotaOwnerOwnsProjection(owner, run.goal_ref) && run.classification === "quota_monitor_poll" && optionalString(run.goal_id) === identity.goal_id && optionalString(run.agent_id) === identity.agent_id && @@ -948,6 +981,12 @@ function readQuotaSettlementFromRequest( ); } const explicitAgentId = normalizeAgentId(request.agent_id); + const ownerRuns = snapshot.runs.filter((run) => + quotaOwnerOwnsProjection(request.owner, run.goal_ref) + ); + const ownerEvents = snapshot.events.filter((event) => + quotaOwnerOwnsProjection(request.owner, event.goal_ref) + ); const explicitEvents = !request.infer_turn_instance_id && explicitAgentId !== null && request.turn_instance_id !== null ? indexedEvents( @@ -955,12 +994,14 @@ function readQuotaSettlementFromRequest( request.goal_id, explicitAgentId, request.turn_instance_id, + ).filter((event) => + quotaOwnerOwnsProjection(request.owner, event.goal_ref) ) - : snapshot.events; + : ownerEvents; const identityResult = resolveIdentity( request, explicitEvents, - snapshot.runs, + ownerRuns, ); if (identityResult === null) { return { @@ -984,8 +1025,12 @@ function readQuotaSettlementFromRequest( identity.goal_id, identity.agent_id, identity.turn_instance_id, + ).filter((event) => + quotaOwnerOwnsProjection(request.owner, event.goal_ref) + ); + const runs = indexedRuns(snapshot, identity).filter((run) => + quotaOwnerOwnsProjection(request.owner, run.goal_ref) ); - const runs = indexedRuns(snapshot, identity); const heartbeatReceipt = effectiveHeartbeatReceipt(events, identity); if (heartbeatReceipt === null) { return failedReadback( @@ -999,7 +1044,9 @@ function readQuotaSettlementFromRequest( const writebackRun = findWriteback(runs, identity); const writebackEvent = findStepEvent(events, identity, "refresh_state"); const spendRun = findSpend( - spendCandidateRuns(snapshot, identity, runs), + spendCandidateRuns(snapshot, identity, runs).filter((run) => + quotaOwnerOwnsProjection(request.owner, run.goal_ref) + ), identity, ); const spendEvent = findStepEvent(events, identity, "quota_spend"); @@ -1026,7 +1073,11 @@ function readQuotaSettlementFromRequest( const withWriteback = settlementBindReduce(identityResult, writeback); const settled = blockedNoSpend ? withWriteback : settlementBindReduce(withWriteback, spend); const terminalSettlement = settlementBindReduce(settled, terminalCloseout); - const monitorPoll = committedMonitorPollFromSnapshot(snapshot, identity); + const monitorPoll = committedMonitorPollFromSnapshot( + snapshot, + identity, + request.owner, + ); const nestedCausality = typeof receiptDetails.delivery_workspace_causality === "object" && receiptDetails.delivery_workspace_causality !== null && !Array.isArray(receiptDetails.delivery_workspace_causality) @@ -1064,6 +1115,8 @@ function readQuotaSettlementFromRequest( workspaceCausality?.requirement, writebackRun !== null && snapshot.runs.slice( (snapshot.runPositions.get(writebackRun) ?? -1) + 1, + ).filter((run) => + quotaOwnerOwnsProjection(request.owner, run.goal_ref) ).some((run) => run.goal_id === identity.goal_id && run.agent_id === identity.agent_id && (jsonObject(run.agent_vision) !== null || jsonObject(run.vision_checkpoint)?.required === true) @@ -1113,6 +1166,21 @@ function readQuotaSettlementFromRequest( export function readQuotaSettlementFromSnapshot( value: unknown, snapshot: QuotaSettlementReadbackSnapshot, +): JsonObject { + const request = decodeRequest(value); + if (request.owner.kind !== "alias") { + throw new EffectRuntimeRequestError( + "exact source settlement readback requires live owner admission", + "quota_source_admission_invalid", + ); + } + return readQuotaSettlementFromRequest(request, snapshot); +} + +/** Read under a source admission already adopted by the enclosing transaction. */ +export function readAdmittedQuotaSettlementFromSnapshot( + value: unknown, + snapshot: QuotaSettlementReadbackSnapshot, ): JsonObject { return readQuotaSettlementFromRequest(decodeRequest(value), snapshot); } @@ -1122,8 +1190,14 @@ export async function readQuotaSettlement(value: unknown): Promise { return prepareBlockedWait(value); } const request = decodeRequest(value); - return readQuotaSettlementFromRequest( - request, - await readQuotaSettlementSnapshot(request.runtime_root, request.goal_id), + const withOwner = request.borrow_source_admission + ? withBorrowedQuotaAccountingOwner + : withQuotaAccountingOwner; + return await withOwner( + request.owner, + async () => readQuotaSettlementFromRequest( + request, + await readQuotaSettlementSnapshot(request.runtime_root, request.goal_id), + ), ); } diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index 46a87ad8e..288f5c247 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -147,6 +147,7 @@ def build_quota_paused_should_run_payload( codex_app_automation_id: Any = None, resolved_scheduler_context: SchedulerExecutionContextResolution, runtime_root: str | Path | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, Any]: """Project one canonical hard-pause contract with no lane contradiction. @@ -224,6 +225,8 @@ def build_quota_paused_should_run_payload( payload=payload, agent_identity=agent_identity, ) + if goal_ref is not None: + payload["goal_ref"] = dict(goal_ref) payload["automation_liveness"] = build_automation_liveness(payload) payload["interaction_contract"] = build_interaction_contract( payload, @@ -271,6 +274,7 @@ def build_quota_should_run( receipt_bound_replan_guard_scoped: bool = False, turn_instance_id: str | None = None, runtime_root: str | Path | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, Any]: safe_goal_id = str(goal_id or "").strip() resolved_scheduler_context = resolve_scheduler_execution_context( @@ -318,6 +322,7 @@ def build_quota_should_run( codex_app_automation_id=codex_app_automation_id, resolved_scheduler_context=resolved_scheduler_context, runtime_root=runtime_root, + goal_ref=goal_ref, ) prepared = _prepare_quota_should_run_item( status_payload, @@ -349,6 +354,7 @@ def build_quota_should_run( turn_instance_id=turn_instance_id, include_agent_todo_detail=include_agent_todo_detail, runtime_root=runtime_root, + goal_ref=goal_ref, ) if health_item: return { diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index d7989d155..12436d6a1 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -1472,6 +1472,7 @@ def _build_quota_should_run_payload( turn_instance_id: str | None = None, include_agent_todo_detail: bool = False, runtime_root: str | Path | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, Any]: if prepared.receipt_bound_replay_phase is ReceiptBoundReplayPhase.SETTLED: payload = _build_settled_quota_payload(prepared, route) @@ -1479,6 +1480,8 @@ def _build_quota_should_run_payload( payload = _build_active_quota_payload( prepared, route, include_agent_todo_detail=include_agent_todo_detail, ) + if goal_ref is not None: + payload["goal_ref"] = dict(goal_ref) apply_settled_monitor_precedence(payload) cadence_root = _interaction_runtime_root(runtime_root, prepared.status_payload) if cadence_root: diff --git a/loopx/control_plane/quota/slot_accounting.py b/loopx/control_plane/quota/slot_accounting.py index 387ed8130..559a4bf81 100644 --- a/loopx/control_plane/quota/slot_accounting.py +++ b/loopx/control_plane/quota/slot_accounting.py @@ -146,6 +146,8 @@ def _resolve_preview_settlement( todo_id: str | None, replan_obligation_id: str | None, turn_instance_id: str | None, + registry_path: Path | None, + goal_ref: Mapping[str, Any] | None, ) -> dict[str, Any]: if turn_instance_id and source not in TURN_SCOPED_SLOT_SPEND_SOURCES: result = SettlementResult.failed( @@ -176,6 +178,8 @@ def _resolve_preview_settlement( and not todo_id and not replan_obligation_id ), + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None: return {} @@ -382,6 +386,7 @@ def _latest_unspent_turn_settlement_run( goal_id: str, *, agent_id: str | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: """Return the latest same-agent Turn settlement that still needs accounting. @@ -395,6 +400,8 @@ def _latest_unspent_turn_settlement_run( safe_agent_id = normalize_todo_claimed_by(agent_id) for run in reversed(_load_goal_run_index_records(runtime_root, goal_id)): + if goal_ref is not None and run.get("goal_ref") != dict(goal_ref): + continue run_agent_id = normalize_todo_claimed_by(run.get("agent_id")) if safe_agent_id and run_agent_id and safe_agent_id != run_agent_id: continue @@ -579,6 +586,8 @@ def build_quota_slot_preview_for_decision( replan_obligation_id: str | None = None, turn_instance_id: str | None = None, source: str = DEFAULT_SLOT_SPEND_SOURCE, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _validate_goal_id_path_segment(str(goal_id or "")) safe_slots = max(1, _int_number(slots, default=1)) @@ -627,6 +636,8 @@ def build_quota_slot_preview_for_decision( todo_id=normalized_todo_id, replan_obligation_id=normalized_replan_obligation_id, turn_instance_id=turn_instance_id, + registry_path=registry_path, + goal_ref=goal_ref, ) settlement_identity = settlement.get("identity") settlement_result = settlement.get("result") @@ -698,6 +709,7 @@ def build_quota_slot_preview_for_decision( Path(str(raw_runtime_root)).expanduser(), safe_goal_id, agent_id=safe_requested_agent_id, + goal_ref=goal_ref, ) if raw_runtime_root else None diff --git a/loopx/control_plane/quota/source_admission.ts b/loopx/control_plane/quota/source_admission.ts new file mode 100644 index 000000000..47526e2a7 --- /dev/null +++ b/loopx/control_plane/quota/source_admission.ts @@ -0,0 +1,387 @@ +import { createHash } from "node:crypto"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +import type { JsonObject } from "../effect_program.ts"; +import { + EffectRuntimeConflictError, + EffectRuntimeRequestError, +} from "../effect_runtime_errors.ts"; +import { + claimFileMutationLock, + mutationLockOwner, + releaseFileMutationLock, + releaseFileMutationLockClaim, + type FileMutationLockClaim, +} from "../effect_runtime_io.ts"; +import { decideFirstPartyHostRuntime } from "../goals/first_party_host_runtime.ts"; +import { + parseExactGoalRef, + type ExactGoalRef, +} from "../goals/goal_instance_identity.ts"; +import { + requireJsonObject, + requireNonEmptyString, +} from "../runtime_decode.ts"; + +const QUOTA_SOURCE_ADMISSION_SCHEMA = "loopx_quota_source_admission_v0"; +const SOURCE_SESSION_PROFILE_ID = "source_session_v1"; + +type LockRole = "run_index" | "source_guard"; + +interface QuotaSourceLock { + role: LockRole; + target: string; + pid: number; + token: string; +} + +interface QuotaSourceAdmission { + registryPath: string; + plannedGoalRef: ExactGoalRef; + authority: unknown; + locks: readonly [QuotaSourceLock, QuotaSourceLock]; +} + +export type QuotaAccountingOwner = + | Readonly<{ kind: "alias" }> + | Readonly<{ + kind: "exact_source"; + goalRef: ExactGoalRef; + admission: QuotaSourceAdmission; + }>; + +interface ClaimedLock { + witness: QuotaSourceLock; + claim: FileMutationLockClaim; +} + +export function quotaAccountingOwnerLocks( + owner: QuotaAccountingOwner, +): readonly Readonly[] { + return owner.kind === "alias" ? [] : owner.admission.locks; +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function sourceGuardTarget(registryPath: string, goalId: string): string { + return join( + dirname(registryPath), + ".loopx", + "lifecycle", + "goal-instance", + "guards", + `${sha256(goalId)}.guard`, + ); +} + +function sameGoalRef(left: ExactGoalRef, right: ExactGoalRef): boolean { + return left.goalId.value === right.goalId.value + && left.goalInstanceId.value === right.goalInstanceId.value; +} + +export function quotaGoalRef(owner: QuotaAccountingOwner): JsonObject | null { + if (owner.kind === "alias") return null; + return { + goal_id: owner.goalRef.goalId.value, + goal_instance_id: owner.goalRef.goalInstanceId.value, + }; +} + +export function requireQuotaOwnerProjection( + owner: QuotaAccountingOwner, + value: unknown, + label: string, +): void { + if (owner.kind === "alias") { + if (value === undefined) return; + throw new EffectRuntimeConflictError( + `${label} belongs to an exact Goal instance and cannot be consumed without GoalRef admission`, + "goal_instance_conflict", + ); + } + const projected = parseExactGoalRef(value); + if ( + projected.kind === "invalid" + || !sameGoalRef(projected.value, owner.goalRef) + ) { + throw new EffectRuntimeConflictError( + `${label} does not belong to the admitted Goal instance`, + "goal_instance_conflict", + ); + } +} + +export function quotaOwnerOwnsProjection( + owner: QuotaAccountingOwner, + value: unknown, +): boolean { + if (owner.kind === "alias") return value === undefined; + const projected = parseExactGoalRef(value); + return projected.kind === "parsed" + && sameGoalRef(projected.value, owner.goalRef); +} + +function quotaSourceLock( + value: unknown, + role: LockRole, + expectedTarget: string, +): QuotaSourceLock { + const witness = requireJsonObject(value, `${role} lock witness`); + if (witness.role !== role) { + throw new EffectRuntimeRequestError( + `quota source admission ${role} lock role mismatch`, + "quota_source_admission_invalid", + ); + } + const target = requireNonEmptyString( + witness.target, + `${role} lock target`, + ); + if ( + !isAbsolute(target) + || resolve(target) !== target + || target !== expectedTarget + ) { + throw new EffectRuntimeRequestError( + `quota source admission ${role} lock target mismatch`, + "quota_source_admission_invalid", + ); + } + if ( + typeof witness.pid !== "number" + || !Number.isSafeInteger(witness.pid) + || witness.pid <= 0 + ) { + throw new EffectRuntimeRequestError( + `quota source admission ${role} lock owner is invalid`, + "quota_source_admission_invalid", + ); + } + return { + role, + target, + pid: witness.pid, + token: requireNonEmptyString(witness.token, `${role} lock token`), + }; +} + +export function parseQuotaAccountingOwner( + { + goalRefValue, + sourceAdmissionValue, + runtimeRoot, + goalId, + }: { + goalRefValue: unknown; + sourceAdmissionValue: unknown; + runtimeRoot: string; + goalId: string; + }, +): QuotaAccountingOwner { + const hasGoalRef = goalRefValue !== undefined; + const hasAdmission = sourceAdmissionValue !== undefined; + if (!hasGoalRef && !hasAdmission) return { kind: "alias" }; + if (!hasGoalRef || !hasAdmission) { + throw new EffectRuntimeRequestError( + "exact quota GoalRef and source admission must be supplied together", + "quota_source_admission_invalid", + ); + } + + const goalRef = parseExactGoalRef(goalRefValue); + if ( + goalRef.kind === "invalid" + || goalRef.value.goalId.value !== goalId + ) { + throw new EffectRuntimeRequestError( + "quota GoalRef is invalid or does not match goal_id", + "quota_source_admission_invalid", + ); + } + const admission = requireJsonObject( + sourceAdmissionValue, + "quota source admission", + ); + if ( + admission.schema_version !== QUOTA_SOURCE_ADMISSION_SCHEMA + || admission.profile_id !== SOURCE_SESSION_PROFILE_ID + ) { + throw new EffectRuntimeRequestError( + "quota source admission is malformed", + "quota_source_admission_invalid", + ); + } + const registryPath = requireNonEmptyString( + admission.registry_path, + "quota source admission registry_path", + ); + if (!isAbsolute(registryPath) || resolve(registryPath) !== registryPath) { + throw new EffectRuntimeRequestError( + "quota source admission registry path must be absolute and normalized", + "quota_source_admission_invalid", + ); + } + const plannedGoalRef = parseExactGoalRef(admission.planned_goal_ref); + if ( + plannedGoalRef.kind === "invalid" + || !sameGoalRef(plannedGoalRef.value, goalRef.value) + ) { + throw new EffectRuntimeRequestError( + "quota source admission does not match the requested GoalRef", + "quota_source_admission_invalid", + ); + } + if (!Array.isArray(admission.locks) || admission.locks.length !== 2) { + throw new EffectRuntimeRequestError( + "quota source admission requires both ordered lock witnesses", + "quota_source_admission_invalid", + ); + } + const indexTarget = resolve( + join(runtimeRoot, "goals", goalId, "runs", "index.jsonl"), + ); + const guardTarget = resolve(sourceGuardTarget(registryPath, goalId)); + const runIndex = quotaSourceLock( + admission.locks[0], + "run_index", + indexTarget, + ); + const sourceGuard = quotaSourceLock( + admission.locks[1], + "source_guard", + guardTarget, + ); + return { + kind: "exact_source", + goalRef: goalRef.value, + admission: { + registryPath, + plannedGoalRef: plannedGoalRef.value, + authority: admission.authority, + locks: [runIndex, sourceGuard], + }, + }; +} + +export async function withQuotaAccountingOwner( + owner: QuotaAccountingOwner, + operation: (indexLockHeld: boolean) => Promise, +): Promise { + if (owner.kind === "alias") return await operation(false); + + const claims: ClaimedLock[] = []; + let adopted = false; + try { + for (const witness of owner.admission.locks) { + const claim = await claimFileMutationLock( + witness.target, + witness.token, + ); + if (!claim) { + throw new EffectRuntimeConflictError( + "quota source admission lock handoff expired", + "quota_source_admission_expired", + ); + } + claims.push({ witness, claim }); + const current = await mutationLockOwner(witness.target); + if ( + current?.pid !== witness.pid + || current.token !== witness.token + ) { + throw new EffectRuntimeConflictError( + "quota source admission lock owner changed", + "quota_source_admission_expired", + ); + } + } + adopted = true; + requireCurrentQuotaAccountingOwner(owner); + return await operation(true); + } finally { + for (const entry of claims.reverse()) { + if (adopted) { + await releaseFileMutationLock( + entry.witness.target, + entry.witness.token, + entry.claim, + true, + ); + } else { + await releaseFileMutationLockClaim(entry.claim); + } + } + } +} + +export function requireCurrentQuotaAccountingOwner( + owner: QuotaAccountingOwner, +): void { + if (owner.kind === "alias") return; + const decision = decideFirstPartyHostRuntime({ + profile_id: SOURCE_SESSION_PROFILE_ID, + operation: "require_current", + planned_goal_ref: quotaGoalRef(owner), + authority: owner.admission.authority, + }); + if (decision.kind === "reject") { + throw new EffectRuntimeConflictError( + `quota source admission rejected: ${decision.code}`, + decision.code, + ); + } + if (decision.kind !== "resume") { + throw new EffectRuntimeRequestError( + "quota source admission did not resume the exact GoalRef", + "quota_source_admission_invalid", + ); + } +} + +/** + * Verify a Python-owned admission without ending its surrounding transaction. + * + * The temporary claims prevent stale-owner reclamation while the callback + * runs. The caller remains responsible for releasing the underlying locks. + */ +export async function withBorrowedQuotaAccountingOwner( + owner: QuotaAccountingOwner, + operation: (indexLockHeld: boolean) => Promise, +): Promise { + if (owner.kind === "alias") return await operation(false); + + const claims: FileMutationLockClaim[] = []; + try { + for (const witness of owner.admission.locks) { + const claim = await claimFileMutationLock( + witness.target, + witness.token, + ); + if (!claim) { + throw new EffectRuntimeConflictError( + "quota source admission lock handoff expired", + "quota_source_admission_expired", + ); + } + claims.push(claim); + const current = await mutationLockOwner(witness.target); + if ( + current?.pid !== witness.pid + || current.token !== witness.token + ) { + throw new EffectRuntimeConflictError( + "quota source admission lock owner changed", + "quota_source_admission_expired", + ); + } + } + requireCurrentQuotaAccountingOwner(owner); + return await operation(true); + } finally { + for (const claim of claims.reverse()) { + await releaseFileMutationLockClaim(claim); + } + } +} diff --git a/loopx/control_plane/quota/spend_commit.py b/loopx/control_plane/quota/spend_commit.py index ee4b5a6f6..077b05185 100644 --- a/loopx/control_plane/quota/spend_commit.py +++ b/loopx/control_plane/quota/spend_commit.py @@ -6,10 +6,15 @@ from typing import Any from ...file_lock import exclusive_file_lock -from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from ..effect_runtime import ( + EffectRuntimeConflict, + EffectRuntimeRejected, + effect_runtime_result, +) from ..runtime.time import now_local_iso from ..todos.contract import normalize_todo_claimed_by from .decision_summary import compact_quota_decision, quota_decision_agent_id +from .accounting_admission import quota_accounting_admission from .spend_sources import DEFAULT_SLOT_SPEND_SOURCE @@ -33,6 +38,8 @@ def _quota_spend_commit_request( execute: bool, runtime_root: Path | None, expected_index_digest: str | None, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, ) -> dict[str, Any]: # Import lazily because runtime loads history, whose quota compatibility # surface re-exports this module during package initialization. @@ -52,7 +59,7 @@ def _quota_spend_commit_request( request_preview = dict(preview) request_preview.pop("expected_index_digest", None) request_preview["after_recommended_action"] = after.get("recommended_action") - return { + request = { "schema_version": QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, "runtime_root": str(runtime_root) if runtime_root is not None else None, "goal_id": goal_id, @@ -65,6 +72,11 @@ def _quota_spend_commit_request( "after": compact_quota_decision(after), "resolved_agent_id": resolved_agent_id, } + if goal_ref is not None: + request["goal_ref"] = dict(goal_ref) + if source_admission is not None: + request["source_admission"] = dict(source_admission) + return request def _quota_spend_commit_result( @@ -75,6 +87,8 @@ def _quota_spend_commit_result( execute: bool, runtime_root: Path | None, expected_index_digest: str | None, + goal_ref: Mapping[str, Any] | None = None, + source_admission: Mapping[str, Any] | None = None, ) -> Mapping[str, Any]: params = _quota_spend_commit_request( preview, @@ -83,10 +97,12 @@ def _quota_spend_commit_result( execute=execute, runtime_root=runtime_root, expected_index_digest=expected_index_digest, + goal_ref=goal_ref, + source_admission=source_admission, ) try: result = effect_runtime_result("quota.spend.commit", params) - except EffectRuntimeRejected as exc: + except (EffectRuntimeConflict, EffectRuntimeRejected) as exc: raise ValueError(str(exc)) from None if ( not isinstance(result, Mapping) @@ -105,6 +121,8 @@ def replay_quota_spend_by_effect_ref( effect_ref: str, agent_id: str | None, read_only: bool = False, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Ask the native quota transaction to validate an effect replay.""" @@ -119,21 +137,31 @@ def replay_quota_spend_by_effect_ref( "replay_found": False, "reason": "effect_ref must be a non-empty string", } - try: - result = effect_runtime_result( - "quota.spend.commit", - { - "schema_version": QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, - "operation": "replay", - "runtime_root": str(runtime_root.expanduser()), - "goal_id": safe_goal_id, - "effect_id": normalized_effect_ref, - "resolved_agent_id": normalize_todo_claimed_by(agent_id), - "read_only": read_only, - }, - ) - except EffectRuntimeRejected as exc: - raise ValueError(str(exc)) from None + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=safe_goal_id, + goal_ref=goal_ref, + operation="quota_spend_replay", + lock_legacy_index=False, + ) as source_admission: + request: dict[str, Any] = { + "schema_version": QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, + "operation": "replay", + "runtime_root": str(runtime_root.expanduser()), + "goal_id": safe_goal_id, + "effect_id": normalized_effect_ref, + "resolved_agent_id": normalize_todo_claimed_by(agent_id), + "read_only": read_only, + } + if goal_ref is not None: + request["goal_ref"] = dict(goal_ref) + if source_admission is not None: + request["source_admission"] = dict(source_admission) + try: + result = effect_runtime_result("quota.spend.commit", request) + except (EffectRuntimeConflict, EffectRuntimeRejected) as exc: + raise ValueError(str(exc)) from None if not isinstance(result, Mapping) or result.get("schema_version") != QUOTA_SPEND_COMMIT_RESULT_SCHEMA: raise RuntimeError("TypeScript quota spend replay result shape mismatch") payload = result.get("payload") @@ -173,6 +201,8 @@ def record_quota_slot_spend_from_preview( goal_id: str, execute: bool = False, source: str = DEFAULT_SLOT_SPEND_SOURCE, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: if not preview.get("ok"): return preview @@ -197,18 +227,37 @@ def record_quota_slot_spend_from_preview( raise ValueError("quota spend preview index basis must be a string or null") if execute: index_path = runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl" - # Legacy Python run writers use this kernel lock. Hold it across the - # single TS transaction until Stage 3 moves every index writer into the - # native runtime; the TS owner also serializes native callers itself. - with exclusive_file_lock(index_path, operation="quota_spend_commit"): - result = _quota_spend_commit_result( - preview, - source=source, - generated_at=None, - execute=True, + if registry_path is None and goal_ref is None: + with exclusive_file_lock( + index_path, + operation="quota_spend_commit", + ): + result = _quota_spend_commit_result( + preview, + source=source, + generated_at=None, + execute=True, + runtime_root=runtime_root, + expected_index_digest=expected_index_digest, + ) + else: + with quota_accounting_admission( runtime_root=runtime_root, - expected_index_digest=expected_index_digest, - ) + registry_path=registry_path, + goal_id=safe_goal_id, + goal_ref=goal_ref, + operation="quota_spend_commit", + ) as source_admission: + result = _quota_spend_commit_result( + preview, + source=source, + generated_at=None, + execute=True, + runtime_root=runtime_root, + expected_index_digest=expected_index_digest, + goal_ref=goal_ref, + source_admission=source_admission, + ) else: result = _quota_spend_commit_result( preview, @@ -217,6 +266,8 @@ def record_quota_slot_spend_from_preview( execute=False, runtime_root=runtime_root, expected_index_digest=expected_index_digest, + goal_ref=None, + source_admission=None, ) payload = result.get("payload") if not isinstance(payload, Mapping): diff --git a/loopx/control_plane/quota/spend_commit.ts b/loopx/control_plane/quota/spend_commit.ts index 484e37416..7a4f7eab4 100644 --- a/loopx/control_plane/quota/spend_commit.ts +++ b/loopx/control_plane/quota/spend_commit.ts @@ -20,6 +20,12 @@ import { requireNonEmptyString as requiredString, requireStringLiteral, } from "../runtime_decode.ts"; +import { + parseQuotaAccountingOwner, + quotaGoalRef, + withQuotaAccountingOwner, + type QuotaAccountingOwner, +} from "./source_admission.ts"; export const QUOTA_SPEND_COMMIT_REQUEST_SCHEMA = "loopx_quota_spend_commit_request_v0"; @@ -92,6 +98,7 @@ interface QuotaSpendCommitRequest { before: CompactQuotaDecision; after: CompactQuotaDecision; resolved_agent_id: string | null; + owner: QuotaAccountingOwner; } interface QuotaSpendReplayRequest { @@ -102,6 +109,7 @@ interface QuotaSpendReplayRequest { effect_id: string; resolved_agent_id: string | null; read_only: boolean; + owner: QuotaAccountingOwner; } type SpendDisposition = @@ -224,11 +232,12 @@ function replayRequestObject(value: unknown): QuotaSpendReplayRequest { if (!isAbsolute(runtimeRoot)) { throw new EffectRuntimeRequestError("runtime_root must be absolute"); } + const goalId = safeGoalId(request.goal_id); return { schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, operation: "replay", runtime_root: runtimeRoot, - goal_id: safeGoalId(request.goal_id), + goal_id: goalId, effect_id: requiredString(request.effect_id, "effect_id").trim(), resolved_agent_id: optionalString( request.resolved_agent_id, @@ -237,6 +246,12 @@ function replayRequestObject(value: unknown): QuotaSpendReplayRequest { read_only: request.read_only === undefined ? false : requiredBoolean(request.read_only, "read_only"), + owner: parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot, + goalId, + }), }; } @@ -261,6 +276,12 @@ function requestObject(value: unknown): QuotaSpendCommitRequest { `quota slot spend source must be one of: ${QUOTA_SPEND_SOURCES.join(", ")}`, ); const requestedEffectId = optionalString(request.effect_id, "effect_id")?.trim(); + const owner = parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot: runtimeRoot ?? "", + goalId, + }); return { schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, effect_id: requestedEffectId || derivedEffectId( @@ -286,6 +307,7 @@ function requestObject(value: unknown): QuotaSpendCommitRequest { request.resolved_agent_id, "resolved_agent_id", )?.trim() ?? null, + owner, }; } @@ -321,6 +343,7 @@ function derivedEffectId( } function requestDigest(request: QuotaSpendCommitRequest): string { + const goalRef = quotaGoalRef(request.owner); return sha256(canonicalJson({ schema_version: request.schema_version, effect_id: request.effect_id, @@ -334,6 +357,7 @@ function requestDigest(request: QuotaSpendCommitRequest): string { before: request.before, after: request.after, resolved_agent_id: request.resolved_agent_id, + ...(goalRef === null ? {} : { goal_ref: goalRef }), })); } @@ -495,6 +519,11 @@ function buildSpendRecord( quota_event: quotaEvent, quota_spend_commit: commit, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) { + record.goal_ref = goalRef; + quotaEvent.goal_ref = goalRef; + } if (request.resolved_agent_id) { record.agent_id = request.resolved_agent_id; quotaEvent.agent_id = request.resolved_agent_id; @@ -535,11 +564,17 @@ async function evaluateQuotaSpendReplay( "runs", "index.jsonl", ); - const lookup = await lookupQuotaAccountingReplay( - "spend", - indexPath, - request.effect_id, - request.read_only, + const goalRef = quotaGoalRef(request.owner); + const lookup = await withQuotaAccountingOwner( + request.owner, + async (indexLockHeld) => await lookupQuotaAccountingReplay( + "spend", + indexPath, + request.effect_id, + request.read_only, + goalRef, + indexLockHeld, + ), ); const requestFingerprint = sha256(canonicalJson(value)); if (lookup.resolution.kind === "conflict") { @@ -563,7 +598,7 @@ async function evaluateQuotaSpendReplay( effect_ref: request.effect_id, reason: lookup.resolution.reason, }, - reason_code: "effect_id_conflict", + reason_code: lookup.resolution.reasonCode, }; } const candidate = lookup.resolution.kind === "matched" @@ -662,6 +697,8 @@ function indexRecordFor( request_digest: fingerprint, }, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) indexRecord.goal_ref = goalRef; for (const field of [ "agent_id", "effect_ref", @@ -711,6 +748,8 @@ function payloadFor( : `${request.execute ? "appended" : "dry-run preview"} quota slot spend event: ` + `${request.goal_id} ${request.before.spent_slots}->${request.after.spent_slots} slots`, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) payload.goal_ref = goalRef; if (request.execute) { payload.before = request.before; payload.after = request.after; @@ -800,40 +839,46 @@ export async function evaluateQuotaSpendCommit( ); } - const outcome = await commitQuotaAccountingArtifactTransaction({ - kind: "spend", - runsDir, - generatedAt: request.generated_at, - effectId: request.effect_id, - requestDigest: fingerprint, - expectedIndexDigest: request.expected_index_digest, - prepare: ({ jsonPath, markdownPath, indexPath: lockedIndexPath }) => { - const payload = payloadFor( - request, - record, - jsonPath, - markdownPath, - lockedIndexPath, - { appended: true, replayed: false, repaired: false }, - ); - return { - kind: "prepared", - record, - indexRecord: indexRecordFor( + const goalRef = quotaGoalRef(request.owner); + const outcome = await withQuotaAccountingOwner( + request.owner, + async (indexLockHeld) => await commitQuotaAccountingArtifactTransaction({ + kind: "spend", + runsDir, + generatedAt: request.generated_at, + effectId: request.effect_id, + requestDigest: fingerprint, + expectedIndexDigest: request.expected_index_digest, + ...(goalRef === null ? {} : { goalRef }), + indexLockHeld, + prepare: ({ jsonPath, markdownPath, indexPath: lockedIndexPath }) => { + const payload = payloadFor( request, record, jsonPath, markdownPath, - fingerprint, - ), - markdown: renderQuotaSlotMarkdown( + lockedIndexPath, + { appended: true, replayed: false, repaired: false }, + ); + return { + kind: "prepared", + record, + indexRecord: indexRecordFor( + request, + record, + jsonPath, + markdownPath, + fingerprint, + ), + markdown: renderQuotaSlotMarkdown( + payload, + QUOTA_SLOT_SPENT_CLASSIFICATION, + ), payload, - QUOTA_SLOT_SPENT_CLASSIFICATION, - ), - payload, - }; - }, - }); + }; + }, + }), + ); if (outcome.status === "conflict") { return result( diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py index 62f95266d..dd5f35de3 100644 --- a/loopx/control_plane/quota/unsettled_host_turn.py +++ b/loopx/control_plane/quota/unsettled_host_turn.py @@ -28,6 +28,7 @@ CloseoutQueryUnavailableError, HeartbeatReceiptIdentityConflictError, ) +from .accounting_admission import quota_accounting_admission UNSETTLED_HOST_TURN_RECOVERY_SCHEMA_VERSION = "unsettled_host_turn_recovery_v0" @@ -104,6 +105,8 @@ def _prior_closeout_preflight( goal_id: str, agent_id: str, current_turn_instance_id: str | None, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> tuple[dict[str, Any], list[str], dict[str, Any]] | None: """Ask the typed owner which prior Turn must still be closed out. @@ -113,17 +116,42 @@ def _prior_closeout_preflight( """ try: - result = effect_runtime_result( - PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_METHOD, - { - "schema_version": PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, - "runtime_root": str(runtime_root.expanduser()), - "goal_id": goal_id, - "agent_id": agent_id, - "exclude_turn_instance_id": current_turn_instance_id, - }, - timeout=PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_TIMEOUT_SECONDS, - ) + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=goal_id, + goal_ref=goal_ref, + operation="prior-host-turn-closeout-preflight", + lock_legacy_index=False, + ) as source_admission: + request_runtime_root = ( + runtime_root.expanduser().resolve() + if source_admission is not None + else runtime_root.expanduser() + ) + result = effect_runtime_result( + PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_METHOD, + { + "schema_version": ( + PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA + ), + "runtime_root": str(request_runtime_root), + "goal_id": goal_id, + "agent_id": agent_id, + "exclude_turn_instance_id": current_turn_instance_id, + **( + {"goal_ref": dict(goal_ref)} + if goal_ref is not None + else {} + ), + **( + {"source_admission": dict(source_admission)} + if source_admission is not None + else {} + ), + }, + timeout=PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_TIMEOUT_SECONDS, + ) except EffectRuntimeResponseAmbiguous as exc: # This method only reads receipts. A lost query response is not a # possibly committed mutation, and must not send the operator hunting @@ -171,6 +199,7 @@ def _unsettled_host_turn_recovery( goal_id: str, agent_id: str | None, current_turn_instance_id: str | None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any] | None: if not agent_id or not current_turn_instance_id: return None @@ -179,6 +208,8 @@ def _unsettled_host_turn_recovery( goal_id=goal_id, agent_id=agent_id, current_turn_instance_id=current_turn_instance_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if preflight is None: return None @@ -244,6 +275,7 @@ def apply_unsettled_host_turn_recovery_if_required( scheduler_execution_context: ( Mapping[str, Any] | SchedulerExecutionContextResolution | None ), + goal_ref: Mapping[str, Any] | None = None, ) -> bool: """Preempt ordinary selection when the preceding host Turn lacks closeout.""" @@ -253,6 +285,7 @@ def apply_unsettled_host_turn_recovery_if_required( goal_id=goal_id, agent_id=agent_id, current_turn_instance_id=current_turn_instance_id, + goal_ref=goal_ref, ) if verdict is None: return False diff --git a/loopx/control_plane/quota/unsettled_host_turn_recovery.ts b/loopx/control_plane/quota/unsettled_host_turn_recovery.ts index c892b3727..bdeb187fd 100644 --- a/loopx/control_plane/quota/unsettled_host_turn_recovery.ts +++ b/loopx/control_plane/quota/unsettled_host_turn_recovery.ts @@ -39,9 +39,15 @@ import { import { committedMonitorPollFromSnapshot, QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, - readQuotaSettlementFromSnapshot, + readAdmittedQuotaSettlementFromSnapshot, readQuotaSettlementSnapshot, } from "./settlement_readback.ts"; +import { + parseQuotaAccountingOwner, + quotaOwnerOwnsProjection, + withQuotaAccountingOwner, + type QuotaAccountingOwner, +} from "./source_admission.ts"; export const PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA = "loopx_prior_host_turn_closeout_preflight_request_v0"; @@ -83,6 +89,8 @@ interface PreflightRequest { goal_id: string; agent_id: string; exclude_turn_instance_id: string | null; + owner: QuotaAccountingOwner; + owner_projection: JsonObject; } function decodePreflightRequest(value: unknown): PreflightRequest { @@ -92,13 +100,31 @@ function decodePreflightRequest(value: unknown): PreflightRequest { "Prior host Turn closeout preflight request schema mismatch", ); } + const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + const owner = parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot, + goalId, + }); return { - runtime_root: requireNonEmptyString(request.runtime_root, "runtime_root"), - goal_id: requireNonEmptyString(request.goal_id, "goal_id"), + runtime_root: runtimeRoot, + goal_id: goalId, agent_id: requireNonEmptyString(request.agent_id, "agent_id"), exclude_turn_instance_id: optionalHeartbeatString( request.exclude_turn_instance_id, ), + owner, + owner_projection: owner.kind === "alias" + ? {} + : { + goal_ref: requireJsonObject(request.goal_ref, "goal_ref"), + source_admission: requireJsonObject( + request.source_admission, + "source_admission", + ), + }, }; } @@ -186,6 +212,7 @@ function settlementReadbackRequest( : null, infer_turn_instance_id: false, allow_unbound_binding: false, + ...request.owner_projection, }; } @@ -209,10 +236,9 @@ function bundleFailed(readback: JsonObject, step: string): boolean { * Turn's settlement so a Turn that already settled never makes the caller read * bound facts. */ -export async function preflightPriorHostTurnCloseout( - value: unknown, +async function preflightPriorHostTurnCloseoutForOwner( + request: PreflightRequest, ): Promise { - const request = decodePreflightRequest(value); const rolloutSnapshot = await readGoalRolloutEventSnapshot( request.runtime_root, request.goal_id, @@ -221,6 +247,8 @@ export async function preflightPriorHostTurnCloseout( rolloutSnapshot, request.goal_id, request.agent_id, + )?.filter((event) => + quotaOwnerOwnsProjection(request.owner, event.goal_ref) ); const { candidates, turnsValidated } = selectCloseoutCandidates( receipts ?? [], @@ -244,7 +272,7 @@ export async function preflightPriorHostTurnCloseout( let newestSettledTurn: string | null = null; let newestAcceptedCloseout: AcceptedCloseout = "validated_writeback_and_quota_spend"; for (const selected of candidates) { - const readback = readQuotaSettlementFromSnapshot( + const readback = readAdmittedQuotaSettlementFromSnapshot( settlementReadbackRequest(request, selected), settlementSnapshot, ); @@ -273,7 +301,7 @@ export async function preflightPriorHostTurnCloseout( goal_id: request.goal_id, agent_id: request.agent_id, turn_instance_id: selected.prior_turn_instance_id, todo_id: selected.binding_id, - }) : null; + }, request.owner) : null; return { schema_version: PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_RESULT_SCHEMA, status: "candidate", @@ -295,6 +323,16 @@ export async function preflightPriorHostTurnCloseout( }; } +export async function preflightPriorHostTurnCloseout( + value: unknown, +): Promise { + const request = decodePreflightRequest(value); + return await withQuotaAccountingOwner( + request.owner, + async () => preflightPriorHostTurnCloseoutForOwner(request), + ); +} + function decodeCandidate(value: unknown): PriorHostTurnCloseoutCandidate { const raw = requireJsonObject(value, "prior host Turn recovery candidate"); const bindingKind = raw.binding_kind; diff --git a/loopx/control_plane/quota/void_commit.py b/loopx/control_plane/quota/void_commit.py index 04b946925..652b903e7 100644 --- a/loopx/control_plane/quota/void_commit.py +++ b/loopx/control_plane/quota/void_commit.py @@ -6,8 +6,13 @@ from uuid import uuid4 from ...file_lock import exclusive_file_lock -from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from ..effect_runtime import ( + EffectRuntimeConflict, + EffectRuntimeRejected, + effect_runtime_result, +) from ..runtime.time import now_local_iso +from .accounting_admission import quota_accounting_admission from .spend_commit import quota_spend_index_digest from .spend_sources import DEFAULT_SLOT_SPEND_SOURCE @@ -54,7 +59,7 @@ def _void_result( ) -> Mapping[str, Any]: try: result = effect_runtime_result("quota.void.commit", dict(params)) - except EffectRuntimeRejected as exc: + except (EffectRuntimeConflict, EffectRuntimeRejected) as exc: raise ValueError(str(exc)) from None if ( not isinstance(result, Mapping) @@ -132,6 +137,8 @@ def commit_quota_slot_void( effect_id: str | None = None, generated_at: str | None = None, _operation: str = "commit", + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Execute one TypeScript-owned quota void transaction.""" @@ -154,15 +161,29 @@ def commit_quota_slot_void( "before": dict(before), } - if execute: - # Legacy Python run writers still use the kernel lock. Hold it across - # the one native transaction until every index writer is in-process TS. - with exclusive_file_lock(index_path, operation="quota_void_commit"): + if registry_path is None and goal_ref is None: + if execute: + with exclusive_file_lock(index_path, operation="quota_void_commit"): + params["expected_index_digest"] = quota_spend_index_digest(index_path) + result = _void_result(params, expected_goal_id=safe_goal_id) + else: params["expected_index_digest"] = quota_spend_index_digest(index_path) result = _void_result(params, expected_goal_id=safe_goal_id) else: - params["expected_index_digest"] = quota_spend_index_digest(index_path) - result = _void_result(params, expected_goal_id=safe_goal_id) + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=safe_goal_id, + goal_ref=goal_ref, + operation="quota_void_commit", + lock_legacy_index=execute, + ) as source_admission: + params["expected_index_digest"] = quota_spend_index_digest(index_path) + if goal_ref is not None: + params["goal_ref"] = dict(goal_ref) + if source_admission is not None: + params["source_admission"] = dict(source_admission) + result = _void_result(params, expected_goal_id=safe_goal_id) return _result_payload(result) @@ -217,6 +238,8 @@ def record_quota_slot_void_from_preview( execute: bool = False, source: str = DEFAULT_SLOT_SPEND_SOURCE, reason_summary: str | None = None, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: del render_markdown if not preview.get("ok"): @@ -240,4 +263,6 @@ def record_quota_slot_void_from_preview( execute=execute, source=source, reason_summary=reason_summary, + registry_path=registry_path, + goal_ref=goal_ref, ) diff --git a/loopx/control_plane/quota/void_commit.ts b/loopx/control_plane/quota/void_commit.ts index 9d2448a70..abb056dec 100644 --- a/loopx/control_plane/quota/void_commit.ts +++ b/loopx/control_plane/quota/void_commit.ts @@ -21,6 +21,13 @@ import { requireNonEmptyString as requiredString, requireStringLiteral, } from "../runtime_decode.ts"; +import { + parseQuotaAccountingOwner, + quotaGoalRef, + requireQuotaOwnerProjection, + withQuotaAccountingOwner, + type QuotaAccountingOwner, +} from "./source_admission.ts"; export const QUOTA_VOID_COMMIT_REQUEST_SCHEMA = "loopx_quota_void_commit_request_v0"; @@ -63,6 +70,7 @@ interface QuotaVoidCommitRequest { execute: boolean; expected_index_digest: string | null; before: JsonObject; + owner: QuotaAccountingOwner; } interface QuotaVoidProjectionRequest { @@ -173,12 +181,13 @@ function commitRequest(value: unknown): QuotaVoidCommitRequest { "quota void preview operation cannot execute durable effects", ); } + const goalId = safeGoalId(request.goal_id); return { schema_version: QUOTA_VOID_COMMIT_REQUEST_SCHEMA, operation, effect_id: effectId, runtime_root: runtimeRoot, - goal_id: safeGoalId(request.goal_id), + goal_id: goalId, voided_run_generated_at: typeof request.voided_run_generated_at === "string" ? request.voided_run_generated_at.trim() @@ -200,6 +209,12 @@ function commitRequest(value: unknown): QuotaVoidCommitRequest { "expected_index_digest", ), before, + owner: parseQuotaAccountingOwner({ + goalRefValue: request.goal_ref, + sourceAdmissionValue: request.source_admission, + runtimeRoot, + goalId, + }), }; } @@ -227,6 +242,7 @@ function projectionRequest(value: unknown): QuotaVoidProjectionRequest { } function requestDigest(request: QuotaVoidCommitRequest): string { + const goalRef = quotaGoalRef(request.owner); return sha256(canonicalJson({ schema_version: request.schema_version, effect_id: request.effect_id, @@ -236,6 +252,7 @@ function requestDigest(request: QuotaVoidCommitRequest): string { source: request.source, reason_summary: request.reason_summary, before: request.before, + ...(goalRef === null ? {} : { goal_ref: goalRef }), })); } @@ -406,6 +423,7 @@ async function findTargetSpend( records: readonly JsonObject[], goalId: string, generatedAt: string, + owner: QuotaAccountingOwner, ): Promise { for (const run of [...records].reverse()) { if (String(run.goal_id || goalId) !== goalId) continue; @@ -413,6 +431,12 @@ async function findTargetSpend( if (run.classification !== QUOTA_SLOT_SPENT_CLASSIFICATION) continue; const event = await readTargetEvent(runsDir, run, goalId); if (event?.event_type !== QUOTA_SLOT_SPENT_CLASSIFICATION) continue; + requireQuotaOwnerProjection(owner, run.goal_ref, "quota void target"); + requireQuotaOwnerProjection( + owner, + event.goal_ref, + "quota void target event", + ); return { run, event }; } return null; @@ -459,7 +483,7 @@ function previewFor( legacyInteger(beforeQuota.spent_slots, 0) - slots, ); after.quota = afterQuota; - return { + const preview: JsonObject = { ok: true, mode: "void-slot", dry_run: true, @@ -479,6 +503,9 @@ function previewFor( rolling_window_note: ROLLING_WINDOW_NOTE, classification: QUOTA_SLOT_VOIDED_CLASSIFICATION, }; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) preview.goal_ref = goalRef; + return preview; } function recordFor( @@ -488,6 +515,7 @@ function recordFor( generatedAt: string, effectId: string | null, fingerprint: string, + goalRef: JsonObject | null = null, ): JsonObject { if (preview.ok !== true) { throw new EffectRuntimeRequestError( @@ -531,6 +559,10 @@ function recordFor( request_digest: fingerprint, }; } + if (goalRef !== null) { + record.goal_ref = goalRef; + event.goal_ref = goalRef; + } return record; } @@ -550,6 +582,7 @@ function artifactsFor( request.generated_at, request.effect_id, fingerprint, + quotaGoalRef(request.owner), ); const event = requiredObject(record.quota_event, "record.quota_event"); const payload: JsonObject = { @@ -586,6 +619,11 @@ function artifactsFor( quota_void_commit: record.quota_void_commit, }; if (record.agent_id) indexRecord.agent_id = record.agent_id; + const goalRef = quotaGoalRef(request.owner); + if (goalRef !== null) { + indexRecord.goal_ref = goalRef; + payload.goal_ref = goalRef; + } return { kind: "prepared", record, @@ -609,6 +647,7 @@ async function prepareArtifacts( context.indexRecords, request.goal_id, request.voided_run_generated_at, + request.owner, ); if (!target) { const payload = missingTargetPayload( @@ -680,6 +719,7 @@ async function previewCommit( records, request.goal_id, request.voided_run_generated_at, + request.owner, ); if (!target) { const payload = missingTargetPayload( @@ -754,72 +794,77 @@ async function evaluateCommit( request.goal_id, "runs", ); - if (!request.execute) { - return await previewCommit(request, fingerprint, runsDir); - } - const outcome = await commitQuotaAccountingArtifactTransaction({ - kind: "void", - runsDir, - generatedAt: request.generated_at, - effectId: request.effect_id, - requestDigest: fingerprint, - expectedIndexDigest: request.expected_index_digest, - prepare: async (context) => - await prepareArtifacts(request, fingerprint, runsDir, context), - }); - if (outcome.status === "conflict") { - return result( - request.effect_id, - fingerprint, - "conflict", - outcome.indexDigest, - outcome.reason, - null, - { - ok: false, - mode: "void-slot", - goal_id: request.goal_id, - effect_id: request.effect_id, - appended: false, - registry_mutated: false, - }, - outcome.reasonCode, - ); - } - if (outcome.status === "not_found") { + return await withQuotaAccountingOwner(request.owner, async (indexLockHeld) => { + if (!request.execute) { + return await previewCommit(request, fingerprint, runsDir); + } + const goalRef = quotaGoalRef(request.owner); + const outcome = await commitQuotaAccountingArtifactTransaction({ + kind: "void", + runsDir, + generatedAt: request.generated_at, + effectId: request.effect_id, + requestDigest: fingerprint, + expectedIndexDigest: request.expected_index_digest, + ...(goalRef === null ? {} : { goalRef }), + indexLockHeld, + prepare: async (context) => + await prepareArtifacts(request, fingerprint, runsDir, context), + }); + if (outcome.status === "conflict") { + return result( + request.effect_id, + fingerprint, + "conflict", + outcome.indexDigest, + outcome.reason, + null, + { + ok: false, + mode: "void-slot", + goal_id: request.goal_id, + effect_id: request.effect_id, + appended: false, + registry_mutated: false, + }, + outcome.reasonCode, + ); + } + if (outcome.status === "not_found") { + return result( + request.effect_id, + fingerprint, + "not_found", + outcome.indexDigest, + outcome.reason, + null, + outcome.payload, + "target_not_found", + ); + } + const replayed = outcome.status === "replayed"; + const repaired = outcome.status === "repaired"; + const responsePayload: JsonObject = { + ...outcome.receipt.payload, + appended: outcome.status === "written" || repaired, + idempotent_replay: replayed, + transaction_repaired: repaired, + reason: replayed + ? "quota void commit replayed for the same effect identity" + : repaired + ? "quota void commit repaired its prepared durable transaction" + : outcome.receipt.payload.reason, + }; return result( request.effect_id, fingerprint, - "not_found", + outcome.status, outcome.indexDigest, - outcome.reason, - null, - outcome.payload, - "target_not_found", + String(responsePayload.reason ?? ""), + outcome.receipt.record, + responsePayload, ); - } - const replayed = outcome.status === "replayed"; - const repaired = outcome.status === "repaired"; - const responsePayload: JsonObject = { - ...outcome.receipt.payload, - appended: outcome.status === "written" || repaired, - idempotent_replay: replayed, - transaction_repaired: repaired, - reason: replayed - ? "quota void commit replayed for the same effect identity" - : repaired - ? "quota void commit repaired its prepared durable transaction" - : outcome.receipt.payload.reason, - }; - return result( - request.effect_id, - fingerprint, - outcome.status, - outcome.indexDigest, - String(responsePayload.reason ?? ""), - outcome.receipt.record, - responsePayload, - ); + }); } function evaluateProjection( diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index f0359a6c8..da2bfe524 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -153,6 +153,8 @@ def collect_status( activation_filter is GoalActivationState.STOPPED ), events_for_goal=rollout_events.events_for_goal, + current_registry=registry, + registry_path=registry_path, ) # No later projection consumes canonical rows; release retained archive # data before assembling the rest of the display. diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index 99e941cfc..267883d09 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -998,6 +998,11 @@ def _ensure_turn_settlement_plan( transaction_plan.get("turn_instance_id") or transaction_plan.get("turn_key") ), + goal_ref=( + plan.get("goal_ref") + if isinstance(plan.get("goal_ref"), Mapping) + else None + ), ) settlement_plan = built.get("settlement_plan") if isinstance(settlement_plan, Mapping): diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 1d17b84dd..5dd272d20 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -9,6 +9,7 @@ import { } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { normalizeVisionUnchangedReason } from "../goals/vision_checkpoint.ts"; +import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { projectMcpInteraction } from "./host_interaction.ts"; import { requireBoolean, @@ -55,6 +56,7 @@ interface HostTodoCompletionRequest { vision_path: string | null; vision_unchanged_reason: string | null; checkpoint_read_context_id: string | null; + goal_instance_id: string | null; provider_outcomes: readonly ProviderOutcome[]; } @@ -148,9 +150,24 @@ function decodeRequest( if (readContextId && phase !== "vision_refresh") { throw new EffectRuntimeRequestError("checkpoint read context belongs only to vision recovery"); } + const goalId = requireNonEmptyString(value.goal_id, "goal_id"); + const parsedGoalRef = value.goal_ref === undefined + ? null + : parseExactGoalRef(value.goal_ref); + if (parsedGoalRef?.kind === "invalid") { + throw new EffectRuntimeRequestError("host Todo completion GoalRef is malformed"); + } + if ( + parsedGoalRef?.kind === "parsed" + && parsedGoalRef.value.goalId.value !== goalId + ) { + throw new EffectRuntimeRequestError( + "host Todo completion GoalRef does not match goal_id", + ); + } const request: HostTodoCompletionRequest = { phase, - goal_id: requireNonEmptyString(value.goal_id, "goal_id"), + goal_id: goalId, agent_id: requireNonEmptyString(value.agent_id, "agent_id"), todo_id: todoId, runtime_profile: requireNonEmptyString( @@ -177,6 +194,9 @@ function decodeRequest( vision_path: visionPath, vision_unchanged_reason: unchanged, checkpoint_read_context_id: readContextId, + goal_instance_id: parsedGoalRef?.kind === "parsed" + ? parsedGoalRef.value.goalInstanceId.value + : null, provider_outcomes: [], }; if (phase === "finalize") { @@ -344,6 +364,9 @@ function writebackArgs(request: HostTodoCompletionRequest, identity: JsonObject) "--classification", "mcp_completed_turn_writeback", "--delivery-batch-scale", "single_surface", "--delivery-outcome", "outcome_progress", "--todo-id", request.todo_id, "--turn-instance-id", String(identity.turn_instance_id), + ...(request.goal_instance_id + ? ["--goal-instance-id", request.goal_instance_id] + : []), "--completion-todo-id", request.todo_id, "--completion-turn-key", String(identity.effect_id), "--no-global-sync", "--suppress-external-sinks", ...(request.vision_path ? ["--agent-vision-json", request.vision_path] : []), @@ -368,10 +391,16 @@ function providerSteps( request.todo_id, "--turn-instance-id", turnId, + ...(request.goal_instance_id + ? ["--goal-instance-id", request.goal_instance_id] + : []), ]; const lifecycleArgs = request.completion_args.filter( (arg) => arg !== "--no-follow-up", ); + if (request.goal_instance_id) { + lifecycleArgs.push("--goal-instance-id", request.goal_instance_id); + } const steps: ProviderStep[] = [ { step_kind: "guard", @@ -417,6 +446,9 @@ function providerSteps( request.todo_id, "--turn-instance-id", turnId, + ...(request.goal_instance_id + ? ["--goal-instance-id", request.goal_instance_id] + : []), ], legacy_args: null, continue_when: request.no_follow_up @@ -427,7 +459,14 @@ function providerSteps( if (request.no_follow_up) { steps.push({ step_kind: "terminal_closeout", - args: [...request.completion_args, "--turn-instance-id", turnId], + args: [ + ...request.completion_args, + "--turn-instance-id", + turnId, + ...(request.goal_instance_id + ? ["--goal-instance-id", request.goal_instance_id] + : []), + ], legacy_args: null, continue_when: null, }); @@ -981,7 +1020,10 @@ export function evaluateHostTodoCompletion(value: JsonObject): JsonObject { schema_version: HOST_TODO_COMPLETION_REDUCTION_SCHEMA_VERSION, phase, identity, args: ["checkpoint-context", "--goal-id", request.goal_id, "--agent-id", request.agent_id, "--todo-id", request.todo_id, - "--turn-instance-id", String(identity.turn_instance_id)], + "--turn-instance-id", String(identity.turn_instance_id), + ...(request.goal_instance_id + ? ["--goal-instance-id", request.goal_instance_id] + : [])], }; } if (phase === "vision_refresh") { diff --git a/loopx/control_plane/turn_driver/transaction.py b/loopx/control_plane/turn_driver/transaction.py index 2722a3940..b96ece6d3 100644 --- a/loopx/control_plane/turn_driver/transaction.py +++ b/loopx/control_plane/turn_driver/transaction.py @@ -199,6 +199,8 @@ def build_loopx_turn_transaction_plan( } if planned: plan["settlement_plan"] = settlement_plan.as_dict() + if goal_ref is not None: + plan["settlement_plan"]["goal_ref"] = dict(goal_ref) if normalized_instance_id is not None: plan["turn_instance_id"] = normalized_instance_id if goal_ref is not None: diff --git a/loopx/control_plane/work_items/accountable_settlement.py b/loopx/control_plane/work_items/accountable_settlement.py index 81fcfb797..7fd8627b8 100644 --- a/loopx/control_plane/work_items/accountable_settlement.py +++ b/loopx/control_plane/work_items/accountable_settlement.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Mapping + from ...turn_identity import normalize_turn_instance_id from ..quota.settlement import ( SettlementPlan, @@ -25,6 +27,7 @@ def build_accountable_work_item_settlement_plan( turn_instance_id: str | None, delivery_boundary: str | None = None, command_prefix: str = "loopx", + goal_ref: Mapping[str, object] | None = None, ) -> SettlementPlan | None: if runtime_profile in APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES: normalized_turn_instance_id = normalize_turn_instance_id(turn_instance_id) @@ -38,6 +41,7 @@ def build_accountable_work_item_settlement_plan( lifecycle_actor_args=lifecycle_actor_args, turn_instance_id_ref=normalized_turn_instance_id, delivery_boundary=delivery_boundary, + goal_ref=goal_ref, ) if runtime_profile in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES: normalized_turn_instance_id = normalize_turn_instance_id(turn_instance_id) @@ -54,6 +58,7 @@ def build_accountable_work_item_settlement_plan( turn_instance_id=normalized_turn_instance_id, delivery_boundary=delivery_boundary, quota_spend_source="visible-goal", + goal_ref=goal_ref, ) if runtime_profile is not SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP: return None @@ -70,4 +75,5 @@ def build_accountable_work_item_settlement_plan( lifecycle_actor_args=lifecycle_actor_args, turn_instance_id=normalized_turn_instance_id, delivery_boundary=delivery_boundary, + goal_ref=goal_ref, ) diff --git a/loopx/control_plane/work_items/action_selection_contract.py b/loopx/control_plane/work_items/action_selection_contract.py index 9f22663ca..cbcc5be00 100644 --- a/loopx/control_plane/work_items/action_selection_contract.py +++ b/loopx/control_plane/work_items/action_selection_contract.py @@ -359,6 +359,7 @@ def action_selection_recovery_command( *, registry_path: str | None = None, runtime_root: str | None = None, goal_id: str, agent_id: str | None, turn_instance_id: str | None, scheduler_args: str, available_capabilities: Any = None, + goal_ref: Mapping[str, object] | None = None, ) -> str: argv = ["loopx"] if registry_path: @@ -370,6 +371,18 @@ def action_selection_recovery_command( argv.extend(["--agent-id", agent_id]) if turn_instance_id: argv.extend(["--turn-instance-id", turn_instance_id]) + if goal_ref is not None: + from ..goals.source_session_registry_state import exact_goal_ref + + normalized_goal_ref = exact_goal_ref( + str(goal_ref.get("goal_id") or ""), + str(goal_ref.get("goal_instance_id") or ""), + ) + if normalized_goal_ref["goal_id"] != goal_id: + raise ValueError("action selection GoalRef does not match goal_id") + argv.extend( + ["--goal-instance-id", normalized_goal_ref["goal_instance_id"]] + ) for capability in runtime_capabilities_for_cli_projection(available_capabilities): argv.extend(["--available-capability", capability]) return shlex.join(argv) + scheduler_args @@ -387,6 +400,7 @@ def bind_action_selection_recovery_command( payload: dict[str, Any], *, registry_path: str, runtime_root: str, goal_id: str, agent_id: str | None, turn_instance_id: str | None, scheduler_args: str, available_capabilities: Any = None, + goal_ref: Mapping[str, object] | None = None, ) -> None: """Bind the existing recovery projection to the invoking CLI's exact argv.""" if not action_selection_needs_recovery(payload): @@ -395,6 +409,7 @@ def bind_action_selection_recovery_command( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, agent_id=agent_id, turn_instance_id=turn_instance_id, scheduler_args=scheduler_args, available_capabilities=available_capabilities, + goal_ref=goal_ref, ) interaction = payload["interaction_contract"] interaction["agent_channel"]["primary_action"] = command diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index d4aa1dbd6..3e39dc9ab 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -550,6 +550,26 @@ def _scoped_cli_args( return f" --agent-id {agent_id}{capability_args}" +def _goal_ref_cli_arg(payload: Mapping[str, Any]) -> str: + value = payload.get("goal_ref") + if value is None: + return "" + if not isinstance(value, Mapping): + raise ValueError("interaction GoalRef must be an object") + from ..goals.source_session_registry_state import exact_goal_ref + + goal_ref = exact_goal_ref( + str(value.get("goal_id") or ""), + str(value.get("goal_instance_id") or ""), + ) + if goal_ref["goal_id"] != str(payload.get("goal_id") or "").strip(): + raise ValueError("interaction GoalRef does not match goal_id") + return ( + " --goal-instance-id " + + shlex.quote(goal_ref["goal_instance_id"]) + ) + + def _turn_scoped_cli_settlement_context( payload: dict[str, Any], *, @@ -614,6 +634,11 @@ def _turn_scoped_cli_settlement_context( == "in_flight_continuation" else None ), + goal_ref=( + payload.get("goal_ref") + if isinstance(payload.get("goal_ref"), Mapping) + else None + ), ) return ( plan.as_dict() if plan is not None else None, @@ -678,7 +703,7 @@ def _selection_recovery_command( agent_id=identity.get("agent_id"), runtime_root=runtime_root, turn_instance_id=turn_instance_id, available_capabilities=available_capabilities, scheduler_args=render_scheduler_execution_args(scheduler_execution_context=scheduler_execution_context), - ) + ) + _goal_ref_cli_arg(payload) def _render_replan_successor_closeout_guard( @@ -750,10 +775,11 @@ def interaction_next_cli_actions( runtime_root=runtime_root, ) if selection_command_template: - return [selection_command_template] + return [selection_command_template + _goal_ref_cli_arg(payload)] + goal_ref_arg = _goal_ref_cli_arg(payload) typed_quota_guard = ( f"{command_prefix} --format json quota should-run --goal-id {goal_id}" - f"{scoped_cli_args}{scheduler_args}" + f"{scoped_cli_args}{scheduler_args}{goal_ref_arg}" if scheduler_args else "rerun the typed quota_guard from the current host packet" ) @@ -763,7 +789,7 @@ def interaction_next_cli_actions( return [turn_reentry_action] typed_monitor_poll = ( f"{command_prefix} quota monitor-poll --goal-id {goal_id}{scoped_cli_args}" - f"{scheduler_args} --execute" + f"{scheduler_args}{goal_ref_arg} --execute" if scheduler_args else "use the current host packet's typed monitor command" ) @@ -1429,6 +1455,7 @@ def _build_interaction_cli_channel( f"{shlex.quote(safe_turn_instance_id)} --todo-id " f"{shlex.quote(selected_monitor_id)}{target_args} --use-current-task-lease --result-hash " f'"${{{AUXILIARY_MONITOR_RESULT_HASH_ENV}:?}}"' + f"{_goal_ref_cli_arg(payload)}" ) auxiliary_projection.update( { diff --git a/loopx/goal_mode_mcp.py b/loopx/goal_mode_mcp.py index 1bec5811b..3a201afc0 100644 --- a/loopx/goal_mode_mcp.py +++ b/loopx/goal_mode_mcp.py @@ -5,7 +5,7 @@ import shutil import subprocess import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Annotated, Any @@ -62,6 +62,19 @@ def context(self) -> tuple[str | None, str | None]: def bound_agent_id(self) -> str | None: return self.state().get("agent_id") + def goal_ref(self) -> dict[str, str] | None: + value = self.state().get("goal_ref") + if not isinstance(value, Mapping): + return None + goal_id = value.get("goal_id") + goal_instance_id = value.get("goal_instance_id") + if not isinstance(goal_id, str) or not isinstance(goal_instance_id, str): + return None + return { + "goal_id": goal_id, + "goal_instance_id": goal_instance_id, + } + def command_prefix(self) -> list[str]: executable = shutil.which("loopx") return [executable] if executable else [sys.executable, "-m", "loopx.cli"] @@ -252,6 +265,7 @@ def complete_task( execution_mode=self.config.execution_mode, completion_args=tuple(args), no_follow_up=no_follow_up, + goal_ref=self.goal_ref(), ) with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: return settle_host_todo_completion(authored, run_cli=self.run_cli) @@ -274,6 +288,7 @@ def review_task_vision( scheduler_owner=self.config.scheduler_owner, execution_mode=self.config.execution_mode, completion_args=(), checkpoint_read_context_id=read_context_id or None, + goal_ref=self.goal_ref(), ) with host_vision_request(request, agent_vision, vision_unchanged_reason) as authored: return refresh_host_todo_vision(authored, run_cli=self.run_cli) diff --git a/loopx/quota.py b/loopx/quota.py index eb9026319..54c2cb23a 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -374,6 +374,25 @@ def goal_quota_with_spend_ledger( continue if str(run.get("goal_id") or goal_id) != goal_id: continue + goal_instance_id = ( + str(goal.get("goal_instance_id") or "").strip() + if goal is not None + else "" + ) + run_goal_ref = ( + run.get("goal_ref") + if isinstance(run.get("goal_ref"), Mapping) + else None + ) + if goal_instance_id: + if ( + run_goal_ref is None + or run_goal_ref.get("goal_id") != goal_id + or run_goal_ref.get("goal_instance_id") != goal_instance_id + ): + continue + elif run_goal_ref is not None: + continue generated_at = _parse_timestamp(run.get("generated_at")) if ( generated_at is None @@ -894,6 +913,7 @@ def build_quota_should_run( receipt_bound_replan_guard_scoped: bool = False, turn_instance_id: str | None = None, runtime_root: str | Path | None = None, + goal_ref: Mapping[str, object] | None = None, ) -> dict[str, Any]: from .control_plane.quota.should_run import ( build_quota_should_run as _build_quota_should_run, @@ -919,6 +939,7 @@ def build_quota_should_run( receipt_bound_replan_guard_scoped=receipt_bound_replan_guard_scoped, turn_instance_id=turn_instance_id, runtime_root=runtime_root, + goal_ref=goal_ref, ) @@ -962,6 +983,8 @@ def build_quota_slot_preview( turn_instance_id: str | None = None, effect_ref: str | None = None, source: str = DEFAULT_SLOT_SPEND_SOURCE, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _validate_goal_id_path_segment(str(goal_id or "")) basis_available, expected_index_digest = _quota_spend_index_basis( @@ -997,6 +1020,8 @@ def build_quota_slot_preview( replan_obligation_id=replan_obligation_id, turn_instance_id=turn_instance_id, source=source, + registry_path=registry_path, + goal_ref=goal_ref, ) if preview.get("ok") and basis_available: preview["expected_index_digest"] = expected_index_digest @@ -1114,6 +1139,7 @@ def record_quota_monitor_poll( Callable[..., Mapping[str, Any] | None] | None ) = None, status_reloader: Callable[[], dict[str, Any]] | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _validate_goal_id_path_segment(str(goal_id or "")) normalized_requested_todo_id = normalize_todo_id(todo_id) if todo_id else None @@ -1161,6 +1187,7 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: scheduler_execution_context=scheduler_execution_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, receipt_bound_todo_id=normalized_receipt_todo_id, + goal_ref=goal_ref, ) before = should_run(status_payload) @@ -1221,6 +1248,7 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: use_current_task_lease=use_current_task_lease, turn_instance_id=turn_instance_id, status_reloader=status_reloader, + goal_ref=goal_ref, ) continuation = result.get("turn_continuation") or {} if ( @@ -1244,6 +1272,8 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: agent_id=agent_id, todo_id=normalized_receipt_todo_id, turn_instance_id=turn_instance_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if readback is None or readback.identity.value is None: raise RuntimeError( @@ -1251,6 +1281,7 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: ) attach_settlement_progress( result, readback, registry_path=registry_path, runtime_root=runtime_root, + goal_ref=goal_ref, ) identity = readback.identity.value prefix = "loopx" @@ -1273,6 +1304,7 @@ def should_run(current_status: dict[str, Any]) -> dict[str, Any]: scoped_cli_args=scoped_args, lifecycle_actor_args="", quota_spend_source=readback.progress["quota_spend_source"], + goal_ref=goal_ref, ) result["settlement_resume"] = { "schema_version": "auxiliary_monitor_settlement_resume_v0", @@ -1337,6 +1369,8 @@ def void_quota_slot( reason_summary: str | None = None, agent_id: str | None = None, operator_inbox_urgency_projector: Callable[..., dict[str, Any]] | None = None, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _normalize_quota_void_goal_id(goal_id) before = build_quota_should_run( @@ -1353,6 +1387,8 @@ def void_quota_slot( execute=execute, source=source, reason_summary=reason_summary, + registry_path=registry_path, + goal_ref=goal_ref, ) @@ -1374,6 +1410,8 @@ def spend_quota_slot( replan_obligation_id: str | None = None, turn_instance_id: str | None = None, effect_ref: str | None = None, + registry_path: Path | None = None, + goal_ref: Mapping[str, Any] | None = None, ) -> dict[str, Any]: safe_goal_id = _validate_goal_id_path_segment(str(goal_id or "")) normalized_effect_ref = str(effect_ref or "").strip() @@ -1404,6 +1442,8 @@ def spend_quota_slot( effect_ref=normalized_effect_ref, agent_id=agent_id, read_only=not execute, + registry_path=registry_path, + goal_ref=goal_ref, ) if replay.get("replay_found"): if not replay.get("ok"): @@ -1428,6 +1468,11 @@ def spend_quota_slot( "agent_id": replay.get("agent_id"), "effect_ref": normalized_effect_ref, "reason": "quota spend replayed for the same provider effect", + **( + {"goal_ref": dict(goal_ref)} + if goal_ref is not None + else {} + ), } if turn_instance_id and source not in TURN_SCOPED_SLOT_SPEND_SOURCES: return { @@ -1463,6 +1508,8 @@ def spend_quota_slot( turn_instance_id=None, infer_turn_instance_id=True, allow_unbound_binding=recover_unbound_visible_goal, + registry_path=registry_path, + goal_ref=goal_ref, ) inferred_result = ( settlement_readback.identity @@ -1499,6 +1546,8 @@ def spend_quota_slot( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, ) if settlement_readback is None: raise RuntimeError("exact settlement readback unexpectedly returned not-found") @@ -1534,6 +1583,11 @@ def spend_quota_slot( "settlement_identity": identity.as_dict(), "settlement_result": settlement_result_payload(spent_result), "reason": "quota spend receipt replayed for the same settlement identity", + **( + {"goal_ref": dict(goal_ref)} + if goal_ref is not None + else {} + ), } prior_spend_run = settlement_readback.spend_run if prior_spend_run is not None: @@ -1551,6 +1605,11 @@ def spend_quota_slot( "settlement_identity": identity.as_dict(), "settlement_result": settlement_result_payload(spent_result), "reason": "quota spend run exists; repair its missing settlement receipt", + **( + {"goal_ref": dict(goal_ref)} + if goal_ref is not None + else {} + ), } preview = build_quota_slot_preview( status_payload, @@ -1566,6 +1625,8 @@ def spend_quota_slot( turn_instance_id=turn_instance_id, effect_ref=normalized_effect_ref or None, source=source, + registry_path=registry_path, + goal_ref=goal_ref, ) if not preview.get("ok"): return preview @@ -1576,4 +1637,6 @@ def spend_quota_slot( goal_id=safe_goal_id, execute=execute, source=source, + registry_path=registry_path, + goal_ref=goal_ref, ) diff --git a/loopx/rollout_event_log.py b/loopx/rollout_event_log.py index a0e4a6c33..de2f9be78 100644 --- a/loopx/rollout_event_log.py +++ b/loopx/rollout_event_log.py @@ -173,6 +173,24 @@ def _normalized_event_kind(event_kind: str) -> str: return text +def _safe_goal_ref( + value: Mapping[str, Any] | None, + *, + goal_id: str, +) -> dict[str, str] | None: + if value is None: + return None + from .control_plane.goals.source_session_registry_state import exact_goal_ref + + goal_ref = exact_goal_ref( + str(value.get("goal_id") or ""), + str(value.get("goal_instance_id") or ""), + ) + if goal_ref["goal_id"] != goal_id: + raise ValueError("rollout event goal_ref does not match goal_id") + return goal_ref + + def _event_id(payload: Mapping[str, Any]) -> str: """Identify one event occurrence, including its observation timestamp.""" @@ -249,6 +267,7 @@ def build_rollout_event( *, goal_id: str, event_kind: str, + goal_ref: Mapping[str, Any] | None = None, agent_id: str | None = None, todo_id: str | None = None, case_id: str | None = None, @@ -323,6 +342,9 @@ def build_rollout_event( "absolute_paths_recorded": False, }, } + safe_goal_ref = _safe_goal_ref(goal_ref, goal_id=safe_goal_id) + if safe_goal_ref is not None: + payload["goal_ref"] = safe_goal_ref optional_scalars = { "agent_id": agent_id, "todo_id": todo_id, @@ -454,7 +476,11 @@ def append_rollout_event_once( fields = tuple(str(field).strip() for field in identity_fields if str(field).strip()) if not fields: raise ValueError("rollout idempotency identity_fields are required") - missing = [field for field in fields if payload.get(field) in {None, ""}] + missing = [ + field + for field in fields + if payload.get(field) is None or payload.get(field) == "" + ] if missing: raise ValueError( "rollout idempotency fields must be populated: " + ", ".join(missing) diff --git a/loopx/semantics/goal_instance_binding_inventory_v1.json b/loopx/semantics/goal_instance_binding_inventory_v1.json index 9a3a42ad9..99db8db39 100644 --- a/loopx/semantics/goal_instance_binding_inventory_v1.json +++ b/loopx/semantics/goal_instance_binding_inventory_v1.json @@ -180,19 +180,21 @@ "locator": "/goals//runs/index.jsonl#quota_event", "revision_signal": "run_generated_at and effect_id", "content_digest_signal": "quota accounting artifact digest", - "observed_reference": "goal_id", + "observed_reference": "goal_id + goal_instance_id for source_session_v1; goal_id otherwise", "producer_sites": [ - "loopx/control_plane/quota/spend_commit.py::record_quota_slot_spend_from_preview" + "loopx/control_plane/quota/accounting_admission.py::quota_accounting_admission", + "loopx/control_plane/quota/spend_commit.py::record_quota_slot_spend_from_preview", + "loopx/control_plane/quota/void_commit.py::commit_quota_slot_void" ], "consumer_sites": [ - "loopx/control_plane/quota/settlement_readback.ts::readQuotaSettlementFromSnapshot", + "loopx/control_plane/quota/settlement_readback.ts::readQuotaSettlement", "loopx/control_plane/quota/slot_accounting.py::net_quota_slot_spend" ], "effect_boundary": "loopx/control_plane/quota/accounting_artifact_transaction.ts::commitQuotaAccountingArtifactTransaction", "authority_role": "quota_accounting_ledger", - "current_identity_strength": "goal_alias_only", + "current_identity_strength": "exact_goal_ref_enforced", "cleanup_support": "typed_quota_void", - "m1_disposition": "alias_only_inventory", + "m1_disposition": "m3_qualified", "target_milestone": "M3" }, { diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 9d8bb4483..5af26a744 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -743,7 +743,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#1", - "line": 560, + "line": 594, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -751,7 +751,7 @@ }, { "site": "loopx/cli_commands/project_lifecycle_refresh_state.py::.handle_refresh_state_command::codec_read:load_registry#2", - "line": 639, + "line": 675, "column": 17, "kind": "codec_read", "api": "load_registry", @@ -767,7 +767,7 @@ }, { "site": "loopx/cli_commands/quota.py::._dispatch_quota_turn_start_hooks::codec_read:load_registry#1", - "line": 271, + "line": 308, "column": 37, "kind": "codec_read", "api": "load_registry", @@ -855,7 +855,7 @@ }, { "site": "loopx/cli_commands/todo.py::._validated_replan_successor_obligation::codec_read:load_registry#1", - "line": 178, + "line": 187, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -863,7 +863,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#1", - "line": 309, + "line": 331, "column": 49, "kind": "codec_read", "api": "load_registry", @@ -871,7 +871,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#2", - "line": 325, + "line": 347, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -879,7 +879,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#3", - "line": 333, + "line": 355, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -887,7 +887,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#4", - "line": 513, + "line": 535, "column": 53, "kind": "codec_read", "api": "load_registry", @@ -895,15 +895,15 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#5", - "line": 634, - "column": 61, + "line": 666, + "column": 25, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#6", - "line": 696, + "line": 732, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -911,7 +911,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#7", - "line": 738, + "line": 776, "column": 38, "kind": "codec_read", "api": "load_registry", @@ -927,7 +927,7 @@ }, { "site": "loopx/cli_rollout.py::.append_cli_rollout_event::codec_read:load_registry#1", - "line": 50, + "line": 52, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -1103,7 +1103,7 @@ }, { "site": "loopx/control_plane/goals/checkpoint_context_io.py::.read_checkpoint_context::codec_read:load_registry#1", - "line": 131, + "line": 156, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -1286,17 +1286,17 @@ "classification": "codec_api" }, { - "site": "loopx/control_plane/goals/first_party_host_admission.py::._source_authority::codec_read:load_project_registry#1", - "line": 40, - "column": 20, + "site": "loopx/control_plane/goals/first_party_host_admission.py::.capture_first_party_host_goal_ref::codec_read:load_project_registry#1", + "line": 75, + "column": 16, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" }, { - "site": "loopx/control_plane/goals/first_party_host_admission.py::.capture_first_party_host_goal_ref::codec_read:load_project_registry#1", - "line": 75, - "column": 16, + "site": "loopx/control_plane/goals/first_party_host_admission.py::.source_goal_authority::codec_read:load_project_registry#1", + "line": 40, + "column": 20, "kind": "codec_read", "api": "load_project_registry", "classification": "codec_api" @@ -1975,7 +1975,7 @@ }, { "site": "loopx/state_refresh.py::.refresh_state_run::codec_read:load_registry#1", - "line": 900, + "line": 901, "column": 16, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index 02d7f10de..924e4cba3 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -3,7 +3,7 @@ import hashlib import json import re -from contextlib import ExitStack, nullcontext +from contextlib import ExitStack from pathlib import Path from typing import Any @@ -30,6 +30,7 @@ finish_external_delivery_refresh, refresh_recovery_payload, ) from .control_plane.quota.blocked_retry import require_blocked_retry_wait +from .control_plane.quota.accounting_admission import quota_accounting_admission from .control_plane.coordination.local_authority import local_authority_is_promoted from .control_plane.todos.active_state_todo_parser import parse_active_state_todos from .control_plane.quota.settlement import ( @@ -100,7 +101,6 @@ from .control_plane.goals.checkpoint_context_io import ( checkpoint_commit_guard, commit_checkpoint_run, require_complete_checkpoint_index, inspect_checkpoint_replay, ) -from .file_lock import exclusive_run_index_lock from .registry import registry_goals, resolve_state_file from .runtime import validate_goal_id_path_segment from .state_projection import ( @@ -825,6 +825,7 @@ def refresh_state_run( dry_run: bool, sync_global: bool = True, external_delivery: dict[str, Any] | None = None, + goal_ref: dict[str, str] | None = None, ) -> dict[str, Any]: from .control_plane.todos.provider_projection import recover_refresh_todo_projection @@ -901,9 +902,15 @@ def refresh_state_run( runtime_root = resolve_runtime_root(registry, runtime_root_override, registry_path=registry_path) # State-dependent admission through the final append remains serialized. # Only pure input validation runs before this transitional persistence lock. - with (nullcontext() if dry_run else exclusive_run_index_lock( - runtime_root / "goals" / safe_goal_id / "runs" / "index.jsonl", operation="refresh-state" - )): + with quota_accounting_admission( + runtime_root=runtime_root, + registry_path=registry_path, + goal_id=safe_goal_id, + goal_ref=goal_ref, + operation="refresh-state", + lock_legacy_index=not dry_run, + handoff_legacy_index=not dry_run, + ) as source_admission: settlement_identity = None settlement_result = None delivery_workspace_causality = None @@ -926,6 +933,10 @@ def refresh_state_run( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=normalized_replan_obligation_id, + registry_path=registry_path, + goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None, refresh_retry=(refresh_retry_request := { "checkpoint_read_context_id": checkpoint_read_context_id, "external_delivery": external_delivery, @@ -968,7 +979,7 @@ def refresh_state_run( inspect_checkpoint_replay(runtime_root, safe_goal_id, prior_writeback_run) recovery_payload = refresh_recovery_payload( settlement_readback, registry_path=registry_path, runtime_root=runtime_root, - goal_id=safe_goal_id, dry_run=dry_run, + goal_id=safe_goal_id, dry_run=dry_run, goal_ref=goal_ref, ) if recovery_payload is not None: return recover_refresh_todo_projection( @@ -1406,6 +1417,9 @@ def refresh_state_run( dry_run=dry_run, autonomous_replan_recorded_requested=bool(autonomous_replan_recorded), ) + if goal_ref is not None: + for projection in (record, index_record, payload): + projection["goal_ref"] = dict(goal_ref) # GH-C95 producer boundary: attach the typed run_usage_v0 row before the # durable record and index rows are written, so malformed or negative usage # fails the whole refresh instead of entering run history. The booking lock @@ -1418,6 +1432,7 @@ def refresh_state_run( runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, read_context_id=checkpoint_read_context_id, + goal_ref=goal_ref, )) for projection in (record, index_record, payload): projection["vision_checkpoint"] = { @@ -1496,7 +1511,8 @@ def refresh_state_run( saved = commit_checkpoint_run(runtime_root=runtime_root, registry_path=registry_path, state_file=resolved_state_file, identity=settlement_identity, refresh_retry=refresh_retry_request, record=record, index_record=index_record, - markdown=render_state_refresh_markdown(payload) + "\n") + markdown=render_state_refresh_markdown(payload) + "\n", + goal_ref=goal_ref, source_admission=source_admission) for projection in (record, index_record, payload): projection["vision_checkpoint"]["read_context"] = saved["context"] for projection in (index_record, payload): @@ -1598,13 +1614,23 @@ def refresh_state_run( runtime_root, goal_id=safe_goal_id, agent_id=settlement_identity.agent_id, todo_id=settlement_identity.todo_id, turn_instance_id=settlement_identity.turn_instance_id, replan_obligation_id=settlement_identity.replan_obligation_id, + registry_path=registry_path, goal_ref=goal_ref, + source_admission=source_admission, + borrow_source_admission=source_admission is not None, ) if committed_readback is None: raise RuntimeError("committed refresh settlement readback missing") - attach_settlement_progress(payload, committed_readback, registry_path=registry_path, runtime_root=runtime_root) + attach_settlement_progress( + payload, committed_readback, registry_path=registry_path, + runtime_root=runtime_root, goal_ref=goal_ref, + ) return recover_refresh_todo_projection( finish_external_delivery_refresh( - payload, settlement_readback, runtime_root, dry_run=dry_run, + payload, + settlement_readback, + runtime_root, + dry_run=dry_run, + goal_ref=goal_ref, ), registry_path=registry_path, runtime_root=runtime_root, goal_id=safe_goal_id, project=resolved_project, state_file=resolved_state_file, diff --git a/loopx/status.py b/loopx/status.py index 9e293a2af..ea6912b0d 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -1090,6 +1090,8 @@ def build_attention_queue( include_stopped_goal_context: bool = False, events_for_goal: EventsForGoal | None = None, todo_snapshot: _CanonicalTodoSnapshot | None = None, + current_registry: dict[str, Any] | None = None, + registry_path: Path | None = None, ) -> dict[str, Any]: def request_active_state_todo_fields( goal: dict[str, Any], @@ -1189,9 +1191,25 @@ def request_active_state_todo_fields( and int(orchestration.get("max_children") or 0) > 0 ): continue + goal_ref = None + if (current_registry or {}).get("profile_id") == "source_session_v1": + matches = [ + goal + for goal in (current_registry or {}).get("goals") or [] + if isinstance(goal, dict) + and goal.get("id") == goal_id + and goal.get("status") == "active" + and isinstance(goal.get("goal_instance_id"), str) + ] + if len(matches) == 1: + goal_ref = { + "goal_id": goal_id, + "goal_instance_id": matches[0]["goal_instance_id"], + } native_activity = latest_native_child_activity( events, goal_id=goal_id, configured_limit=int(orchestration["max_children"]), + goal_ref=goal_ref, ) if native_activity: # The shared status snapshot is bounded for Todo work. Once it @@ -1203,6 +1221,8 @@ def request_active_state_todo_fields( agent_id=native_activity["agent_id"], turn_instance_id=native_activity["turn_instance_id"], configured_limit=int(orchestration["max_children"]), + goal_ref=goal_ref, + registry_path=registry_path, ) return queue diff --git a/tests/architecture/test_goal_instance_binding_inventory.py b/tests/architecture/test_goal_instance_binding_inventory.py index 466461766..6fbbe284e 100644 --- a/tests/architecture/test_goal_instance_binding_inventory.py +++ b/tests/architecture/test_goal_instance_binding_inventory.py @@ -56,6 +56,7 @@ QUALIFIED_OWNER_IDS = { "attached_host_chat_session", "handoff_inbox_outbox", + "quota_settlement", "turn_journal", } TYPESCRIPT_DECLARATION = re.compile( diff --git a/tests/capabilities/test_native_child_receipts.py b/tests/capabilities/test_native_child_receipts.py index f5131f005..5476fbdc1 100644 --- a/tests/capabilities/test_native_child_receipts.py +++ b/tests/capabilities/test_native_child_receipts.py @@ -13,6 +13,9 @@ native_child_activity, record_native_child, ) +from loopx.control_plane.projects.registry_codec import ( + source_session_registry_transaction, +) from loopx.rollout_event_log import ( append_rollout_event, build_rollout_event, @@ -24,9 +27,15 @@ GOAL = "native-child-fixture" AGENT = "generic-coordinator" TURN = "turn-native-1" +INSTANCE_A = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +INSTANCE_B = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -def _admit(runtime_root: Path) -> None: +def _admit( + runtime_root: Path, + *, + goal_ref: dict[str, str] | None = None, +) -> None: append_rollout_event( rollout_event_log_path(runtime_root, GOAL), build_rollout_event( @@ -34,6 +43,7 @@ def _admit(runtime_root: Path) -> None: run_id=TURN, todo_id="todo_native_1", status="normal_run", details={"todo_id": "todo_native_1", "settlement_effect_id": f"{GOAL}:{AGENT}:todo_native_1:{TURN}"}, + goal_ref=goal_ref, ), ) @@ -47,6 +57,84 @@ def _record(runtime_root: Path, operation_id: str, *, stage: str, outcome: str, ) +def _write_source_registry( + registry_path: Path, + runtime_root: Path, + instance_id: str, +) -> None: + payload = { + "schema_version": "0.2", + "registry_role": "project-local", + "profile_id": "source_session_v1", + "common_runtime_root": str(runtime_root), + "projects": [], + "goals": [ + { + "id": GOAL, + "goal_instance_id": instance_id, + "status": "active", + "execution_authority": False, + } + ], + "session_bindings": [], + "session_receipts": [], + "lifetime_receipts": [], + "retired_goal_instances": [], + } + with source_session_registry_transaction( + registry_path, + operation="native_child_goal_instance_test", + create=lambda: payload, + ) as transaction: + transaction.commit(payload) + + +def test_native_child_receipts_are_partitioned_by_exact_goal_owner( + tmp_path: Path, +) -> None: + runtime = tmp_path / "runtime" + registry = tmp_path / "project" / ".loopx" / "registry.json" + goal_ref_a = {"goal_id": GOAL, "goal_instance_id": INSTANCE_A} + goal_ref_b = {"goal_id": GOAL, "goal_instance_id": INSTANCE_B} + _admit(runtime, goal_ref=goal_ref_a) + _admit(runtime, goal_ref=goal_ref_b) + _write_source_registry(registry, runtime, INSTANCE_B) + + result = record_native_child( + runtime_root=runtime, + registry_path=registry, + goal_ref=goal_ref_b, + goal_id=GOAL, + agent_id=AGENT, + turn_instance_id=TURN, + operation_id="op-b", + configured_limit=6, + stage="decision", + operation="spawn", + outcome="started", + entrypoint_id="generic_host", + execute=True, + ) + assert result["native_child_activity"]["operation_count"] == 1 + events = load_rollout_events(rollout_event_log_path(runtime, GOAL)) + assert events[-1]["goal_ref"] == goal_ref_b + assert native_child_activity( + events, + goal_id=GOAL, + agent_id=AGENT, + turn_instance_id=TURN, + configured_limit=6, + goal_ref=goal_ref_a, + )["operation_count"] == 0 + assert native_child_activity( + events, + goal_id=GOAL, + agent_id=AGENT, + turn_instance_id=TURN, + configured_limit=6, + )["operation_count"] == 0 + + def test_generic_report_adoption_is_idempotent_and_survives_restart(tmp_path: Path): _admit(tmp_path) unknown = load_native_child_activity( diff --git a/tests/control_plane/checkpoint_process.py b/tests/control_plane/checkpoint_process.py index f1001c8dc..16ef63026 100644 --- a/tests/control_plane/checkpoint_process.py +++ b/tests/control_plane/checkpoint_process.py @@ -32,7 +32,7 @@ def wait_for(path: Path, child=None, timeout=20): time.sleep(0.01) -def refresh(registry, runtime, token): +def refresh(registry, runtime, token, *, goal_ref=None): from loopx.state_refresh import refresh_state_run from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID return refresh_state_run(registry_path=Path(registry), runtime_root_override=str(runtime), @@ -41,7 +41,8 @@ def refresh(registry, runtime, token): delivery_batch_scale="implementation", delivery_outcome="outcome_progress", vision_unchanged_reason="The current basis remains applicable.", checkpoint_read_context_id=token, dry_run=False, sync_global=False, - external_delivery={"suppress": True, "resume_key": None}) + external_delivery={"suppress": True, "resume_key": None}, + goal_ref=goal_ref) def main(request): diff --git a/tests/control_plane/test_checkpoint_provider_fence.py b/tests/control_plane/test_checkpoint_provider_fence.py index b82efa20b..3c94c84e1 100644 --- a/tests/control_plane/test_checkpoint_provider_fence.py +++ b/tests/control_plane/test_checkpoint_provider_fence.py @@ -12,12 +12,84 @@ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.effect_runtime import EffectRuntimeConflict from loopx.control_plane.goals import checkpoint_context_io as context_io +from loopx.control_plane.projects.registry_codec import ( + load_project_registry, + source_session_registry_transaction, +) from tests.control_plane.test_checkpoint_read_context import _missing from tests.control_plane.test_quota_settlement_cli import GOAL_ID, AGENT_ID, TODO_ID, TURN_ID, _run_cli, _spend_run_count from tests.control_plane.checkpoint_process import REPO, start_probe, wait_for, refresh +INSTANCE_A = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +INSTANCE_B = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +def _stamp_runtime_owner(runtime: Path, goal_ref: dict[str, str]) -> None: + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + rows = [json.loads(line) for line in index.read_text().splitlines()] + for row in rows: + row["goal_ref"] = dict(goal_ref) + json_path = row.get("json_path") + if isinstance(json_path, str) and Path(json_path).is_file(): + record = json.loads(Path(json_path).read_text()) + record["goal_ref"] = dict(goal_ref) + Path(json_path).write_text(json.dumps(record) + "\n") + index.write_text("".join(json.dumps(row) + "\n" for row in rows)) + + event_log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" + events = [json.loads(line) for line in event_log.read_text().splitlines()] + for event in events: + event["goal_ref"] = dict(goal_ref) + event_log.write_text("".join(json.dumps(event) + "\n" for event in events)) + + +def _replace_with_source_registry( + registry: Path, + runtime: Path, + instance_id: str, +) -> None: + legacy = json.loads(registry.read_text()) + goal = dict(legacy["goals"][0]) + goal.update( + status="active", + goal_instance_id=instance_id, + execution_authority=False, + ) + payload = { + **legacy, + "schema_version": "0.2", + "registry_role": "project-local", + "profile_id": "source_session_v1", + "common_runtime_root": str(runtime), + "projects": [], + "goals": [goal], + "session_bindings": [], + "session_receipts": [], + "lifetime_receipts": [], + "retired_goal_instances": [], + } + registry.unlink() + with source_session_registry_transaction( + registry, + operation="checkpoint_goal_instance_test", + create=lambda: payload, + ) as transaction: + transaction.commit(payload) + + +def _replace_source_goal(registry: Path, instance_id: str) -> None: + with source_session_registry_transaction( + registry, + operation="checkpoint_goal_instance_recreate", + ) as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["goal_instance_id"] = instance_id + transaction.commit(payload) + + def fixture(tmp_path, monkeypatch, provider): isolate_sqlite_runtime(tmp_path, monkeypatch) project, runtime, registry, binding, delivery, original = _missing(tmp_path) @@ -86,6 +158,75 @@ def test_public_update_before_final_read_rejects_then_reread_succeeds(tmp_path, assert _spend_run_count(runtime) == 0 +def test_exact_source_checkpoint_commits_only_for_the_current_goal_instance( + tmp_path, + monkeypatch, +): + from loopx import state_refresh + + current = _missing(tmp_path / "current") + stale = _missing(tmp_path / "stale") + goal_ref = {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_A} + for _, runtime, registry, _, _, _ in (current, stale): + _stamp_runtime_owner(runtime, goal_ref) + _replace_with_source_registry(registry, runtime, INSTANCE_A) + + monkeypatch.setattr(context_io, "load_registry", load_project_registry) + monkeypatch.setattr(state_refresh, "load_registry", load_project_registry) + + _, current_runtime, current_registry, _, _, current_original = current + current_context = context_io.read_checkpoint_context( + registry_path=current_registry, + runtime_root_override=str(current_runtime), + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=TODO_ID, + turn_instance_id=TURN_ID, + goal_ref=goal_ref, + ) + committed = refresh( + current_registry, + current_runtime, + current_context["read_context_id"], + goal_ref=goal_ref, + ) + assert committed["appended"] is True + assert committed["goal_ref"] == goal_ref + assert committed["settlement_identity"] == current_original["settlement_identity"] + committed_row = json.loads( + (current_runtime / "goals" / GOAL_ID / "runs" / "index.jsonl") + .read_text() + .splitlines()[-1] + ) + assert committed_row["goal_ref"] == goal_ref + + _, stale_runtime, stale_registry, _, _, _ = stale + stale_context = context_io.read_checkpoint_context( + registry_path=stale_registry, + runtime_root_override=str(stale_runtime), + goal_id=GOAL_ID, + agent_id=AGENT_ID, + todo_id=TODO_ID, + turn_instance_id=TURN_ID, + goal_ref=goal_ref, + ) + stale_index = stale_runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + before = stale_index.read_bytes() + _replace_source_goal(stale_registry, INSTANCE_B) + with pytest.raises( + EffectRuntimeConflict, + match="stale_goal_instance", + ) as rejected: + refresh( + stale_registry, + stale_runtime, + stale_context["read_context_id"], + goal_ref=goal_ref, + ) + assert rejected.value.diagnostic_code == "stale_goal_instance" + assert stale_index.read_bytes() == before + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_existing_cli_maintenance_guard_precedes_provider_commit(tmp_path, monkeypatch, provider): """Characterize the reviewed head accurately: normal CLI already holds M.""" diff --git a/tests/control_plane/test_quota_rolling_window_projection.py b/tests/control_plane/test_quota_rolling_window_projection.py index 98d3ca675..477dce99f 100644 --- a/tests/control_plane/test_quota_rolling_window_projection.py +++ b/tests/control_plane/test_quota_rolling_window_projection.py @@ -87,3 +87,39 @@ def test_rolling_counter_can_decrease_without_replaying_or_voiding_a_spend() -> assert after["spend_event_count"] == 1 assert append_only_runs[0]["classification"] == "quota_slot_spent" assert all(run["classification"] != "quota_slot_voided" for run in append_only_runs) + + +def test_rolling_counter_isolated_by_exact_goal_instance() -> None: + goal_ref_a = { + "goal_id": GOAL_ID, + "goal_instance_id": "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + goal_ref_b = { + "goal_id": GOAL_ID, + "goal_instance_id": "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + } + legacy = _spend("2026-01-01T00:10:00+00:00", "turn-legacy") + spend_a = { + **_spend("2026-01-01T00:20:00+00:00", "turn-a"), + "goal_ref": goal_ref_a, + } + spend_b = { + **_spend("2026-01-01T00:30:00+00:00", "turn-b"), + "goal_ref": goal_ref_b, + } + + current = goal_quota_with_spend_ledger( + {**_goal(), "goal_instance_id": goal_ref_b["goal_instance_id"]}, + [legacy, spend_a, spend_b], + now=datetime(2026, 1, 1, 0, 59, tzinfo=timezone.utc), + ) + legacy_owner = goal_quota_with_spend_ledger( + _goal(), + [legacy, spend_a, spend_b], + now=datetime(2026, 1, 1, 0, 59, tzinfo=timezone.utc), + ) + + assert current["spent_slots"] == 1 + assert current["spend_event_count"] == 1 + assert legacy_owner["spent_slots"] == 1 + assert legacy_owner["spend_event_count"] == 1 diff --git a/tests/control_plane/test_quota_spend_commit_runtime.py b/tests/control_plane/test_quota_spend_commit_runtime.py index 1f7df5be1..af9726c61 100644 --- a/tests/control_plane/test_quota_spend_commit_runtime.py +++ b/tests/control_plane/test_quota_spend_commit_runtime.py @@ -10,6 +10,9 @@ build_quota_slot_spend_event, record_quota_slot_spend_from_preview, ) +from loopx.control_plane.projects.registry_codec import ( + source_session_registry_transaction, +) from loopx.control_plane.testing.quota_fixtures import ( quota_status_payload, quota_todo_item, @@ -22,6 +25,8 @@ GOAL_ID = "quota-spend-commit-runtime" AGENT_ID = "codex-main-control" +INSTANCE_A = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +INSTANCE_B = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" def _decision(spent_slots: int) -> dict[str, object]: @@ -67,16 +72,59 @@ def _preview(**updates: object) -> dict[str, object]: } -def _commit(runtime_root: Path, preview: dict[str, object]) -> dict[str, object]: +def _commit( + runtime_root: Path, + preview: dict[str, object], + *, + registry_path: Path | None = None, + goal_ref: dict[str, str] | None = None, +) -> dict[str, object]: return record_quota_slot_spend_from_preview( preview, {"runtime_root": str(runtime_root)}, goal_id=GOAL_ID, execute=True, source="heartbeat", + registry_path=registry_path, + goal_ref=goal_ref, ) +def _write_source_registry( + registry_path: Path, + runtime_root: Path, + instance_id: str, +) -> None: + expected = { + "schema_version": "0.2", + "registry_role": "project-local", + "profile_id": "source_session_v1", + "common_runtime_root": str(runtime_root), + "projects": [], + "goals": [ + { + "id": GOAL_ID, + "goal_instance_id": instance_id, + "status": "active", + "execution_authority": False, + } + ], + "session_bindings": [], + "session_receipts": [], + "lifetime_receipts": [], + "retired_goal_instances": [], + } + create = None if registry_path.exists() else lambda: expected + with source_session_registry_transaction( + registry_path, + operation="quota_spend_goal_instance_test", + create=create, + ) as transaction: + payload = transaction.payload_copy() + payload["goals"] = expected["goals"] + transaction.commit(payload) + + def _status(runtime_root: Path) -> dict[str, object]: todo = quota_todo_item( todo_id="todo_quota_basis", @@ -159,6 +207,52 @@ def test_python_facade_serializes_concurrent_exact_effect_retries( assert len(index_path.read_text(encoding="utf-8").splitlines()) == 1 +def test_source_spend_rejects_stale_goal_before_any_write_and_stamps_successor( + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = tmp_path / "project" / ".loopx" / "registry.json" + goal_ref_a = {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_A} + goal_ref_b = {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_B} + _write_source_registry(registry_path, runtime_root, INSTANCE_A) + preview_a = _preview(effect_ref="provider-effect-a#quota_spend") + + _write_source_registry(registry_path, runtime_root, INSTANCE_B) + with pytest.raises(ValueError, match="stale_goal_instance"): + _commit( + runtime_root, + preview_a, + registry_path=registry_path, + goal_ref=goal_ref_a, + ) + + runs_dir = runtime_root / "goals" / GOAL_ID / "runs" + assert not (runs_dir / "index.jsonl").exists() + assert not (runs_dir / ".transactions").exists() + assert not list(runs_dir.glob("*.json")) + assert not list(runs_dir.glob("*.md")) + assert not list(tmp_path.rglob("*.ts-effect.lock")) + + written = _commit( + runtime_root, + _preview(effect_ref="provider-effect-b#quota_spend"), + registry_path=registry_path, + goal_ref=goal_ref_b, + ) + + assert written["goal_ref"] == goal_ref_b + record = json.loads(Path(written["json_path"]).read_text(encoding="utf-8")) + row = json.loads(Path(written["index_path"]).read_text(encoding="utf-8")) + receipt_paths = list(runs_dir.glob(".transactions/quota-spend/*.json")) + assert len(receipt_paths) == 1 + receipt = json.loads(receipt_paths[0].read_text(encoding="utf-8")) + assert record["goal_ref"] == goal_ref_b + assert record["quota_event"]["goal_ref"] == goal_ref_b + assert row["goal_ref"] == goal_ref_b + assert receipt["goal_ref"] == goal_ref_b + assert not list(tmp_path.rglob("*.ts-effect.lock")) + + def test_python_facade_rejects_a_second_effect_from_the_same_quota_basis( tmp_path: Path, ) -> None: diff --git a/tests/control_plane/test_quota_void_commit_runtime.py b/tests/control_plane/test_quota_void_commit_runtime.py index 671c7fb2f..8190f1cae 100644 --- a/tests/control_plane/test_quota_void_commit_runtime.py +++ b/tests/control_plane/test_quota_void_commit_runtime.py @@ -20,6 +20,9 @@ commit_quota_slot_void, record_quota_slot_void_from_preview, ) +from loopx.control_plane.projects.registry_codec import ( + source_session_registry_transaction, +) from loopx.history import repair_index_duplicates from loopx.presentation.renderers.quota_event_markdown import ( render_quota_slot_preview_markdown, @@ -32,6 +35,8 @@ TARGET_AT = "2026-08-31T09:00:00+08:00" VOID_AT = "2026-08-31T09:05:00+08:00" REASON = "void duplicate quota spend" +INSTANCE_A = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +INSTANCE_B = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" def test_legacy_void_commit_import_paths_remain_compatible() -> None: @@ -75,7 +80,11 @@ def _decision(spent_slots: int) -> dict[str, Any]: } -def _write_spend_target(runtime_root: Path) -> tuple[Path, Path]: +def _write_spend_target( + runtime_root: Path, + *, + goal_ref: dict[str, str] | None = None, +) -> tuple[Path, Path]: runs_dir = runtime_root / "goals" / GOAL_ID / "runs" runs_dir.mkdir(parents=True, exist_ok=True) target_path = runs_dir / "target-quota-slot-spent.json" @@ -94,6 +103,9 @@ def _write_spend_target(runtime_root: Path) -> tuple[Path, Path]: "after": compact_quota_decision(_decision(2)), }, } + if goal_ref is not None: + target["goal_ref"] = goal_ref + target["quota_event"]["goal_ref"] = goal_ref target_path.write_text( json.dumps(target, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", @@ -101,23 +113,55 @@ def _write_spend_target(runtime_root: Path) -> tuple[Path, Path]: index_path = runs_dir / "index.jsonl" # Cover the legacy bounded-artifact fallback instead of relying only on # quota_event being embedded in the compact index row. + index_record = { + "generated_at": TARGET_AT, + "goal_id": GOAL_ID, + "classification": "quota_slot_spent", + "agent_id": AGENT_ID, + "json_path": str(target_path), + "markdown_path": str(target_path.with_suffix(".md")), + } + if goal_ref is not None: + index_record["goal_ref"] = goal_ref index_path.write_text( - json.dumps( - { - "generated_at": TARGET_AT, - "goal_id": GOAL_ID, - "classification": "quota_slot_spent", - "agent_id": AGENT_ID, - "json_path": str(target_path), - "markdown_path": str(target_path.with_suffix(".md")), - } - ) - + "\n", + json.dumps(index_record) + "\n", encoding="utf-8", ) return index_path, target_path +def _write_source_registry( + registry_path: Path, + runtime_root: Path, + instance_id: str, +) -> None: + payload = { + "schema_version": "0.2", + "registry_role": "project-local", + "profile_id": "source_session_v1", + "common_runtime_root": str(runtime_root), + "projects": [], + "goals": [ + { + "id": GOAL_ID, + "goal_instance_id": instance_id, + "status": "active", + "execution_authority": False, + } + ], + "session_bindings": [], + "session_receipts": [], + "lifetime_receipts": [], + "retired_goal_instances": [], + } + with source_session_registry_transaction( + registry_path, + operation="quota_void_goal_instance_test", + create=lambda: payload, + ) as transaction: + transaction.commit(payload) + + def _preview(runtime_root: Path) -> dict[str, Any]: return { "ok": True, @@ -275,6 +319,91 @@ def test_real_runtime_preserves_public_payloads_and_three_artifact_write( assert rows[-1]["markdown_path"] == str(markdown_path) +@pytest.mark.parametrize("execute", [False, True]) +@pytest.mark.parametrize( + "target_goal_ref", + [ + {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_A}, + None, + ], +) +def test_source_void_rejects_foreign_and_legacy_spend_targets( + tmp_path: Path, + target_goal_ref: dict[str, str] | None, + execute: bool, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = tmp_path / "project" / ".loopx" / "registry.json" + goal_ref_b = {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_B} + _write_source_registry(registry_path, runtime_root, INSTANCE_B) + index_path, _ = _write_spend_target( + runtime_root, + goal_ref=target_goal_ref, + ) + before = index_path.read_bytes() + + with pytest.raises(ValueError, match="Goal instance"): + commit_quota_slot_void( + {"runtime_root": str(runtime_root)}, + goal_id=GOAL_ID, + voided_run_generated_at=TARGET_AT, + before=_decision(2), + execute=execute, + source="heartbeat", + registry_path=registry_path, + goal_ref=goal_ref_b, + ) + + assert index_path.read_bytes() == before + assert not list( + (index_path.parent / ".transactions" / "quota-void").glob("*.json") + ) + + +def test_source_void_stamps_the_current_goal_ref( + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = tmp_path / "project" / ".loopx" / "registry.json" + goal_ref_b = {"goal_id": GOAL_ID, "goal_instance_id": INSTANCE_B} + _write_source_registry(registry_path, runtime_root, INSTANCE_B) + index_path, _ = _write_spend_target(runtime_root, goal_ref=goal_ref_b) + + preview = commit_quota_slot_void( + {"runtime_root": str(runtime_root)}, + goal_id=GOAL_ID, + voided_run_generated_at=TARGET_AT, + before=_decision(2), + execute=False, + source="heartbeat", + registry_path=registry_path, + goal_ref=goal_ref_b, + ) + assert preview["goal_ref"] == goal_ref_b + assert index_path.read_text(encoding="utf-8").count("\n") == 1 + + written = commit_quota_slot_void( + {"runtime_root": str(runtime_root)}, + goal_id=GOAL_ID, + voided_run_generated_at=TARGET_AT, + before=_decision(2), + execute=True, + source="heartbeat", + registry_path=registry_path, + goal_ref=goal_ref_b, + ) + + assert written["goal_ref"] == goal_ref_b + rows = [ + json.loads(line) + for line in index_path.read_text(encoding="utf-8").splitlines() + ] + assert rows[-1]["goal_ref"] == goal_ref_b + record = json.loads(Path(written["json_path"]).read_text(encoding="utf-8")) + assert record["goal_ref"] == goal_ref_b + assert record["quota_event"]["goal_ref"] == goal_ref_b + + def test_real_runtime_normalizes_ecmascript_goal_and_effect_identity( tmp_path: Path, ) -> None: diff --git a/tests/control_plane/test_refresh_external_delivery.py b/tests/control_plane/test_refresh_external_delivery.py index 3f80e5c25..1e1a5ba8c 100644 --- a/tests/control_plane/test_refresh_external_delivery.py +++ b/tests/control_plane/test_refresh_external_delivery.py @@ -1,10 +1,12 @@ """Exercise legacy, local and failed-persistence paths through the real backend.""" import json +from types import SimpleNamespace import pytest from loopx import cli from loopx.control_plane.quota import refresh_external_delivery as bridge +from loopx.control_plane.quota.settlement import SettlementIdentity from loopx.state_refresh import refresh_state_run from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, _write_fixture, @@ -101,3 +103,41 @@ def test_receipt_only_repair_preserves_pause_without_requiring_confirmation(sess assert run(args)["receipt_repaired"] is True assert index.read_bytes() == before assert run(args, expected=1)["error_code"] == "external_delivery_resume_required" + + +def test_exact_external_delivery_transition_persists_goal_ref(tmp_path): + goal_ref = { + "goal_id": GOAL_ID, + "goal_instance_id": "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + identity = SettlementIdentity( + GOAL_ID, + AGENT_ID, + TODO_ID, + TURN_ID, + ) + readback = SimpleNamespace( + identity=SimpleNamespace(value=identity), + external_delivery={ + "schema_version": "refresh_external_delivery_v0", + "authorized": False, + "transition": { + "state": "paused", + "resume_key": "resume-fixture", + }, + }, + ) + result = bridge.finish_external_delivery_refresh( + {"ok": True}, + readback, + tmp_path, + dry_run=False, + goal_ref=goal_ref, + ) + events = json.loads( + (tmp_path / "goals" / GOAL_ID / "rollout-event-log.jsonl") + .read_text(encoding="utf-8") + .strip() + ) + assert events["goal_ref"] == goal_ref + assert result["external_sink_delivery_authorized"] is False diff --git a/tests/control_plane_ts/host_todo_completion.test.ts b/tests/control_plane_ts/host_todo_completion.test.ts index c608bb392..459fcc7db 100644 --- a/tests/control_plane_ts/host_todo_completion.test.ts +++ b/tests/control_plane_ts/host_todo_completion.test.ts @@ -44,6 +44,30 @@ test("host context read uses the original identity and cannot carry a decision", checkpoint_read_context_id: "receipt-a"}), /only to vision recovery/); }); +test("host settlement commands preserve an exact GoalRef", () => { + const instanceId = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const goalRef = {goal_id: "goal", goal_instance_id: instanceId}; + const prepared = prepare({goal_ref: goalRef}); + const steps = ( + prepared.provider_effect as {steps: {args: string[]}[]} + ).steps; + for (const step of steps) { + assert.deepEqual( + step.args.slice(step.args.indexOf("--goal-instance-id"), step.args.indexOf("--goal-instance-id") + 2), + ["--goal-instance-id", instanceId], + ); + } + const context = evaluateHostTodoCompletion(request("prepare", { + schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, + phase: "vision_context", + goal_ref: goalRef, + })); + assert.deepEqual( + (context.args as string[]).slice(-2), + ["--goal-instance-id", instanceId], + ); +}); + test("vision decisions require v1 and cannot combine patch with unchanged", () => { assert.throws(() => prepare({vision_path: "vision.json"}), /requires v1/); assert.throws(() => prepare({schema_version: HOST_TODO_VISION_TRANSACTION_SCHEMA_VERSION, diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index fd5304638..397f0c634 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { appendFile, mkdir, @@ -8,10 +9,14 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import test from "node:test"; import { settlementIdentity } from "../../loopx/control_plane/effect_program.ts"; +import { + acquireFileMutationLock, + releaseFileMutationLock, +} from "../../loopx/control_plane/effect_runtime_io.ts"; import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait } from "../../loopx/control_plane/quota/blocked_wait.ts"; import { evaluateTodoResumeConditions, TODO_RESUME_EVALUATION_REQUEST_SCHEMA_VERSION } from "../../loopx/control_plane/todos/resume_condition.ts"; import { @@ -20,11 +25,14 @@ import { readQuotaSettlement, readQuotaSettlementSnapshot, } from "../../loopx/control_plane/quota/settlement_readback.ts"; +import { requireJsonObject } from "../../loopx/control_plane/runtime_decode.ts"; const goalId = "settlement-goal"; const agentId = "codex-settlement"; const todoId = "todo_settlement"; const turnId = "turn-settlement-1"; +const instanceA = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const instanceB = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const identity = settlementIdentity({ goal_id: goalId, agent_id: agentId, @@ -82,10 +90,20 @@ async function fixture(options: { progressObservation?: Record; blockedRetry?: boolean | Record; visionCheckpoint?: Record; + goalRef?: Record; + turnId?: string; } = {}) { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-settlement-readback-")); const goalRoot = join(runtimeRoot, "goals", goalId); const runsRoot = join(goalRoot, "runs"); + const ownerProjection = options.goalRef ? { goal_ref: options.goalRef } : {}; + const fixtureTurnId = options.turnId ?? turnId; + const fixtureIdentity = settlementIdentity({ + goal_id: goalId, + agent_id: agentId, + todo_id: todoId, + turn_instance_id: fixtureTurnId, + }); await mkdir(runsRoot, { recursive: true }); const events: Record[] = options.guard === false ? [] @@ -95,13 +113,14 @@ async function fixture(options: { event_kind: "quota_should_run", goal_id: goalId, agent_id: agentId, - run_id: turnId, + run_id: fixtureTurnId, + ...ownerProjection, details: { ...(options.guardUnbound ? {} : { todo_id: todoId, - settlement_effect_id: identity.effect_id, + settlement_effect_id: fixtureIdentity.effect_id, }), ...(options.workspace ? { @@ -124,8 +143,9 @@ async function fixture(options: { event_kind: "quota_should_run", goal_id: goalId, agent_id: agentId, - run_id: turnId, + run_id: fixtureTurnId, status: "action_selection_deferred", + ...ownerProjection, details: { pending_action_selection_todo_id: todoId, pending_action_selection_state: "deferred", @@ -143,8 +163,9 @@ async function fixture(options: { event_kind: "refresh_state", goal_id: goalId, agent_id: agentId, - run_id: turnId, - details: { settlement_effect_id: identity.effect_id }, + run_id: fixtureTurnId, + ...ownerProjection, + details: { settlement_effect_id: fixtureIdentity.effect_id }, }); runs.push({ classification: "state_refreshed", @@ -152,8 +173,9 @@ async function fixture(options: { goal_id: goalId, agent_id: agentId, todo_id: todoId, - turn_instance_id: turnId, - settlement_identity: identity, + turn_instance_id: fixtureTurnId, + settlement_identity: fixtureIdentity, + ...ownerProjection, ...(options.visionCheckpoint ? {vision_checkpoint: options.visionCheckpoint} : {}), ...(options.blockedRetry ? {blocked_retry: typeof options.blockedRetry === "object" ? options.blockedRetry : { schema_version: "quota_blocked_retry_v0", @@ -175,16 +197,18 @@ async function fixture(options: { event_kind: "quota_spend", goal_id: goalId, agent_id: agentId, - run_id: turnId, - details: { settlement_effect_id: identity.effect_id }, + run_id: fixtureTurnId, + ...ownerProjection, + details: { settlement_effect_id: fixtureIdentity.effect_id }, }); runs.push({ classification: "quota_slot_spent", goal_id: goalId, agent_id: agentId, todo_id: todoId, - turn_instance_id: turnId, - settlement_identity: identity, + turn_instance_id: fixtureTurnId, + settlement_identity: fixtureIdentity, + ...ownerProjection, }); } if (options.completion) { @@ -194,9 +218,10 @@ async function fixture(options: { event_kind: "todo_complete", goal_id: goalId, agent_id: agentId, - run_id: turnId, + run_id: fixtureTurnId, + ...ownerProjection, details: { - settlement_effect_id: identity.effect_id, + settlement_effect_id: fixtureIdentity.effect_id, no_followup: options.noFollowup === true, }, }); @@ -207,11 +232,12 @@ async function fixture(options: { goal_id: goalId, agent_id: agentId, todo_id: todoId, - turn_instance_id: turnId, + turn_instance_id: fixtureTurnId, material_change: true, + ...ownerProjection, quota_monitor_poll_commit: { schema_version: "quota_monitor_poll_commit_receipt_v0", - effect_id: `quota-monitor-poll:${goalId}:${agentId}:${turnId}:todo:${todoId}`, + effect_id: `quota-monitor-poll:${goalId}:${agentId}:${fixtureTurnId}:todo:${todoId}`, request_digest: "fixture", }, }); @@ -227,6 +253,19 @@ async function fixture(options: { return runtimeRoot; } +async function appendHistory(targetRoot: string, sourceRoot: string): Promise { + const relativePaths = [ + join("goals", goalId, "rollout-event-log.jsonl"), + join("goals", goalId, "runs", "index.jsonl"), + ]; + for (const relativePath of relativePaths) { + await appendFile( + join(targetRoot, relativePath), + await readFile(join(sourceRoot, relativePath), "utf8"), + ); + } +} + function request(runtimeRoot: string, overrides: Record = {}) { return { schema_version: QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, @@ -242,6 +281,72 @@ function request(runtimeRoot: string, overrides: Record = {}) { }; } +function sourceGuardPath(registryPath: string): string { + const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); + return join( + dirname(registryPath), + ".loopx", + "lifecycle", + "goal-instance", + "guards", + `${digest}.guard`, + ); +} + +async function withSourceAdmission( + runtimeRoot: string, + plannedInstanceId: string, + currentInstanceId: string, + run: (binding: Record) => Promise, +): Promise { + const indexPath = join(runtimeRoot, "goals", goalId, "runs", "index.jsonl"); + const registryPath = join(runtimeRoot, "project", ".loopx", "registry.json"); + const guardPath = sourceGuardPath(registryPath); + const indexLock = await acquireFileMutationLock(indexPath); + const guardLock = await acquireFileMutationLock(guardPath); + try { + return await run({ + goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + source_admission: { + schema_version: "loopx_quota_source_admission_v0", + profile_id: "source_session_v1", + registry_path: registryPath, + planned_goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + authority: { + kind: "present", + goal_ref: { + goal_id: goalId, + goal_instance_id: currentInstanceId, + }, + }, + locks: [ + { + role: "run_index", + target: indexPath, + pid: process.pid, + token: indexLock.token, + }, + { + role: "source_guard", + target: guardPath, + pid: process.pid, + token: guardLock.token, + }, + ], + }, + }); + } finally { + await releaseFileMutationLock(guardPath, guardLock.token, null, true); + await releaseFileMutationLock(indexPath, indexLock.token, null, true); + } +} + test("scoped supersede settles only its exact Turn and never terminal acceptance", async t => { const cases = [ {name: "original tuple", expected: "settled", patch: {}}, @@ -298,6 +403,213 @@ test("settlement progress requires both effects and exact receipts", async t => }); }); +test("settlement readback enforces exact source ownership before identity inference", async () => { + const goalRefA = { + goal_id: goalId, + goal_instance_id: instanceA, + }; + const root = await fixture({ + writeback: true, + spend: true, + goalRef: goalRefA, + }); + try { + const unscoped = await readQuotaSettlement(request(root)); + assert.equal(unscoped.found, true); + const unscopedIdentity = requireJsonObject( + requireJsonObject(unscoped.identity, "unscoped identity").result, + "unscoped identity result", + ); + assert.equal( + requireJsonObject( + unscopedIdentity.failure, + "unscoped identity failure", + ).kind, + "receipt_missing", + ); + assert.equal(unscoped.spend_run, null); + + const inferred = await withSourceAdmission( + root, + instanceB, + instanceB, + async (binding) => await readQuotaSettlement(request(root, { + ...binding, + turn_instance_id: null, + infer_turn_instance_id: true, + })), + ); + assert.deepEqual(inferred, { + schema_version: "loopx_quota_settlement_readback_result_v0", + found: false, + }); + + await assert.rejects( + withSourceAdmission( + root, + instanceA, + instanceB, + async (binding) => await readQuotaSettlement(request(root, binding)), + ), + (error: unknown) => { + assert.equal( + requireJsonObject(error, "stale GoalRef error").code, + "stale_goal_instance", + ); + return true; + }, + ); + + const matching = await withSourceAdmission( + root, + instanceA, + instanceA, + async (binding) => await readQuotaSettlement(request(root, binding)), + ); + const matchingSpend = requireJsonObject(matching.spend, "matching spend"); + const matchingPayload = requireJsonObject( + matchingSpend.payload, + "matching spend payload", + ); + assert.equal(matchingPayload.ok, true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("alias and exact histories remain independently readable", async () => { + const legacy = await fixture({ writeback: true, spend: true }); + const exact = await fixture({ + writeback: true, + spend: true, + goalRef: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + }); + try { + await appendHistory(legacy, exact); + const aliasReadback = await readQuotaSettlement(request(legacy)); + assert.equal( + requireJsonObject( + requireJsonObject(aliasReadback.settlement, "alias settlement").payload, + "alias settlement payload", + ).ok, + true, + ); + const exactReadback = await withSourceAdmission( + legacy, + instanceA, + instanceA, + async (binding) => await readQuotaSettlement(request(legacy, binding)), + ); + assert.equal( + requireJsonObject( + requireJsonObject(exactReadback.settlement, "exact settlement").payload, + "exact settlement payload", + ).ok, + true, + ); + } finally { + await rm(legacy, { recursive: true, force: true }); + await rm(exact, { recursive: true, force: true }); + } +}); + +test("a delayed stale owner cannot replace the current owner's inferred Turn", async () => { + const current = await fixture({ + writeback: true, + spend: true, + goalRef: { + goal_id: goalId, + goal_instance_id: instanceB, + }, + }); + const delayed = await fixture({ + writeback: true, + spend: true, + turnId: "turn-stale-a", + goalRef: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + }); + try { + await appendHistory(current, delayed); + const inferred = await withSourceAdmission( + current, + instanceB, + instanceB, + async (binding) => await readQuotaSettlement(request(current, { + ...binding, + turn_instance_id: null, + infer_turn_instance_id: true, + })), + ); + assert.equal(inferred.found, true); + assert.equal( + requireJsonObject( + requireJsonObject(inferred.settlement, "current settlement").payload, + "current settlement payload", + ).ok, + true, + ); + } finally { + await rm(current, { recursive: true, force: true }); + await rm(delayed, { recursive: true, force: true }); + } +}); + +test("borrowed exact admission remains valid for an enclosing transaction", async () => { + const root = await fixture({ + writeback: true, + goalRef: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + }); + try { + await withSourceAdmission( + root, + instanceA, + instanceA, + async (binding) => { + const borrowed = { + ...binding, + borrow_source_admission: true, + }; + assert.equal( + (await readQuotaSettlement(request(root, borrowed))).found, + true, + ); + assert.equal( + (await readQuotaSettlement(request(root, borrowed))).found, + true, + ); + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("legacy settlement still consumes legacy rows", async () => { + const root = await fixture({ writeback: true, spend: true }); + try { + const result = await readQuotaSettlement(request(root)); + assert.equal(result.found, true); + assert.equal( + requireJsonObject( + requireJsonObject(result.settlement, "legacy settlement").payload, + "legacy settlement payload", + ).ok, + true, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("settlement progress preserves typed source and rejects malformed sources", async () => { const root = await fixture({writeback: true}); try { diff --git a/tests/control_plane_ts/quota_spend_commit.test.ts b/tests/control_plane_ts/quota_spend_commit.test.ts index 346c61af2..553c25ed9 100644 --- a/tests/control_plane_ts/quota_spend_commit.test.ts +++ b/tests/control_plane_ts/quota_spend_commit.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { readFile, mkdir, @@ -12,6 +13,20 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; +import { + acquireFileMutationLock, + releaseFileMutationLock, +} from "../../loopx/control_plane/effect_runtime_io.ts"; +import { + EffectRuntimeConflictError, + EffectRuntimeLockTimeoutError, + EffectRuntimeRequestError, +} from "../../loopx/control_plane/effect_runtime_errors.ts"; +import { requireJsonObject } from "../../loopx/control_plane/runtime_decode.ts"; +import { + parseQuotaAccountingOwner, + withQuotaAccountingOwner, +} from "../../loopx/control_plane/quota/source_admission.ts"; import { evaluateQuotaSpendCommit, quotaSpendIndexDigest, @@ -19,6 +34,12 @@ import { } from "../../loopx/control_plane/quota/spend_commit.ts"; const goalId = "quota-spend-transaction"; +const instanceA = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const instanceB = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +interface TestSourceAdmission extends Record { + locks: [Record, Record]; +} function decision( spentSlots: number, @@ -94,6 +115,76 @@ async function tempRuntime(t: test.TestContext): Promise { return runtimeRoot; } +function sourceGuardPath(registryPath: string): string { + const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); + return join( + dirname(registryPath), + ".loopx", + "lifecycle", + "goal-instance", + "guards", + `${digest}.guard`, + ); +} + +async function withSourceAdmission( + runtimeRoot: string, + plannedInstanceId: string, + currentInstanceId: string, + run: (binding: Record) => Promise, + mutateAdmission?: (admission: TestSourceAdmission) => void, + ownerPid = process.pid, +): Promise { + const indexPath = join(runtimeRoot, "goals", goalId, "runs", "index.jsonl"); + const registryPath = join(runtimeRoot, "project", ".loopx", "registry.json"); + const guardPath = sourceGuardPath(registryPath); + const indexLock = await acquireFileMutationLock(indexPath, ownerPid); + const guardLock = await acquireFileMutationLock(guardPath, ownerPid); + const admission: TestSourceAdmission = { + schema_version: "loopx_quota_source_admission_v0", + profile_id: "source_session_v1", + registry_path: registryPath, + planned_goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + authority: { + kind: "present", + goal_ref: { + goal_id: goalId, + goal_instance_id: currentInstanceId, + }, + }, + locks: [ + { + role: "run_index", + target: indexPath, + pid: ownerPid, + token: indexLock.token, + }, + { + role: "source_guard", + target: guardPath, + pid: ownerPid, + token: guardLock.token, + }, + ], + }; + mutateAdmission?.(admission); + try { + return await run({ + goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + source_admission: admission, + }); + } finally { + await releaseFileMutationLock(guardPath, guardLock.token, null, true); + await releaseFileMutationLock(indexPath, indexLock.token, null, true); + } +} + function replayRequest(runtimeRoot: string, effectId: string) { return { schema_version: QUOTA_SPEND_COMMIT_REQUEST_SCHEMA, @@ -252,6 +343,287 @@ test("commit owns JSON, Markdown, index, and exact-effect replay", async (t) => assert.equal((await readFile(indexPath, "utf8")).trim().split("\n").length, 1); }); +test("source owner rejects stale Goal A before writes and stamps Goal B", async (t) => { + const runtimeRoot = await tempRuntime(t); + await assert.rejects( + withSourceAdmission(runtimeRoot, instanceA, instanceB, async (binding) => + await evaluateQuotaSpendCommit({ + ...request(runtimeRoot), + ...binding, + }) + ), + (error: unknown) => { + assert.ok(error instanceof EffectRuntimeConflictError); + assert.equal(error.code, "stale_goal_instance"); + return true; + }, + ); + const runsDir = join(runtimeRoot, "goals", goalId, "runs"); + await assert.rejects(readFile(join(runsDir, "index.jsonl")), { code: "ENOENT" }); + await assert.rejects(readdir(join(runsDir, ".transactions")), { code: "ENOENT" }); + + const written = await withSourceAdmission( + runtimeRoot, + instanceB, + instanceB, + async (binding) => await evaluateQuotaSpendCommit({ + ...request(runtimeRoot), + ...binding, + }), + ); + const expectedGoalRef = { + goal_id: goalId, + goal_instance_id: instanceB, + }; + assert.deepEqual(written.payload.goal_ref, expectedGoalRef); + const record = requireJsonObject( + JSON.parse(await readFile(String(written.payload.json_path), "utf8")), + "quota spend record", + ); + const event = requireJsonObject(record.quota_event, "quota spend event"); + const row = requireJsonObject( + JSON.parse(await readFile(String(written.payload.index_path), "utf8")), + "quota spend index row", + ); + const receiptDirectory = join(runsDir, ".transactions", "quota-spend"); + const [receiptName] = await readdir(receiptDirectory); + const receipt = requireJsonObject( + JSON.parse(await readFile(join(receiptDirectory, receiptName), "utf8")), + "quota spend receipt", + ); + assert.deepEqual(record.goal_ref, expectedGoalRef); + assert.deepEqual(event.goal_ref, expectedGoalRef); + assert.deepEqual(row.goal_ref, expectedGoalRef); + assert.deepEqual(receipt.goal_ref, expectedGoalRef); +}); + +test("source owner validates every witness target before claiming locks", async (t) => { + const runtimeRoot = await tempRuntime(t); + await assert.rejects( + withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => await evaluateQuotaSpendCommit({ + ...request(runtimeRoot), + ...binding, + }), + (admission) => { + admission.locks[1].target = join(runtimeRoot, "wrong-source.guard"); + }, + ), + (error: unknown) => { + assert.ok(error instanceof EffectRuntimeRequestError); + assert.equal(error.code, "quota_source_admission_invalid"); + return true; + }, + ); + await assert.rejects( + readFile(join(runtimeRoot, "goals", goalId, "runs", "index.jsonl")), + { code: "ENOENT" }, + ); +}); + +test("source owner rejects an expired lock handoff", async (t) => { + const runtimeRoot = await tempRuntime(t); + const indexPath = join(runtimeRoot, "goals", goalId, "runs", "index.jsonl"); + const registryPath = join(runtimeRoot, "project", ".loopx", "registry.json"); + const guardPath = sourceGuardPath(registryPath); + const indexLock = await acquireFileMutationLock(indexPath); + const guardLock = await acquireFileMutationLock(guardPath); + await releaseFileMutationLock(guardPath, guardLock.token, null, true); + await releaseFileMutationLock(indexPath, indexLock.token, null, true); + + await assert.rejects( + evaluateQuotaSpendCommit({ + ...request(runtimeRoot), + goal_ref: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + source_admission: { + schema_version: "loopx_quota_source_admission_v0", + profile_id: "source_session_v1", + registry_path: registryPath, + planned_goal_ref: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + authority: { + kind: "present", + goal_ref: { + goal_id: goalId, + goal_instance_id: instanceA, + }, + }, + locks: [ + { + role: "run_index", + target: indexPath, + pid: process.pid, + token: indexLock.token, + }, + { + role: "source_guard", + target: guardPath, + pid: process.pid, + token: guardLock.token, + }, + ], + }, + }), + (error: unknown) => { + assert.ok(error instanceof EffectRuntimeConflictError); + assert.equal(error.code, "quota_source_admission_expired"); + return true; + }, + ); +}); + +test("source owner keeps both claims through durable completion after parent loss", async (t) => { + const runtimeRoot = await tempRuntime(t); + const indexPath = join(runtimeRoot, "goals", goalId, "runs", "index.jsonl"); + const registryPath = join(runtimeRoot, "project", ".loopx", "registry.json"); + const guardPath = sourceGuardPath(registryPath); + const durableResult = join(runtimeRoot, "durable-result.json"); + const deadOwnerPid = 2_147_483_647; + + await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => { + const owner = parseQuotaAccountingOwner({ + goalRefValue: binding.goal_ref, + sourceAdmissionValue: binding.source_admission, + runtimeRoot, + goalId, + }); + await withQuotaAccountingOwner(owner, async (indexLockHeld) => { + assert.equal(indexLockHeld, true); + await writeFile(durableResult, "{\"status\":\"committed\"}\n", "utf8"); + for (const target of [indexPath, guardPath]) { + await assert.rejects( + acquireFileMutationLock(target, process.pid, 0), + EffectRuntimeLockTimeoutError, + ); + } + }); + }, + undefined, + deadOwnerPid, + ); + + assert.deepEqual( + JSON.parse(await readFile(durableResult, "utf8")), + { status: "committed" }, + ); + const reacquiredIndex = await acquireFileMutationLock(indexPath); + const reacquiredGuard = await acquireFileMutationLock(guardPath); + await releaseFileMutationLock( + guardPath, + reacquiredGuard.token, + null, + true, + ); + await releaseFileMutationLock( + indexPath, + reacquiredIndex.token, + null, + true, + ); +}); + +test("unscoped replay cannot consume an exact source-owned spend", async (t) => { + const runtimeRoot = await tempRuntime(t); + const params = request(runtimeRoot); + await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => await evaluateQuotaSpendCommit({ + ...params, + ...binding, + }), + ); + + const replay = await evaluateQuotaSpendCommit(params); + + assert.equal(replay.status, "conflict"); + assert.equal(replay.reason_code, "goal_instance_conflict"); +}); + +test("native replay requires the exact source GoalRef", async (t) => { + const runtimeRoot = await tempRuntime(t); + const effectId = "quota-spend-source-replay"; + await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => + await evaluateQuotaSpendCommit({ + ...request(runtimeRoot, { effect_id: effectId }), + ...binding, + }), + ); + + const unscoped = await evaluateQuotaSpendCommit( + replayRequest(runtimeRoot, effectId), + ); + assert.equal(unscoped.status, "conflict"); + assert.equal(unscoped.reason_code, "goal_instance_conflict"); + + const replayed = await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => + await evaluateQuotaSpendCommit({ + ...replayRequest(runtimeRoot, effectId), + ...binding, + }), + ); + assert.equal(replayed.status, "replayed"); + assert.equal(replayed.payload.replay_found, true); +}); + +test("legacy spend artifacts keep the alias-only wire shape", async (t) => { + const runtimeRoot = await tempRuntime(t); + const written = await evaluateQuotaSpendCommit(request(runtimeRoot)); + const record = requireJsonObject( + JSON.parse(await readFile(String(written.payload.json_path), "utf8")), + "quota spend record", + ); + const event = requireJsonObject(record.quota_event, "quota spend event"); + const row = requireJsonObject( + JSON.parse(await readFile(String(written.payload.index_path), "utf8")), + "quota spend index row", + ); + const receiptDirectory = join( + runtimeRoot, + "goals", + goalId, + "runs", + ".transactions", + "quota-spend", + ); + const [receiptName] = await readdir(receiptDirectory); + const receipt = requireJsonObject( + JSON.parse(await readFile(join(receiptDirectory, receiptName), "utf8")), + "quota spend receipt", + ); + + for (const value of [ + written.payload, + record, + event, + row, + receipt, + ]) { + assert.equal(Object.hasOwn(value, "goal_ref"), false); + } +}); + test("native replay validates legacy rows by goal and agent", async (t) => { const runtimeRoot = await tempRuntime(t); const runsDir = join(runtimeRoot, "goals", goalId, "runs"); @@ -440,6 +812,96 @@ test("prepared transaction repairs partial artifacts exactly once", async (t) => assert.equal(replayed.status, "replayed"); }); +test("prepared source transaction repairs only for its current GoalRef", async (t) => { + const runtimeRoot = await tempRuntime(t); + const params = request(runtimeRoot); + const written = await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => + await evaluateQuotaSpendCommit({ + ...params, + ...binding, + }), + ); + const indexPath = String(written.payload.index_path); + const markdownPath = String(written.payload.markdown_path); + const transactionDir = join( + dirname(indexPath), + ".transactions", + "quota-spend", + ); + const [receiptName] = await readdir(transactionDir); + assert.ok(receiptName); + const receiptPath = join(transactionDir, receiptName); + const receipt = requireJsonObject( + JSON.parse(await readFile(receiptPath, "utf8")), + "prepared quota spend receipt", + ); + receipt.status = "prepared"; + await Promise.all([ + writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"), + unlink(markdownPath), + ]); + + const repaired = await withSourceAdmission( + runtimeRoot, + instanceA, + instanceA, + async (binding) => + await evaluateQuotaSpendCommit({ + ...params, + ...binding, + }), + ); + assert.equal(repaired.status, "repaired"); + assert.match(await readFile(markdownPath, "utf8"), /quota_slot_spent/); + + receipt.status = "prepared"; + await Promise.all([ + writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"), + unlink(markdownPath), + ]); + await assert.rejects( + withSourceAdmission( + runtimeRoot, + instanceA, + instanceB, + async (binding) => + await evaluateQuotaSpendCommit({ + ...params, + ...binding, + }), + ), + (error: unknown) => { + assert.ok(error instanceof EffectRuntimeConflictError); + assert.equal(error.code, "stale_goal_instance"); + return true; + }, + ); + await assert.rejects(readFile(markdownPath), { code: "ENOENT" }); + + const foreign = await withSourceAdmission( + runtimeRoot, + instanceB, + instanceB, + async (binding) => + await evaluateQuotaSpendCommit({ + ...params, + ...binding, + }), + ); + assert.equal(foreign.status, "conflict"); + assert.equal(foreign.reason_code, "goal_instance_conflict"); + await assert.rejects(readFile(markdownPath), { code: "ENOENT" }); + const foreignReceipt = requireJsonObject( + JSON.parse(await readFile(receiptPath, "utf8")), + "foreign quota spend receipt", + ); + assert.equal(foreignReceipt.status, "prepared"); +}); + test("prepared transactions keep artifact paths reserved across later spends", async (t) => { const runtimeRoot = await tempRuntime(t); const firstParams = request(runtimeRoot); diff --git a/tests/control_plane_ts/quota_void_commit.test.ts b/tests/control_plane_ts/quota_void_commit.test.ts index c81f6249c..5b13f97fe 100644 --- a/tests/control_plane_ts/quota_void_commit.test.ts +++ b/tests/control_plane_ts/quota_void_commit.test.ts @@ -11,9 +11,15 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import test from "node:test"; +import { + acquireFileMutationLock, + releaseFileMutationLock, +} from "../../loopx/control_plane/effect_runtime_io.ts"; +import { EffectRuntimeConflictError } from "../../loopx/control_plane/effect_runtime_errors.ts"; +import { requireJsonObject } from "../../loopx/control_plane/runtime_decode.ts"; import { evaluateQuotaVoidCommit, quotaVoidIndexDigest, @@ -21,9 +27,16 @@ import { } from "../../loopx/control_plane/quota/void_commit.ts"; const goalId = "quota-void-transaction"; +const instanceA = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const instanceB = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const targetGeneratedAt = "2026-08-25T11:59:00+08:00"; const voidGeneratedAt = "2026-08-25T12:00:00+08:00"; +interface GoalRef { + goal_id: string; + goal_instance_id: string; +} + interface TargetFixture { runtimeRoot: string; runsDir: string; @@ -56,8 +69,11 @@ function beforeDecision(spentSlots: unknown = 5): Record { }; } -function quotaSpendEvent(slots: unknown): Record { - return { +function quotaSpendEvent( + slots: unknown, + goalRef?: GoalRef, +): Record { + const event: Record = { event_type: "quota_slot_spent", source: "heartbeat", slots, @@ -65,6 +81,8 @@ function quotaSpendEvent(slots: unknown): Record { before: { spent_slots: 3 }, after: { spent_slots: 5 }, }; + if (goalRef) event.goal_ref = goalRef; + return event; } async function tempRuntime(t: test.TestContext): Promise { @@ -80,6 +98,7 @@ async function targetFixture( slots?: unknown; generatedAt?: string; jsonPath?: string; + goalRef?: GoalRef; } = {}, ): Promise { const runtimeRoot = await tempRuntime(t); @@ -91,13 +110,14 @@ async function targetFixture( runsDir, "20260825-115900-quota-slot-spent.json", ); - const event = quotaSpendEvent(options.slots ?? 2); + const event = quotaSpendEvent(options.slots ?? 2, options.goalRef); const indexRecord: Record = { generated_at: generatedAt, goal_id: goalId, classification: "quota_slot_spent", json_path: targetJsonPath, }; + if (options.goalRef) indexRecord.goal_ref = options.goalRef; if (options.inline !== false) { indexRecord.quota_event = event; } else { @@ -108,6 +128,7 @@ async function targetFixture( goal_id: goalId, classification: "quota_slot_spent", quota_event: event, + ...(options.goalRef ? { goal_ref: options.goalRef } : {}), }, null, 2)}\n`, "utf8", ); @@ -117,6 +138,81 @@ async function targetFixture( return { runtimeRoot, runsDir, indexPath, indexContent, targetJsonPath }; } +function sourceGuardPath(registryPath: string): string { + const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); + return join( + dirname(registryPath), + ".loopx", + "lifecycle", + "goal-instance", + "guards", + `${digest}.guard`, + ); +} + +async function withSourceAdmission( + fixture: TargetFixture, + plannedInstanceId: string, + currentInstanceId: string, + run: (binding: Record) => Promise, +): Promise { + const registryPath = join( + fixture.runtimeRoot, + "project", + ".loopx", + "registry.json", + ); + const guardPath = sourceGuardPath(registryPath); + const indexLock = await acquireFileMutationLock(fixture.indexPath); + const guardLock = await acquireFileMutationLock(guardPath); + try { + return await run({ + goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + source_admission: { + schema_version: "loopx_quota_source_admission_v0", + profile_id: "source_session_v1", + registry_path: registryPath, + planned_goal_ref: { + goal_id: goalId, + goal_instance_id: plannedInstanceId, + }, + authority: { + kind: "present", + goal_ref: { + goal_id: goalId, + goal_instance_id: currentInstanceId, + }, + }, + locks: [ + { + role: "run_index", + target: fixture.indexPath, + pid: process.pid, + token: indexLock.token, + }, + { + role: "source_guard", + target: guardPath, + pid: process.pid, + token: guardLock.token, + }, + ], + }, + }); + } finally { + await releaseFileMutationLock(guardPath, guardLock.token, null, true); + await releaseFileMutationLock( + fixture.indexPath, + indexLock.token, + null, + true, + ); + } +} + async function rawIndexDigest(indexPath: string): Promise { const value = await readFile(indexPath); return `sha256:${createHash("sha256").update(value).digest("hex")}`; @@ -328,6 +424,71 @@ test("commit atomically owns the JSON, Markdown, and index artifacts", async (t) ); }); +test("source Goal B voids only a spend owned by B", async (t) => { + const goalRefA = { goal_id: goalId, goal_instance_id: instanceA }; + const goalRefB = { goal_id: goalId, goal_instance_id: instanceB }; + for (const targetOwner of [goalRefA, undefined]) { + const fixture = await targetFixture(t, { goalRef: targetOwner }); + await assert.rejects( + withSourceAdmission( + fixture, + instanceB, + instanceB, + async (binding) => + await evaluateQuotaVoidCommit({ + ...await request(fixture), + ...binding, + }), + ), + (error: unknown) => { + assert.ok(error instanceof EffectRuntimeConflictError); + assert.equal(error.code, "goal_instance_conflict"); + return true; + }, + ); + assert.equal(await readFile(fixture.indexPath, "utf8"), fixture.indexContent); + await assert.rejects( + readdir(join(fixture.runsDir, ".transactions")), + { code: "ENOENT" }, + ); + } + + const fixture = await targetFixture(t, { goalRef: goalRefB }); + const written = await withSourceAdmission( + fixture, + instanceB, + instanceB, + async (binding) => + await evaluateQuotaVoidCommit({ + ...await request(fixture), + ...binding, + }), + ); + + assert.equal(written.status, "written"); + assert.deepEqual(written.payload.goal_ref, goalRefB); + const record = requireJsonObject( + JSON.parse(await readFile(String(written.payload.json_path), "utf8")), + "quota void record", + ); + const event = requireJsonObject(record.quota_event, "quota void event"); + const rows = (await readFile(fixture.indexPath, "utf8")) + .trim() + .split("\n") + .map((line) => requireJsonObject( + JSON.parse(line), + "quota void index row", + )); + const receipt = await transactionReceipt( + fixture.runsDir, + String(written.effect_id), + ); + assert.deepEqual(record.goal_ref, goalRefB); + assert.deepEqual(event.goal_ref, goalRefB); + assert.deepEqual(rows[1]?.goal_ref, goalRefB); + assert.deepEqual(receipt.value.goal_ref, goalRefB); +}); + test("the same effect replays without appending a second void", async (t) => { const fixture = await targetFixture(t); const params = await request(fixture); diff --git a/tests/control_plane_ts/unsettled_host_turn_recovery.test.ts b/tests/control_plane_ts/unsettled_host_turn_recovery.test.ts index e65f26892..8ec1f750a 100644 --- a/tests/control_plane_ts/unsettled_host_turn_recovery.test.ts +++ b/tests/control_plane_ts/unsettled_host_turn_recovery.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { performance } from "node:perf_hooks"; import test from "node:test"; @@ -9,6 +10,10 @@ import { settlementIdentity, type JsonObject, } from "../../loopx/control_plane/effect_program.ts"; +import { + acquireFileMutationLock, + releaseFileMutationLock, +} from "../../loopx/control_plane/effect_runtime_io.ts"; import { ACCEPTED_CLOSEOUTS, PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, @@ -153,6 +158,58 @@ function preflight(runtimeRoot: string, exclude: string | null = "current-turn") }); } +async function withSourceAdmission( + runtimeRoot: string, + instanceId: string, + run: (binding: JsonObject) => Promise, +): Promise { + const indexPath = join(runtimeRoot, "goals", GOAL, "runs", "index.jsonl"); + const registryPath = join(runtimeRoot, "project", ".loopx", "registry.json"); + const guardPath = join( + dirname(registryPath), + ".loopx", + "lifecycle", + "goal-instance", + "guards", + `${createHash("sha256").update(GOAL, "utf8").digest("hex")}.guard`, + ); + const indexLock = await acquireFileMutationLock(indexPath); + const guardLock = await acquireFileMutationLock(guardPath); + try { + const goalRef = { + goal_id: GOAL, + goal_instance_id: instanceId, + }; + return await run({ + goal_ref: goalRef, + source_admission: { + schema_version: "loopx_quota_source_admission_v0", + profile_id: "source_session_v1", + registry_path: registryPath, + planned_goal_ref: goalRef, + authority: {kind: "present", goal_ref: goalRef}, + locks: [ + { + role: "run_index", + target: indexPath, + pid: process.pid, + token: indexLock.token, + }, + { + role: "source_guard", + target: guardPath, + pid: process.pid, + token: guardLock.token, + }, + ], + }, + }); + } finally { + await releaseFileMutationLock(guardPath, guardLock.token, null, true); + await releaseFileMutationLock(indexPath, indexLock.token, null, true); + } +} + function candidateFrom(result: JsonObject): JsonObject { assert.equal(result.status, "candidate"); return result.candidate as JsonObject; @@ -218,6 +275,60 @@ test("the preflight reads the persisted receipts and names the newest required c } }); +test("exact preflight filters stale owners before selecting the newest Turn", async () => { + const instanceA = "ginst_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const instanceB = "ginst_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const runtime = await runtimeWith([ + receipt("turn-b", closeoutRequired("turn-b", "todo_current"), { + goal_ref: {goal_id: GOAL, goal_instance_id: instanceB}, + }), + receipt("turn-delayed-a", closeoutRequired("turn-delayed-a", "todo_stale"), { + goal_ref: {goal_id: GOAL, goal_instance_id: instanceA}, + }), + ]); + try { + const result = await withSourceAdmission( + runtime.root, + instanceB, + async (binding) => await preflightPriorHostTurnCloseout({ + schema_version: PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, + runtime_root: runtime.root, + goal_id: GOAL, + agent_id: AGENT, + exclude_turn_instance_id: "current-turn", + ...binding, + }), + ); + assert.equal(candidateFrom(result).prior_turn_instance_id, "turn-b"); + + const onlyStale = await runtimeWith([ + receipt("turn-a", closeoutRequired("turn-a", "todo_stale"), { + goal_ref: {goal_id: GOAL, goal_instance_id: instanceA}, + }), + ]); + try { + const none = await withSourceAdmission( + onlyStale.root, + instanceB, + async (binding) => await preflightPriorHostTurnCloseout({ + schema_version: PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, + runtime_root: onlyStale.root, + goal_id: GOAL, + agent_id: AGENT, + exclude_turn_instance_id: "current-turn", + ...binding, + }), + ); + assert.equal(none.status, "none"); + assert.equal(none.reason, "no_prior_turn_requires_closeout"); + } finally { + await onlyStale.close(); + } + } finally { + await runtime.close(); + } +}); + test("a receipt that does not opt in never becomes a recovery obligation", async () => { const runtime = await runtimeWith([ receipt("turn-a", {todo_id: "todo_alpha", settlement_effect_id: "e"}), From 7026e463868ac8f9205d6a5f0a17e16128e8fa59 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 30 Sep 2026 21:10:08 +0800 Subject: [PATCH 2/3] fix(types): isolate GoalRef validation from registry state Signed-off-by: duanjialing.777 --- .../goals/goal_instance_identity.py | 22 ++++++++++++++++ .../goals/source_session_registry_state.py | 25 ++++--------------- loopx/control_plane/quota/effect_program.py | 2 +- 3 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 loopx/control_plane/goals/goal_instance_identity.py diff --git a/loopx/control_plane/goals/goal_instance_identity.py b/loopx/control_plane/goals/goal_instance_identity.py new file mode 100644 index 000000000..72848a8f1 --- /dev/null +++ b/loopx/control_plane/goals/goal_instance_identity.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import re + + +GOAL_ID = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") +GOAL_INSTANCE_ID = re.compile(r"^ginst_[0-9a-f]{32}$") + + +def require_goal_id(goal_id: str) -> None: + if not GOAL_ID.fullmatch(goal_id): + raise ValueError("source-session goal_id must be 1-200 safe characters") + + +def exact_goal_ref(goal_id: str, goal_instance_id: str) -> dict[str, str]: + require_goal_id(goal_id) + if not GOAL_INSTANCE_ID.fullmatch(goal_instance_id): + raise ValueError("goal_instance_id must be a Goal instance identifier") + return { + "goal_id": goal_id, + "goal_instance_id": goal_instance_id, + } diff --git a/loopx/control_plane/goals/source_session_registry_state.py b/loopx/control_plane/goals/source_session_registry_state.py index 0b9a8fa37..ba9d55efe 100644 --- a/loopx/control_plane/goals/source_session_registry_state.py +++ b/loopx/control_plane/goals/source_session_registry_state.py @@ -3,16 +3,16 @@ import hashlib import json from pathlib import Path -import re from typing import Any from ...registry import atomic_write_json from ..projects.registry_codec import SOURCE_SESSION_PROFILE_ID from ..todos.active_state_editing import fsync_state_directory - - -GOAL_ID = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") -GOAL_INSTANCE_ID = re.compile(r"^ginst_[0-9a-f]{32}$") +from .goal_instance_identity import ( + GOAL_INSTANCE_ID, + exact_goal_ref, + require_goal_id as require_goal_id, +) def canonical_digest(value: object) -> str: @@ -43,21 +43,6 @@ def write_journal(path: Path, payload: dict[str, Any]) -> None: fsync_state_directory(path) -def require_goal_id(goal_id: str) -> None: - if not GOAL_ID.fullmatch(goal_id): - raise ValueError("source-session goal_id must be 1-200 safe characters") - - -def exact_goal_ref(goal_id: str, goal_instance_id: str) -> dict[str, str]: - require_goal_id(goal_id) - if not GOAL_INSTANCE_ID.fullmatch(goal_instance_id): - raise ValueError("goal_instance_id must be a Goal instance identifier") - return { - "goal_id": goal_id, - "goal_instance_id": goal_instance_id, - } - - def required_list( registry: dict[str, Any], field: str, diff --git a/loopx/control_plane/quota/effect_program.py b/loopx/control_plane/quota/effect_program.py index 0c65ee785..09f9adbd6 100644 --- a/loopx/control_plane/quota/effect_program.py +++ b/loopx/control_plane/quota/effect_program.py @@ -26,6 +26,7 @@ receipt_bound_terminal_phase, settlement_result_payload, ) +from ..goals.goal_instance_identity import exact_goal_ref __all__ = [ "SETTLEMENT_IDENTITY_SCHEMA_VERSION", @@ -83,7 +84,6 @@ def _settlement_goal_ref( ) -> dict[str, str] | None: if goal_ref is None: return None - from ..goals.source_session_registry_state import exact_goal_ref normalized = exact_goal_ref( str(goal_ref.get("goal_id") or ""), From b8fb1069cd1e28cc7fb4c2066324581f10cd18c4 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Wed, 30 Sep 2026 22:10:35 +0800 Subject: [PATCH 3/3] fix(quota): keep GoalRef validation single-runtime Signed-off-by: duanjialing.777 --- .../goals/{goal_instance_identity.py => goal_ref_validation.py} | 0 loopx/control_plane/goals/source_session_registry_state.py | 2 +- loopx/control_plane/quota/effect_program.py | 2 +- tests/control_plane/test_quota_monitor_poll_runtime.py | 2 ++ 4 files changed, 4 insertions(+), 2 deletions(-) rename loopx/control_plane/goals/{goal_instance_identity.py => goal_ref_validation.py} (100%) diff --git a/loopx/control_plane/goals/goal_instance_identity.py b/loopx/control_plane/goals/goal_ref_validation.py similarity index 100% rename from loopx/control_plane/goals/goal_instance_identity.py rename to loopx/control_plane/goals/goal_ref_validation.py diff --git a/loopx/control_plane/goals/source_session_registry_state.py b/loopx/control_plane/goals/source_session_registry_state.py index ba9d55efe..2d6dc1bd8 100644 --- a/loopx/control_plane/goals/source_session_registry_state.py +++ b/loopx/control_plane/goals/source_session_registry_state.py @@ -8,7 +8,7 @@ from ...registry import atomic_write_json from ..projects.registry_codec import SOURCE_SESSION_PROFILE_ID from ..todos.active_state_editing import fsync_state_directory -from .goal_instance_identity import ( +from .goal_ref_validation import ( GOAL_INSTANCE_ID, exact_goal_ref, require_goal_id as require_goal_id, diff --git a/loopx/control_plane/quota/effect_program.py b/loopx/control_plane/quota/effect_program.py index 09f9adbd6..df1a79fa2 100644 --- a/loopx/control_plane/quota/effect_program.py +++ b/loopx/control_plane/quota/effect_program.py @@ -26,7 +26,7 @@ receipt_bound_terminal_phase, settlement_result_payload, ) -from ..goals.goal_instance_identity import exact_goal_ref +from ..goals.goal_ref_validation import exact_goal_ref __all__ = [ "SETTLEMENT_IDENTITY_SCHEMA_VERSION", diff --git a/tests/control_plane/test_quota_monitor_poll_runtime.py b/tests/control_plane/test_quota_monitor_poll_runtime.py index be3da145a..19798b03f 100644 --- a/tests/control_plane/test_quota_monitor_poll_runtime.py +++ b/tests/control_plane/test_quota_monitor_poll_runtime.py @@ -135,6 +135,7 @@ def test_turn_monitor_effect_identity_replays_legacy_receipt_then_scopes_new_tod turn_instance_id=turn_id, todo_id="todo_monitor_legacy", target_key="legacy-target", + goal_ref=None, ) == legacy_effect_id assert monitor_poll._monitor_poll_effect_id( runtime_root=tmp_path, @@ -143,6 +144,7 @@ def test_turn_monitor_effect_identity_replays_legacy_receipt_then_scopes_new_tod turn_instance_id=turn_id, todo_id="todo_monitor_new", target_key="new-target", + goal_ref=None, ) == ( f"quota-monitor-poll:{goal_id}:{agent_id}:{turn_id}:" "todo:todo_monitor_new"