From 60794190057236f1a082541187402096050c76f6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 8 Sep 2026 00:58:12 +0800 Subject: [PATCH] fix(todos): restore coarse completion transaction Signed-off-by: huangruiteng --- .../typescript-control-plane-migration-v0.md | 7 +- .../control_plane/effect_runtime_handlers.ts | 2 - .../control_plane/todos/completion_policy.py | 48 +++----- .../todos/completion_transaction.py | 62 ++++++++-- .../todos/completion_transaction.ts | 78 ++++++++++-- .../todos/completion_validation.py | 10 +- loopx/todos.py | 4 - ...est_todo_completion_transaction_runtime.py | 113 ++++++++++++------ .../test_todo_completion_validation.py | 64 +++++----- .../effect_runtime_handlers.test.ts | 11 ++ .../todo_completion_transaction.test.ts | 18 +++ 11 files changed, 290 insertions(+), 127 deletions(-) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 8e42dab950..6d830cc18b 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -445,8 +445,11 @@ shipped Stage 2B cutovers are in place: reduction. A real caller-approved validation command remains an explicit Python provider between two reductions. Todo and policy-source snapshots are compared after the mutation lock so a receipt for one declaration or agent - registry cannot authorize changed facts. Materialized and event-projected - writes consume the same typed result. + registry cannot authorize changed facts. Policy admission failures are + returned as typed data by that same reduction and consumed only after Python + actor/lease admission, preserving legacy error priority without a leaf + runtime call inside the writer critical section. Materialized and + event-projected writes consume the same typed result. - Scheduler heartbeat/state: TypeScript owns receipt freshness, ACK and host-failure validation, identity-aware progression, failure-cache retention/counting, replay and CAS fencing, preview reduction, the locked diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 75ed0251e4..e7229a066c 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -64,7 +64,6 @@ import { selectTodoCompletionContinuation, } from "./todos/completion_state.ts"; import { reduceTodoCompletionTransaction } from "./todos/completion_transaction.ts"; -import { resolveTodoCompletionPolicy } from "./todos/completion_policy.ts"; import { transitionTodoNextAction } from "./todos/next_action.ts"; import { evaluateTodoResumeConditions, @@ -375,7 +374,6 @@ export function createEffectRuntimeHandlers( ), ], ["todo.completion.reduce", reduceTodoCompletionTransaction], - ["todo.completion_policy.resolve", resolveTodoCompletionPolicy], ["todo.next_action.transition", transitionTodoNextAction], ["todo.resume_condition.normalize", normalizeTodoResumeWhen], ["todo.resume_condition.evaluate", evaluateTodoResumeConditions], diff --git a/loopx/control_plane/todos/completion_policy.py b/loopx/control_plane/todos/completion_policy.py index 1b63a3ebb3..a99b6f8f82 100644 --- a/loopx/control_plane/todos/completion_policy.py +++ b/loopx/control_plane/todos/completion_policy.py @@ -8,7 +8,6 @@ load_goal_from_registry, registered_agent_ids_for_goal, ) -from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from .active_state_editing import find_todo_block from .contract import ( normalize_todo_claimed_by, @@ -19,6 +18,7 @@ TODO_COMPLETION_POLICY_REQUEST_SCHEMA = "loopx_todo_completion_policy_request_v0" TODO_COMPLETION_POLICY_RESULT_SCHEMA = "loopx_todo_completion_policy_result_v0" +TODO_COMPLETION_POLICY_FAILURE_SCHEMA = "loopx_todo_completion_policy_failure_v0" @dataclass(frozen=True) @@ -148,6 +148,23 @@ def completion_policy_from_transaction( # These fields are dead on the event-projected replay path; the TS # completion fence has already prohibited every write. return CompletionPolicy(None, [], None, [], False) + if transaction.get("decision") == "policy_reject": + failure = transaction.get("completion_policy_failure") + if not ( + isinstance(failure, Mapping) + and failure.get("schema_version") + == TODO_COMPLETION_POLICY_FAILURE_SCHEMA + and failure.get("kind") == "completion_policy_rejected" + and isinstance(failure.get("diagnostic_code"), str) + and bool(failure.get("diagnostic_code")) + and isinstance(failure.get("summary"), str) + and bool(failure.get("summary")) + and transaction.get("completion_policy") is None + ): + raise RuntimeError( + "TypeScript Todo completion policy failure shape mismatch" + ) + raise ValueError(str(failure["summary"])) policy = transaction.get("completion_policy") if not isinstance(policy, Mapping) or ( policy.get("schema_version") != TODO_COMPLETION_POLICY_RESULT_SCHEMA @@ -179,32 +196,3 @@ def completion_policy_from_transaction( self_merged=bool(policy["self_merged"]), linked_successor_id=linked_successor_id, ) - - -def bind_completion_policy_to_transaction( - transaction: Mapping[str, Any], - completion_policy_request: Mapping[str, Any], -) -> dict[str, Any]: - """Attach the TS-owned policy after actor and lease admission. - - External validation and completion-state reduction stay single-shot. Only - the pure policy reducer runs under the locked authority boundary, which - preserves the legacy actor -> lease -> policy error priority. - """ - - bound = dict(transaction) - if bound.get("decision") != "commit": - return bound - try: - result = effect_runtime_result( - "todo.completion_policy.resolve", - dict(completion_policy_request), - ) - except EffectRuntimeRejected as exc: - raise ValueError(str(exc)) from None - if not isinstance(result, Mapping): - raise RuntimeError("TypeScript Todo completion policy result must be an object") - bound["completion_policy"] = dict(result) - # Reuse the public adapter as the exact result-shape guard. - completion_policy_from_transaction(bound) - return bound diff --git a/loopx/control_plane/todos/completion_transaction.py b/loopx/control_plane/todos/completion_transaction.py index 3e4962741c..4a57d532c9 100644 --- a/loopx/control_plane/todos/completion_transaction.py +++ b/loopx/control_plane/todos/completion_transaction.py @@ -14,7 +14,7 @@ "loopx_todo_completion_transaction_result_v0" ) -_DECISIONS = {"execute_validation", "commit", "replay", "reject"} +_DECISIONS = {"execute_validation", "commit", "policy_reject", "replay", "reject"} _IDENTITY_SOURCES = { "turn_settlement", "unscoped_completion", @@ -37,6 +37,7 @@ "validation_timeout_seconds", ) _COMPLETION_POLICY_RESULT_SCHEMA = "loopx_todo_completion_policy_result_v0" +_COMPLETION_POLICY_FAILURE_SCHEMA = "loopx_todo_completion_policy_failure_v0" def _json_sequence(value: Any) -> Any: @@ -335,6 +336,18 @@ def _valid_completion_policy(value: Any) -> bool: ) +def _valid_completion_policy_failure(value: Any) -> bool: + return ( + isinstance(value, Mapping) + and value.get("schema_version") == _COMPLETION_POLICY_FAILURE_SCHEMA + and value.get("kind") == "completion_policy_rejected" + and isinstance(value.get("diagnostic_code"), str) + and bool(value.get("diagnostic_code")) + and isinstance(value.get("summary"), str) + and bool(value.get("summary")) + ) + + def _valid_execute_validation_result(result: Mapping[str, Any]) -> bool: effect = result.get("validation_effect") return ( @@ -374,15 +387,10 @@ def _valid_execute_validation_result(result: Mapping[str, Any]) -> bool: ) -def _valid_commit_result( - result: Mapping[str, Any], - *, - completion_policy_required: bool, -) -> bool: +def _valid_completion_settlement(result: Mapping[str, Any]) -> bool: state = result.get("completion_state") updates = result.get("metadata_updates") receipt = result.get("validation_receipt") - policy = result.get("completion_policy") return ( isinstance(state, Mapping) and state.get("continuation") in _CONTINUATIONS @@ -396,10 +404,38 @@ def _valid_commit_result( and updates.get("completion_continuation") == state.get("continuation") and updates.get("completion_recovery") == state.get("recovery") and (receipt is None or _valid_receipt(receipt)) + ) + + +def _valid_commit_result( + result: Mapping[str, Any], + *, + completion_policy_required: bool, +) -> bool: + policy = result.get("completion_policy") + return ( + _valid_completion_settlement(result) + and result.get("completion_policy_failure") is None and ( _valid_completion_policy(policy) - if completion_policy_required or policy is not None - else True + if completion_policy_required + else policy is None + ) + ) + + +def _valid_policy_reject_result( + result: Mapping[str, Any], + *, + completion_policy_required: bool, +) -> bool: + if not completion_policy_required: + return False + return ( + _valid_completion_settlement(result) + and result.get("completion_policy") is None + and _valid_completion_policy_failure( + result.get("completion_policy_failure") ) ) @@ -431,6 +467,11 @@ def _valid_result( result, completion_policy_required=completion_policy_required, ) + if decision == "policy_reject": + return _valid_policy_reject_result( + result, + completion_policy_required=completion_policy_required, + ) if decision == "replay": return result["fence"].get("outcome") == "replay" return _valid_reject_result(result) @@ -482,7 +523,8 @@ def reduce_todo_completion_transaction( if not _valid_result( result, completion_policy_required=( - completion_policy_request is not None and result.get("decision") == "commit" + completion_policy_request is not None + and result.get("decision") in {"commit", "policy_reject"} ), ): raise RuntimeError( diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index b96798a246..d4c7652da0 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -1,5 +1,8 @@ import type { JsonObject } from "../effect_program.ts"; -import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { + effectRuntimeErrorPayload, + EffectRuntimeRequestError, +} from "../effect_runtime_errors.ts"; import { optionalNonEmptyString, requireBoolean, @@ -38,6 +41,8 @@ export const TODO_COMPLETION_TRANSACTION_REQUEST_SCHEMA = "loopx_todo_completion_transaction_v0"; export const TODO_COMPLETION_TRANSACTION_RESULT_SCHEMA = "loopx_todo_completion_transaction_result_v0"; +export const TODO_COMPLETION_POLICY_FAILURE_SCHEMA = + "loopx_todo_completion_policy_failure_v0"; const CALLER_VALIDATION_RECEIPT_SCHEMA = "issue_fix_validation_command_v0"; const PROJECTION_SOURCES = ["materialized", "event_log"] as const; @@ -85,14 +90,29 @@ export interface TodoCompletionExecuteValidation validation_effect: TodoCompletionValidationEffect; } -export interface TodoCompletionCommit extends CompletionTransactionBase { - decision: "commit"; +interface TodoCompletionSettlement extends CompletionTransactionBase { completion_state: CompletionStateProjection; metadata_updates: JsonObject; validation_receipt: CallerValidationReceipt | null; +} + +export interface TodoCompletionCommit extends TodoCompletionSettlement { + decision: "commit"; completion_policy?: TodoCompletionPolicyResult; } +export interface TodoCompletionPolicyFailure extends JsonObject { + schema_version: typeof TODO_COMPLETION_POLICY_FAILURE_SCHEMA; + kind: "completion_policy_rejected"; + diagnostic_code: string; + summary: string; +} + +export interface TodoCompletionPolicyReject extends TodoCompletionSettlement { + decision: "policy_reject"; + completion_policy_failure: TodoCompletionPolicyFailure; +} + export interface TodoCompletionReplay extends CompletionTransactionBase { decision: "replay"; } @@ -109,6 +129,7 @@ export interface TodoCompletionReject extends CompletionTransactionBase { export type TodoCompletionTransactionResult = | TodoCompletionExecuteValidation | TodoCompletionCommit + | TodoCompletionPolicyReject | TodoCompletionReplay | TodoCompletionReject; @@ -294,6 +315,33 @@ function baseResult( }; } +function evaluateCompletionPolicy( + request: JsonObject | null, +): + | { outcome: "not_requested" } + | { outcome: "accepted"; policy: TodoCompletionPolicyResult } + | { outcome: "rejected"; failure: TodoCompletionPolicyFailure } { + if (request === null) return { outcome: "not_requested" }; + try { + return { + outcome: "accepted", + policy: resolveTodoCompletionPolicy(request), + }; + } catch (error) { + if (!(error instanceof EffectRuntimeRequestError)) throw error; + const failure = effectRuntimeErrorPayload(error); + return { + outcome: "rejected", + failure: { + schema_version: TODO_COMPLETION_POLICY_FAILURE_SCHEMA, + kind: "completion_policy_rejected", + diagnostic_code: failure.code, + summary: failure.message, + }, + }; + } +} + /** * Reduce one Todo completion admission/settlement transaction. * @@ -414,20 +462,30 @@ export function reduceTodoCompletionTransaction( metadataResult.updates, "completion metadata updates", ); - const completionPolicy = request.completion_policy_request === null - ? null - : resolveTodoCompletionPolicy(request.completion_policy_request); - return { + const completionPolicy = evaluateCompletionPolicy( + request.completion_policy_request, + ); + const settlement = { ...base, - decision: "commit", completion_state: { continuation: completionStateResult.continuation, recovery: completionStateResult.recovery, }, metadata_updates: updates, validation_receipt: request.validation_receipt, - ...(completionPolicy === null + }; + if (completionPolicy.outcome === "rejected") { + return { + ...settlement, + decision: "policy_reject", + completion_policy_failure: completionPolicy.failure, + }; + } + return { + ...settlement, + decision: "commit", + ...(completionPolicy.outcome === "not_requested" ? {} - : { completion_policy: completionPolicy }), + : { completion_policy: completionPolicy.policy }), }; } diff --git a/loopx/control_plane/todos/completion_validation.py b/loopx/control_plane/todos/completion_validation.py index 66e7e7901e..dc29dccfc7 100644 --- a/loopx/control_plane/todos/completion_validation.py +++ b/loopx/control_plane/todos/completion_validation.py @@ -256,10 +256,10 @@ def run_completion_validation_gate_with_source( todo_id=todo_id, requested_has_successor=requested_has_successor, validation_receipt=None, - # Preserve the legacy error priority: policy admission is evaluated - # only after actor authority and the task-lease fence are established - # under the write lock. The source is still captured here for CAS. - completion_policy_request=None, + # The coarse reducer returns policy success or typed failure as data. + # The public writer consumes that projection only after actor and lease + # admission, preserving legacy error priority without a second IPC. + completion_policy_request=completion_policy_source, ) completion_validation = None if transaction["decision"] == "execute_validation": @@ -302,7 +302,7 @@ def run_completion_validation_gate_with_source( todo_id=todo_id, requested_has_successor=requested_has_successor, validation_receipt=completion_validation, - completion_policy_request=None, + completion_policy_request=completion_policy_source, ) if transaction["decision"] != "reject": return { diff --git a/loopx/todos.py b/loopx/todos.py index 546874e530..d4ca1b8d3e 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -62,7 +62,6 @@ from .control_plane.todos.addition import matching_todo_block, require_replan_successor_rebinding, require_replan_successor_scope from .control_plane.todos.completed_archive import archive_completed_todo_lines from .control_plane.todos.completion_policy import ( - bind_completion_policy_to_transaction, completion_policy_from_transaction, ) from .control_plane.todos.completion_transaction import ( @@ -1768,9 +1767,6 @@ def complete_goal_todo( runtime_root=shadow_runtime_root, ) ) - completion_transaction = bind_completion_policy_to_transaction( - completion_transaction, locked_completion_policy_source - ) completion_fence = completion_transaction["fence"] completion_state = completion_transaction.get("completion_state") completion_policy = completion_policy_from_transaction(completion_transaction) diff --git a/tests/control_plane/test_todo_completion_transaction_runtime.py b/tests/control_plane/test_todo_completion_transaction_runtime.py index 760e0f0d93..bf891f4c91 100644 --- a/tests/control_plane/test_todo_completion_transaction_runtime.py +++ b/tests/control_plane/test_todo_completion_transaction_runtime.py @@ -4,6 +4,9 @@ from loopx.control_plane import effect_runtime from loopx.control_plane.todos import completion_transaction +from loopx.control_plane.todos.completion_policy import ( + completion_policy_from_transaction, +) def _commit_result(**overrides: object) -> dict[str, object]: @@ -43,6 +46,24 @@ def _completion_policy_result(**overrides: object) -> dict[str, object]: } +def _completion_policy_failure(**overrides: object) -> dict[str, object]: + return { + "schema_version": "loopx_todo_completion_policy_failure_v0", + "kind": "completion_policy_rejected", + "diagnostic_code": "invalid_request", + "summary": "completion policy was rejected", + **overrides, + } + + +def _policy_reject_result(**overrides: object) -> dict[str, object]: + return _commit_result( + decision="policy_reject", + completion_policy_failure=_completion_policy_failure(), + **overrides, + ) + + def test_python_adapter_sends_one_coarse_transaction(monkeypatch) -> None: captured: dict[str, object] = {} @@ -160,6 +181,28 @@ def call(method: str, params: dict[str, object]) -> dict[str, object]: completion_policy_request=policy_request, ) + monkeypatch.setattr( + completion_transaction, + "effect_runtime_result", + lambda _method, _params: _policy_reject_result(), + ) + failure_result = completion_transaction.reduce_todo_completion_transaction( + todo={"status": "open"}, + projection_source="materialized", + goal_id="goal-example", + todo_id="todo_example001", + completion_turn_key=None, + completion_identity_source=None, + no_followup=False, + requested_has_successor=True, + dry_run=False, + completion_policy_request=policy_request, + ) + assert failure_result["completion_policy_failure"] == ( + _completion_policy_failure() + ) + assert failure_result["decision"] == "policy_reject" + def test_python_adapter_and_typescript_runtime_share_agent_identity_semantics() -> None: policy_request = { @@ -221,43 +264,45 @@ def test_python_adapter_and_typescript_runtime_share_agent_identity_semantics() self_merged=True, ) + rejected_evidence = completion_transaction.reduce_todo_completion_transaction( + todo={"status": "open"}, + projection_source="materialized", + goal_id="goal-example", + todo_id="todo_example001", + completion_turn_key=None, + completion_identity_source=None, + no_followup=False, + requested_has_successor=False, + dry_run=False, + completion_policy_request={ + **policy_request, + "next_claimed_by": None, + "next_agent_todo": None, + "next_excluded_agents": [], + "self_merged": True, + "evidence": "\u0085", + }, + ) with pytest.raises(ValueError, match="--self-merged requires --evidence"): - completion_transaction.reduce_todo_completion_transaction( - todo={"status": "open"}, - projection_source="materialized", - goal_id="goal-example", - todo_id="todo_example001", - completion_turn_key=None, - completion_identity_source=None, - no_followup=False, - requested_has_successor=False, - dry_run=False, - completion_policy_request={ - **policy_request, - "next_claimed_by": None, - "next_agent_todo": None, - "next_excluded_agents": [], - "self_merged": True, - "evidence": "\u0085", - }, - ) + completion_policy_from_transaction(rejected_evidence) + rejected_agent = completion_transaction.reduce_todo_completion_transaction( + todo={"status": "open"}, + projection_source="materialized", + goal_id="goal-example", + todo_id="todo_example001", + completion_turn_key=None, + completion_identity_source=None, + no_followup=False, + requested_has_successor=True, + dry_run=False, + completion_policy_request={ + **policy_request, + "claimed_by": "\ufeffagent-a", + }, + ) with pytest.raises(ValueError, match="public-safe registered agent id"): - completion_transaction.reduce_todo_completion_transaction( - todo={"status": "open"}, - projection_source="materialized", - goal_id="goal-example", - todo_id="todo_example001", - completion_turn_key=None, - completion_identity_source=None, - no_followup=False, - requested_has_successor=True, - dry_run=False, - completion_policy_request={ - **policy_request, - "claimed_by": "\ufeffagent-a", - }, - ) + completion_policy_from_transaction(rejected_agent) @pytest.mark.parametrize( diff --git a/tests/control_plane/test_todo_completion_validation.py b/tests/control_plane/test_todo_completion_validation.py index 3b000222d4..1d09a5eb27 100644 --- a/tests/control_plane/test_todo_completion_validation.py +++ b/tests/control_plane/test_todo_completion_validation.py @@ -9,8 +9,8 @@ import pytest +import loopx.control_plane.effect_runtime as effect_runtime_module import loopx.control_plane.todos.completion_validation as completion_validation_module -import loopx.control_plane.todos.completion_transaction as completion_transaction_module from loopx.control_plane.todos.completion_validation_projection import ( project_completion_validation_authority, ) @@ -111,6 +111,22 @@ def _add_todo( ) +def _record_completion_runtime_calls( + monkeypatch: pytest.MonkeyPatch, +) -> list[str]: + calls: list[str] = [] + original_request = effect_runtime_module._request_with_info + + def recording_request(*args, **kwargs): # type: ignore[no-untyped-def] + method = kwargs.get("method") + if isinstance(method, str) and method.startswith("todo.completion"): + calls.append(method) + return original_request(*args, **kwargs) + + monkeypatch.setattr(effect_runtime_module, "_request_with_info", recording_request) + return calls + + def test_validation_command_declared_and_passing_commits_completion( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -130,19 +146,7 @@ def counting_runner(*args, **kwargs): # type: ignore[no-untyped-def] return original_runner(*args, **kwargs) monkeypatch.setattr(completion_validation_module, "run_caller_validation", counting_runner) - original_effect_call = completion_transaction_module.effect_runtime_result - transaction_calls: list[str] = [] - - def counting_effect_call(method, params): # type: ignore[no-untyped-def] - if method == "todo.completion.reduce": - transaction_calls.append(method) - return original_effect_call(method, params) - - monkeypatch.setattr( - completion_transaction_module, - "effect_runtime_result", - counting_effect_call, - ) + transaction_calls = _record_completion_runtime_calls(monkeypatch) result = complete_goal_todo( registry_path=registry, @@ -152,10 +156,14 @@ def counting_effect_call(method, params): # type: ignore[no-untyped-def] evidence="validated completion", ) assert calls["count"] == 1 # the gate actually ran the declared command - assert transaction_calls == [ + assert [ + method for method in transaction_calls + if method == "todo.completion.reduce" + ] == [ "todo.completion.reduce", "todo.completion.reduce", ] + assert "todo.completion_policy.resolve" not in transaction_calls assert result["ok"] is True assert result["changed"] is True assert "validation_blocked_completion" not in result @@ -242,31 +250,27 @@ def test_no_validation_command_keeps_fast_path_unchanged( ) -> None: registry, state = _write_fixture(tmp_path) todo = _add_todo(registry) # no validation_command declared - original_effect_call = completion_transaction_module.effect_runtime_result - transaction_calls: list[str] = [] - - def counting_effect_call(method, params): # type: ignore[no-untyped-def] - if method == "todo.completion.reduce": - transaction_calls.append(method) - return original_effect_call(method, params) - - monkeypatch.setattr( - completion_transaction_module, - "effect_runtime_result", - counting_effect_call, - ) + transaction_calls = _record_completion_runtime_calls(monkeypatch) + note = "post-merge note parity" result = complete_goal_todo( registry_path=registry, goal_id=GOAL_ID, todo_id=str(todo["todo_id"]), agent_id=AGENT, evidence="plain completion", + note=note, ) assert result["ok"] is True assert result["changed"] is True - assert transaction_calls == ["todo.completion.reduce"] + assert [ + method for method in transaction_calls + if method == "todo.completion.reduce" + ] == ["todo.completion.reduce"] + assert "todo.completion_policy.resolve" not in transaction_calls assert "validation_blocked_completion" not in result - assert _agent_todo(state, str(todo["todo_id"]))["status"] == "done" + persisted = _agent_todo(state, str(todo["todo_id"])) + assert persisted["status"] == "done" + assert persisted["note"] == note def test_validation_receipt_cannot_commit_a_changed_completion_source( diff --git a/tests/control_plane_ts/effect_runtime_handlers.test.ts b/tests/control_plane_ts/effect_runtime_handlers.test.ts index cfa994ae05..d5088afad6 100644 --- a/tests/control_plane_ts/effect_runtime_handlers.test.ts +++ b/tests/control_plane_ts/effect_runtime_handlers.test.ts @@ -145,3 +145,14 @@ test("runtime boundary registers the quota monitor-poll transaction", async () = /Quota monitor-poll commit request schema mismatch/, ); }); + +test("completion policy has no standalone runtime handler", async () => { + await assert.rejects( + dispatchEffectRuntimeMethod( + handlers, + "todo.completion_policy.resolve", + {}, + ), + /unsupported Effect runtime method/, + ); +}); diff --git a/tests/control_plane_ts/todo_completion_transaction.test.ts b/tests/control_plane_ts/todo_completion_transaction.test.ts index b63e98d90d..e8e720f8a8 100644 --- a/tests/control_plane_ts/todo_completion_transaction.test.ts +++ b/tests/control_plane_ts/todo_completion_transaction.test.ts @@ -176,6 +176,24 @@ test("completion policy joins the coarse transaction only at commit", () => { self_merged: false, linked_successor_id: null, }); + + const policyRejected = reduceTodoCompletionTransaction( + request({ + completion_policy_request: { + ...completionPolicyRequest, + claimed_by: "not registered", + }, + }), + ); + assert.equal(policyRejected.decision, "policy_reject"); + assert.deepEqual(policyRejected.completion_policy_failure, { + schema_version: "loopx_todo_completion_policy_failure_v0", + kind: "completion_policy_rejected", + diagnostic_code: "invalid_request", + summary: + "claimed_by='not-registered' is not registered for goal " + + "'goal-example'; registered_agents=agent-a", + }); }); test("terminal replay bypasses a stale validation declaration", () => {