From 74417f0a08885fb260249a652b1cd049b6cc49d8 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 1 Oct 2026 16:41:35 +0800 Subject: [PATCH 1/3] fix(todo): close bound user actions without execution authority Signed-off-by: huangruiteng --- .../canonical-todo-completion-update.md | 50 ++++++++++++++ loopx/cli_commands/todo_registration.py | 4 +- .../coordination/todo_lifecycle_decision.ts | 15 +++++ .../coordination/todo_terminal_lifecycle.ts | 10 ++- loopx/control_plane/todos/unblock_resume.py | 40 ++++-------- loopx/control_plane/todos/user_completion.ts | 34 ++++++++-- ..._user_completion_provider_followthrough.py | 53 ++++++++++++++- .../todo_terminal_decision.test.ts | 33 ++++++++++ .../control_plane_ts/user_completion.test.ts | 29 ++++++++- ...er_completion_followthrough_conformance.ts | 65 +++++++++++++++++++ 10 files changed, 299 insertions(+), 34 deletions(-) diff --git a/docs/reference/canonical-todo-completion-update.md b/docs/reference/canonical-todo-completion-update.md index 8791931803..0be95b8d3c 100644 --- a/docs/reference/canonical-todo-completion-update.md +++ b/docs/reference/canonical-todo-completion-update.md @@ -158,6 +158,7 @@ snapshot extraction and writeback, not a second scope/resume implementation. | Approve a linked gate | Consume only covered required scopes and their recorded negative outcomes; preserve independent requirements. | | Reject or cancel a linked gate | Keep requirements, replace the latest outcome for that exact scope, preserve independent outcomes, and block the active target. | | Complete a linked User action | Attempt resume without consuming decision authority. | +| Cancel a linked User action | Close only the source reminder; do not edit or resume its target. | | Another active linked User Todo, remaining requirement or negative outcome | Keep the blocked target blocked. | | Explicit blocker task | Require explicit blocker repair. | | Completed, deferred or archived target | Do not change or reactivate it. | @@ -196,6 +197,55 @@ User Todo、剩余要求或拒绝结果仍会阻止恢复;已完成、延期 重放返回历史回执,不补做旧版本遗漏的联动,也不产生新的授权;历史不一致须根据 原决定显式核对修复。目标任务的执行租约与用户批准仍是不同合同。 +## Closing an ordinary bound User action / 关闭普通绑定用户事项 + +For a promoted Goal in `hard_lease` mode, the exact registered bound Agent may +close an ordinary `user_action` without acquiring an execution lease. User +actions cannot acquire execution leases; this is an administrative terminal +edit under the existing provider CAS, not an execution grant. A foreign actor, +an excluded/unregistered actor, an active lease holder or stale explicit lease +proof is not exempted. No claim, lease generation or decision scope is created. + +```sh +loopx todo complete --goal-id example --todo-id todo_observation \ + --role user --agent-id agent-a --decision-outcome cancel \ + --evidence 'The observation request was withdrawn' +``` + +Only `cancel` is accepted as an explicit ordinary-action outcome. A linked +reminder reports `decision_cancelled` and leaves its Agent Todo, requirements and scope +outcomes unchanged. Ordinary completion with no decision outcome retains the +existing guarded resume behavior. Gate approval/rejection/cancellation retains +its explicit gate contract. Cancelling a reminder is not cancelling an order, +withdrawing an external message or authorizing a trade; expiry is not detected +or acted on automatically by this change. + +The delivered cancellation entry point is CLI/managed CLI. Existing Chat +completion continues through the shared terminal owner, and frontend/Lark +consumers read the canonical completed status; no new cancellation button, +configuration setting or chat-specific authority is introduced. Direct +frontend/Lark cancellation controls are not part of this bounded slice. The +canonical transaction is qualified on File, SQLite and real PostgreSQL. The +legacy Markdown adapter shares outcome validation and cancellation effects, +but its separate hard-lease terminal fence is not changed. Historical receipts +are replayed as recorded, not reinterpreted as a new cancellation. + +已晋升且启用 `hard_lease` 的 Goal 中,精确绑定、已注册且未被排除的 Agent 可以 +关闭普通 `user_action`,无需取得执行租约。用户事项本来不能领取执行租约;这里是 +既有 provider CAS 下的行政关闭,不产生认领、租约代次或批准权限。异主体、活跃 +租约及显式过期/错误租约证明仍不绕过检查。 + +普通事项仅接受显式 `cancel`:关联提醒报告 `decision_cancelled`,不修改或恢复关联 Agent +任务,不消解要求或写入决策范围结果。不带决定的普通完成保留既有受保护恢复行为; +用户 gate 的批准、拒绝和取消仍遵循原契约。取消提醒不等于撤单、撤回外部消息或 +交易授权,本改动也不自动检测到期。 + +本交付的取消入口是 CLI/managed CLI;既有 Chat 完成入口复用同一终结权威,前端和 +Lark 读取 canonical 完成状态,但不新增取消按钮、配置或独立聊天权威。直接前端/ +Lark 取消控件不属于本有界切片。File、SQLite 和真实 PostgreSQL 已验证该事务;旧 +Markdown 路径复用结果校验和取消联动,但不改变其独立 hard-lease 终结门禁。历史 +回执按原记录重放,不会被重新解释成新的取消。 + ## Recovery and callers - Historical replay precedes current source admission and returns the original diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 15c27e5539..0b1bbb3df0 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -272,7 +272,9 @@ def register_todo_command( choices=["approve", "reject", "cancel"], help=( "For todo complete on a user_gate, record the explicit owner decision. " - "Only approve consumes authority and resumes linked work." + "For a user_action, only cancel is accepted; it closes the reminder " + "without approving or resuming linked work. Only gate approval " + "consumes decision authority." ), ) todo_parser.add_argument( diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index 40712b08dc..2f7af2e4b2 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -431,6 +431,21 @@ function terminalFence( const explicitFence = request.lease_idempotency_key !== null || request.lease_expected_version !== null; const delegated = authorityMode === "delegated_orchestration_override"; + // User actions cannot claim execution leases. Closing an ordinary reminder + // by its exact registered bound actor is instead a provider-CAS lifecycle + // edit. Never bypass an active holder or an explicitly supplied fence, and + // never mint a gate lease, execution claim or broader decision authority. + if (request.command === "complete" && request.handoff_mode === "hard_lease" && + request.todo.role === "user" && request.todo.task_class === "user_action" && + request.todo.bound_agent !== null && request.todo.bound_agent === request.actor_agent_id && + !delegated && !timeActive && !explicitFence && + ownerIdentityEligible(request, request.actor_agent_id)) { + return result("apply", "terminal_fence_not_required", { + authority_mode: authorityMode, lease_fence: "not_required", + next_lease: lease?.present && lease.status !== "released" + ? {...lease, active: false, status: "released"} : null, + }); + } // Deferred work cannot acquire a lease. Superseding a retired wait is a // terminal lifecycle edit, not execution, and the provider CAS retires any // expired lease lineage together with the Todo transition. diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index fb53fe13d3..1a1188e867 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1,4 +1,4 @@ -import {planUserCompletion} from "../todos/user_completion.ts"; +import {planUserCompletion, requireCompletionDecisionOutcome} from "../todos/user_completion.ts"; import {AUTHORITY_SOURCE_CHANGED, uncheckedAuthoritySource, type AuthoritySourceCheck} from "./authority_source.ts"; import {normalizeTodoUpdateInput, prepareUpdatedTodo, type CoordinationTodoUpdateInput, type TodoCompletionEdit} from "./todo_update_intent.ts"; import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts"; @@ -1204,6 +1204,14 @@ export async function executeCoordinationTodoTerminalLifecycle( "decision_rejection", ); } + if (update === undefined && input.command === "complete" && authority.outcome === "apply") { + try { + requireCompletionDecisionOutcome(todo, input.decision_outcome); + } catch (error) { + return terminalFailure("invalid_coordination_todo_terminal_lifecycle", + error instanceof Error ? error.message : "invalid completion outcome"); + } + } const implicitMonitorNoChange = normalized.operation_identity.kind === "current_monitor_cycle" && authority.outcome === "no_change"; diff --git a/loopx/control_plane/todos/unblock_resume.py b/loopx/control_plane/todos/unblock_resume.py index aa20d55d2a..9d2cadaaba 100644 --- a/loopx/control_plane/todos/unblock_resume.py +++ b/loopx/control_plane/todos/unblock_resume.py @@ -3,11 +3,7 @@ from typing import Any, Callable from .active_state_editing import section_bounds, todo_blocks -from .contract import ( - TODO_TASK_CLASS_USER_GATE, - normalize_todo_id, - require_todo_decision_outcome, -) +from .contract import normalize_todo_id def require_completion_decision_outcome( @@ -16,27 +12,19 @@ def require_completion_decision_outcome( *, materialized: bool, ) -> str | None: - is_user_gate = ( - str((completion_todo or {}).get("role") or "") == "user" - and str((completion_todo or {}).get("task_class") or "") - == TODO_TASK_CLASS_USER_GATE - ) - if not is_user_gate: - if decision_outcome is not None: - raise ValueError( - "decision_outcome is only valid when completing a user_gate" - ) - return None - if decision_outcome is None: - raise ValueError( - "user_gate completion requires decision_outcome=approve, reject, or cancel" - ) - if not materialized: - raise ValueError( - "event-projected user_gate completion must first materialize the gate " - "in active state so its decision outcome is durable" - ) - return require_todo_decision_outcome(decision_outcome) + from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result + + # The shared plan validates its input even with no dependent rows. Python + # transports the locked source fact; it does not own a second outcome rule. + try: + effect_runtime_result("todo.user_completion.plan", { + "schema_version": "todo_user_completion_request_v0", + "source": dict(completion_todo or {}), "todos": [], + "decision_outcome": decision_outcome, "materialized": materialized, + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + return decision_outcome def _find_todo( diff --git a/loopx/control_plane/todos/user_completion.ts b/loopx/control_plane/todos/user_completion.ts index e739ad0b61..bdfb81e9c9 100644 --- a/loopx/control_plane/todos/user_completion.ts +++ b/loopx/control_plane/todos/user_completion.ts @@ -3,7 +3,8 @@ * under their existing lock/CAS; replay returns the original transaction receipt. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject, requireStringLiteral} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtime_decode.ts"; import {decisionScopeCovers} from "./decision_scope.ts"; import {normalizeTodoDecisionScope, normalizeTodoRequiredDecisionScopes} from "./decision_metadata.ts"; @@ -31,6 +32,24 @@ const unavailableSource = (todo: JsonObject): boolean => { !["open", "blocked", "deferred"].includes(status); }; +/** One input rule for native completion and the locked Markdown adapter. + * Cancelling a reminder is not an owner approval or a decision-scope outcome. + */ +export function requireCompletionDecisionOutcome( + source: JsonObject, outcome: DecisionOutcome | null, materialized = true, +): DecisionOutcome | null { + if (source.role !== "user" || source.task_class !== "user_gate") { + if (outcome !== null && !(source.role === "user" && + source.task_class === "user_action" && outcome === "cancel")) { + throw new EffectRuntimeRequestError("decision_outcome is only valid when completing a user_gate or cancelling a user_action"); + } + return outcome; + } + if (outcome === null) throw new EffectRuntimeRequestError("user_gate completion requires decision_outcome=approve, reject, or cancel"); + if (!materialized) throw new EffectRuntimeRequestError("event-projected user_gate completion must first materialize the gate in active state so its decision outcome is durable"); + return outcome; +} + export function planUserCompletion( source: JsonObject, todos: readonly JsonObject[], outcome: DecisionOutcome | null, ): UserCompletionPlan { @@ -41,6 +60,10 @@ export function planUserCompletion( const id = source.unblocks_todo_id; const base = {schema_version: "todo_unblock_resume_v0", source_todo_id: source.todo_id, target_todo_id: id, changed: false}; + // Ordinary cancellation closes only its source. It neither approves nor + // rejects a scope, and must not resume or otherwise edit its dependent. + if (!gate && outcome === "cancel") return {...empty, + unblock_resume: {...base, state: "decision_cancelled"}}; const target = todos.find(row => row.todo_id === id && row.role === "agent" && row.archive_state !== "archive"); const scope = gate ? normalizeTodoDecisionScope(source.decision_scope) : null; if (!target) return {...empty, unblock_resume: {...base, @@ -101,7 +124,10 @@ export function evaluateUserCompletion(value: unknown): UserCompletionPlan { if (request.schema_version !== "todo_user_completion_request_v0" || !Array.isArray(request.todos)) { throw new TypeError("invalid user completion snapshot"); } - return planUserCompletion(requireJsonObject(request.source, "source"), - request.todos.map(row => requireJsonObject(row, "todo")), request.decision_outcome == null ? null : - requireStringLiteral(request.decision_outcome, ["approve", "reject", "cancel"] as const, "decision_outcome")); + const source = requireJsonObject(request.source, "source"); + const outcome = request.decision_outcome == null ? null : + requireStringLiteral(request.decision_outcome, ["approve", "reject", "cancel"] as const, "decision_outcome"); + requireCompletionDecisionOutcome(source, outcome, request.materialized === undefined + ? true : requireBoolean(request.materialized, "materialized")); + return planUserCompletion(source, request.todos.map(row => requireJsonObject(row, "todo")), outcome); } diff --git a/tests/control_plane/test_user_completion_provider_followthrough.py b/tests/control_plane/test_user_completion_provider_followthrough.py index a009afe04c..925522da6a 100644 --- a/tests/control_plane/test_user_completion_provider_followthrough.py +++ b/tests/control_plane/test_user_completion_provider_followthrough.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import subprocess +import sys from pathlib import Path import pytest @@ -11,7 +13,7 @@ AGENT_ID, GOAL_ID, PUBLISH_SCOPE, _add_target_and_gate, _write_fixture, ) from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection -from loopx.todos import complete_goal_todo, list_goal_todos, update_goal_todo +from loopx.todos import add_goal_todo, complete_goal_todo, list_goal_todos, update_goal_todo @pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) @@ -62,3 +64,52 @@ def test_public_completion_commits_linked_decision( assert dependent["decision_scope_outcomes"][0]["outcome"] == outcome assert result["unblock_resume"]["state"] == ( "decision_rejected" if outcome == "reject" else "decision_cancelled") + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +@pytest.mark.parametrize("cancel", [False, True]) +def test_public_action_closure_and_cli_cancel(tmp_path: Path, monkeypatch, provider, cancel): + isolate_sqlite_runtime(tmp_path, monkeypatch) + repo, state, registry = _write_fixture(tmp_path) + config = json.loads(registry.read_text()) + config["common_runtime_root"] = str(tmp_path / "runtime") + registry.write_text(json.dumps(config)) + target = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Continue after the user's observation", status="blocked", claimed_by=AGENT_ID) + action = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="user", + text="Read the observation request", task_class="user_action", bound_agent=AGENT_ID, + unblocks_todo_id=target["todo_id"]) + if provider != "legacy": + config["goals"][0]["coordination"]["handoff_mode"] = "hard_lease" + registry.write_text(json.dumps(config)) + source = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode="hard_lease", todos=source) + initialize_canonical_authority(tmp_path / "runtime", GOAL_ID, projection, + state_path=state, provider=provider) + base = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), + "todo", "complete", "--goal-id", GOAL_ID, "--todo-id", action["todo_id"], "--role", "user"] + for actor, outcome in [("codex-review", "cancel"), (AGENT_ID, "approve"), (AGENT_ID, "reject")]: + rejected = subprocess.run([*base, "--agent-id", actor, "--decision-outcome", outcome], + capture_output=True, text=True, timeout=45, cwd=repo) + assert rejected.returncode != 0, rejected.stdout + if actor == AGENT_ID: + assert "decision_outcome is only valid" in rejected.stdout + assert "handler failed unexpectedly" not in rejected.stdout + command = [*base, "--agent-id", AGENT_ID, "--evidence", "Synthetic ordinary observation"] + if cancel: + command += ["--decision-outcome", "cancel"] + result = subprocess.run(command, capture_output=True, text=True, timeout=45, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + rows = {row["todo_id"]: row for row in list_goal_todos( + registry_path=registry, goal_id=GOAL_ID)["todos"]} + assert rows[action["todo_id"]]["status"] == "done" + assert rows[target["todo_id"]]["status"] == ("blocked" if cancel else "open") + assert rows[target["todo_id"]]["claimed_by"] == AGENT_ID + assert not rows[target["todo_id"]].get("decision_scope_outcomes") + assert payload["unblock_resume"]["state"] == ("decision_cancelled" if cancel else "resumed") + replay = subprocess.run(command, capture_output=True, text=True, timeout=45, cwd=repo) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert rows == {row["todo_id"]: row for row in list_goal_todos( + registry_path=registry, goal_id=GOAL_ID)["todos"]} diff --git a/tests/control_plane_ts/todo_terminal_decision.test.ts b/tests/control_plane_ts/todo_terminal_decision.test.ts index 8dcac00fb1..9c482ac797 100644 --- a/tests/control_plane_ts/todo_terminal_decision.test.ts +++ b/tests/control_plane_ts/todo_terminal_decision.test.ts @@ -52,6 +52,39 @@ function mutation(overrides: Record = {}) { }); } +test("bound User action closure is administrative, not a fabricated execution lease", () => { + const todo = {...request().todo, role: "user", task_class: "user_action", + claimed_by: null, bound_agent: "agent-a"}; + const base = request({todo, handoff_mode: "hard_lease", decision_outcome: "cancel"}); + for (const status of ["open", "blocked", "deferred"]) { + const result = evaluateCoordinationTodoTerminalDecision({...base, todo: {...todo, status}}); + assert.equal(result.outcome, "apply"); + assert.equal(result.lease_fence, "not_required"); + assert.equal(result.next_lease, null); + } + for (const [override, code] of [ + [{actor_agent_id: null}, "actor_required"], + [{actor_agent_id: "unknown"}, "actor_not_registered"], + [{actor_agent_id: "agent-b"}, "bound_agent_mismatch"], + [{todo: {...todo, excluded_agents: ["agent-a"]}}, "actor_excluded"], + [{todo: {...todo, bound_agent: null}}, "handoff_mode_requires_lease"], + [{todo: {...todo, claimed_by: "agent-b"}}, "claim_owner_mismatch"], + [{lease_idempotency_key: "expired-key"}, "handoff_mode_requires_lease"], + [{command: "supersede", authority_action: "supersede"}, "handoff_mode_requires_lease"], + ] as const) assert.equal(evaluateCoordinationTodoTerminalDecision({...base, ...override}).code, code); + const lease = {present: true, active: true, status: "active", owner: "agent-a", + idempotency_key: "holder", version: 3, lease_epoch: 1, write_scopes: []}; + assert.equal(evaluateCoordinationTodoTerminalDecision({...base, lease}).code, "lease_fence_required"); + assert.equal(evaluateCoordinationTodoTerminalDecision({...base, lease, + lease_idempotency_key: "holder", lease_expected_version: 2}).code, "version_mismatch"); + assert.equal(evaluateCoordinationTodoTerminalDecision({...base, lease: {...lease, owner: "agent-b"}}).outcome, + "rejected", "an active foreign holder cannot be bypassed"); + assert.equal(evaluateCoordinationTodoTerminalDecision({...base, lease: {...lease, owner: "agent-b"}, + lease_idempotency_key: "holder", lease_expected_version: 3}).code, "lease_cas_mismatch"); + assert.equal(evaluateCoordinationTodoTerminalDecision({...base, + lease: {...lease, active: false, status: "expired"}}).next_lease?.status, "released"); +}); + test("update admission shares actor rules without inventing terminal effects", () => { const base = mutation({ todo: { ...request().todo as object, claimed_by: null } }); for (const mode of ["legacy", "soft_claim", "hard_lease"]) { diff --git a/tests/control_plane_ts/user_completion.test.ts b/tests/control_plane_ts/user_completion.test.ts index d3aff8beb0..5bae004138 100644 --- a/tests/control_plane_ts/user_completion.test.ts +++ b/tests/control_plane_ts/user_completion.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {planUserCompletion} from "../../loopx/control_plane/todos/user_completion.ts"; +import {evaluateUserCompletion, planUserCompletion} from "../../loopx/control_plane/todos/user_completion.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; const scope = {schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "publish"}; @@ -76,3 +76,30 @@ test("malformed decision history cannot be treated as an empty approval history" assert.throws(() => planUserCompletion(gate, [{...target, decision_scope_outcomes: "invalid"}], "approve"), /decision_scope_outcomes must be an array/); }); + +test("ordinary User cancellation leaves the dependent and its authority untouched", () => { + const action = {...gate, task_class: "user_action", bound_agent: "agent-a"}; + for (const current of [target, {...target, required_decision_scopes: []}]) { + const before = structuredClone(current); + const plan = planUserCompletion(action, [current], "cancel"); + assert.deepEqual(plan.updates, {}); + assert.equal(plan.decision_scope_resolution, null); + assert.equal(plan.unblock_resume?.state, "decision_cancelled"); + assert.equal(plan.unblock_resume?.changed, false); + assert.deepEqual(current, before); + } +}); + +test("shared completion bridge permits action cancellation but not invented approval", () => { + const request = {schema_version: "todo_user_completion_request_v0", todos: [], + source: {...gate, task_class: "user_action"}, decision_outcome: "cancel"}; + assert.doesNotThrow(() => evaluateUserCompletion(request)); + for (const outcome of ["approve", "reject"]) { + assert.throws(() => evaluateUserCompletion({...request, decision_outcome: outcome}), + /user_gate or cancelling a user_action/); + } + assert.throws(() => evaluateUserCompletion({...request, source: gate, decision_outcome: null}), + /user_gate completion requires/); + assert.throws(() => evaluateUserCompletion({...request, source: gate, materialized: false}), + /must first materialize/); +}); diff --git a/tests/control_plane_ts/user_completion_followthrough_conformance.ts b/tests/control_plane_ts/user_completion_followthrough_conformance.ts index 37dcdb1068..983a6219ea 100644 --- a/tests/control_plane_ts/user_completion_followthrough_conformance.ts +++ b/tests/control_plane_ts/user_completion_followthrough_conformance.ts @@ -6,6 +6,7 @@ import {executeCoordinationTodoTerminalLifecycle as complete, type CoordinationT import {prepareCoordinationProjectionCommit} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; import {productionScaleUserCompletionFixture} from "./production_scale_coordination_fixture.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; async function loaded(store: AuthorityStore) { const result = await store.loadAuthority(); @@ -15,6 +16,70 @@ async function loaded(store: AuthorityStore) { export function registerUserCompletionFollowthroughConformance(provider: string, factory: AuthorityStoreConformanceFactory): void { for (const schema of ["legacy", "native"] as const) { + for (const outcome of [null, "cancel"] as const) { + test(`${provider}: ordinary bound User ${outcome ?? "completion"} closes without execution authority (${schema})`, async t => { + const {store, contender} = await factory(t); + const goal = "ordinary-user-closure"; + const source = {todo_id: "todo_action", role: "user", task_class: "user_action", status: "open", + text: "Read the observation request", done: false, archive_state: "active", bound_agent: "agent-a", + unblocks_todo_id: "todo_dependent"}; + const target = {todo_id: "todo_dependent", role: "agent", task_class: "advancement_task", status: "blocked", + text: "Continue after the observation", done: false, archive_state: "active", claimed_by: "agent-a"}; + await store.commitAuthority({operation_id: "seed-action", expected_provider_revision: null, + next_projection: authorityProjectionFixture(goal, [source, target], [], schema, {handoff_mode: "hard_lease"}), + events: [], receipts: []}); + const before = await loaded(store); + const request: CoordinationTodoTerminalLifecycleInput = {goal_id: goal, todo_id: source.todo_id, expected_role: "user", command: "complete", + actor_agent_id: "agent-a", registered_agents: ["agent-a", "agent-b"], lifecycle_grants: [], + authority_reason: null, decision_outcome: outcome, operation_identity: {kind: "explicit", operation_id: "close-action"}, + lease_idempotency_key: null, lease_expected_version: null, allow_user_gate_auto_acquire: true, + requested_no_followup: false, requested_completion_turn_key: null, requested_completion_identity_source: null, + linked_successor_todo_ids: [], successor_intents: [], note: null, evidence: "Synthetic ordinary observation", + reason: null, clear_claim: false, validation_declaration: null, validation_receipt: null, + completion_policy_request: null, dry_run: false, now: new Date("2026-09-18T06:00:00Z")}; + for (const change of [{actor_agent_id: "agent-b"}, {actor_agent_id: null}, + {lease_idempotency_key: "stale"}, {decision_outcome: "approve" as const}, {decision_outcome: "reject" as const}]) { + assert.equal((await complete(store, {...request, ...change})).status, "failed"); + assert.deepEqual(await loaded(store), before); + assert.equal((await store.readReceipt("close-action")).status, "missing"); + } + assert.equal((await complete(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await loaded(store), before); + const originalCommit = store.commitAuthority.bind(store); + let raced = false; + store.commitAuthority = async commit => { + if (!raced && commit.operation_id === "close-action") { + raced = true; + await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, + operation_id: "race", expected_provider_revision: before.provider_revision, + projection: before.head, mutations: []})); + } + return originalCommit(commit); + }; + assert.notEqual((await complete(store, request)).status, "applied"); + assert.deepEqual((await loaded(store)).head.todos, before.head.todos); + assert.equal((await store.readReceipt("close-action")).status, "missing"); + store.commitAuthority = async commit => {await originalCommit(commit); throw new Error("injected lost response");}; + const result = await complete(store, request); + store.commitAuthority = originalCommit; + assert.equal(result.status, "recovered", JSON.stringify(result)); + const after = await loaded(store); + const rows = after.head.todos as JsonObject[]; + assert.equal(rows.find(row => row.todo_id === source.todo_id)!.status, "done"); + const dependent = rows.find(row => row.todo_id === target.todo_id)!; + assert.equal(dependent.status, outcome === "cancel" ? "blocked" : "open"); + if (outcome === "cancel") assert.deepEqual(dependent, + (before.head.todos as JsonObject[]).find(row => row.todo_id === target.todo_id)); + assert.deepEqual(after.head.leases, before.head.leases, "closure must not mint or transfer a lease"); + assert.equal((result.unblock_resume as JsonObject).state, outcome === "cancel" ? "decision_cancelled" : "resumed"); + const receipt = await store.readReceipt("close-action"); + assert.equal((await complete(contender, request, async () => {throw new Error("replay precedes admission");})).status, "replayed"); + assert.deepEqual(await loaded(store), after); + assert.deepEqual(await store.readReceipt("close-action"), receipt); + assert.equal((await complete(store, {...request, decision_outcome: outcome === null ? "cancel" : null})).reason_code, + "coordination_operation_identity_mismatch"); + }); + } for (const outcome of ["approve", "reject", "cancel"] as const) { test(`${provider}: linked User ${outcome} is atomic and replay-safe (${schema})`, async t => { const {store, contender} = await factory(t); From e7b90576c2bac42018c194e94a8a81c969b50c20 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 1 Oct 2026 17:30:56 +0800 Subject: [PATCH 2/3] test(todo): disclose explicit gate completion boundary Signed-off-by: huangruiteng --- .../canonical-todo-completion-update.md | 14 +++++++ ..._user_completion_provider_followthrough.py | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/docs/reference/canonical-todo-completion-update.md b/docs/reference/canonical-todo-completion-update.md index 0be95b8d3c..50dcea3f71 100644 --- a/docs/reference/canonical-todo-completion-update.md +++ b/docs/reference/canonical-todo-completion-update.md @@ -220,6 +220,14 @@ its explicit gate contract. Cancelling a reminder is not cancelling an order, withdrawing an external message or authorizing a trade; expiry is not detected or acted on automatically by this change. +Compatibility tightening: canonical `todo complete` previously accepted a +`user_gate` without `--decision-outcome` and closed it without a decision. +It now rejects that input before any state or dependent effect, matching the +existing legacy explicit-completion contract. Callers must supply +`approve|reject|cancel`. The separate `todo update --status done` closure remains +valid and grants no decision authority; required scopes and blocked dependents +are preserved. Historical successful receipts remain replayable as recorded. + The delivered cancellation entry point is CLI/managed CLI. Existing Chat completion continues through the shared terminal owner, and frontend/Lark consumers read the canonical completed status; no new cancellation button, @@ -240,6 +248,12 @@ are replayed as recorded, not reinterpreted as a new cancellation. 用户 gate 的批准、拒绝和取消仍遵循原契约。取消提醒不等于撤单、撤回外部消息或 交易授权,本改动也不自动检测到期。 +兼容收紧:此前 canonical `todo complete` 接受未带 `--decision-outcome` 的 +`user_gate` 并只关闭事项;现在在任何状态或下游写入前拒绝,与旧路径的显式完成 +契约对齐。调用方须明确传入 `approve|reject|cancel`。独立的 +`todo update --status done` 仍可记录关闭,但不产生决定或批准,保留关联任务的 +阻塞和范围要求。历史成功回执仍按原记录重放。 + 本交付的取消入口是 CLI/managed CLI;既有 Chat 完成入口复用同一终结权威,前端和 Lark 读取 canonical 完成状态,但不新增取消按钮、配置或独立聊天权威。直接前端/ Lark 取消控件不属于本有界切片。File、SQLite 和真实 PostgreSQL 已验证该事务;旧 diff --git a/tests/control_plane/test_user_completion_provider_followthrough.py b/tests/control_plane/test_user_completion_provider_followthrough.py index 925522da6a..9be6206a4a 100644 --- a/tests/control_plane/test_user_completion_provider_followthrough.py +++ b/tests/control_plane/test_user_completion_provider_followthrough.py @@ -113,3 +113,44 @@ def test_public_action_closure_and_cli_cancel(tmp_path: Path, monkeypatch, provi assert replay.returncode == 0, replay.stdout + replay.stderr assert rows == {row["todo_id"]: row for row in list_goal_todos( registry_path=registry, goal_id=GOAL_ID)["todos"]} + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_public_gate_requires_decision_but_update_closure_grants_none( + tmp_path: Path, monkeypatch, provider, +): + isolate_sqlite_runtime(tmp_path, monkeypatch) + repo, state, registry = _write_fixture(tmp_path) + config = json.loads(registry.read_text()) + config["common_runtime_root"] = str(tmp_path / "runtime") + registry.write_text(json.dumps(config)) + target, gate = _add_target_and_gate( + registry, required_scopes=[PUBLISH_SCOPE], target_status="blocked", + ) + if provider != "legacy": + config["goals"][0]["coordination"]["handoff_mode"] = "hard_lease" + registry.write_text(json.dumps(config)) + rows = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] + projection = build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, handoff_mode="hard_lease", todos=rows, + ) + initialize_canonical_authority(tmp_path / "runtime", GOAL_ID, projection, + state_path=state, provider=provider) + before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] + cli = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), "todo"] + identity = ["--goal-id", GOAL_ID, "--todo-id", gate["todo_id"], "--agent-id", AGENT_ID] + missing = subprocess.run([*cli, "complete", *identity, "--role", "user"], + capture_output=True, text=True, timeout=45, cwd=repo) + assert missing.returncode != 0, missing.stdout + assert "user_gate completion requires decision_outcome" in json.loads(missing.stdout)["error"] + assert "handler failed unexpectedly" not in missing.stdout + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] == before + # Update-done records closure, not a decision; it must not approve the target. + closed = subprocess.run([*cli, "update", *identity, "--status", "done", "--no-follow-up", + "--note", "Record closure without a decision"], + capture_output=True, text=True, timeout=45, cwd=repo) + assert closed.returncode == 0, closed.stdout + closed.stderr + after = {row["todo_id"]: row for row in list_goal_todos( + registry_path=registry, goal_id=GOAL_ID)["todos"]} + assert after[gate["todo_id"]]["status"] == "done" + assert after[target["todo_id"]] == next(row for row in before if row["todo_id"] == target["todo_id"]) From 7d125b2666c8ecf3980489e6640cd793c8624bb7 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 1 Oct 2026 17:58:26 +0800 Subject: [PATCH 3/3] fix(todo): preserve native gate closure without a decision Signed-off-by: huangruiteng --- .../canonical-todo-completion-update.md | 26 +++++++------- loopx/control_plane/todos/user_completion.ts | 12 ++++--- ..._user_completion_provider_followthrough.py | 36 ++++++++++++------- .../control_plane_ts/user_completion.test.ts | 11 +++++- ...er_completion_followthrough_conformance.ts | 11 ++++-- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/docs/reference/canonical-todo-completion-update.md b/docs/reference/canonical-todo-completion-update.md index 50dcea3f71..b51458fa99 100644 --- a/docs/reference/canonical-todo-completion-update.md +++ b/docs/reference/canonical-todo-completion-update.md @@ -220,13 +220,14 @@ its explicit gate contract. Cancelling a reminder is not cancelling an order, withdrawing an external message or authorizing a trade; expiry is not detected or acted on automatically by this change. -Compatibility tightening: canonical `todo complete` previously accepted a -`user_gate` without `--decision-outcome` and closed it without a decision. -It now rejects that input before any state or dependent effect, matching the -existing legacy explicit-completion contract. Callers must supply -`approve|reject|cancel`. The separate `todo update --status done` closure remains -valid and grants no decision authority; required scopes and blocked dependents -are preserved. Historical successful receipts remain replayable as recorded. +Compatibility: canonical `todo complete` continues to accept a `user_gate` +without `--decision-outcome` as closure only, not approval, rejection or +cancellation. Its required scopes, scope outcomes and blocked dependents remain +unchanged, just as for `todo update --status done`. To record a decision and its +linked effects, explicitly supply `approve|reject|cancel`. The legacy Markdown +explicit-completion adapter keeps its pre-existing requirement for a decision; +that adapter's stricter input rule is not imposed on native callers. Historical +successful receipts remain replayable as recorded. The delivered cancellation entry point is CLI/managed CLI. Existing Chat completion continues through the shared terminal owner, and frontend/Lark @@ -248,11 +249,12 @@ are replayed as recorded, not reinterpreted as a new cancellation. 用户 gate 的批准、拒绝和取消仍遵循原契约。取消提醒不等于撤单、撤回外部消息或 交易授权,本改动也不自动检测到期。 -兼容收紧:此前 canonical `todo complete` 接受未带 `--decision-outcome` 的 -`user_gate` 并只关闭事项;现在在任何状态或下游写入前拒绝,与旧路径的显式完成 -契约对齐。调用方须明确传入 `approve|reject|cancel`。独立的 -`todo update --status done` 仍可记录关闭,但不产生决定或批准,保留关联任务的 -阻塞和范围要求。历史成功回执仍按原记录重放。 +保持兼容:canonical `todo complete` 继续接受未带 `--decision-outcome` 的 +`user_gate`,仅关闭事项,不视为批准、拒绝或取消决定;关联任务的阻塞、范围要求 +和范围结果保持不变,与 `todo update --status done` 一样。如需记录决定及关联 +效果,须显式传入 `approve|reject|cancel`。旧 Markdown 显式完成适配器保留原有的 +决定必填规则,不将该适配器更严格的输入规则强加给原生调用方。历史成功回执仍 +按原记录重放。 本交付的取消入口是 CLI/managed CLI;既有 Chat 完成入口复用同一终结权威,前端和 Lark 读取 canonical 完成状态,但不新增取消按钮、配置或独立聊天权威。直接前端/ diff --git a/loopx/control_plane/todos/user_completion.ts b/loopx/control_plane/todos/user_completion.ts index bdfb81e9c9..090cf384d3 100644 --- a/loopx/control_plane/todos/user_completion.ts +++ b/loopx/control_plane/todos/user_completion.ts @@ -32,11 +32,12 @@ const unavailableSource = (todo: JsonObject): boolean => { !["open", "blocked", "deferred"].includes(status); }; -/** One input rule for native completion and the locked Markdown adapter. +/** Shared outcome semantics with each caller's existing presence contract. * Cancelling a reminder is not an owner approval or a decision-scope outcome. + * Native Gate closure may omit a decision; the legacy explicit bridge cannot. */ export function requireCompletionDecisionOutcome( - source: JsonObject, outcome: DecisionOutcome | null, materialized = true, + source: JsonObject, outcome: DecisionOutcome | null, materialized = true, gateOutcomeRequired = false, ): DecisionOutcome | null { if (source.role !== "user" || source.task_class !== "user_gate") { if (outcome !== null && !(source.role === "user" && @@ -45,7 +46,10 @@ export function requireCompletionDecisionOutcome( } return outcome; } - if (outcome === null) throw new EffectRuntimeRequestError("user_gate completion requires decision_outcome=approve, reject, or cancel"); + if (outcome === null) { + if (gateOutcomeRequired) throw new EffectRuntimeRequestError("user_gate completion requires decision_outcome=approve, reject, or cancel"); + return null; + } if (!materialized) throw new EffectRuntimeRequestError("event-projected user_gate completion must first materialize the gate in active state so its decision outcome is durable"); return outcome; } @@ -128,6 +132,6 @@ export function evaluateUserCompletion(value: unknown): UserCompletionPlan { const outcome = request.decision_outcome == null ? null : requireStringLiteral(request.decision_outcome, ["approve", "reject", "cancel"] as const, "decision_outcome"); requireCompletionDecisionOutcome(source, outcome, request.materialized === undefined - ? true : requireBoolean(request.materialized, "materialized")); + ? true : requireBoolean(request.materialized, "materialized"), true); return planUserCompletion(source, request.todos.map(row => requireJsonObject(row, "todo")), outcome); } diff --git a/tests/control_plane/test_user_completion_provider_followthrough.py b/tests/control_plane/test_user_completion_provider_followthrough.py index 9be6206a4a..9d45210d15 100644 --- a/tests/control_plane/test_user_completion_provider_followthrough.py +++ b/tests/control_plane/test_user_completion_provider_followthrough.py @@ -116,8 +116,9 @@ def test_public_action_closure_and_cli_cancel(tmp_path: Path, monkeypatch, provi @pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) -def test_public_gate_requires_decision_but_update_closure_grants_none( - tmp_path: Path, monkeypatch, provider, +@pytest.mark.parametrize("method", ["complete", "update"]) +def test_public_gate_closure_without_decision_preserves_existing_contract( + tmp_path: Path, monkeypatch, provider, method, ): isolate_sqlite_runtime(tmp_path, monkeypatch) repo, state, registry = _write_fixture(tmp_path) @@ -139,18 +140,29 @@ def test_public_gate_requires_decision_but_update_closure_grants_none( before = list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] cli = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", str(registry), "todo"] identity = ["--goal-id", GOAL_ID, "--todo-id", gate["todo_id"], "--agent-id", AGENT_ID] - missing = subprocess.run([*cli, "complete", *identity, "--role", "user"], - capture_output=True, text=True, timeout=45, cwd=repo) - assert missing.returncode != 0, missing.stdout - assert "user_gate completion requires decision_outcome" in json.loads(missing.stdout)["error"] - assert "handler failed unexpectedly" not in missing.stdout - assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] == before - # Update-done records closure, not a decision; it must not approve the target. - closed = subprocess.run([*cli, "update", *identity, "--status", "done", "--no-follow-up", - "--note", "Record closure without a decision"], - capture_output=True, text=True, timeout=45, cwd=repo) + command = [*cli, method, *identity] + if method == "complete": + command += ["--role", "user", "--evidence", "Record closure without a decision"] + else: + command += ["--status", "done", "--no-follow-up", "--note", "Record closure without a decision"] + closed = subprocess.run(command, capture_output=True, text=True, timeout=45, cwd=repo) + if provider == "legacy" and method == "complete": + # Preserve this older adapter's explicit-decision rule, not impose it on native callers. + assert closed.returncode != 0, closed.stdout + assert "user_gate completion requires decision_outcome" in json.loads(closed.stdout)["error"] + assert "handler failed unexpectedly" not in closed.stdout + assert list_goal_todos(registry_path=registry, goal_id=GOAL_ID)["todos"] == before + return assert closed.returncode == 0, closed.stdout + closed.stderr + payload = json.loads(closed.stdout) + assert payload.get("decision_outcome") is None + assert payload.get("decision_scope_resolution") is None + assert payload.get("unblock_resume") is None after = {row["todo_id"]: row for row in list_goal_todos( registry_path=registry, goal_id=GOAL_ID)["todos"]} assert after[gate["todo_id"]]["status"] == "done" assert after[target["todo_id"]] == next(row for row in before if row["todo_id"] == target["todo_id"]) + replay = subprocess.run(command, capture_output=True, text=True, timeout=45, cwd=repo) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert after == {row["todo_id"]: row for row in list_goal_todos( + registry_path=registry, goal_id=GOAL_ID)["todos"]} diff --git a/tests/control_plane_ts/user_completion.test.ts b/tests/control_plane_ts/user_completion.test.ts index 5bae004138..f53309d1f3 100644 --- a/tests/control_plane_ts/user_completion.test.ts +++ b/tests/control_plane_ts/user_completion.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {evaluateUserCompletion, planUserCompletion} from "../../loopx/control_plane/todos/user_completion.ts"; +import {evaluateUserCompletion, planUserCompletion, requireCompletionDecisionOutcome} from "../../loopx/control_plane/todos/user_completion.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; const scope = {schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "publish"}; @@ -103,3 +103,12 @@ test("shared completion bridge permits action cancellation but not invented appr assert.throws(() => evaluateUserCompletion({...request, source: gate, materialized: false}), /must first materialize/); }); + +test("native Gate closure without a decision preserves requirements and grants no approval", () => { + const before = structuredClone(target); + assert.equal(requireCompletionDecisionOutcome(gate, null), null); + assert.deepEqual(planUserCompletion(gate, [target, gate], null), { + updates: {}, unblock_resume: null, decision_scope_resolution: null, + }); + assert.deepEqual(target, before); +}); diff --git a/tests/control_plane_ts/user_completion_followthrough_conformance.ts b/tests/control_plane_ts/user_completion_followthrough_conformance.ts index 983a6219ea..079e0d97a9 100644 --- a/tests/control_plane_ts/user_completion_followthrough_conformance.ts +++ b/tests/control_plane_ts/user_completion_followthrough_conformance.ts @@ -80,8 +80,8 @@ export function registerUserCompletionFollowthroughConformance(provider: string, "coordination_operation_identity_mismatch"); }); } - for (const outcome of ["approve", "reject", "cancel"] as const) { - test(`${provider}: linked User ${outcome} is atomic and replay-safe (${schema})`, async t => { + for (const outcome of [null, "approve", "reject", "cancel"] as const) { + test(`${provider}: linked User ${outcome ?? "closure without decision"} is atomic and replay-safe (${schema})`, async t => { const {store, contender} = await factory(t); const goal = "user-decision-followthrough"; const fixture = productionScaleUserCompletionFixture(goal, schema, outcome === "approve"); @@ -134,7 +134,12 @@ export function registerUserCompletionFollowthroughConformance(provider: string, assert.equal(rows.find(row => row.todo_id === fixture.source)!.status, "done"); assert.equal(dependent.status, "blocked"); assert.equal(dependent.claimed_by, "agent-a"); - if (outcome === "approve") { + if (outcome === null) { + assert.deepEqual(dependent, (before.head.todos as JsonObject[]).find(row => row.todo_id === fixture.target), + "Gate closure without a decision must not change dependent authority or state"); + assert.equal(applied.unblock_resume == null, true); + assert.equal(applied.decision_scope_resolution == null, true); + } else if (outcome === "approve") { assert.deepEqual(dependent.required_decision_scopes, []); assert.equal((applied.unblock_resume as JsonObject).state, "other_user_blockers_active"); } else {