From 75cf9c45d58770c7b7e1348dc43ed7613f8361a9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:43:21 +0800 Subject: [PATCH] fix(coordination): allow reviewed pause of expired leased Todo --- docs/project-agent-todo-contract.md | 24 +++ loopx/chat_todo_actions.py | 7 +- .../coordination/todo_blocked_lifecycle.ts | 71 +++++++++ .../control_plane/coordination/todo_update.ts | 18 ++- .../coordination/todo_update_admission.ts | 12 ++ .../coordination/todo_update_intent.ts | 9 ++ .../test_reviewed_canonical_edits.py | 21 +++ .../blocked_hard_lease_lifecycle.test.ts | 142 ++++++++++++++++++ 8 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 loopx/control_plane/coordination/todo_blocked_lifecycle.ts create mode 100644 tests/control_plane_ts/blocked_hard_lease_lifecycle.test.ts diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index d4c531eff0..6db3e0d817 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -1196,6 +1196,30 @@ loopx todo list --goal-id Preview with `--dry-run` before the real attempt. A cleared resume condition also clears its generation fence; an omitted condition is retained. Empty successor arrays and explicit `no_followup=false` in API intent remain meaningful values. +For a promoted hard-lease Agent Todo that the owner has paused, use a narrow +nonterminal lifecycle edit after its execution lease has been released or has +expired: + +```bash +loopx todo update --goal-id --todo-id --agent-id \ + --status blocked --clear-resume-when --reason '' \ + --update-operation-id --update-expected-provider-revision +loopx todo list --goal-id --todo-id --role agent +``` + +The same provider CAS blocks the Todo and retires only an inactive retained +lease. An active lease must be released first. The edit grants no execution, +does not complete validation or spend quota, and preserves claim and successor +links. Reopening requires another explicit `--status open --clear-resume-when +--reason ...` operation and a fresh execution lease. Do not combine this +transition with text, ownership, work requirements or a lease proof. + +已晋升的 hard-lease Agent Todo 如被 owner 暂停,应先确认旧执行租约已释放或到期, +再以稳定操作 ID、读回的 provider revision 和明确原因执行上述窄范围状态更新。 +同一次 CAS 会把 Todo 标为 `blocked` 并退休非活跃租约,同时清除旧等待条件及其派生观察; +它不会完成验收、扣额或授予新的执行权。活跃租约须先释放;恢复执行要明确改回 `open` +并重新领取租约,不能把阻塞记录当作交付完成。 + Dependency validation sees the complete canonical inventory, not a hot-path summary or a Markdown buffer. A satisfied Monitor wait is not silently re-armed by an evidence edit; changing its topology requires clearing that old condition. diff --git a/loopx/chat_todo_actions.py b/loopx/chat_todo_actions.py index 758d76f674..abe7c46a58 100644 --- a/loopx/chat_todo_actions.py +++ b/loopx/chat_todo_actions.py @@ -53,11 +53,16 @@ def _run_todo_update( status=status, **({"role": "user", "no_followup": bool(parameters.get("no_followup", True))} if operation == "complete" else {}), - note=parameters.get("note"), + # A reviewed block is a lifecycle transition, not a copy edit. + # Its UI/Lark note supplies the public reason; a combined note + # patch would cross the hard-lease execution fence. + note=(parameters.get("note") if operation != "block" or basis is None else None), + reason=(parameters.get("note") if operation == "block" and basis is not None else None), claimed_by=( parameters.get("agent_id") if operation == "reassign" else None ), resume_when=parameters.get("resume_when"), + clear_resume_when=operation == "block" and basis is not None, successor_todo_ids=parameters.get("successor_todo_ids"), agent_id=parameters.get("agent_id"), authority_reason="owner-confirmed typed Chat action", diff --git a/loopx/control_plane/coordination/todo_blocked_lifecycle.ts b/loopx/control_plane/coordination/todo_blocked_lifecycle.ts new file mode 100644 index 0000000000..d6610e0498 --- /dev/null +++ b/loopx/control_plane/coordination/todo_blocked_lifecycle.ts @@ -0,0 +1,71 @@ +/** A user-directed pause is a Todo lifecycle change, not a completed delivery. + * Retire only an inactive execution generation in the same provider CAS; a + * live holder must release its lease before another actor can pause the work. */ +import type {JsonObject} from "../effect_program.ts"; +import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; +import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; +import {canonicalTaskLease} from "./task_lease_state.ts"; +import {leaseEpoch, leaseIsActive, leaseVersion} from "../work_items/task_lease_acquire.ts"; +import {releasedTaskLeaseRecord} from "../work_items/task_lease_lifecycle_decision.ts"; + +const LIFECYCLE_FIELDS = new Set(["status", "reason", "clear_resume_when"]); + +export function isBlockedLifecycleTransition( + input: CoordinationTodoUpdateInput, todo: JsonObject, +): boolean { + const intent = input.planning_intent ?? {}; + return todo.role === "agent" && + ((todo.status === "open" && intent.status === "blocked") || + (todo.status === "blocked" && intent.status === "open")) && + intent.clear_resume_when === true && + typeof intent.reason === "string" && Boolean(intent.reason.trim()) && + Object.keys(input.patch).length === 0 && input.clear_fields.length === 0 && + Object.keys(intent).every(field => LIFECYCLE_FIELDS.has(field)); +} + +export function blockedLifecycleRejection(input: { + goal_id: string; todo_id: string; actor_agent_id: string | null; + registered_agents: readonly string[]; lease: JsonObject | undefined; + lease_idempotency_key: string | null; lease_expected_version: number | null; + now: Date; +}): {code: string; reason: string} | null { + if (input.actor_agent_id === null || !input.registered_agents.includes(input.actor_agent_id)) { + return {code: "actor_not_registered", reason: "Blocked lifecycle transition requires a registered actor"}; + } + if (input.lease_idempotency_key !== null || input.lease_expected_version !== null) { + return {code: "blocked_lifecycle_execution_proof_not_allowed", + reason: "Blocked lifecycle transition does not consume an old execution lease"}; + } + if (input.lease === undefined) return null; + const lease = canonicalTaskLease(input.lease, input.goal_id, input.todo_id); + if (leaseIsActive(lease, input.now)) { + return {code: "blocked_lifecycle_active_lease", + reason: "Release the active execution lease before changing the Todo lifecycle"}; + } + return null; +} + +export function planBlockedLifecycleTransition(input: { + goal_id: string; before: JsonObject; after: JsonObject; + lease: JsonObject | undefined; now: Date; +}): {mutations: CoordinationProjectionMutation[]; transition: JsonObject | null} { + const from = input.before.status; + const to = input.after.status; + if (input.before.role !== "agent" || + !((from === "open" && to === "blocked") || (from === "blocked" && to === "open"))) { + return {mutations: [], transition: null}; + } + const lease = input.lease === undefined ? null : + canonicalTaskLease(input.lease, input.goal_id, String(input.before.todo_id)); + const retiring = lease !== null && lease.status !== "released"; + return { + mutations: retiring ? [{kind: "lease_upsert", lease: releasedTaskLeaseRecord(lease, input.now)}] : [], + transition: {kind: to === "blocked" ? "todo_blocked" : "todo_reopened", + execution_authority_granted: false, + lease_retirement: lease === null ? "absent" : retiring ? "released" : "already_released", + next_execution: to === "open" ? "acquire_fresh_lease" : "wait_for_explicit_resume", + ...(lease === null ? {} : {retired_lease_version: leaseVersion(lease), + retired_lease_epoch: leaseEpoch(lease)}), + }, + }; +} diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index def23b168d..39c297f67c 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -16,6 +16,7 @@ import { import {planMonitorCycleTransition} from "./todo_monitor_cycle.ts"; import {isDeferredReopen, planDeferredReopen} from "./todo_deferred_reopen.ts"; +import {isBlockedLifecycleTransition, planBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; import {todoUpdateAdmissionRejection} from "./todo_update_admission.ts"; import { CoordinationCommandReceipt } from "./command_receipt.ts"; import {canonicalTodoRecord} from "./todo_presentation.ts"; @@ -71,7 +72,9 @@ function updateReceipt(input: CoordinationTodoUpdateInput, requestSha: string) { ...(original.monitor_lifecycle_transition === undefined ? {} : {monitor_lifecycle_transition: canonicalAuthorityObject(original.monitor_lifecycle_transition, "Monitor lifecycle receipt transition")}), ...(original.deferred_resume_transition === undefined ? {} : {deferred_resume_transition: - canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")})}, changed: original.changed}; + canonicalAuthorityObject(original.deferred_resume_transition, "Deferred resume receipt transition")}), + ...(original.blocked_lifecycle_transition === undefined ? {} : {blocked_lifecycle_transition: + canonicalAuthorityObject(original.blocked_lifecycle_transition, "Blocked lifecycle receipt transition")})}, changed: original.changed}; }}); } @@ -222,6 +225,7 @@ export async function executeCoordinationTodoUpdate( } let cycle: ReturnType; let deferredCycle: ReturnType | null = null; + let blockedCycle: ReturnType | null = null; try { cycle = planMonitorCycleTransition({goal_id: input.goal_id, before: target.todo, after: next, lease: target.leases.get(input.todo_id), handoff_mode: head.head.handoff_mode, now: input.now}); @@ -229,6 +233,10 @@ export async function executeCoordinationTodoUpdate( deferredCycle = planDeferredReopen({goal_id: input.goal_id, before: target.todo, after: next, lease: target.leases.get(input.todo_id), now: input.now}); } + if (head.head.handoff_mode === "hard_lease" && isBlockedLifecycleTransition(input, target.todo)) { + blockedCycle = planBlockedLifecycleTransition({goal_id: input.goal_id, before: target.todo, after: next, + lease: target.leases.get(input.todo_id), now: input.now}); + } } catch (error) { return failure("invalid_coordination_projection", error instanceof Error ? error.message : "invalid retained lease"); } @@ -236,7 +244,7 @@ export async function executeCoordinationTodoUpdate( goal_id: input.goal_id, operation_id: input.operation_id, expected_provider_revision: head.provider_revision, projection: head.head, mutations: [{kind: "todo_upsert", todo: next, clear_fields: clearFields}, - ...cycle.mutations, ...(deferredCycle?.mutations ?? [])], + ...cycle.mutations, ...(deferredCycle?.mutations ?? []), ...(blockedCycle?.mutations ?? [])], }) : {operation_id: input.operation_id, expected_provider_revision: head.provider_revision, next_projection: head.head, events: [], receipts: []}; @@ -259,7 +267,8 @@ export async function executeCoordinationTodoUpdate( ...(completionValidationRevisionReceipt === null ? {} : {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), - ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition})}; + ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}), + ...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}; commit.receipts = [{schema_version: COORDINATION_TODO_UPDATE_RECEIPT_SCHEMA, operation_id: input.operation_id, goal_id: input.goal_id, todo_id: input.todo_id, request_sha256: requestSha, changed, @@ -267,6 +276,7 @@ export async function executeCoordinationTodoUpdate( ...(completionValidationRevisionReceipt === null ? {} : {completion_validation_revision: completionValidationRevisionReceipt}), ...(cycle.transition === null ? {} : {monitor_lifecycle_transition: cycle.transition}), - ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition})}]; + ...(deferredCycle?.transition == null ? {} : {deferred_resume_transition: deferredCycle.transition}), + ...(blockedCycle?.transition == null ? {} : {blocked_lifecycle_transition: blockedCycle.transition})}]; return receipt.commit(store, commit); } diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index c81c0f4754..d2c8c3e1a2 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -11,6 +11,7 @@ import {evaluateCoordinationTodoMutationDecision, COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts"; import {decodeTaskLeaseProof, evaluateCanonicalTaskLeaseProof} from "./task_lease_proof.ts"; import {deferredReopenRejection, isDeferredReopen} from "./todo_deferred_reopen.ts"; +import {blockedLifecycleRejection, isBlockedLifecycleTransition} from "./todo_blocked_lifecycle.ts"; interface TodoUpdateRejection {code: string; reason: string} const reject = (code: string, reason: string): TodoUpdateRejection => ({code, reason}); @@ -113,6 +114,17 @@ export function todoUpdateAdmissionRejection( } return null; } + if (mode === "hard_lease" && isBlockedLifecycleTransition(input, todo)) { + try { + return blockedLifecycleRejection({goal_id: input.goal_id, todo_id: input.todo_id, + actor_agent_id: input.actor_agent_id, registered_agents: input.registered_agents, + lease, lease_idempotency_key: input.lease_idempotency_key ?? null, + lease_expected_version: input.lease_expected_version ?? null, now: input.now}); + } catch (error) { + return reject("invalid_coordination_projection", + error instanceof Error ? error.message : "invalid retained lease facts"); + } + } if (mode === "hard_lease" && isDeferredReopen(input, todo)) { try { return deferredReopenRejection({goal_id: input.goal_id, todo_id: input.todo_id, diff --git a/loopx/control_plane/coordination/todo_update_intent.ts b/loopx/control_plane/coordination/todo_update_intent.ts index b386c0fa2a..9d3f5555f3 100644 --- a/loopx/control_plane/coordination/todo_update_intent.ts +++ b/loopx/control_plane/coordination/todo_update_intent.ts @@ -176,6 +176,15 @@ export function prepareUpdatedTodo( if (value === null || value === "") { delete next[field]; clearFields.add(field); } else next[field] = value; } + if (input.planning_intent?.clear_resume_when === true) { + // These are observations of the old condition, not independent resume + // authority. Keeping either after an explicit clear resurrects a stale + // wait in canonical readback even though resume_when is absent. + delete next.resume_condition; + delete next.resume_ready; + clearFields.add("resume_condition"); + clearFields.add("resume_ready"); + } next.done = next.status === "done" || next.status === "deferred"; } // Derive text/title/priority together from the original record and caller intent. diff --git a/tests/control_plane/test_reviewed_canonical_edits.py b/tests/control_plane/test_reviewed_canonical_edits.py index cf9f73875b..3759a23a1a 100644 --- a/tests/control_plane/test_reviewed_canonical_edits.py +++ b/tests/control_plane/test_reviewed_canonical_edits.py @@ -121,6 +121,27 @@ def test_registry_change_invalidates_uncommitted_review(tmp_path): assert records(registry) == before +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_reviewed_chat_block_uses_the_narrow_lifecycle_intent(tmp_path, provider): + registry, state, service = service_fixture(tmp_path, provider) + request = {"action_kind": "todo.update", "summary": "Pause research", + "normalized_parameters": {"goal_id": "goal-a", "todo_id": "todo_target", + "agent_id": "agent-a", "operation": "block", + "note": "Owner paused this research lane"}, + "context": {}, "idempotency_key": "reviewed-pause"} + before = records(registry) + proposal = service.preview(request) + assert records(registry) == before and not state.exists() + applied = service.apply(proposal["proposal_id"])["proposal"] + assert applied["status"] == "applied" + todo = records(registry)["todo_target"] + assert todo["status"] == "blocked" + assert todo["reason"] == "Owner paused this research lane" + assert todo.get("resume_when") is None + assert todo["claimed_by"] == "agent-a" + assert service.apply(proposal["proposal_id"])["proposal"] == applied + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_cli_reason_is_evidence_not_a_grant_and_cas_is_recoverable(tmp_path, provider): registry, _state, service = service_fixture(tmp_path, provider) diff --git a/tests/control_plane_ts/blocked_hard_lease_lifecycle.test.ts b/tests/control_plane_ts/blocked_hard_lease_lifecycle.test.ts new file mode 100644 index 0000000000..3ae99b0989 --- /dev/null +++ b/tests/control_plane_ts/blocked_hard_lease_lifecycle.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import {mkdtemp} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import test from "node:test"; + +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from + "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import {executeCoordinationTodoUpdate, type CoordinationTodoUpdateInput} from + "../../loopx/control_plane/coordination/todo_update.ts"; + +const NOW = new Date("2026-09-25T06:00:00Z"); +const GOAL = "synthetic-goal"; +const TODO = "todo_paused"; +const OWNER = "agent-a"; +const CONTROLLER = "agent-b"; +const AGENTS = [OWNER, CONTROLLER]; +const sqliteSkip = sqliteRuntimeIdentity().sqlite_authority_qualified + ? false : "requires the qualified SQLite runtime"; + +function lease(expires_at: string): JsonObject { + return {schema_version: "task_lease_v0", goal_id: GOAL, todo_id: TODO, + owner: OWNER, idempotency_key: "old-execution", status: "active", expires_at, + version: 29, lease_epoch: 29, write_scopes: [], acquire_ttl_seconds: 900}; +} + +async function seeded(provider: "file" | "sqlite", expires_at: string, + resume_when?: string) { + const root = await mkdtemp(join(tmpdir(), `loopx-blocked-${provider}-`)); + const path = join(root, "authority"); + const store = provider === "file" ? new FileAuthorityStore(path, GOAL) : + new SqliteAuthorityStore(path, GOAL); + const todo: JsonObject = {schema_version: TODO_DOMAIN_ITEM_SCHEMA, + todo_id: TODO, role: "agent", status: "open", done: false, + archive_state: "active", task_class: "advancement_task", + text: "Paused research", claimed_by: OWNER, priority: "P4", + completion_validation_required: true, + successor_todo_ids: ["todo_successor"], + resume_condition: {resume_when: "todo_done:todo_successor", satisfied: false}, + resume_ready: false, + ...(resume_when === undefined ? {} : {resume_when})}; + const todos = [todo]; + const result = await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, + events: [], receipts: [], next_projection: {goal_id: GOAL, handoff_mode: "hard_lease", + todos, leases: [lease(expires_at)], + todo_read_model: {schema_version: TODO_DOMAIN_READ_RECORD_SCHEMA, + todo_count: 1, records_sha256: canonicalAuthoritySha256(todos), + contract_fields: [...TODO_DOMAIN_RECORD_CONTRACT.fields]}}}); + assert.equal(result.status, "applied", JSON.stringify(result)); + return store; +} + +async function read(store: FileAuthorityStore | SqliteAuthorityStore) { + const head = await store.loadAuthority(); + assert.equal(head.status, "loaded"); + if (head.status !== "loaded") throw new Error("synthetic authority missing"); + return head; +} + +function pause(operation_id: string): CoordinationTodoUpdateInput { + return {goal_id: GOAL, todo_id: TODO, expected_role: "agent", + actor_agent_id: CONTROLLER, registered_agents: AGENTS, operation_id, + lifecycle_grants: [{agent_id: CONTROLLER, actions: ["update"], requires_reason: true}], + authority_reason: "Owner-directed pause of research", patch: {}, clear_fields: [], + planning_intent: {status: "blocked", reason: "Owner paused this lane", + clear_resume_when: true}, dry_run: false, now: NOW}; +} + +for (const provider of ["file", "sqlite"] as const) { + const providerTest = (name: string, run: () => Promise) => + test(name, {skip: provider === "sqlite" && sqliteSkip}, run); + + providerTest(`${provider}: blocked lifecycle retires expired lease with Todo in one CAS`, async () => { + const store = await seeded(provider, "2026-09-25T05:00:00Z", "todo_done:todo_successor"); + const input = pause("pause-once"); + const before = await read(store); + const planned = await executeCoordinationTodoUpdate(store, {...input, dry_run: true}); + assert.equal(planned.status, "planned", JSON.stringify(planned)); + assert.deepEqual(await read(store), before); + const applied = await executeCoordinationTodoUpdate(store, input); + assert.equal(applied.status, "applied", JSON.stringify(applied)); + assert.equal((applied.blocked_lifecycle_transition as JsonObject).execution_authority_granted, false); + assert.equal((applied.blocked_lifecycle_transition as JsonObject).lease_retirement, "released"); + const after = await read(store); + const todo = (after.head.todos as JsonObject[])[0]!; + assert.equal(todo.status, "blocked"); + assert.equal(todo.done, false); + assert.equal(todo.reason, "Owner paused this lane"); + assert.equal(todo.resume_when, undefined); + assert.equal(todo.resume_condition, undefined); + assert.equal(todo.resume_ready, undefined); + assert.equal(todo.claimed_by, OWNER); + assert.equal(todo.completion_validation_required, true); + assert.deepEqual(todo.successor_todo_ids, ["todo_successor"]); + const retired = (after.head.leases as JsonObject[])[0]!; + assert.equal(retired.status, "released"); + assert.equal(retired.version, 29); + assert.equal((await executeCoordinationTodoUpdate(store, input)).status, "replayed"); + assert.deepEqual(await read(store), after); + assert.equal((await executeCoordinationTodoUpdate(store, {...input, + planning_intent: {...input.planning_intent, reason: "A different pause"}})).reason_code, + "coordination_operation_identity_mismatch"); + }); + + providerTest(`${provider}: stale derived wait without resume_when clears on pause and can reopen`, async () => { + const store = await seeded(provider, "2026-09-25T05:00:00Z"); + assert.equal((await executeCoordinationTodoUpdate(store, pause("pause-stale-wait"))).status, "applied"); + const paused = await read(store); + assert.equal((paused.head.todos as JsonObject[])[0]!.resume_condition, undefined); + const reopened = await executeCoordinationTodoUpdate(store, {...pause("reopen-once"), + actor_agent_id: OWNER, lifecycle_grants: [], authority_reason: null, + planning_intent: {status: "open", clear_resume_when: true, + reason: "Owner resumed research"}}); + assert.equal(reopened.status, "applied", JSON.stringify(reopened)); + assert.equal((reopened.blocked_lifecycle_transition as JsonObject).execution_authority_granted, false); + const after = await read(store); + assert.equal((after.head.todos as JsonObject[])[0]!.status, "open"); + assert.equal((after.head.leases as JsonObject[])[0]!.status, "released"); + }); + + providerTest(`${provider}: live lease, unauthorized actor and bundled work are rejected`, async () => { + const active = await seeded(provider, "2026-09-25T07:00:00Z"); + const before = await read(active); + const cases: Array<[Partial, string]> = [ + [{}, "blocked_lifecycle_active_lease"], + [{lifecycle_grants: []}, "update_owner_mismatch"], + [{lease_idempotency_key: "old-execution", lease_expected_version: 29}, + "blocked_lifecycle_execution_proof_not_allowed"], + [{patch: {note: "Bundled work"}}, "lease_fence_required"], + ]; + for (const [change, code] of cases) { + const result = await executeCoordinationTodoUpdate(active, {...pause("reject-pause"), ...change}); + assert.equal(result.reason_code, code, JSON.stringify(result)); + assert.deepEqual(await read(active), before); + } + }); +}