From 289e30d1dc7d1fcbceca62dcde21f1263156a06a Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 8 Sep 2026 22:38:42 +0800 Subject: [PATCH 1/3] refactor(todos): retire duplicate Python lifecycle authority and fence rules Signed-off-by: huangruiteng --- .../coordination/authority_core.py | 364 ++++-------------- ...decision.ts => todo_lifecycle_decision.ts} | 142 +++++-- .../coordination/todo_terminal_lifecycle.ts | 2 +- .../control_plane/effect_runtime_handlers.ts | 10 +- .../test_coordination_authority_core.py | 70 ++++ .../todo_terminal_decision.test.ts | 91 ++++- 6 files changed, 361 insertions(+), 318 deletions(-) rename loopx/control_plane/coordination/{todo_terminal_decision.ts => todo_lifecycle_decision.ts} (71%) diff --git a/loopx/control_plane/coordination/authority_core.py b/loopx/control_plane/coordination/authority_core.py index 66e31f401f..874ae80429 100644 --- a/loopx/control_plane/coordination/authority_core.py +++ b/loopx/control_plane/coordination/authority_core.py @@ -3,8 +3,9 @@ Adapters normalize their persisted state while holding the existing lock, call ``decide``, and only then perform their current write. A returned transition is a proposal, not proof that any write committed. Durable execution results and -storage outcomes deliberately live outside this module. Task-lease acquire, -renew, transfer, and release adapt to canonical pure TypeScript decisions; +storage outcomes deliberately live outside this module. Todo lifecycle admission, +ownership routing, terminal fences and task-lease acquire/renew/transfer/release +adapt to canonical pure TypeScript decisions; Python retains typed snapshot/result projection rather than a second rule set. """ @@ -237,10 +238,6 @@ def _result( ) -def _actual_version(lease: LeaseSnapshot | None) -> int: - return lease.version if lease is not None and lease.present else 0 - - def _invalid_lease_snapshot(lease: LeaseSnapshot | None) -> bool: """Reject contradictory normalized states at the pure-core boundary.""" @@ -251,13 +248,6 @@ def _invalid_lease_snapshot(lease: LeaseSnapshot | None) -> bool: ) -def _version_conflict( - lease: LeaseSnapshot | None, - expected_version: int | None, -) -> bool: - return expected_version is not None and _actual_version(lease) != expected_version - - def _lease_owner_rejection( snapshot: CoordinationSnapshot, owner: str | None, @@ -305,214 +295,6 @@ def write_scopes_overlap( return bool(payload["overlap"]) -def _exact_user_gate_override( - snapshot: CoordinationSnapshot, - command: TodoMutationCommand, -) -> bool: - todo = snapshot.todo - target = snapshot.decision_target - return bool( - todo is not None - and target is not None - and command.action is TodoAction.COMPLETE - and todo.role == "user" - and todo.task_class == "user_gate" - and command.decision_outcome - and todo.decision_scope is not None - and todo.unblocks_todo_id == target.todo_id - and todo.decision_scope in target.required_decision_scopes - ) - - -def _todo_after_command( - todo: TodoSnapshot, - command: TodoMutationCommand, -) -> TodoSnapshot: - if command.action in {TodoAction.COMPLETE, TodoAction.SUPERSEDE}: - return replace(todo, status="done") - if command.action is TodoAction.CLAIM: - return replace(todo, claimed_by=command.requested_claimed_by) - if command.ownership_mutation: - return replace( - todo, - claimed_by=(None if command.clear_claim else command.requested_claimed_by), - ) - return todo - - -def _authority_for_todo( - snapshot: CoordinationSnapshot, - command: TodoMutationCommand, -) -> tuple[str | None, str | None]: - """Return ``(authority_mode, rejection_code)``.""" - - todo = snapshot.todo - if todo is None: - return None, "todo_not_found" - actor = command.actor_agent_id - if len(snapshot.registered_agents) <= 1: - if actor and snapshot.registered_agents and actor not in snapshot.registered_agents: - return None, "actor_not_registered" - return "single_agent_compatibility", None - if _exact_user_gate_override(snapshot, command): - return "exact_user_gate_decision_scope_override", None - if not actor: - return None, "actor_required" - if actor not in snapshot.registered_agents: - return None, "actor_not_registered" - if actor in todo.excluded_agents: - return None, "actor_excluded" - bound_agent = todo.bound_agent - if not bound_agent and todo.role == "user": - bound_agent = todo.blocks_agent - if bound_agent and bound_agent != actor: - return None, "bound_agent_mismatch" - if todo.claimed_by and todo.claimed_by != actor: - action = command.authority_action or command.action.value - grant = next( - (item for item in snapshot.lifecycle_grants if item.agent_id == actor), - None, - ) - if grant is None: - return None, "claim_owner_mismatch" - if action not in grant.actions: - return None, "delegation_action_not_granted" - if grant.requires_reason and not str(command.authority_reason or "").strip(): - return None, "delegation_reason_required" - return "delegated_orchestration_override", None - if ( - command.action is TodoAction.CLAIM - and command.requested_claimed_by != actor - ): - return None, "claim_actor_mismatch" - return "registered_peer_actor", None - - -def _terminal_fence_decision( - snapshot: CoordinationSnapshot, - *, - actor_agent_id: str | None, - lease_idempotency_key: str | None, - lease_expected_version: int | None, - delegated_authority: bool, - allow_user_gate_auto_acquire: bool, - require_active_when_fence_supplied: bool, -) -> TransitionPlan: - todo = snapshot.todo - assert todo is not None - lease = snapshot.lease - time_active = bool(lease and lease.present and lease.active) - effective = _lease_is_effective(snapshot, lease) - explicit_fence = bool( - lease_idempotency_key is not None or lease_expected_version is not None - ) - auto_acquire = bool( - snapshot.handoff_mode is HandoffMode.HARD_LEASE - and not delegated_authority - and allow_user_gate_auto_acquire - and todo.role == "user" - and todo.task_class == "user_gate" - ) - if auto_acquire and not effective and not time_active: - rejection = _lease_owner_rejection(snapshot, actor_agent_id) - if rejection is not None: - return _result( - DecisionOutcome.REJECTED, - "handoff_mode_requires_lease", - lease_fence=LeaseFence.AUTO_ACQUIRE, - ) - existing = lease or LeaseSnapshot() - acquired = LeaseSnapshot( - present=True, - active=True, - status="active", - owner=actor_agent_id, - idempotency_key=lease_idempotency_key or f"auto-{todo.todo_id}", - version=_actual_version(existing) + 1, - lease_epoch=existing.lease_epoch + 1, - write_scopes=(), - acquire_ttl_seconds=2700, - ) - next_snapshot = replace( - snapshot, - lease=replace(acquired, active=False, status="released"), - ) - return _result( - DecisionOutcome.APPLY, - "terminal_fence_verified", - next_snapshot=next_snapshot, - lease_fence=LeaseFence.AUTO_ACQUIRE, - ) - if not effective: - if ( - snapshot.handoff_mode is HandoffMode.HARD_LEASE - and not delegated_authority - ): - return _result( - DecisionOutcome.REJECTED, - ( - "handoff_mode_lease_claim_divergence" - if time_active - else "handoff_mode_requires_lease" - ), - lease_fence=LeaseFence.REQUIRED, - ) - if explicit_fence and require_active_when_fence_supplied: - return _result( - DecisionOutcome.REJECTED, - "lease_not_active", - ) - return _result( - DecisionOutcome.APPLY, - "terminal_fence_not_required", - next_snapshot=snapshot, - lease_fence=( - LeaseFence.DELEGATED_OVERRIDE - if delegated_authority - and snapshot.handoff_mode is HandoffMode.HARD_LEASE - else LeaseFence.NOT_REQUIRED - ), - ) - assert lease is not None - if lease_idempotency_key is None: - return _result( - DecisionOutcome.REJECTED, - "lease_fence_required", - lease_fence=LeaseFence.REQUIRED, - ) - if ( - lease.owner != actor_agent_id - or lease.idempotency_key != lease_idempotency_key - ): - return _result( - DecisionOutcome.REJECTED, - "lease_cas_mismatch", - lease_fence=LeaseFence.REQUIRED, - ) - if lease_expected_version is None: - return _result( - DecisionOutcome.REJECTED, - "version_required", - lease_fence=LeaseFence.REQUIRED, - ) - if _version_conflict(lease, lease_expected_version): - return _result( - DecisionOutcome.CONFLICT, - "version_mismatch", - lease_fence=LeaseFence.REQUIRED, - ) - next_snapshot = replace( - snapshot, - lease=replace(lease, active=False, status="released"), - ) - return _result( - DecisionOutcome.APPLY, - "terminal_fence_verified", - next_snapshot=next_snapshot, - lease_fence=LeaseFence.REQUIRED, - ) - - def _decision_scope_payload(scope: DecisionScope | None) -> dict[str, str] | None: if scope is None: return None @@ -584,18 +366,21 @@ def _lease_fact_from_payload(value: Any) -> LeaseSnapshot | None: ) -def _typescript_terminal_decision( +def _typescript_todo_decision( snapshot: CoordinationSnapshot, command: TodoMutationCommand, ) -> TransitionPlan: - """Adapt the TypeScript-owned complete/supersede decision into the legacy plan.""" + """Project the typed lifecycle decision; storage and locks stay with callers.""" todo = snapshot.todo - assert todo is not None + if todo is None: + return _result(DecisionOutcome.REJECTED, "todo_not_found") + terminal = command.action in {TodoAction.COMPLETE, TodoAction.SUPERSEDE} + operation = "terminal" if terminal else "mutation" payload = effect_runtime_result( - "todo.terminal.decide", + f"todo.{operation}.decide", { - "schema_version": "loopx_coordination_todo_terminal_decision_request_v0", + "schema_version": f"loopx_coordination_todo_{operation}_decision_request_v0", "command": command.action.value, "handoff_mode": snapshot.handoff_mode.value, "registered_agents": list(snapshot.registered_agents), @@ -621,30 +406,39 @@ def _typescript_terminal_decision( "lease_idempotency_key": command.lease_idempotency_key, "lease_expected_version": command.lease_expected_version, "allow_user_gate_auto_acquire": command.allow_user_gate_auto_acquire, + "requested_claimed_by": command.requested_claimed_by, + "clear_claim": command.clear_claim, + "ownership_mutation": command.ownership_mutation, }, ) if not isinstance(payload, dict) or payload.get("schema_version") != ( - "loopx_coordination_todo_terminal_decision_result_v0" + f"loopx_coordination_todo_{operation}_decision_result_v0" ): - raise RuntimeError("TypeScript terminal decision result shape mismatch") + raise RuntimeError("TypeScript Todo lifecycle decision result shape mismatch") try: outcome = DecisionOutcome(str(payload["outcome"])) ownership_gate = OwnershipGate(str(payload["ownership_gate"])) lease_fence = LeaseFence(str(payload["lease_fence"])) except (KeyError, ValueError) as exc: raise RuntimeError( - "TypeScript terminal decision result shape mismatch" + "TypeScript Todo lifecycle decision result shape mismatch" ) from exc next_snapshot = None if outcome is DecisionOutcome.APPLY: - if payload.get("next_todo_status") != "done": + if payload.get("next_todo_status") != ("done" if terminal else todo.status): raise RuntimeError( - "TypeScript terminal decision omitted terminal Todo state" + "TypeScript Todo lifecycle decision returned invalid next Todo state" ) next_lease_payload = payload.get("next_lease") next_snapshot = replace( snapshot, - todo=replace(todo, status="done"), + todo=replace( + todo, + status=str(payload["next_todo_status"]), + claimed_by=( + todo.claimed_by if terminal else payload["next_todo_claimed_by"] + ), + ), lease=( snapshot.lease if next_lease_payload is None @@ -682,53 +476,61 @@ def ownership_gate_requirement( re-deriving the mode/door predicates at the edge. """ - if not ownership_mutation or handoff_mode is not HandoffMode.HARD_LEASE: - return OwnershipGate.NOT_REQUIRED - if authority_mode == "delegated_orchestration_override": - return OwnershipGate.DELEGATED_OVERRIDE - return OwnershipGate.REQUIRE_HOLDER + payload = effect_runtime_result( + "todo.ownership_gate.decide", + { + "handoff_mode": handoff_mode.value, + "ownership_mutation": ownership_mutation, + "authority_mode": authority_mode, + }, + ) + if not isinstance(payload, dict): + raise RuntimeError("TypeScript ownership gate result shape mismatch") + return OwnershipGate(payload["ownership_gate"]) -def _decide_todo( +def _typescript_terminal_fence( snapshot: CoordinationSnapshot, - command: TodoMutationCommand, + command: TerminalFenceCommand, ) -> TransitionPlan: - if command.action in {TodoAction.COMPLETE, TodoAction.SUPERSEDE}: - return _typescript_terminal_decision(snapshot, command) - authority_mode, rejection = _authority_for_todo(snapshot, command) - if rejection is not None: - return _result(DecisionOutcome.REJECTED, rejection) - assert authority_mode is not None and snapshot.todo is not None - ownership_gate = ownership_gate_requirement( - handoff_mode=snapshot.handoff_mode, - ownership_mutation=command.ownership_mutation, - authority_mode=authority_mode, + if snapshot.todo is None: + return _result(DecisionOutcome.REJECTED, "todo_not_found") + payload = effect_runtime_result( + "task_lease.terminal_fence.decide", + { + "schema_version": "loopx_coordination_terminal_fence_request_v0", + "todo": _todo_fact_payload(snapshot.todo), + "lease": _lease_fact_payload(snapshot.lease), + "registered_agents": list(snapshot.registered_agents), + "handoff_mode": snapshot.handoff_mode.value, + "actor_agent_id": command.actor_agent_id, + "lease_idempotency_key": command.lease_idempotency_key, + "lease_expected_version": command.lease_expected_version, + "delegated_authority": command.delegated_authority, + "allow_user_gate_auto_acquire": command.allow_user_gate_auto_acquire, + "require_active_when_fence_supplied": command.require_active_when_fence_supplied, + }, ) - if ownership_gate is OwnershipGate.REQUIRE_HOLDER: - lease = snapshot.lease - if not lease or not lease.present or not lease.active: - return _result( - DecisionOutcome.REJECTED, - "handoff_mode_requires_lease", - authority_mode=authority_mode, - ownership_gate=ownership_gate, - ) - if lease.owner != command.actor_agent_id: - return _result( - DecisionOutcome.REJECTED, - "handoff_mode_requires_lease", - authority_mode=authority_mode, - ownership_gate=ownership_gate, - ) - return _result( - DecisionOutcome.APPLY, - "todo_transition", - next_snapshot=replace( + if not isinstance(payload, dict) or payload.get("schema_version") != ( + "loopx_coordination_terminal_fence_result_v0" + ): + raise RuntimeError("TypeScript terminal fence result shape mismatch") + outcome = DecisionOutcome(payload["outcome"]) + next_snapshot = None + if outcome is DecisionOutcome.APPLY: + next_snapshot = replace( snapshot, - todo=_todo_after_command(snapshot.todo, command), - ), - authority_mode=authority_mode, - ownership_gate=ownership_gate, + lease=( + snapshot.lease + if payload["next_lease"] is None + else _lease_fact_from_payload(payload["next_lease"]) + ), + ) + return TransitionPlan( + outcome=outcome, + code=payload["code"], + next_snapshot=next_snapshot, + lease_fence=LeaseFence(payload["lease_fence"]), ) @@ -1089,7 +891,7 @@ def decide( if _invalid_lease_snapshot(snapshot.lease): return _result(DecisionOutcome.REJECTED, "invalid_lease_snapshot") if isinstance(command, TodoMutationCommand): - return _decide_todo(snapshot, command) + return _typescript_todo_decision(snapshot, command) if isinstance(command, LeaseRenewCommand): return _decide_renew(snapshot, command) if isinstance(command, LeaseTransferCommand): @@ -1101,19 +903,7 @@ def decide( if isinstance(command, LeaseModeGateCommand): return _decide_lease_mode_gate(snapshot, command) if isinstance(command, TerminalFenceCommand): - if snapshot.todo is None: - return _result(DecisionOutcome.REJECTED, "todo_not_found") - return _terminal_fence_decision( - snapshot, - actor_agent_id=command.actor_agent_id, - lease_idempotency_key=command.lease_idempotency_key, - lease_expected_version=command.lease_expected_version, - delegated_authority=command.delegated_authority, - allow_user_gate_auto_acquire=command.allow_user_gate_auto_acquire, - require_active_when_fence_supplied=( - command.require_active_when_fence_supplied - ), - ) + return _typescript_terminal_fence(snapshot, command) if isinstance(command, HandoffModeTransitionCommand): return _decide_handoff_transition(snapshot, command) raise TypeError(f"unsupported coordination command: {type(command).__name__}") diff --git a/loopx/control_plane/coordination/todo_terminal_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts similarity index 71% rename from loopx/control_plane/coordination/todo_terminal_decision.ts rename to loopx/control_plane/coordination/todo_lifecycle_decision.ts index d52f4904c5..a387040857 100644 --- a/loopx/control_plane/coordination/todo_terminal_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -16,11 +16,20 @@ export const COORDINATION_TODO_TERMINAL_DECISION_RESULT_SCHEMA = "loopx_coordination_todo_terminal_decision_result_v0"; const COMMANDS = ["complete", "supersede"] as const; +const MUTATION_COMMANDS = ["claim", "update"] as const; +export const COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA = + "loopx_coordination_todo_mutation_decision_request_v0"; +export const COORDINATION_TODO_MUTATION_DECISION_RESULT_SCHEMA = + "loopx_coordination_todo_mutation_decision_result_v0"; +export const COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA = + "loopx_coordination_terminal_fence_request_v0"; +export const COORDINATION_TERMINAL_FENCE_RESULT_SCHEMA = + "loopx_coordination_terminal_fence_result_v0"; const HANDOFF_MODES = ["legacy", "soft_claim", "hard_lease"] as const; const OUTCOMES = ["approve", "reject", "cancel"] as const; const AUTHORITY_ACTIONS = ["complete", "reassign", "supersede", "update"] as const; -type TerminalCommand = typeof COMMANDS[number]; +type LifecycleCommand = typeof COMMANDS[number] | typeof MUTATION_COMMANDS[number]; type HandoffMode = typeof HANDOFF_MODES[number]; type DecisionOutcome = typeof OUTCOMES[number]; @@ -62,8 +71,8 @@ interface LifecycleGrant extends JsonObject { readonly requires_reason: boolean; } -interface TerminalDecisionRequest { - readonly command: TerminalCommand; +interface LifecycleDecisionRequest { + readonly command: LifecycleCommand; readonly handoff_mode: HandoffMode; readonly registered_agents: readonly string[]; readonly lifecycle_grants: readonly LifecycleGrant[]; @@ -77,6 +86,9 @@ interface TerminalDecisionRequest { readonly lease_idempotency_key: string | null; readonly lease_expected_version: number | null; readonly allow_user_gate_auto_acquire: boolean; + readonly requested_claimed_by: string | null; + readonly clear_claim: boolean; + readonly ownership_mutation: boolean; } export interface CoordinationTodoTerminalDecisionResult extends JsonObject { @@ -222,49 +234,55 @@ function lifecycleGrants( }); } -function decodeRequest(value: unknown): TerminalDecisionRequest { +function decodeRequest(value: unknown, kind: "terminal" | "mutation" = "terminal"): LifecycleDecisionRequest { const request = requireJsonObject(value, "Todo terminal decision request"); requireStringLiteral( request.schema_version, - [COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA] as const, + [kind === "terminal" ? COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA + : COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA], "schema_version", ); const registeredAgents = normalizeRegisteredTodoAgents( requireStringArray(request.registered_agents, "registered_agents"), ); - const outcome = optionalString(request.decision_outcome, "decision_outcome"); + const outcome = kind === "terminal" ? optionalString(request.decision_outcome, "decision_outcome") : null; return { - command: requireStringLiteral(request.command, COMMANDS, "command"), + command: requireStringLiteral(request.command, + kind === "terminal" ? COMMANDS : MUTATION_COMMANDS, "command"), handoff_mode: requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"), registered_agents: registeredAgents, lifecycle_grants: lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), todo: todoFact(request.todo, "todo"), - decision_target: request.decision_target === null || request.decision_target === undefined + decision_target: kind !== "terminal" || request.decision_target === null || request.decision_target === undefined ? null : todoFact(request.decision_target, "decision_target"), lease: leaseFact(request.lease), actor_agent_id: optionalAgent(request.actor_agent_id, "actor_agent_id"), authority_action: requireStringLiteral( request.authority_action, - AUTHORITY_ACTIONS, + kind === "terminal" ? AUTHORITY_ACTIONS : [...AUTHORITY_ACTIONS, "claim"], "authority_action", ), authority_reason: optionalString(request.authority_reason, "authority_reason"), decision_outcome: outcome === null ? null : requireStringLiteral(outcome, OUTCOMES, "decision_outcome"), - lease_idempotency_key: optionalString( + lease_idempotency_key: kind === "terminal" ? optionalString( request.lease_idempotency_key, "lease_idempotency_key", - ), - lease_expected_version: optionalNonNegativeInteger( + ) : null, + lease_expected_version: kind === "terminal" ? optionalNonNegativeInteger( request.lease_expected_version, "lease_expected_version", - ), - allow_user_gate_auto_acquire: requireBoolean( + ) : null, + allow_user_gate_auto_acquire: kind === "terminal" ? requireBoolean( request.allow_user_gate_auto_acquire, "allow_user_gate_auto_acquire", - ), + ) : false, + requested_claimed_by: optionalAgent(request.requested_claimed_by, "requested_claimed_by"), + clear_claim: kind === "mutation" ? requireBoolean(request.clear_claim, "clear_claim") : false, + ownership_mutation: kind === "mutation" + ? requireBoolean(request.ownership_mutation, "ownership_mutation") : false, }; } @@ -272,7 +290,7 @@ function scopeKey(scope: DecisionScope): string { return `${scope.kind}\u0000${scope.granularity}\u0000${scope.scope_key}`; } -function exactUserGateOverride(request: TerminalDecisionRequest): boolean { +function exactUserGateOverride(request: LifecycleDecisionRequest): boolean { const { todo, decision_target: target } = request; return request.command === "complete" && target !== null && todo.role === "user" && todo.task_class === "user_gate" && request.decision_outcome !== null && @@ -301,7 +319,7 @@ function result( }; } -function authority(request: TerminalDecisionRequest): +function authority(request: LifecycleDecisionRequest): | { mode: string; ownershipGate: CoordinationTodoTerminalDecisionResult["ownership_gate"] } | CoordinationTodoTerminalDecisionResult { const { todo, actor_agent_id: actor, registered_agents: registered } = request; @@ -332,10 +350,17 @@ function authority(request: TerminalDecisionRequest): } return { mode: "delegated_orchestration_override", ownershipGate: "not_required" }; } + if (request.command === "claim" && request.requested_claimed_by !== actor) { + return result("rejected", "claim_actor_mismatch"); + } return { mode: "registered_peer_actor", ownershipGate: "not_required" }; } -function ownerEligible(request: TerminalDecisionRequest, owner: string | null): boolean { +type FenceRequest = Pick; + +function ownerEligible(request: FenceRequest, owner: string | null): boolean { const todo = request.todo; return todo.status === "open" && owner !== null && request.registered_agents.includes(owner) && !todo.excluded_agents.includes(owner) && @@ -343,8 +368,9 @@ function ownerEligible(request: TerminalDecisionRequest, owner: string | null): } function terminalFence( - request: TerminalDecisionRequest, - authorityMode: string, + request: FenceRequest, + authorityMode: string | null, + requireActiveWhenFenceSupplied = true, ): CoordinationTodoTerminalDecisionResult { const lease = request.lease; const timeActive = lease !== null && lease.present && lease.active; @@ -364,10 +390,9 @@ function terminalFence( } const version = (lease?.present ? lease.version : 0) + 1; const epoch = (lease?.lease_epoch ?? 0) + 1; - return result("apply", "terminal_transition", { + return result("apply", "terminal_fence_verified", { authority_mode: authorityMode, lease_fence: "auto_acquire", - next_todo_status: "done", next_lease: { present: true, active: false, @@ -390,15 +415,14 @@ function terminalFence( lease_fence: "required", }); } - if (explicitFence) { + if (explicitFence && requireActiveWhenFenceSupplied) { return result("rejected", "lease_not_active", { authority_mode: authorityMode }); } - return result("apply", "terminal_transition", { + return result("apply", "terminal_fence_not_required", { authority_mode: authorityMode, lease_fence: delegated && request.handoff_mode === "hard_lease" ? "delegated_override" : "not_required", - next_todo_status: "done", }); } if (request.lease_idempotency_key === null) { @@ -426,10 +450,9 @@ function terminalFence( lease_fence: "required", }); } - return result("apply", "terminal_transition", { + return result("apply", "terminal_fence_verified", { authority_mode: authorityMode, lease_fence: "required", - next_todo_status: "done", next_lease: { ...lease!, active: false, status: "released" }, }); } @@ -448,5 +471,68 @@ export function evaluateCoordinationTodoTerminalDecision( idempotent: true, }); } - return terminalFence(request, authorityResult.mode); + const fence = terminalFence(request, authorityResult.mode); + return fence.outcome === "apply" + ? { ...fence, code: "terminal_transition", next_todo_status: "done" } + : fence; +} + +function ownershipGate(mode: HandoffMode, mutation: boolean, authorityMode: string | null): + CoordinationTodoTerminalDecisionResult["ownership_gate"] { + if (!mutation || mode !== "hard_lease") return "not_required"; + return authorityMode === "delegated_orchestration_override" ? "delegated_override" : "require_holder"; +} + +export function evaluateTodoOwnershipGate(value: unknown): JsonObject { + const input = requireJsonObject(value, "Todo ownership gate"); + return { ownership_gate: ownershipGate( + requireStringLiteral(input.handoff_mode, HANDOFF_MODES, "handoff_mode"), + requireBoolean(input.ownership_mutation, "ownership_mutation"), + optionalString(input.authority_mode, "authority_mode"), + ) }; +} + +/** Claim/update admission only: this neither edits arbitrary fields nor releases a lease. */ +export function evaluateCoordinationTodoMutationDecision(value: unknown): JsonObject { + const request = decodeRequest(value, "mutation"); + const decided = authority(request); + if ("outcome" in decided) { + return { ...decided, schema_version: COORDINATION_TODO_MUTATION_DECISION_RESULT_SCHEMA }; + } + const gate = ownershipGate(request.handoff_mode, request.ownership_mutation, decided.mode); + const lease = request.lease; + const denied = gate === "require_holder" && + (lease === null || !lease.present || !lease.active || lease.owner !== request.actor_agent_id); + return { + ...result(denied ? "rejected" : "apply", denied ? "handoff_mode_requires_lease" : "todo_transition", { + authority_mode: decided.mode, ownership_gate: gate, + }), + schema_version: COORDINATION_TODO_MUTATION_DECISION_RESULT_SCHEMA, + next_todo_status: denied ? null : request.todo.status, + next_todo_claimed_by: denied ? null : request.command === "claim" + ? request.requested_claimed_by + : request.ownership_mutation + ? request.clear_claim ? null : request.requested_claimed_by + : request.todo.claimed_by, + }; +} + +/** Preauthorized lease fence; authority was checked by the caller under its state lock. */ +export function evaluateCoordinationTerminalFence(value: unknown): JsonObject { + const input = requireJsonObject(value, "terminal fence request"); + requireStringLiteral(input.schema_version, [COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA], "schema_version"); + const request: FenceRequest = { + todo: todoFact(input.todo, "todo"), + lease: leaseFact(input.lease), + registered_agents: normalizeRegisteredTodoAgents(requireStringArray(input.registered_agents, "registered_agents")), + handoff_mode: requireStringLiteral(input.handoff_mode, HANDOFF_MODES, "handoff_mode"), + actor_agent_id: optionalAgent(input.actor_agent_id, "actor_agent_id"), + lease_idempotency_key: optionalString(input.lease_idempotency_key, "lease_idempotency_key"), + lease_expected_version: optionalNonNegativeInteger(input.lease_expected_version, "lease_expected_version"), + allow_user_gate_auto_acquire: requireBoolean(input.allow_user_gate_auto_acquire, "allow_user_gate_auto_acquire"), + }; + const delegated = requireBoolean(input.delegated_authority, "delegated_authority"); + const fence = terminalFence(request, delegated ? "delegated_orchestration_override" : null, + requireBoolean(input.require_active_when_fence_supplied, "require_active_when_fence_supplied")); + return { ...fence, schema_version: COORDINATION_TERMINAL_FENCE_RESULT_SCHEMA, authority_mode: null }; } diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 5993bdcacf..c634c26094 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -33,7 +33,7 @@ import { import { evaluateCoordinationTodoTerminalDecision, type CoordinationTodoTerminalDecisionResult, -} from "./todo_terminal_decision.ts"; +} from "./todo_lifecycle_decision.ts"; import { reduceTodoCompletionTransaction, TODO_COMPLETION_TRANSACTION_REQUEST_SCHEMA, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 30e8a86711..8cef4b2c98 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -127,7 +127,12 @@ import { terminalLifecycleLocalCoordinationTodo, } from "./coordination/local_authority_runtime.ts"; import { evaluateCoordinationTodoClaimDecision } from "./coordination/todo_claim.ts"; -import { evaluateCoordinationTodoTerminalDecision } from "./coordination/todo_terminal_decision.ts"; +import { + evaluateCoordinationTodoTerminalDecision, + evaluateCoordinationTodoMutationDecision, + evaluateCoordinationTerminalFence, + evaluateTodoOwnershipGate, +} from "./coordination/todo_lifecycle_decision.ts"; import { evaluateCoordinationTodoArchiveSelection } from "./coordination/todo_archive_selection.ts"; import { evaluateCoordinationTodoSuccessorDerivation } from "./coordination/todo_successor_derivation.ts"; import { @@ -380,6 +385,9 @@ export function createEffectRuntimeHandlers( ), ], ["todo.terminal.decide", evaluateCoordinationTodoTerminalDecision], + ["todo.mutation.decide", evaluateCoordinationTodoMutationDecision], + ["task_lease.terminal_fence.decide", evaluateCoordinationTerminalFence], + ["todo.ownership_gate.decide", evaluateTodoOwnershipGate], ["todo.archive.select", evaluateCoordinationTodoArchiveSelection], ["todo.successor.derive", evaluateCoordinationTodoSuccessorDerivation], ["todo.completion.reduce", reduceTodoCompletionTransaction], diff --git a/tests/control_plane/test_coordination_authority_core.py b/tests/control_plane/test_coordination_authority_core.py index 2f93cca231..4ff5e03bfe 100644 --- a/tests/control_plane/test_coordination_authority_core.py +++ b/tests/control_plane/test_coordination_authority_core.py @@ -95,6 +95,76 @@ def terminal( return TodoMutationCommand(**values) # type: ignore[arg-type] +@pytest.mark.parametrize("mode", list(HandoffMode)) +@pytest.mark.parametrize("ownership", [False, True]) +def test_update_authority_keeps_claim_neutral_edits_separate_from_ownership( + mode, ownership +): + state = snapshot(handoff_mode=mode) + command = TodoMutationCommand( + action=TodoAction.UPDATE, + actor_agent_id=AGENT_A, + requested_claimed_by=AGENT_A if ownership else None, + ownership_mutation=ownership, + ) + result = decide(state, command) + if mode is HandoffMode.HARD_LEASE and ownership: + assert result.code == "handoff_mode_requires_lease" + assert result.next_snapshot is None + else: + assert result.outcome is DecisionOutcome.APPLY + assert result.next_snapshot.todo.claimed_by == (AGENT_A if ownership else None) + assert result.next_snapshot.lease is None + assert result.authority_mode == "registered_peer_actor" + + +@pytest.mark.parametrize("strict", [False, True]) +def test_standalone_fence_does_not_complete_todo_or_reauthorize_the_actor(strict): + state = snapshot(todo=todo(claimed_by=AGENT_B)) + result = decide( + state, + TerminalFenceCommand( + actor_agent_id=None, + lease_idempotency_key="stale-key", + require_active_when_fence_supplied=strict, + ), + ) + assert result.authority_mode is None + if strict: + assert result.code == "lease_not_active" + assert result.next_snapshot is None + else: + assert result.code == "terminal_fence_not_required" + assert result.next_snapshot == state + + +@pytest.mark.parametrize("clear", [False, True]) +def test_delegated_update_requires_the_actual_action_and_never_releases_holder(clear): + state = snapshot( + handoff_mode=HandoffMode.HARD_LEASE, + todo=todo(claimed_by=AGENT_B), + lease=lease(owner=AGENT_B), + lifecycle_grants=(LifecycleGrant(AGENT_A, frozenset({"reassign"})),), + ) + command = TodoMutationCommand( + action=TodoAction.UPDATE, + actor_agent_id=AGENT_A, + authority_action="reassign", + authority_reason="recover abandoned work", + ownership_mutation=True, + requested_claimed_by=AGENT_A, + clear_claim=clear, + ) + result = decide(state, command) + assert result.outcome is DecisionOutcome.APPLY + assert result.ownership_gate is OwnershipGate.DELEGATED_OVERRIDE + assert result.next_snapshot.todo.claimed_by == (None if clear else AGENT_A) + assert result.next_snapshot.lease == state.lease + rejected = decide(state, replace(command, authority_action="update")) + assert rejected.code == "delegation_action_not_granted" + assert rejected.next_snapshot is None + + def test_decision_is_deterministic_and_target_scoped() -> None: base = snapshot() command = claim() diff --git a/tests/control_plane_ts/todo_terminal_decision.test.ts b/tests/control_plane_ts/todo_terminal_decision.test.ts index 46022e6893..63190ac885 100644 --- a/tests/control_plane_ts/todo_terminal_decision.test.ts +++ b/tests/control_plane_ts/todo_terminal_decision.test.ts @@ -4,7 +4,12 @@ import test from "node:test"; import { COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA, evaluateCoordinationTodoTerminalDecision, -} from "../../loopx/control_plane/coordination/todo_terminal_decision.ts"; + COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA, + COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, + evaluateCoordinationTodoMutationDecision, + evaluateCoordinationTerminalFence, + evaluateTodoOwnershipGate, +} from "../../loopx/control_plane/coordination/todo_lifecycle_decision.ts"; function request(overrides: Record = {}) { return { @@ -39,6 +44,90 @@ function request(overrides: Record = {}) { }; } +function mutation(overrides: Record = {}) { + return request({ + schema_version: COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA, + command: "update", authority_action: "update", requested_claimed_by: null, + clear_claim: false, ownership_mutation: false, ...overrides, + }); +} + +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"]) { + const edited = evaluateCoordinationTodoMutationDecision({ ...base, handoff_mode: mode }); + assert.equal(edited.outcome, "apply"); + assert.equal(edited.next_todo_status, "open"); + assert.equal(edited.next_todo_claimed_by, null); + assert.equal(edited.next_lease, null); + assert.equal(edited.authority_mode, "registered_peer_actor"); + } + for (const [override, code] of [ + [{ actor_agent_id: null }, "actor_required"], + [{ actor_agent_id: "unknown" }, "actor_not_registered"], + [{ todo: { ...base.todo as object, excluded_agents: ["agent-a"] } }, "actor_excluded"], + [{ todo: { ...base.todo as object, bound_agent: "agent-b" } }, "bound_agent_mismatch"], + [{ todo: { ...base.todo as object, claimed_by: "agent-b" } }, "claim_owner_mismatch"], + ] as const) { + const rejected = evaluateCoordinationTodoMutationDecision({ ...base, ...override }); + assert.equal(rejected.code, code); + assert.equal(rejected.outcome, "rejected"); + } +}); + +test("ownership changes use the same holder gate as the locked writer", () => { + const base = mutation({ handoff_mode: "hard_lease", ownership_mutation: true, clear_claim: true }); + assert.equal(evaluateCoordinationTodoMutationDecision(base).code, "handoff_mode_requires_lease"); + const lease = { present: true, active: true, status: "active", owner: "agent-a", + idempotency_key: "execution-a", version: 3, lease_epoch: 5, write_scopes: [] }; + const cleared = evaluateCoordinationTodoMutationDecision({ ...base, lease }); + assert.equal(cleared.outcome, "apply"); + assert.equal(cleared.next_todo_claimed_by, null); + assert.equal(cleared.next_lease, null, "ownership admission must not release the holder lease"); + assert.equal(cleared.ownership_gate, evaluateTodoOwnershipGate({ + handoff_mode: "hard_lease", ownership_mutation: true, authority_mode: "registered_peer_actor", + }).ownership_gate); + assert.equal(evaluateCoordinationTodoMutationDecision({ ...base, lease: { ...lease, owner: "agent-b" } }).code, + "handoff_mode_requires_lease"); +}); + +test("delegated update is action/reason bound, not a general ownership bypass", () => { + const base = mutation({ actor_agent_id: "agent-b", handoff_mode: "hard_lease", + ownership_mutation: true, requested_claimed_by: "agent-b", authority_action: "reassign", + lifecycle_grants: [{agent_id: "agent-b", actions: ["reassign"], requires_reason: true}] }); + assert.equal(evaluateCoordinationTodoMutationDecision(base).code, "delegation_reason_required"); + const accepted = evaluateCoordinationTodoMutationDecision({ ...base, authority_reason: "recover work" }); + assert.equal(accepted.ownership_gate, "delegated_override"); + assert.equal(accepted.next_todo_claimed_by, "agent-b"); + assert.equal(evaluateCoordinationTodoMutationDecision({ ...base, authority_action: "update", + authority_reason: "recover work" }).code, "delegation_action_not_granted"); +}); + +test("mutation protocol cannot masquerade as completion or smuggle boolean strings", () => { + const minimal = mutation(); + for (const key of ["allow_user_gate_auto_acquire", "decision_target", "decision_outcome", + "lease_idempotency_key", "lease_expected_version"]) delete minimal[key]; + assert.equal(evaluateCoordinationTodoMutationDecision(minimal).outcome, "apply"); + assert.throws(() => evaluateCoordinationTodoMutationDecision(mutation({ command: "complete" }))); + assert.throws(() => evaluateCoordinationTodoTerminalDecision(mutation())); + assert.throws(() => evaluateCoordinationTodoMutationDecision(mutation({ ownership_mutation: "false" }))); + assert.equal(evaluateCoordinationTodoMutationDecision(mutation({ command: "claim", + authority_action: "claim", requested_claimed_by: "agent-b" })).code, "claim_actor_mismatch"); +}); + +test("standalone fence is preauthorized and never completes or attributes a Todo", () => { + const base = request({ schema_version: COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, + actor_agent_id: null, delegated_authority: false, require_active_when_fence_supplied: false, + lease_idempotency_key: "old-key" }); + const accepted = evaluateCoordinationTerminalFence(base); + assert.equal(accepted.code, "terminal_fence_not_required"); + assert.equal(accepted.next_todo_status, null); + assert.equal(accepted.authority_mode, null); + assert.equal(evaluateCoordinationTerminalFence({ ...base, require_active_when_fence_supplied: true }).code, + "lease_not_active"); + assert.throws(() => evaluateCoordinationTerminalFence({ ...base, delegated_authority: "true" })); +}); + test("terminal decision owns complete and supersede authority", () => { for (const command of ["complete", "supersede"]) { const decided = evaluateCoordinationTodoTerminalDecision(request({ From 6453de68919d120b1dd1bf94a1fdc03ca4b284ad Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 8 Sep 2026 22:39:14 +0800 Subject: [PATCH 2/3] docs(rfcs): record lifecycle deletion payoff and remaining update closure Signed-off-by: huangruiteng --- .../shared-goal-authority-state-provider-v0.md | 8 ++++++++ ...red-goal-authority-state-provider-v0.zh-CN.md | 5 +++++ .../typescript-control-plane-migration-v0.md | 16 +++++++++++++++- ...ypescript-control-plane-migration-v0.zh-CN.md | 12 +++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 32b0638898..0d7a512450 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2492,6 +2492,14 @@ The next complete stage packages are: never revive legacy Todos. This is consumer progress, not promotion proof: Turn, quota, planning, standing decisions, leases and monitor writeback still need their own parity inventory. Read authority does not grant writeback. + Lifecycle admission and the preauthorized terminal fence now share the TS + owner across legacy writers and native terminal transactions; the replaced + Python rules are removed without changing provider defaults or promotion. + This is not full native field-edit support: retain the strict text/note + transaction boundary until update's fields, ownership, validation and + monitor/resume effects close together. Neither an admission result nor a + lease-fence result is a commit receipt. Keep provider CAS/replay and existing + writer lock lifetimes unchanged while collecting this deletion payoff. 2. **Permanent projection closure.** Reuse `provider_projection.py`, the Todo-section renderer and existing journal/outbox. Preserve non-owned human narrative; render owned sections from a known canonical revision, with diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index f68cd5c021..363dba3072 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -1977,6 +1977,11 @@ backend、实时双向同步或按命令拆开的权威;晋升后不支持的 不要求 Markdown 文件存在;provider 缺失 fail closed,canonical 空集合不能复活旧 Todo。这是 consumer 进展,不是 promotion 证明:Turn、quota、planning、standing decision、lease、monitor writeback 仍需各自的 parity 清单。读权威不授予写回能力。 + Lifecycle 准入及预授权 terminal fence 现由 legacy writer 与 native terminal + transaction 共用 TS owner;删除对应 Python 规则,不改变 provider 默认或 promotion。 + 这不是完整 native 字段编辑:在 update 的字段、ownership、validation 和 monitor/resume + effect 一起闭合前,保留严格 text/note 事务边界。准入结果和 lease-fence 结果都不是 + commit receipt;兑现删除收益时,provider CAS/replay 与既有 writer 持锁生命周期不变。 2. **永久投影闭合。** 复用 `provider_projection.py`、Todo-section renderer 和既有 journal/outbox。保留非托管的人工叙述,从已知 canonical revision 渲染托管 section, 提供幂等修复与 freshness/readback 证据。投影 pending 独立于业务 commit/replay。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 0bfb8432aa..d057cb7784 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -204,6 +204,20 @@ owns durable truth, recovery, cutover, and projection delivery. Neither a fully TypeScript CLI nor `loopxd` is a prerequisite for removing Python decisions. An input adapter or external-effect executor may remain Python. +The lifecycle-admission slice now uses `todo_lifecycle_decision.ts` for legacy +claim/update admission, delegated action/reason checks, ownership-holder routing, +and the preauthorized terminal fence, alongside native complete/supersede. +`authority_core.py` projects results rather than retaining those decisions. +The terminal wire contract stays terminal-only; mutation admission cannot complete +a Todo, and a standalone fence neither grants actor authority nor completes it. +This deletes duplicate rules now, **not** the complete legacy update writer. +Field patches, omission/clear semantics, monitor/resume effects and validation +still need one complete update transaction before the writer can retire. Legacy +callers still cross the runtime boundary for admission and their locked gate; +this slice reduces semantic owners, not crossing count. Native transactions stay +in-process. Fold the remaining crossings into that complete transaction rather +than extending these adapters field by field. + 1. **Close the actual command and consumer inventory.** Build on the merged create/claim/update and #4053 terminal/successor/archive transactions; do not recreate them. Inventory remaining field-edit, monitor, lease, and event @@ -653,7 +667,7 @@ not start a second independent operation while that handler may still be live. | Field | Receipt | | --- | --- | -| Canonical owner | Before: Python owned terminal admission, successor derivation, and archive retention, while completion reduction and lease operations crossed narrower TS boundaries. After: `todo_terminal_decision.ts`, `todo_successor_derivation.ts`, `todo_terminal_lifecycle.ts`, and `todo_archive_selection.ts` are the typed owners of terminal admission, successor defaults/inheritance/bindings, lease release, completion reduction, CAS, receipt replay, and archive selection. The terminal transaction imports the successor and archive owners directly; legacy Markdown/event writers call their strict wire handlers and only materialize the returned proposal. | +| Canonical owner | Before: Python owned terminal admission, successor derivation, and archive retention, while completion reduction and lease operations crossed narrower TS boundaries. After: `todo_lifecycle_decision.ts`, `todo_successor_derivation.ts`, `todo_terminal_lifecycle.ts`, and `todo_archive_selection.ts` are the typed owners of terminal admission, successor defaults/inheritance/bindings, lease release, completion reduction, CAS, receipt replay, and archive selection. The terminal transaction imports the successor and archive owners directly; legacy Markdown/event writers call their strict wire handlers and only materialize the returned proposal. | | Legacy semantic code deleted | 284 Python product LOC are removed from semantic ownership: 74 lines for terminal decision plus archive eligibility/order/standing-receipt selection, and 210 lines of duplicated successor priority, capability/binding, exclusion, continuation, and predecessor-link derivation across Markdown complete/supersede and event completion. The remaining Python complete/supersede bodies are unpromoted compatibility writers, not a second terminal decision owner. Other deleted lines are adapter reshaping and moves and are not counted as payoff. | | Bridge code added | 937 gross product LOC are classified as bounded transport/compatibility: the 538-line `provider_terminal_lifecycle.py`, 135-line successor intent/result adapter, 173-line local TS request decoder/router delta, 33-line legacy archive result adapter, 10 handler-registration lines, 6 projection-settlement lines, and 42 lines that route Turn durable readback to canonical authority after promotion. The 29-line `resolve_todo_state_path` extraction is a move, not payoff. Host-local validation declaration storage/execution is a retained external effect and is not mislabeled as bridge deletion. | | Successor ownership | The public caller owns requested successor text and options. Python serializes that intent and adapts the typed proposal to the legacy writer. TypeScript alone derives inherited priority, default task class, capability binding, user binding, exclusions, same-agent continuity, and `unblocks_todo_id`; the promoted lifecycle derives and validates these facts inside the same provider transaction before atomically committing the target, successors, lease, and receipt. The legacy and event paths invoke the same pure TS decision through one effect-runtime call. | diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 2a00c78380..c4d8055549 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -159,6 +159,16 @@ lifecycle classification code 或其他 cadence policy,不能宣称全局已 负责 durable truth、恢复、cutover 与投影交付。删除 Python decision 不以前端 CLI 全部改成 TypeScript 或 `loopxd` 落地为前提;输入适配和外部 effect 执行可以保留 Python。 +本次 lifecycle-admission 切片将 legacy claim/update 准入、委托 action/reason 检查、 +ownership-holder 路由及预授权 terminal fence 统一到 `todo_lifecycle_decision.ts`, +与 native complete/supersede 共用规则;`authority_core.py` 只投影这些决策结果。 +Terminal wire 合同仍只接受 terminal 命令;mutation admission 不能完成 Todo, +独立 fence 不能授予 actor 权限或完成 Todo。这立即删除重复规则,**不等于删除完整 +legacy update writer**。字段 patch、省略/清空、monitor/resume effect 和 validation +仍需收口为完整 update transaction。Legacy 准入及持锁 gate 仍跨 runtime;本次减少 +语义 owner,不宣称减少 crossings,native transaction 仍进程内调用。下一步将这些 +crossing 一起折叠进完整事务,不能沿着 adapter 逐字段继续加桥。 + 1. **闭合实际命令与 consumer 清单。** 基于已合入的 create/claim/update 和 #4053 terminal/successor/archive transaction 推进,不重复建设。按真实合同盘点剩余 字段编辑、monitor、lease、event caller,把规则迁入既有 TS owner,并在同一切片 @@ -533,7 +543,7 @@ exactly-once 保证;原 handler 可能仍存活时,caller 不得启动第二 | 字段 | 回执 | | --- | --- | -| Canonical owner | 迁移前,Python 持有 terminal admission、successor derivation 与 archive retention,completion reduction 和 lease operation 则跨越更窄的 TS 边界。迁移后,`todo_terminal_decision.ts`、`todo_successor_derivation.ts`、`todo_terminal_lifecycle.ts` 与 `todo_archive_selection.ts` 成为 terminal admission、successor 默认值/继承/绑定、lease release、completion reduction、CAS、receipt replay 与 archive selection 的 typed owner。Terminal transaction 直接 import successor 与 archive owner;legacy Markdown/event writer 只调用其严格 wire handler 并物化返回 proposal。 | +| Canonical owner | 迁移前,Python 持有 terminal admission、successor derivation 与 archive retention,completion reduction 和 lease operation 则跨越更窄的 TS 边界。迁移后,`todo_lifecycle_decision.ts`、`todo_successor_derivation.ts`、`todo_terminal_lifecycle.ts` 与 `todo_archive_selection.ts` 成为 terminal admission、successor 默认值/继承/绑定、lease release、completion reduction、CAS、receipt replay 与 archive selection 的 typed owner。Terminal transaction 直接 import successor 与 archive owner;legacy Markdown/event writer 只调用其严格 wire handler 并物化返回 proposal。 | | 删除的旧语义代码 | 从 Python 语义 ownership 删除 284 行产品代码:74 行 terminal decision 与 archive eligibility/order/standing-receipt selection,加上 Markdown complete/supersede 和 event completion 三条路径中重复的 210 行 successor priority、capability/binding、exclusion、continuation 与 predecessor-link derivation。其余 Python complete/supersede body 是未 promotion 路径的 compatibility writer,不是第二个 terminal decision owner。其他删除属于 adapter reshaping 或搬移,不计为 payoff。 | | 新增的 bridge 代码 | 有界 transport/compatibility 共 937 行 gross 产品代码:538 行 `provider_terminal_lifecycle.py`、135 行 successor intent/result adapter、173 行 local TS request decoder/router 增量、33 行 legacy archive result adapter、10 行 handler registration、6 行 projection settlement,以及 42 行 promotion 后将 Turn durable readback 指向 canonical authority 的路由。29 行 `resolve_todo_state_path` extraction 是搬移,不是收益。Host-local validation declaration 的存储与执行是保留的 external effect,不冒充已删除 bridge。 | | Successor ownership | Public caller 持有请求的 successor text 与 option。Python 仅序列化 intent,并把 typed proposal 适配给 legacy writer。只有 TypeScript 推导继承 priority、默认 task class、capability binding、user binding、exclusion、same-agent continuity 与 `unblocks_todo_id`;promotion 后 lifecycle 在同一 provider transaction 内完成推导和校验,再原子提交 target、successor、lease 与 receipt。Legacy 与 event 路径通过一次 effect-runtime 调用复用同一纯 TS 决策。 | From 30f0941e511b417ffef21df506dfdf8381f9745b Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 00:51:36 +0800 Subject: [PATCH 3/3] fix(coordination): preserve internal reclaim authority and caller closure Signed-off-by: huangruiteng --- docs/development/testing-and-quality.md | 12 +++++++ loopx/control_plane/coordination/executor.py | 6 +++- .../coordination/todo_lifecycle_decision.ts | 33 +++++++++++++++++-- .../todo_terminal_decision.test.ts | 29 ++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 2be7b9a472..881556b017 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -259,6 +259,18 @@ lease、successor link、validation marker、归档压力,以及足以触发 ### Production-scale fixture stewardship / 生产规模 fixture 维护契约 +For decision-owner migrations, inventory command producers as well as state +fields. A large real-state snapshot does not exercise commands synthesized only +inside an executor: run unchanged production caller chains, including reclaim, +replay and stale-writer rejection, before claiming caller closure. Persistent +public grants and clock-authorized ephemeral executor grants are separate +contracts; neither a broad allowlist nor agreement across providers proves parity. + +迁移决策 owner 时,既要盘点状态字段,也要盘点命令生产者。真实大快照不会自动覆盖 +executor 内部生成的 reclaim 等命令;必须运行未改动的完整调用链,包括接管、重放和 +旧执行者拒绝,再声明调用方已闭合。持久公开 grant 与时钟授权的临时 executor grant +是不同合同,不能用扩大 allowlist 或 provider 间一致替代行为对齐。 + Treat `tests/fixtures/control_plane/coordination_production_scale_v0.json` and its generator as a shared acceptance input for both the TypeScript control-plane migration and shared-goal-authority RFCs. A pull request that diff --git a/loopx/control_plane/coordination/executor.py b/loopx/control_plane/coordination/executor.py index c1459972f3..2cd604ad4a 100644 --- a/loopx/control_plane/coordination/executor.py +++ b/loopx/control_plane/coordination/executor.py @@ -21,6 +21,7 @@ import json import math import re +from dataclasses import replace from datetime import datetime, timezone from typing import Any, Callable @@ -957,7 +958,10 @@ def _reclaim_transition( return _classified(clear_plan) assert clear_plan.next_snapshot is not None core_lease = self._acquire_and_claim( - clear_plan.next_snapshot, + # The clock-authorized delegation applies only to clearing the + # expired claim. Acquire/claim must use ordinary holder rules, + # without carrying that ephemeral authority into a later command. + replace(clear_plan.next_snapshot, lifecycle_grants=()), actor, request["operation_id"], command["lease_ttl_seconds"], diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index a387040857..7c4f83f34e 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -28,6 +28,7 @@ export const COORDINATION_TERMINAL_FENCE_RESULT_SCHEMA = const HANDOFF_MODES = ["legacy", "soft_claim", "hard_lease"] as const; const OUTCOMES = ["approve", "reject", "cancel"] as const; const AUTHORITY_ACTIONS = ["complete", "reassign", "supersede", "update"] as const; +const EXECUTOR_RECLAIM_ACTION = "reclaim"; type LifecycleCommand = typeof COMMANDS[number] | typeof MUTATION_COMMANDS[number]; type HandoffMode = typeof HANDOFF_MODES[number]; @@ -234,6 +235,28 @@ function lifecycleGrants( }); } +function executorReclaimGrant(request: JsonObject, actor: string | null): LifecycleGrant[] { + // This is the existing executor's ephemeral, clock-authorized clear-claim + // intent, not a configurable public lifecycle grant. The executor checks + // expiry/grace under CAS; this pure decision still owns actor eligibility. + const grants = request.lifecycle_grants; + if (request.command !== "update" || request.clear_claim !== true || + request.ownership_mutation !== true || request.requested_claimed_by != null || + actor === null || !Array.isArray(grants) || grants.length !== 1) { + throw new EffectRuntimeRequestError("executor reclaim requires one standing clear-claim grant"); + } + const grant = requireJsonObject(grants[0], "executor reclaim grant"); + const agent = normalizeTodoAgent(grant.agent_id, "executor reclaim grant.agent_id"); + const actions = requireStringArray(grant.actions, "executor reclaim grant.actions"); + if (agent !== actor || actions.length !== 1 || actions[0] !== EXECUTOR_RECLAIM_ACTION || + grant.requires_reason !== false) { + throw new EffectRuntimeRequestError("executor reclaim grant must match its actor and action"); + } + // Do not require registration here: authority() must return the established + // typed actor rejection before considering this synthesized delegation. + return [{agent_id: agent, actions: [EXECUTOR_RECLAIM_ACTION], requires_reason: false}]; +} + function decodeRequest(value: unknown, kind: "terminal" | "mutation" = "terminal"): LifecycleDecisionRequest { const request = requireJsonObject(value, "Todo terminal decision request"); requireStringLiteral( @@ -245,22 +268,26 @@ function decodeRequest(value: unknown, kind: "terminal" | "mutation" = "terminal const registeredAgents = normalizeRegisteredTodoAgents( requireStringArray(request.registered_agents, "registered_agents"), ); + const actor = optionalAgent(request.actor_agent_id, "actor_agent_id"); + const internalReclaim = kind === "mutation" && request.authority_action === EXECUTOR_RECLAIM_ACTION; const outcome = kind === "terminal" ? optionalString(request.decision_outcome, "decision_outcome") : null; return { command: requireStringLiteral(request.command, kind === "terminal" ? COMMANDS : MUTATION_COMMANDS, "command"), handoff_mode: requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"), registered_agents: registeredAgents, - lifecycle_grants: lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), + lifecycle_grants: internalReclaim ? executorReclaimGrant(request, actor) + : lifecycleGrants(request.lifecycle_grants ?? [], registeredAgents), todo: todoFact(request.todo, "todo"), decision_target: kind !== "terminal" || request.decision_target === null || request.decision_target === undefined ? null : todoFact(request.decision_target, "decision_target"), lease: leaseFact(request.lease), - actor_agent_id: optionalAgent(request.actor_agent_id, "actor_agent_id"), + actor_agent_id: actor, authority_action: requireStringLiteral( request.authority_action, - kind === "terminal" ? AUTHORITY_ACTIONS : [...AUTHORITY_ACTIONS, "claim"], + kind === "terminal" ? AUTHORITY_ACTIONS + : [...AUTHORITY_ACTIONS, "claim", EXECUTOR_RECLAIM_ACTION], "authority_action", ), authority_reason: optionalString(request.authority_reason, "authority_reason"), diff --git a/tests/control_plane_ts/todo_terminal_decision.test.ts b/tests/control_plane_ts/todo_terminal_decision.test.ts index 63190ac885..20f667030d 100644 --- a/tests/control_plane_ts/todo_terminal_decision.test.ts +++ b/tests/control_plane_ts/todo_terminal_decision.test.ts @@ -115,6 +115,35 @@ test("mutation protocol cannot masquerade as completion or smuggle boolean strin authority_action: "claim", requested_claimed_by: "agent-b" })).code, "claim_actor_mismatch"); }); +test("executor reclaim remains internal and preserves actor rejection precedence", () => { + const reclaim = (actor: string) => mutation({ + actor_agent_id: actor, authority_action: "reclaim", ownership_mutation: true, + clear_claim: true, handoff_mode: "hard_lease", + lifecycle_grants: [{ agent_id: actor, actions: ["reclaim"], requires_reason: false }], + }); + const accepted = evaluateCoordinationTodoMutationDecision(reclaim("agent-b")); + assert.equal(accepted.outcome, "apply"); + assert.equal(accepted.ownership_gate, "delegated_override"); + assert.equal(accepted.next_todo_claimed_by, null); + assert.equal(accepted.next_lease, null); + assert.equal(evaluateCoordinationTodoMutationDecision(reclaim("agent-z")).code, + "actor_not_registered"); + assert.equal(evaluateCoordinationTodoMutationDecision({ ...reclaim("agent-b"), + todo: { ...request().todo as object, excluded_agents: ["agent-b"] }, + }).code, "actor_excluded"); + for (const change of [ + { command: "claim" }, { clear_claim: false }, { ownership_mutation: false }, + { requested_claimed_by: "agent-b" }, + { lifecycle_grants: [{ agent_id: "agent-a", actions: ["reclaim"], requires_reason: false }] }, + { lifecycle_grants: [{ agent_id: "agent-b", actions: ["update", "reclaim"], requires_reason: false }] }, + ]) assert.throws(() => evaluateCoordinationTodoMutationDecision({ ...reclaim("agent-b"), ...change })); + // The public grant decoder and terminal wire must not gain reclaim authority. + assert.throws(() => evaluateCoordinationTodoMutationDecision({ ...reclaim("agent-b"), authority_action: "update" })); + assert.throws(() => evaluateCoordinationTodoTerminalDecision(request({ + lifecycle_grants: reclaim("agent-b").lifecycle_grants, + }))); +}); + test("standalone fence is preauthorized and never completes or attributes a Todo", () => { const base = request({ schema_version: COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, actor_agent_id: null, delegated_authority: false, require_active_when_fence_supplied: false,