diff --git a/examples/cli-command-module-size-ownership-command-modularization-smoke.py b/examples/cli-command-module-size-ownership-command-modularization-smoke.py index 310b022c03..a142b380ac 100644 --- a/examples/cli-command-module-size-ownership-command-modularization-smoke.py +++ b/examples/cli-command-module-size-ownership-command-modularization-smoke.py @@ -12,7 +12,6 @@ STARTER_MODULE_LIMITS = { # Legacy command owners are frozen at their current baseline while each # cohesive extraction lands; the default budget still catches new growth. - "todo.py": 1098, "starter.py": 180, "starter_bootstrap.py": 220, "starter_bootstrap_registration.py": 240, diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index a00bf02e75..dcd36ec482 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -6,9 +6,6 @@ from ..control_plane.todos.contract import ( TODO_CONTINUATION_POLICY_VALUES, - TODO_TASK_CLASS_ADVANCEMENT, - normalize_todo_continuation_policy, - normalize_todo_task_class, replan_successor_semantic_binding, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration @@ -18,11 +15,6 @@ load_task_lease_runtime_shadow_records, resolve_coordination_runtime_shadow_config, ) -from ..control_plane.quota.settlement import ( - QuotaSettlementReadback, - read_heartbeat_settlement, - settlement_result_payload, -) from ..control_plane.todos.markdown import render_todo_markdown from ..history import load_index, load_registry from ..paths import resolve_runtime_root @@ -41,7 +33,6 @@ ARCHIVE_COMPLETED_DEFAULT_MAX_ACTIVE_DONE, add_goal_todo, archive_completed_todos, - complete_goal_todo, list_goal_todos, resolve_todo_state, supersede_goal_todo, @@ -56,7 +47,6 @@ validate_todo_archive_completed_options, validate_todo_capture_followups_options, validate_todo_claim_options, - validate_todo_complete_options, validate_todo_list_options, validate_todo_suggest_options, validate_todo_supersede_options, @@ -68,9 +58,11 @@ append_todo_rollout_event, todo_error_payload, ) -from .post_writeback import ( - PostWritebackProjectionBuilder, - dispatch_committed_cli_post_writeback_hooks, +from .post_writeback import PostWritebackProjectionBuilder +from .todo_complete import ( + TodoCompletionOutcome, + finalize_committed_todo_completion, + run_todo_complete, ) PrintPayload = Callable[ @@ -160,48 +152,6 @@ def _mirror_committed_todo_runtime_shadow( ) -def _completion_settlement_requirement( - todo: dict[str, object], - *, - no_follow_up: bool, -) -> str | None: - if no_follow_up: - return "terminal no-follow-up closeout" - task_class = normalize_todo_task_class( - todo.get("task_class"), - text=str(todo.get("text") or ""), - action_kind=todo.get("action_kind"), - ) - continuation_policy = normalize_todo_continuation_policy( - todo.get("continuation_policy") - ) - if ( - str(todo.get("role") or "") == "agent" - and task_class == TODO_TASK_CLASS_ADVANCEMENT - and continuation_policy != "same_agent_non_delivery" - ): - return "turn-scoped advancement completion" - return None - - -def _completion_settlement_error( - todo: dict[str, object], - settlement_readback: QuotaSettlementReadback, - *, - no_follow_up: bool, -) -> str | None: - requirement = _completion_settlement_requirement( - todo, - no_follow_up=no_follow_up, - ) - if requirement is None or settlement_readback.settlement.failure is None: - return None - return ( - f"{requirement} requires matching writeback and quota spend receipts: " - + settlement_readback.settlement.failure.reason - ) - - def _validated_replan_successor_obligation( args: argparse.Namespace, *, @@ -747,6 +697,7 @@ def handle_todo_command( if args.todo_command == "suggest" else render_todo_markdown ) + completion: TodoCompletionOutcome | None = None try: if args.todo_command is None: raise ValueError( @@ -910,128 +861,13 @@ def handle_todo_command( dry_run=bool(args.dry_run), ) elif args.todo_command == "complete": - validate_todo_complete_options(args) - settlement_result = None - settlement_identity = None - settlement_readback = None - completion_requires_settlement = False - completion_error = None - completion_turn_key = None - completion_identity_source = None - if getattr(args, "turn_instance_id", None): - runtime_root = resolve_runtime_root( - load_registry(registry_path), - runtime_root_arg, - ) - settlement_readback = read_heartbeat_settlement( - runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, - todo_id=args.todo_id, - turn_instance_id=getattr(args, "turn_instance_id", None), - ) - if settlement_readback is None: - raise RuntimeError( - "exact settlement readback unexpectedly returned not-found" - ) - settlement_result = settlement_readback.identity - if settlement_result.failure is not None: - raise ValueError(settlement_result.failure.reason) - if settlement_result.value is None: - raise ValueError("turn-scoped Todo completion has no identity") - identity = settlement_result.value - settlement_identity = identity - todo_payload = list_goal_todos( - registry_path=registry_path, - goal_id=args.goal_id, - todo_id=args.todo_id, - project=Path(args.project).expanduser() if args.project else None, - state_file=( - Path(args.state_file).expanduser() - if args.state_file - else None - ), - runtime_root_arg=runtime_root_arg, - ) - todo = ( - todo_payload.get("todo") - if isinstance(todo_payload.get("todo"), dict) - else None - ) - if todo is None: - raise ValueError( - "turn-scoped Todo completion requires one durable Todo" - ) - completion_requirement = _completion_settlement_requirement( - todo, - no_follow_up=bool(args.no_follow_up), - ) - completion_requires_settlement = completion_requirement is not None - completion_error = _completion_settlement_error( - todo, - settlement_readback=settlement_readback, - no_follow_up=bool(args.no_follow_up), - ) - if completion_error is not None: - settlement_result = settlement_readback.settlement - payload = { - "ok": False, - "dry_run": bool(args.dry_run), - "completed": False, - "changed": False, - "goal_id": args.goal_id, - "todo_id": args.todo_id, - "settlement_blocked_completion": True, - "settlement_identity": identity.as_dict(), - "settlement_result": settlement_result_payload( - settlement_result - ), - "error": completion_error, - } - completion_turn_key = identity.effect_id - completion_identity_source = "turn_settlement" - elif getattr(args, "completion_identity_key", None): - completion_turn_key = str(args.completion_identity_key) - completion_identity_source = "lifecycle_reentry" - if completion_error is None: - payload = complete_goal_todo( - registry_path=registry_path, - runtime_root_arg=runtime_root_arg, - goal_id=args.goal_id, - todo_id=args.todo_id, - role=args.role, - decision_outcome=args.decision_outcome, - evidence=args.evidence, - completion_turn_key=completion_turn_key, - completion_identity_source=completion_identity_source, - task_lease_idempotency_key=args.task_lease_idempotency_key, - task_lease_expected_version=args.task_lease_expected_version, - note=args.note, - no_followup=bool(args.no_follow_up), - successor_todo_ids=args.successor_todo_ids, - claimed_by=args.claimed_by, - clear_claim=bool(args.clear_claim), - next_agent_todo=args.next_agent_todo, - next_user_todo=args.next_user_todo, - next_user_task_class=args.next_user_task_class, - next_claimed_by=args.next_claimed_by, - next_task_class=args.next_task_class, - next_action_kind=args.next_action_kind, - next_task_repository=args.next_task_repository, - next_required_capabilities=args.next_required_capabilities, - next_continuation_policy=args.next_continuation_policy, - next_excluded_agents=args.next_excluded_agents, - self_merged=bool(args.self_merged), - agent_id=args.agent_id, - authority_reason=args.authority_reason, - **_todo_path_args(args), - dry_run=bool(args.dry_run), - ) - if settlement_identity is not None: - payload["settlement_identity"] = settlement_identity.as_dict() - payload["settlement_result"] = settlement_result_payload( - settlement_result - ) + completion = run_todo_complete( + args, + registry_path=registry_path, + runtime_root_arg=runtime_root_arg, + path_args=_todo_path_args(args), + ) + payload = completion.payload elif args.todo_command == "supersede": validate_todo_supersede_options(args) payload = supersede_goal_todo( @@ -1119,64 +955,15 @@ def handle_todo_command( ) if runtime_shadow is not None: payload["coordination_runtime_shadow"] = runtime_shadow - if ( - args.todo_command == "complete" - and getattr(args, "turn_instance_id", None) - and payload.get("ok") - and not payload.get("dry_run") - ): - runtime_root = resolve_runtime_root( - load_registry(registry_path), - runtime_root_arg, - ) - settlement_readback = read_heartbeat_settlement( - runtime_root, - goal_id=args.goal_id, - agent_id=args.agent_id, - todo_id=args.todo_id, - turn_instance_id=getattr(args, "turn_instance_id", None), - ) - if settlement_readback is None: - raise RuntimeError("exact settlement readback unexpectedly returned not-found") - settlement_result = ( - settlement_readback.terminal_settlement - if args.no_follow_up and settlement_identity is not None - else settlement_readback.settlement - if completion_requires_settlement - else settlement_readback.identity - ) - payload["settlement_result"] = settlement_result_payload( - settlement_result + if completion is not None: + finalize_committed_todo_completion( + completion, + args=args, + registry_path=registry_path, + runtime_root_arg=runtime_root_arg, + post_writeback_hooks=post_writeback_hooks, + post_writeback_projection_builder=post_writeback_projection_builder, ) - if settlement_result.failure is not None: - payload["ok"] = False - payload["receipt_repair_required"] = True - payload["error"] = settlement_result.failure.reason - if ( - args.todo_command == "complete" - and payload.get("ok") - and payload.get("completed") - and not payload.get("dry_run") - and post_writeback_hooks - and settlement_identity is not None - ): - identity = settlement_identity.as_dict() - committed_at = str(payload.get("updated_at") or "").strip() - if committed_at: - payload["post_writeback_hooks"] = ( - dispatch_committed_cli_post_writeback_hooks( - payload=payload, - registry_path=registry_path, - runtime_root_arg=runtime_root_arg, - goal_id=args.goal_id, - event_kind="todo_complete", - identity=identity, - state_version=committed_at, - committed_at=committed_at, - hooks=post_writeback_hooks, - projection_builder=post_writeback_projection_builder, - ) - ) print_payload( payload, format_name or str(getattr(args, "format", None) or "markdown"), diff --git a/loopx/cli_commands/todo_complete.py b/loopx/cli_commands/todo_complete.py new file mode 100644 index 0000000000..62494439c8 --- /dev/null +++ b/loopx/cli_commands/todo_complete.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import argparse +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +from ..control_plane.capability_hooks import PostWritebackHookRegistration +from ..control_plane.quota.effect_program import SettlementIdentity +from ..control_plane.quota.settlement import ( + QuotaSettlementReadback, + read_heartbeat_settlement, + settlement_result_payload, +) +from ..control_plane.todos.contract import ( + TODO_TASK_CLASS_ADVANCEMENT, + normalize_todo_continuation_policy, + normalize_todo_task_class, +) +from ..history import load_registry +from ..paths import resolve_runtime_root +from ..todos import complete_goal_todo, list_goal_todos +from .post_writeback import ( + PostWritebackProjectionBuilder, + dispatch_committed_cli_post_writeback_hooks, +) +from .todo_argument_validation import validate_todo_complete_options + + +@dataclass(frozen=True, slots=True) +class TodoCompletionOutcome: + """`loopx todo complete` result plus the settlement facts its closeout needs.""" + + payload: dict[str, object] + settlement_identity: SettlementIdentity | None + completion_requires_settlement: bool + + +def completion_settlement_requirement( + todo: dict[str, object], + *, + no_follow_up: bool, +) -> str | None: + if no_follow_up: + return "terminal no-follow-up closeout" + task_class = normalize_todo_task_class( + todo.get("task_class"), + text=str(todo.get("text") or ""), + action_kind=todo.get("action_kind"), + ) + continuation_policy = normalize_todo_continuation_policy( + todo.get("continuation_policy") + ) + if ( + str(todo.get("role") or "") == "agent" + and task_class == TODO_TASK_CLASS_ADVANCEMENT + and continuation_policy != "same_agent_non_delivery" + ): + return "turn-scoped advancement completion" + return None + + +def completion_settlement_error( + todo: dict[str, object], + settlement_readback: QuotaSettlementReadback, + *, + no_follow_up: bool, +) -> str | None: + requirement = completion_settlement_requirement( + todo, + no_follow_up=no_follow_up, + ) + if requirement is None or settlement_readback.settlement.failure is None: + return None + return ( + f"{requirement} requires matching writeback and quota spend receipts: " + + settlement_readback.settlement.failure.reason + ) + + +def run_todo_complete( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + path_args: Mapping[str, Path | None], +) -> TodoCompletionOutcome: + """Validate, settlement-gate, and write one `loopx todo complete` request.""" + + validate_todo_complete_options(args) + settlement_result = None + settlement_identity = None + settlement_readback = None + completion_requires_settlement = False + completion_error = None + completion_turn_key = None + completion_identity_source = None + if getattr(args, "turn_instance_id", None): + runtime_root = resolve_runtime_root( + load_registry(registry_path), + runtime_root_arg, + ) + settlement_readback = read_heartbeat_settlement( + runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + todo_id=args.todo_id, + turn_instance_id=getattr(args, "turn_instance_id", None), + ) + if settlement_readback is None: + raise RuntimeError( + "exact settlement readback unexpectedly returned not-found" + ) + settlement_result = settlement_readback.identity + if settlement_result.failure is not None: + raise ValueError(settlement_result.failure.reason) + if settlement_result.value is None: + raise ValueError("turn-scoped Todo completion has no identity") + identity = settlement_result.value + settlement_identity = identity + todo_payload = list_goal_todos( + registry_path=registry_path, + goal_id=args.goal_id, + todo_id=args.todo_id, + **path_args, + runtime_root_arg=runtime_root_arg, + ) + todo = ( + todo_payload.get("todo") + if isinstance(todo_payload.get("todo"), dict) + else None + ) + if todo is None: + raise ValueError( + "turn-scoped Todo completion requires one durable Todo" + ) + completion_requirement = completion_settlement_requirement( + todo, + no_follow_up=bool(args.no_follow_up), + ) + completion_requires_settlement = completion_requirement is not None + completion_error = completion_settlement_error( + todo, + settlement_readback=settlement_readback, + no_follow_up=bool(args.no_follow_up), + ) + if completion_error is not None: + settlement_result = settlement_readback.settlement + payload = { + "ok": False, + "dry_run": bool(args.dry_run), + "completed": False, + "changed": False, + "goal_id": args.goal_id, + "todo_id": args.todo_id, + "settlement_blocked_completion": True, + "settlement_identity": identity.as_dict(), + "settlement_result": settlement_result_payload( + settlement_result + ), + "error": completion_error, + } + completion_turn_key = identity.effect_id + completion_identity_source = "turn_settlement" + elif getattr(args, "completion_identity_key", None): + completion_turn_key = str(args.completion_identity_key) + completion_identity_source = "lifecycle_reentry" + if completion_error is None: + payload = complete_goal_todo( + registry_path=registry_path, + runtime_root_arg=runtime_root_arg, + goal_id=args.goal_id, + todo_id=args.todo_id, + role=args.role, + decision_outcome=args.decision_outcome, + evidence=args.evidence, + completion_turn_key=completion_turn_key, + completion_identity_source=completion_identity_source, + task_lease_idempotency_key=args.task_lease_idempotency_key, + task_lease_expected_version=args.task_lease_expected_version, + note=args.note, + no_followup=bool(args.no_follow_up), + successor_todo_ids=args.successor_todo_ids, + claimed_by=args.claimed_by, + clear_claim=bool(args.clear_claim), + next_agent_todo=args.next_agent_todo, + next_user_todo=args.next_user_todo, + next_user_task_class=args.next_user_task_class, + next_claimed_by=args.next_claimed_by, + next_task_class=args.next_task_class, + next_action_kind=args.next_action_kind, + next_task_repository=args.next_task_repository, + next_required_capabilities=args.next_required_capabilities, + next_continuation_policy=args.next_continuation_policy, + next_excluded_agents=args.next_excluded_agents, + self_merged=bool(args.self_merged), + agent_id=args.agent_id, + authority_reason=args.authority_reason, + **path_args, + dry_run=bool(args.dry_run), + ) + if settlement_identity is not None: + payload["settlement_identity"] = settlement_identity.as_dict() + payload["settlement_result"] = settlement_result_payload( + settlement_result + ) + return TodoCompletionOutcome( + payload=payload, + settlement_identity=settlement_identity, + completion_requires_settlement=completion_requires_settlement, + ) + + +def finalize_committed_todo_completion( + outcome: TodoCompletionOutcome, + *, + args: argparse.Namespace, + registry_path: Path, + runtime_root_arg: str | None, + post_writeback_hooks: Sequence[PostWritebackHookRegistration] | None, + post_writeback_projection_builder: PostWritebackProjectionBuilder | None, +) -> None: + """Re-read settlement receipts and dispatch hooks after a committed completion. + + Runs after the rollout event and runtime-shadow observers so their payload + fields are already present; mutates ``outcome.payload`` in place. + """ + + payload = outcome.payload + settlement_identity = outcome.settlement_identity + if ( + getattr(args, "turn_instance_id", None) + and payload.get("ok") + and not payload.get("dry_run") + ): + runtime_root = resolve_runtime_root( + load_registry(registry_path), + runtime_root_arg, + ) + settlement_readback = read_heartbeat_settlement( + runtime_root, + goal_id=args.goal_id, + agent_id=args.agent_id, + todo_id=args.todo_id, + turn_instance_id=getattr(args, "turn_instance_id", None), + ) + if settlement_readback is None: + raise RuntimeError("exact settlement readback unexpectedly returned not-found") + settlement_result = ( + settlement_readback.terminal_settlement + if args.no_follow_up and settlement_identity is not None + else settlement_readback.settlement + if outcome.completion_requires_settlement + else settlement_readback.identity + ) + payload["settlement_result"] = settlement_result_payload( + settlement_result + ) + if settlement_result.failure is not None: + payload["ok"] = False + payload["receipt_repair_required"] = True + payload["error"] = settlement_result.failure.reason + if ( + payload.get("ok") + and payload.get("completed") + and not payload.get("dry_run") + and post_writeback_hooks + and settlement_identity is not None + ): + identity = settlement_identity.as_dict() + committed_at = str(payload.get("updated_at") or "").strip() + if committed_at: + payload["post_writeback_hooks"] = ( + dispatch_committed_cli_post_writeback_hooks( + payload=payload, + registry_path=registry_path, + runtime_root_arg=runtime_root_arg, + goal_id=args.goal_id, + event_kind="todo_complete", + identity=identity, + state_version=committed_at, + committed_at=committed_at, + hooks=post_writeback_hooks, + projection_builder=post_writeback_projection_builder, + ) + ) diff --git a/tests/control_plane/test_quota_settlement.py b/tests/control_plane/test_quota_settlement.py index e6368ea89d..24e30ef309 100644 --- a/tests/control_plane/test_quota_settlement.py +++ b/tests/control_plane/test_quota_settlement.py @@ -40,7 +40,7 @@ build_interaction_contract, interaction_next_cli_actions, ) -from loopx.cli_commands.todo import _completion_settlement_error +from loopx.cli_commands.todo_complete import completion_settlement_error from loopx.rollout_event_log import rollout_event_log_path GOAL_ID = "settlement-goal" @@ -288,7 +288,7 @@ def test_advancement_completion_requires_the_complete_settlement_chain( turn_instance_id=TURN_ID, ) assert incomplete is not None - error = _completion_settlement_error( + error = completion_settlement_error( { "role": "agent", "task_class": "advancement_task", @@ -317,7 +317,7 @@ def test_advancement_completion_requires_the_complete_settlement_chain( ), ) assert ( - _completion_settlement_error( + completion_settlement_error( { "role": "agent", "task_class": "advancement_task", @@ -330,7 +330,7 @@ def test_advancement_completion_requires_the_complete_settlement_chain( is None ) assert ( - _completion_settlement_error( + completion_settlement_error( { "role": "agent", "task_class": "advancement_task", diff --git a/tests/test_cli_argument_diagnostics.py b/tests/test_cli_argument_diagnostics.py index 02cf5d51fa..b2e3780b87 100644 --- a/tests/test_cli_argument_diagnostics.py +++ b/tests/test_cli_argument_diagnostics.py @@ -8,6 +8,7 @@ from loopx.cli import build_parser, main, output_format, resolve_global_output_format from loopx.cli_commands import doctor as doctor_command from loopx.cli_commands import todo as todo_command +from loopx.cli_commands import todo_complete as todo_complete_command from loopx.cli_commands.quota_request import validate_quota_command_request from loopx.cli_commands.todo_argument_validation import ( validate_todo_add_options, @@ -770,7 +771,9 @@ def reject_stale_instance(**_kwargs: object) -> dict[str, object]: ) captured: dict[str, object] = {} - monkeypatch.setattr(todo_command, "complete_goal_todo", reject_stale_instance) + monkeypatch.setattr( + todo_complete_command, "complete_goal_todo", reject_stale_instance + ) args = build_parser().parse_args( [ "todo",