From dfca3d89c61d06eeb76879ebf8f5ba9654a6093a Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 20:17:20 +0800 Subject: [PATCH 1/3] refactor(todos): unify authoring scope with explicit global gate intent Signed-off-by: huangruiteng --- examples/shared-goal-authority-e2e/mutants.py | 10 + loopx/cli_commands/todo_registration.py | 8 +- .../coordination/todo_terminal_lifecycle.ts | 48 ++--- .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/todos/authoring_scope.py | 41 ++++ loopx/control_plane/todos/authoring_scope.ts | 168 +++++++++++++++ loopx/control_plane/todos/write_policy.py | 135 ------------ loopx/todos.py | 204 ++++-------------- .../test_todo_authoring_scope.py | 98 +++++++++ .../authority_store_conformance.ts | 35 ++- .../todo_authoring_scope.test.ts | 104 +++++++++ 11 files changed, 516 insertions(+), 337 deletions(-) create mode 100644 loopx/control_plane/todos/authoring_scope.py create mode 100644 loopx/control_plane/todos/authoring_scope.ts delete mode 100644 loopx/control_plane/todos/write_policy.py create mode 100644 tests/control_plane/test_todo_authoring_scope.py create mode 100644 tests/control_plane_ts/todo_authoring_scope.test.ts diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 71684b6cb9..755ce95762 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -47,6 +47,16 @@ def command(self) -> list[str]: CASES = [ + Case('todo_global_gate_inferred', (('loopx/control_plane/todos/authoring_scope.ts', replacement( + 'intent.global_gate ? true : todo.global_gate', + '(intent.global_gate || intent.goal_bound) ? true : todo.global_gate')),), + 'tests/control_plane_ts/todo_authoring_scope.test.ts', 'global blocking is never inferred'), + Case('todo_explicit_scope_overwritten', (('loopx/control_plane/todos/authoring_scope.ts', replacement( + 'if (requestedBound) fail(', 'if (false) fail(')),), + 'tests/control_plane_ts/todo_authoring_scope.test.ts', 'explicit continuation and gate'), + Case('todo_successor_scope_unbound', (('loopx/control_plane/todos/authoring_scope.ts', replacement( + 'if (blocks && (goal || !bound || bound !== blocks)) return "agent_binding_conflict";', '')),), + 'tests/control_plane_ts/todo_authoring_scope.test.ts', 'resolved successor scope'), Case('delivery_wait_target_unbound', (('loopx/control_plane/todos/resume_condition.ts', replacement( 'condition.target_todo_id !== spec.target || ', '')),), 'tests/control_plane_ts/delivery_response.test.ts', 'exact dependency identity'), diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index bdf9947a9e..731e4388bc 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -304,7 +304,8 @@ def register_todo_command( action="store_true", help=( "For user todo add/update, explicitly bind the item to the whole goal " - "instead of one agent lane." + "instead of one agent lane. This scopes continuation, not blocking: " + "it does not create a global gate." ), ) todo_parser.add_argument( @@ -337,8 +338,9 @@ def register_todo_command( action="store_true", help=( "For todo add/update on role=user task-class=user_gate, explicitly mark " - "that the gate blocks every registered agent. Prefer --blocks-agent or " - "--agent-id when only one lane is waiting." + "that the gate blocks EVERY registered agent until resolved. This broad " + "scope is never inferred from --agent-id, --goal-bound, or missing binding. " + "Prefer --blocks-agent or --agent-id when only one lane is waiting." ), ) todo_parser.add_argument( diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index c634c26094..cf33e096c3 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -46,6 +46,7 @@ import { normalizeWriteScopes, } from "../work_items/task_lease_acquire.ts"; import { selectCoordinationTodoArchive } from "./todo_archive_selection.ts"; +import { userTodoScopeConflict, USER_TODO_TASK_CLASSES } from "../todos/authoring_scope.ts"; import { deriveCoordinationTodoSuccessorProposals, TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, @@ -68,7 +69,6 @@ const COMPLETION_IDENTITY_SOURCES = [ "unscoped_completion", "lifecycle_reentry", ] as const; -const USER_TODO_TASK_CLASSES = new Set(["user_action", "user_gate"]); type TerminalCommand = typeof TERMINAL_COMMANDS[number]; type TodoRole = typeof TODO_ROLES[number]; @@ -257,39 +257,25 @@ function validateSuccessorSemantics( "generated User successor cannot carry claimed_by ownership", ); } - if (boundAgent !== null && goalBound === true) { - throw new AuthorityStoreProtocolError( - "generated User successor cannot be both agent-bound and goal-bound", - ); + const scopeConflict = userTodoScopeConflict(taskClass, { + bound_agent: boundAgent, goal_bound: goalBound, blocks_agent: blocksAgent, global_gate: globalGate, + }, registeredAgents.length); + // Preserve the terminal protocol's diagnostic vocabulary. The invariant is + // shared; a resolved successor never goes through draft authoring inference. + if (scopeConflict === "binding_conflict") { + throw new AuthorityStoreProtocolError("generated User successor cannot be both agent-bound and goal-bound"); } if (taskClass === "user_action" && (blocksAgent !== null || globalGate === true)) { - throw new AuthorityStoreProtocolError( - "generated user_action successor cannot carry blocking gate scope", - ); - } - if (taskClass === "user_gate") { - if (globalGate === true && - (blocksAgent !== null || boundAgent !== null || goalBound !== true)) { - throw new AuthorityStoreProtocolError( - "goal-wide User gate successor requires goal_bound and no Agent binding", - ); - } - if (blocksAgent !== null && - (goalBound === true || boundAgent !== blocksAgent)) { - throw new AuthorityStoreProtocolError( - "Agent-scoped User gate successor must bind to its blocks_agent", - ); - } - if (registeredAgents.length > 1 && blocksAgent === null && globalGate !== true) { - throw new AuthorityStoreProtocolError( - "multi-agent User gate successor requires an explicit blocking scope", - ); - } + throw new AuthorityStoreProtocolError("generated user_action successor cannot carry blocking gate scope"); } - if (registeredAgents.length > 1 && boundAgent === null && goalBound !== true) { - throw new AuthorityStoreProtocolError( - "multi-agent User successor requires an explicit Agent or Goal binding", - ); + if (scopeConflict) { + throw new AuthorityStoreProtocolError({ + gate_scope_conflict: "goal-wide User gate successor requires goal_bound and no Agent binding", + global_binding_conflict: "goal-wide User gate successor requires goal_bound and no Agent binding", + agent_binding_conflict: "Agent-scoped User gate successor must bind to its blocks_agent", + gate_scope_missing: "multi-agent User gate successor requires an explicit blocking scope", + binding_missing: "multi-agent User successor requires an explicit Agent or Goal binding", + }[scopeConflict]); } } } diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 10461959b2..99e1c86296 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -65,6 +65,7 @@ import { import { reduceTodoCompletionTransaction } from "./todos/completion_transaction.ts"; import { transitionTodoNextAction } from "./todos/next_action.ts"; import { planTodoFieldUpdate } from "./todos/field_update.ts"; +import { planTodoAuthoringScope } from "./todos/authoring_scope.ts"; import { evaluateTodoResumeConditions, normalizeTodoResumeWhen, @@ -368,6 +369,7 @@ export function createEffectRuntimeHandlers( ["todo.completion_state.require_metadata", requireTodoCompletionMetadataValue], ["todo.completion_state.continuation_for_write", selectTodoCompletionContinuation], ["todo.field_update.plan", planTodoFieldUpdate], + ["todo.authoring_scope.plan", planTodoAuthoringScope], [ "todo.claim.decide", (params) => evaluateCoordinationTodoClaimDecision( diff --git a/loopx/control_plane/todos/authoring_scope.py b/loopx/control_plane/todos/authoring_scope.py new file mode 100644 index 0000000000..6333ae7a22 --- /dev/null +++ b/loopx/control_plane/todos/authoring_scope.py @@ -0,0 +1,41 @@ +"""Fact transport for TS authoring scope. Permission and commit stay with callers.""" +from typing import Any + +from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result +from .contract import ( + normalize_todo_blocks_agent, normalize_todo_bound_agent, + normalize_todo_global_gate, normalize_todo_goal_bound, +) + + +def plan_todo_authoring_scope( + *, command: str, role: str, intent: dict[str, Any], + registered_agents: list[str], goal_id: str, todo: dict[str, Any] | None = None, +) -> dict[str, Any]: + source = todo or {} + facts = {key: source.get(key) for key in ("status", "task_class", "resume_when", "excluded_agents")} + facts.update({"blocks_agent": normalize_todo_blocks_agent(source.get("blocks_agent")), + "bound_agent": normalize_todo_bound_agent(source.get("bound_agent")), + "global_gate": normalize_todo_global_gate(source.get("global_gate")), + "goal_bound": normalize_todo_goal_bound(source.get("goal_bound"))}) + try: + result = effect_runtime_result("todo.authoring_scope.plan", { + "schema_version": "todo_authoring_scope_request_v0", "command": command, + "role": role, "todo": facts, "intent": intent, + "registered_agents": registered_agents, "goal_id": goal_id, + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from None + if not isinstance(result, dict) or result.get("schema_version") != "todo_authoring_scope_result_v0": + raise RuntimeError("TypeScript Todo authoring scope result shape mismatch") + return result + + +def require_user_todo_task_class( + *, role: str, task_class: str | None, blocks_agent: str | None = None, + global_gate: bool | None = None, +) -> None: + # Early syntactic check used by create and the Markdown import codec. + plan_todo_authoring_scope(command="class", role=role, intent={ + "task_class": task_class, "blocks_agent": blocks_agent, "global_gate": global_gate, + }, registered_agents=[], goal_id="") diff --git a/loopx/control_plane/todos/authoring_scope.ts b/loopx/control_plane/todos/authoring_scope.ts new file mode 100644 index 0000000000..8221aa958d --- /dev/null +++ b/loopx/control_plane/todos/authoring_scope.ts @@ -0,0 +1,168 @@ +/** Public Todo authoring scope, not lifecycle permission or a commit receipt. + * Explicit scope is intent; an author identity is only a default. */ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { requireJsonObject } from "../runtime_decode.ts"; +import { normalizeRegisteredTodoAgents, normalizeTodoAgent, stripPythonWhitespace } from "../coordination/todo_agents.ts"; +import { normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION } from "./resume_condition.ts"; + +export const TODO_AUTHORING_SCOPE_REQUEST_SCHEMA = "todo_authoring_scope_request_v0"; +export const TODO_AUTHORING_SCOPE_RESULT_SCHEMA = "todo_authoring_scope_result_v0"; +export const USER_TODO_TASK_CLASSES: ReadonlySet = new Set(["user_action", "user_gate"]); +function fail(message: string): never { throw new EffectRuntimeRequestError(message); } +const INTENT_FIELDS = new Set(["task_class", "status", "actor_agent_id", "claimed_by", "bound_agent", + "goal_bound", "blocks_agent", "global_gate", "clear_global_gate", "clear_blocks_agent", "excluded_agents", + "task_repository", "task_domain", "capability_binding_ref", "resume_when", "clear_resume_when"]); + +function string(value: unknown, field: string): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== "string") return fail(`${field} must be a string or null`); + return value; +} + +function requireTaskClass(role: string, taskClass: string | null, blocks: unknown, global: unknown): void { + if (role !== "user") { + if (USER_TODO_TASK_CLASSES.has(taskClass ?? "")) fail("user_action and user_gate task_class are only valid for --role user"); + return; + } + const normalized = stripPythonWhitespace(taskClass ?? ""); + if (!USER_TODO_TASK_CLASSES.has(normalized)) fail("user todo requires explicit --task-class user_gate or user_action; " + + "use user_gate for blocking owner/controller decisions and user_action for non-blocking user-visible todos"); + if (normalized === "user_action" && (blocks || global)) fail("user_action is non-blocking and cannot set blocks_agent or global_gate; " + + "use --task-class user_gate for blocking decisions"); +} + +interface Scope { + bound_agent: string | null; + goal_bound: boolean | null; + blocks_agent: string | null; + global_gate: boolean | null; +} + +export type UserTodoScopeConflict = "binding_conflict" | "gate_scope_conflict" | + "global_binding_conflict" | "agent_binding_conflict" | "gate_scope_missing" | "binding_missing"; + +/** Check a fully resolved scope without inventing any authoring defaults. + * Both public edits and materialized terminal successors consume this rule. */ +export function userTodoScopeConflict(taskClass: string | null, scope: Scope, + agentCount: number): UserTodoScopeConflict | null { + const { bound_agent: bound, goal_bound: goal, blocks_agent: blocks, global_gate: global } = scope; + if (bound && goal) return "binding_conflict"; + if (taskClass === "user_gate") { + if (global && blocks) return "gate_scope_conflict"; + if (global && (bound || goal !== true)) return "global_binding_conflict"; + if (blocks && (goal || !bound || bound !== blocks)) return "agent_binding_conflict"; + if (agentCount > 1 && !blocks && global !== true) return "gate_scope_missing"; + } + return agentCount > 1 && !bound && goal !== true ? "binding_missing" : null; +} + +function validateScope(role: string, taskClass: string | null, scope: Scope, agentCount: number): void { + const { bound_agent: bound, goal_bound: goal, blocks_agent: blocks, global_gate: global } = scope; + if (role !== "user") { + if (bound || goal) fail("bound_agent and goal_bound are only valid for user todos"); + return; + } + const conflict = userTodoScopeConflict(taskClass, scope, agentCount); + if (conflict) fail({ + binding_conflict: "user todo cannot set both bound_agent and goal_bound=true; bind the continuation to one agent lane or explicitly to the whole goal", + gate_scope_conflict: "user_gate cannot set both blocks_agent and global_gate=true; use blocks_agent for one registered agent or global_gate=true for a goal-wide gate", + global_binding_conflict: "a goal-wide user_gate must use goal_bound=true and cannot bind its continuation to one agent", + agent_binding_conflict: "an agent-scoped user_gate must bind to the same agent named by blocks_agent", + binding_missing: "multi-agent user todo requires an explicit binding: pass --bound-agent (or --agent-id for authoring), or pass --goal-bound for an intentionally goal-wide user todo", + gate_scope_missing: "multi-agent user_gate requires an explicit scope: pass --blocks-agent (or --agent-id for authoring) when the gate blocks one lane; only explicit --global-gate may block every registered agent. --goal-bound alone does not create a global gate", + }[conflict]); +} + +function planScope(command: string, role: string, taskClass: string | null, todo: JsonObject, + intent: JsonObject, agents: string[], goalId: string): Scope { + const registered = (field: string): string | null => { + if (!intent[field]) return null; + const value = normalizeTodoAgent(intent[field], field); + if (!value || !agents.includes(value)) fail(`${field}='${value}' is not registered for goal '${goalId}'; registered_agents=${agents.join(", ")}`); + return value; + }; + const requestedBound = registered("bound_agent"); + const requestedBlocks = registered("blocks_agent"); + const actor = registered("actor_agent_id"); + if (requestedBound && intent.goal_bound) fail("todo update accepts either bound_agent or goal_bound, not both"); + if (requestedBlocks && intent.clear_blocks_agent) fail("todo update accepts either blocks_agent or clear_blocks_agent, not both"); + if (intent.global_gate && intent.clear_global_gate) fail("todo update accepts either global_gate or clear_global_gate, not both"); + if ((intent.global_gate || intent.clear_global_gate) && !(role === "user" && taskClass === "user_gate")) { + fail(`${intent.clear_global_gate ? "clear_global_gate" : "global_gate"} is only valid for user_gate todos`); + } + if (role === "agent" && requestedBlocks) fail("blocks_agent is only valid for user gates; use excluded_agents for agent executor constraints"); + if (role === "user" && intent.claimed_by) fail("claimed_by is execution ownership for agent todos, not a user-todo binding; use --bound-agent or --goal-bound"); + for (const field of ["task_repository", "task_domain", "capability_binding_ref"]) { + if (intent[field] && role !== "agent") fail(`${field} is only valid for agent todos`); + } + const creating = command === "create"; + let blocks = intent.clear_blocks_agent ? null : requestedBlocks || string(todo.blocks_agent, "blocks_agent"); + const global = intent.clear_global_gate ? null : intent.global_gate ? true : todo.global_gate as boolean | null ?? null; + let bound = requestedBound || (intent.goal_bound ? null : string(todo.bound_agent, "bound_agent")); + let goal = intent.goal_bound ? true : requestedBound ? false : todo.goal_bound as boolean | null ?? null; + // Gate scope can determine continuation scope, but never overwrite a + // contradictory explicit continuation. Only old/inferred binding is replaced. + if (creating && role === "user" && taskClass === "user_gate" && !global && !blocks) blocks = actor; + if (role === "user" && taskClass === "user_gate" && global) { + if (requestedBound) fail("a goal-wide user_gate must use goal_bound=true and cannot bind its continuation to one agent"); + bound = null; goal = true; + } else if (role === "user" && taskClass === "user_gate" && blocks) { + if (intent.goal_bound || (requestedBound && requestedBound !== blocks)) fail("an agent-scoped user_gate must bind to the same agent named by blocks_agent"); + bound = blocks; goal = false; + } else if (role === "user" && !bound && !goal) { + bound = (creating ? actor : null) || (agents.length === 1 ? agents[0] : null); + } + if (!creating && role !== "user") { + if (requestedBound || intent.goal_bound) fail("bound_agent and goal_bound are only valid for user todos"); + bound = null; goal = null; + } + return {bound_agent: bound, goal_bound: goal, blocks_agent: blocks, global_gate: global}; +} + +export function planTodoAuthoringScope(value: unknown): JsonObject { + const request = requireJsonObject(value, "Todo authoring scope request"); + if (request.schema_version !== TODO_AUTHORING_SCOPE_REQUEST_SCHEMA) fail("Todo authoring scope schema mismatch"); + const command = string(request.command, "command"); + if (!["class", "create", "update"].includes(command ?? "")) fail("unsupported Todo authoring scope command"); + const role = string(request.role, "role"); + if (role !== "agent" && role !== "user") fail("todo role must be one of: user, agent"); + const intent = requireJsonObject(request.intent, "Todo authoring intent"); + for (const key of Object.keys(intent)) if (!INTENT_FIELDS.has(key)) fail(`Todo authoring scope does not own ${key}`); + const todo = requireJsonObject(request.todo, "Todo authoring source"); + for (const object of [intent, todo]) for (const field of ["goal_bound", "global_gate", "clear_global_gate", "clear_blocks_agent", "clear_resume_when"]) { + if (object[field] !== undefined && object[field] !== null && typeof object[field] !== "boolean") fail(`${field} must be a boolean or null`); + } + const taskClass = string(intent.task_class, "task_class") || string(todo.task_class, "task_class"); + if (command === "class") { + requireTaskClass(role, taskClass, intent.blocks_agent, intent.global_gate); + return {schema_version: TODO_AUTHORING_SCOPE_RESULT_SCHEMA}; + } + const registeredAgents = request.registered_agents; + if (!Array.isArray(registeredAgents)) fail("registered_agents must be an array"); + const agents = normalizeRegisteredTodoAgents(registeredAgents); + const status = stripPythonWhitespace(string(intent.status, "status") ?? "").toLowerCase() || string(todo.status, "status") || "open"; + if (!["open", "done", "blocked", "deferred"].includes(status)) fail("todo status must be one of: open, done, blocked, deferred"); + if (command === "create" && status === "done") fail("todo add cannot create completed work; add it open and use `loopx todo complete`"); + if (command === "update" && role === "agent" && intent.status && status === "done") fail("agent todo completion must use complete_goal_todo " + + "(CLI: `loopx todo complete`) so completion policy, successor, and no-follow-up contracts are enforced"); + const scope = planScope(command ?? "", role, taskClass, todo, intent, agents, string(request.goal_id, "goal_id") ?? ""); + const exclusions = intent.excluded_agents ?? todo.excluded_agents; + if (role !== "agent" && Array.isArray(exclusions) && exclusions.length) fail("excluded_agents is only valid for agent todos; clear exclusions before moving this todo to a user role"); + // Completed history remains repairable; it does not create an active gate. + if (status !== "done") { + requireTaskClass(role, taskClass, scope.blocks_agent, scope.global_gate); + validateScope(role, taskClass, scope, agents.length); + } + const resume = intent.resume_when ? normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, + resume_when: intent.resume_when}) : null; + if (intent.resume_when && !resume) fail("unsupported Todo resume condition"); + if (resume && intent.clear_resume_when) fail("todo update accepts either resume_when or clear_resume_when, not both"); + const existingResume = todo.resume_when ? normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, + resume_when: todo.resume_when}) : null; + const effectiveResume = intent.clear_resume_when ? null : resume || existingResume; + if (status === "deferred" && !effectiveResume) fail("transition to deferred requires --resume-when with a supported condition"); + return {schema_version: TODO_AUTHORING_SCOPE_RESULT_SCHEMA, ...scope, task_class: taskClass, + status, normalized_resume_when: resume, effective_resume_when: effectiveResume, + clear_user_binding: role !== "user" && Boolean(todo.bound_agent || todo.goal_bound != null)}; +} diff --git a/loopx/control_plane/todos/write_policy.py b/loopx/control_plane/todos/write_policy.py deleted file mode 100644 index cbe47e4b6c..0000000000 --- a/loopx/control_plane/todos/write_policy.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from ...agent_registry import registered_agent_ids_from_registry -from .contract import TODO_TASK_CLASS_USER_ACTION, TODO_TASK_CLASS_USER_GATE - - -USER_TODO_TASK_CLASSES = { - TODO_TASK_CLASS_USER_ACTION, - TODO_TASK_CLASS_USER_GATE, -} - - -def require_user_todo_task_class( - *, - role: str, - task_class: str | None, - blocks_agent: str | None = None, - global_gate: bool | None = None, -) -> None: - if role != "user": - if task_class in USER_TODO_TASK_CLASSES: - raise ValueError( - "user_action and user_gate task_class are only valid for --role user" - ) - return - normalized = str(task_class or "").strip() - if normalized not in USER_TODO_TASK_CLASSES: - raise ValueError( - "user todo requires explicit --task-class user_gate or user_action; " - "use user_gate for blocking owner/controller decisions and user_action " - "for non-blocking user-visible todos" - ) - if normalized == TODO_TASK_CLASS_USER_ACTION and (blocks_agent or global_gate): - raise ValueError( - "user_action is non-blocking and cannot set blocks_agent or global_gate; " - "use --task-class user_gate for blocking decisions" - ) - - -def require_user_gate_scope( - *, - registry_path: Path, - goal_id: str, - role: str, - task_class: str | None, - blocks_agent: str | None, - global_gate: bool | None, -) -> None: - if role != "user" or task_class != TODO_TASK_CLASS_USER_GATE: - return - if global_gate and blocks_agent: - raise ValueError( - "user_gate cannot set both blocks_agent and global_gate=true; " - "use blocks_agent for one registered agent or global_gate=true for a goal-wide gate" - ) - registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) - if len(registered_agents) <= 1: - return - if blocks_agent or global_gate is True: - return - raise ValueError( - "multi-agent user_gate requires an explicit scope: pass --blocks-agent " - " (or --agent-id for authoring) " - "when the gate blocks one lane, or pass --global-gate when it genuinely " - "blocks every registered agent" - ) - - -def require_user_todo_binding( - *, - registry_path: Path, - goal_id: str, - role: str, - task_class: str | None, - bound_agent: str | None, - goal_bound: bool | None, - blocks_agent: str | None, - global_gate: bool | None, -) -> None: - if role != "user": - if bound_agent or goal_bound: - raise ValueError("bound_agent and goal_bound are only valid for user todos") - return - if bound_agent and goal_bound: - raise ValueError( - "user todo cannot set both bound_agent and goal_bound=true; bind the " - "continuation to one agent lane or explicitly to the whole goal" - ) - if task_class == TODO_TASK_CLASS_USER_GATE: - if global_gate and (bound_agent or goal_bound is not True): - raise ValueError( - "a goal-wide user_gate must use goal_bound=true and cannot bind " - "its continuation to one agent" - ) - if blocks_agent and ( - goal_bound or not bound_agent or bound_agent != blocks_agent - ): - raise ValueError( - "an agent-scoped user_gate must bind to the same agent named by " - "blocks_agent" - ) - registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) - if len(registered_agents) <= 1: - return - if bound_agent or goal_bound is True: - return - raise ValueError( - "multi-agent user todo requires an explicit binding: pass --bound-agent " - " (or --agent-id for authoring), " - "or pass --goal-bound for an intentionally goal-wide user todo" - ) - - -def resolve_user_gate_global_gate_update( - *, - role: str, - task_class: str | None, - existing_global_gate: bool | None, - global_gate: bool, - clear_global_gate: bool, -) -> bool | None: - if global_gate and clear_global_gate: - raise ValueError( - "todo update accepts either global_gate or clear_global_gate, not both" - ) - if (global_gate or clear_global_gate) and not ( - role == "user" and task_class == TODO_TASK_CLASS_USER_GATE - ): - field = "clear_global_gate" if clear_global_gate else "global_gate" - raise ValueError(f"{field} is only valid for user_gate todos") - if clear_global_gate: - return None - return True if global_gate else existing_global_gate diff --git a/loopx/todos.py b/loopx/todos.py index 77f659d874..6927d5b4f1 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -37,7 +37,6 @@ normalize_todo_required_decision_scopes, normalize_todo_replan_obligation_id, normalize_todo_resume_when, - normalize_supported_todo_resume_when, normalize_todo_status, normalize_todo_task_domain, normalize_todo_task_repository, @@ -111,11 +110,9 @@ from .control_plane.todos.write_correctness import ( attach_todo_write_correctness_dry_run_packet as _attach_todo_write_correctness_dry_run_packet, ) -from .control_plane.todos.write_policy import ( - require_user_gate_scope, - require_user_todo_binding, +from .control_plane.todos.authoring_scope import ( + plan_todo_authoring_scope, require_user_todo_task_class, - resolve_user_gate_global_gate_update, ) from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction from .control_plane.coordination.local_authority import ( @@ -774,50 +771,23 @@ def add_goal_todo( ) if agent_id else None ) registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) - inferred_blocks_agent = blocks_agent - if ( - effective_agent_id and not inferred_blocks_agent and role == "user" - and task_class == TODO_TASK_CLASS_USER_GATE - ): - inferred_blocks_agent = effective_agent_id - effective_blocks_agent = ( - require_registered_agent_id( - registry_path=registry_path, goal_id=goal_id, - agent_id=inferred_blocks_agent, field="blocks_agent", - ) if inferred_blocks_agent else None - ) - inferred_bound_agent = bound_agent - if role == "user" and not inferred_bound_agent and not goal_bound: - if effective_agent_id: - inferred_bound_agent = effective_agent_id - elif task_class == TODO_TASK_CLASS_USER_GATE and effective_blocks_agent: - inferred_bound_agent = effective_blocks_agent - elif len(registered_agents) == 1: - inferred_bound_agent = registered_agents[0] - effective_bound_agent = ( - require_registered_agent_id( - registry_path=registry_path, goal_id=goal_id, - agent_id=inferred_bound_agent, field="bound_agent", - ) if inferred_bound_agent else None - ) - effective_goal_bound = bool(goal_bound or global_gate) effective_excluded_agents = require_registered_todo_excluded_agents( - registry_path=registry_path, goal_id=goal_id, - excluded_agents=excluded_agents, + registry_path=registry_path, goal_id=goal_id, excluded_agents=excluded_agents, ) - if role != "agent" and effective_excluded_agents: - raise ValueError("excluded_agents is only valid for agent todos") - require_user_gate_scope( - registry_path=registry_path, goal_id=goal_id, role=role, - task_class=task_class, blocks_agent=effective_blocks_agent, - global_gate=True if global_gate else None, - ) - require_user_todo_binding( - registry_path=registry_path, goal_id=goal_id, role=role, - task_class=task_class, bound_agent=effective_bound_agent, - goal_bound=effective_goal_bound, blocks_agent=effective_blocks_agent, - global_gate=True if global_gate else None, + authoring_scope = plan_todo_authoring_scope( + command="create", role=role, goal_id=goal_id, registered_agents=registered_agents, + intent={ + "task_class": task_class, "status": status, "actor_agent_id": effective_agent_id, + "claimed_by": effective_claimed_by, "bound_agent": bound_agent, "goal_bound": goal_bound, + "blocks_agent": blocks_agent, "global_gate": global_gate, + "excluded_agents": effective_excluded_agents, "resume_when": resume_when, + "task_repository": task_repository, "task_domain": task_domain, + "capability_binding_ref": capability_binding_ref, + }, ) + effective_blocks_agent = authoring_scope["blocks_agent"] + effective_bound_agent = authoring_scope["bound_agent"] + effective_goal_bound = authoring_scope["goal_bound"] normalized_unblocks_todo_id = normalize_todo_id(unblocks_todo_id) if unblocks_todo_id else None if unblocks_todo_id and not normalized_unblocks_todo_id: raise ValueError("unblocks_todo_id must use the public token shape todo_") @@ -1300,56 +1270,26 @@ def update_goal_todo( ownership_mutation=(claimed_by is not None or clear_claim) and target_role == "agent", runtime_root=shadow_runtime_root, ) - target_task_class = task_class or str(existing_block.get("task_class") or "") - if target_role == "user" and claimed_by: - raise ValueError( - "claimed_by is execution ownership for agent todos, not a user-todo " - "binding; use --bound-agent or --goal-bound" - ) - if target_role == "agent" and blocks_agent: - raise ValueError( - "blocks_agent is only valid for user gates; use excluded_agents for " - "agent executor constraints" - ) - if task_repository and target_role != "agent": - raise ValueError("task_repository is only valid for agent todos") - if task_domain and target_role != "agent": - raise ValueError("task_domain is only valid for agent todos") effective_excluded_agents = ( - [] - if clear_excluded_agents - else require_registered_todo_excluded_agents( - registry_path=registry_path, - goal_id=goal_id, - excluded_agents=excluded_agents, - ) - if excluded_agents is not None - else None - ) - existing_excluded_agents = normalize_todo_excluded_agents( - existing_block.get("excluded_agents") - ) - target_excluded_agents = ( - effective_excluded_agents - if effective_excluded_agents is not None - else existing_excluded_agents - ) - if target_role != "agent" and target_excluded_agents: - raise ValueError( - "excluded_agents is only valid for agent todos; clear exclusions before " - "moving this todo to a user role" - ) - target_status = ( - normalize_todo_status(status) - if status - else str(existing_block.get("status") or TODO_STATUS_OPEN) + [] if clear_excluded_agents else require_registered_todo_excluded_agents( + registry_path=registry_path, goal_id=goal_id, excluded_agents=excluded_agents, + ) if excluded_agents is not None else None + ) + authoring_scope = plan_todo_authoring_scope( + command="update", role=target_role, goal_id=goal_id, todo=existing_block, + registered_agents=registered_agent_ids_from_registry(registry_path, goal_id), + intent={ + "task_class": task_class, "status": status, "actor_agent_id": effective_agent_id, + "claimed_by": effective_claimed_by, "bound_agent": effective_bound_agent, + "goal_bound": goal_bound, "blocks_agent": effective_blocks_agent, + "clear_blocks_agent": clear_blocks_agent, "global_gate": global_gate, + "clear_global_gate": clear_global_gate, "excluded_agents": effective_excluded_agents, + "task_repository": task_repository, "task_domain": task_domain, + "resume_when": resume_when, "clear_resume_when": clear_resume_when, + }, ) - if status and target_role == "agent" and target_status == TODO_STATUS_DONE: - raise ValueError( - "agent todo completion must use complete_goal_todo " - "(CLI: `loopx todo complete`) so completion policy, successor, " - "and no-follow-up contracts are enforced" - ) + target_task_class = authoring_scope["task_class"] + target_status = authoring_scope["status"] completion_metadata_updates_override = None if completion_validation_gate is not None: locked_completion = locked_todo_completion_transaction( @@ -1372,77 +1312,14 @@ def update_goal_todo( ), ) ) - existing_blocks_agent = normalize_todo_blocks_agent(existing_block.get("blocks_agent")) - existing_global_gate = normalize_todo_global_gate(existing_block.get("global_gate")) - existing_bound_agent = normalize_todo_bound_agent(existing_block.get("bound_agent")) - existing_goal_bound = normalize_todo_goal_bound(existing_block.get("goal_bound")) - target_blocks_agent = None if clear_blocks_agent else effective_blocks_agent or existing_blocks_agent - target_global_gate = resolve_user_gate_global_gate_update( - role=target_role, - task_class=target_task_class, - existing_global_gate=existing_global_gate, - global_gate=global_gate, - clear_global_gate=clear_global_gate, - ) - target_bound_agent = ( - effective_bound_agent - if bound_agent - else None - if goal_bound - else existing_bound_agent - ) - target_goal_bound = True if goal_bound else False if bound_agent else existing_goal_bound - registered_agents = registered_agent_ids_from_registry(registry_path, goal_id) - if target_role != "user": - target_bound_agent = None - target_goal_bound = None - elif target_task_class == TODO_TASK_CLASS_USER_GATE and target_global_gate: - target_bound_agent = None - target_goal_bound = True - elif target_task_class == TODO_TASK_CLASS_USER_GATE and target_blocks_agent: - target_bound_agent = target_blocks_agent - target_goal_bound = False - elif not target_bound_agent and not target_goal_bound: - if len(registered_agents) == 1: - target_bound_agent = registered_agents[0] - if target_status != TODO_STATUS_DONE: - require_user_todo_task_class( - role=target_role, - task_class=target_task_class, - blocks_agent=target_blocks_agent, - global_gate=target_global_gate, - ) - require_user_todo_binding( - registry_path=registry_path, - goal_id=goal_id, - role=target_role, - task_class=target_task_class, - bound_agent=target_bound_agent, - goal_bound=target_goal_bound, - blocks_agent=target_blocks_agent, - global_gate=target_global_gate, - ) - require_user_gate_scope( - registry_path=registry_path, - goal_id=goal_id, - role=target_role, - task_class=target_task_class, - blocks_agent=target_blocks_agent, - global_gate=target_global_gate, - ) + target_bound_agent = authoring_scope["bound_agent"] + target_goal_bound = authoring_scope["goal_bound"] normalized_unblocks_todo_id = normalize_todo_id(unblocks_todo_id) if unblocks_todo_id else None if unblocks_todo_id and not normalized_unblocks_todo_id: raise ValueError("unblocks_todo_id must use the public token shape todo_") normalized_successor_todo_ids = requested_successor_todo_ids - normalized_resume_when = require_supported_todo_resume_when(resume_when) - effective_resume_when = ( - None - if clear_resume_when - else normalized_resume_when - or normalize_supported_todo_resume_when(existing_block.get("resume_when")) - ) - if target_status == TODO_STATUS_DEFERRED and not effective_resume_when: - raise ValueError("transition to deferred requires --resume-when with a supported condition") + normalized_resume_when = authoring_scope["normalized_resume_when"] + effective_resume_when = authoring_scope["effective_resume_when"] external_wait_transition, resume_monitor_generation = ( plan_todo_external_wait_update( lines=lines, @@ -1494,10 +1371,7 @@ def update_goal_todo( if target_role == "user" and target_goal_bound else None ), - clear_user_binding=( - target_role != "user" - and bool(existing_bound_agent or existing_goal_bound is not None) - ), + clear_user_binding=authoring_scope["clear_user_binding"], blocks_agent=effective_blocks_agent, clear_blocks_agent=clear_blocks_agent, excluded_agents=effective_excluded_agents, diff --git a/tests/control_plane/test_todo_authoring_scope.py b/tests/control_plane/test_todo_authoring_scope.py new file mode 100644 index 0000000000..c65ce70c09 --- /dev/null +++ b/tests/control_plane/test_todo_authoring_scope.py @@ -0,0 +1,98 @@ +"""Explicit authoring scope outranks actor-based defaults, never authority.""" +from pathlib import Path + +import pytest + +from loopx.control_plane.testing.canary_harness import run_json_cli_result, write_fixture_registry +from loopx.control_plane.todos.active_state_editing import find_todo_block +from loopx.todos import add_goal_todo, update_goal_todo + +GOAL = "authoring-scope" + + +@pytest.fixture +def authored_goal(tmp_path): + project = tmp_path / "project" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text("# Goal\n\n## Agent Todo\n\n## User Todo\n", encoding="utf-8") + registry = tmp_path / "registry.json" + write_fixture_registry(project=project, runtime_root=tmp_path / "runtime", + registry_path=registry, goal_id=GOAL, domain="scope-test", + adapter_kind="generic_project_goal_v0", registered_agents=["agent-a", "agent-b"], + quota_allowed_slots=None) + return registry, state + + +def add(registry, state, **intent): + return add_goal_todo(registry_path=registry, goal_id=GOAL, state_file=state, + role="user", text="Decide the next step", **intent) + + +def stored(state: Path, todo_id): + return find_todo_block(state.read_text(encoding="utf-8").splitlines(), todo_id=todo_id)[4] + + +@pytest.mark.parametrize("intent, bound, goal, blocks, global_", [ + ({"task_class": "user_action", "agent_id": "agent-a"}, "agent-a", False, None, False), + ({"task_class": "user_action", "agent_id": "agent-a", "goal_bound": True}, None, True, None, False), + ({"task_class": "user_gate", "agent_id": "agent-a"}, "agent-a", False, "agent-a", False), + ({"task_class": "user_gate", "agent_id": "agent-a", "blocks_agent": "agent-b"}, "agent-b", False, "agent-b", False), + ({"task_class": "user_gate", "agent_id": "agent-a", "global_gate": True}, None, True, None, True), +]) +def test_add_persists_declared_scope_not_the_author_identity(authored_goal, intent, bound, goal, blocks, global_): + registry, state = authored_goal + result = add(registry, state, **intent) + todo = stored(state, result["todo_id"]) + assert todo.get("bound_agent") == bound + assert bool(todo.get("goal_bound")) == goal + assert todo.get("blocks_agent") == blocks + assert bool(todo.get("global_gate")) == global_ + + +@pytest.mark.parametrize("intent", [ + {"global_gate": True, "bound_agent": "agent-a"}, + {"blocks_agent": "agent-a", "bound_agent": "agent-b"}, + {"blocks_agent": "agent-a", "goal_bound": True}, +]) +def test_update_rejects_explicit_scope_conflicts_without_writing(authored_goal, intent): + registry, state = authored_goal + result = add(registry, state, task_class="user_gate", global_gate=True, goal_bound=True) + before = state.read_bytes() + with pytest.raises(ValueError): + update_goal_todo(registry_path=registry, goal_id=GOAL, state_file=state, + todo_id=result["todo_id"], agent_id="agent-a", role="user", + clear_global_gate=not intent.get("global_gate", False), **intent) + assert state.read_bytes() == before + + +def test_real_cli_requires_explicit_global_flag_and_preserves_dry_run(authored_goal): + registry, state = authored_goal + common = ("todo", "add", "--goal-id", GOAL, "--role", "user", "--task-class", "user_gate", + "--text", "Decide whole-goal policy", "--state-file", str(state)) + before = state.read_bytes() + code, payload = run_json_cli_result(*common, "--goal-bound", registry_path=registry) + assert code != 0 + assert state.read_bytes() == before + code, preview = run_json_cli_result(*common, "--global-gate", "--agent-id", "agent-a", "--dry-run", registry_path=registry) + assert code == 0, preview + assert state.read_bytes() == before + code, written = run_json_cli_result(*common, "--global-gate", "--agent-id", "agent-a", registry_path=registry) + assert code == 0, written + todo_id = written["todo_id"] + assert stored(state, todo_id)["global_gate"] is True + code, edited = run_json_cli_result("todo", "update", "--goal-id", GOAL, "--todo-id", todo_id, + "--role", "user", "--agent-id", "agent-a", "--clear-global-gate", "--blocks-agent", "agent-b", + "--state-file", str(state), registry_path=registry) + assert code == 0, edited + assert stored(state, todo_id)["bound_agent"] == "agent-b" + assert not stored(state, todo_id).get("global_gate") + + +def test_missing_scope_never_creates_global_gate(authored_goal): + registry, state = authored_goal + before = state.read_bytes() + for intent in ({}, {"goal_bound": True}): + with pytest.raises(ValueError, match="scope|binding"): + add(registry, state, task_class="user_gate", **intent) + assert state.read_bytes() == before diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 22e7156896..930941a6d0 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -373,7 +373,8 @@ export function registerAuthorityStoreConformance( requested_completion_turn_key: null, requested_completion_identity_source: null, linked_successor_todo_ids: [], - successor_intents: [successorIntent], + successor_intents: [successorIntent, {role: "user", task_class: "user_gate", + text: "Decide the completing agent's next step"}], note: "completed atomically", evidence: "focused provider conformance", reason: null, @@ -481,7 +482,14 @@ export function registerAuthorityStoreConformance( assert.equal(completed?.status, "done"); assert.equal(completed?.note, "completed atomically"); assert.equal(completed?.evidence, "focused provider conformance"); - assert.deepEqual(completed?.successor_todo_ids, [generatedId]); + assert.deepEqual(completed?.successor_todo_ids, generatedIds); + assert.equal(generatedIds?.length, 2); + const userSuccessor = (afterCompletion.head.todos as Record[]) + .find((todo) => todo.todo_id === generatedIds?.[1]); + assert.equal(userSuccessor?.role, "user"); + assert.equal(userSuccessor?.blocks_agent, "agent-a"); + assert.equal(userSuccessor?.bound_agent, "agent-a"); + assert.notEqual(userSuccessor?.global_gate, true); assert.equal(created?.claimed_by, "agent-b"); assert.equal(created?.created_by, "agent-a"); const releasedLease = (afterCompletion.head.leases as Record[]) @@ -1097,10 +1105,31 @@ export function registerAuthorityStoreConformance( now: new Date("2026-09-05T04:30:00Z"), }); + // Promise.all alone does not guarantee a CAS race: a late reader may + // correctly reject the already-claimed Todo before reaching commit. + // Hold the first two real reads so both transactions see the same head. + let releaseReaders!: () => void; // Promise executor assigns synchronously. + const ready = new Promise((resolve) => { releaseReaders = resolve; }); + let readers = 0; + const originals = [store, contender].map((backend) => { + const load = backend.loadAuthority.bind(backend); + backend.loadAuthority = async () => { + backend.loadAuthority = load; + const snapshot = await load(); + if (++readers === 2) releaseReaders(); + await ready; + return snapshot; + }; + return load; + }); const results = await Promise.all([ executeCoordinationTodoClaim(store, request("agent-a")), executeCoordinationTodoClaim(contender, request("agent-b")), - ]); + ]).finally(() => { + [store, contender].forEach((backend, index) => { + backend.loadAuthority = originals[index]!; + }); + }); assert.deepEqual( results.map((result) => result.status).sort(), ["applied", "conflict"], diff --git a/tests/control_plane_ts/todo_authoring_scope.test.ts b/tests/control_plane_ts/todo_authoring_scope.test.ts new file mode 100644 index 0000000000..251f4e9f09 --- /dev/null +++ b/tests/control_plane_ts/todo_authoring_scope.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; +import { planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, + userTodoScopeConflict } from "../../loopx/control_plane/todos/authoring_scope.ts"; + +function plan(intent: JsonObject, overrides: JsonObject = {}): JsonObject { + return planTodoAuthoringScope({schema_version: TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, + command: "create", role: "user", todo: {}, goal_id: "goal-a", + registered_agents: ["agent-a", "agent-b"], intent, ...overrides}); +} + +test("global blocking is never inferred from actor, goal binding, or missing scope", () => { + for (const actor of [undefined, "agent-a"]) for (const goal of [false, true]) { + const intent: JsonObject = {task_class: "user_gate", goal_bound: goal}; + if (actor) intent.actor_agent_id = actor; + if (actor && !goal) { + const scope = plan(intent); + assert.equal(scope.global_gate, null); + assert.equal(scope.blocks_agent, actor); + assert.equal(scope.bound_agent, actor); + } else assert.throws(() => plan(intent), /scope|bind/); + } + const action = plan({task_class: "user_action", goal_bound: true, actor_agent_id: "agent-a"}); + assert.equal(action.global_gate, null); + assert.equal(action.blocks_agent, null); + const single = plan({task_class: "user_gate"}, {registered_agents: ["agent-a"]}); + assert.equal(single.global_gate, null); // Preserve legacy single-agent scope; never upgrade it. +}); + +test("explicit all-agent intent is accepted without turning authorship into lane binding", () => { + for (const agents of [[], ["agent-a"], ["agent-a", "agent-b"]]) { + const scope = plan({task_class: "user_gate", global_gate: true, + ...(agents.length ? {actor_agent_id: "agent-a"} : {})}, {registered_agents: agents}); + assert.equal(scope.global_gate, true); + assert.equal(scope.goal_bound, true); + assert.equal(scope.blocks_agent, null); + assert.equal(scope.bound_agent, null); + } +}); + +test("explicit continuation and gate constraints cannot silently overwrite each other", () => { + for (const command of ["create", "update"]) for (const intent of [ + {global_gate: true, bound_agent: "agent-a"}, + {global_gate: true, blocks_agent: "agent-a"}, + {blocks_agent: "agent-a", bound_agent: "agent-b"}, + {blocks_agent: "agent-a", goal_bound: true}, + {bound_agent: "agent-a", goal_bound: true}, + {global_gate: true, clear_global_gate: true}, + {blocks_agent: "agent-a", clear_blocks_agent: true}, + ]) assert.throws(() => plan({task_class: "user_gate", ...intent}, {command}), /gate|bind|bound|blocks/); + const scope = plan({task_class: "user_gate", actor_agent_id: "agent-a", blocks_agent: "agent-b"}); + assert.equal(scope.bound_agent, "agent-b"); +}); + +test("update retains omitted scope and permits an explicit global-to-lane transition", () => { + const todo = {task_class: "user_gate", status: "open", global_gate: true, goal_bound: true}; + const before = structuredClone(todo); + assert.equal(plan({}, {command: "update", todo}).global_gate, true); + const scope = plan({clear_global_gate: true, blocks_agent: "agent-b"}, {command: "update", todo}); + assert.equal(scope.global_gate, null); + assert.equal(scope.goal_bound, false); + assert.equal(scope.bound_agent, "agent-b"); + assert.throws(() => plan({clear_global_gate: true}, {command: "update", todo}), /explicit scope/); + assert.deepEqual(todo, before); +}); + +test("authoring plan cannot grant terminal, executor, or non-user gate semantics", () => { + for (const intent of [{task_class: "user_action", global_gate: true}, + {task_class: "user_action", blocks_agent: "agent-a"}, + {task_class: "user_gate", claimed_by: "agent-a"}, + {task_class: "user_action", bound_agent: "agent-other"}]) assert.throws(() => plan(intent)); + for (const intent of [{bound_agent: "agent-a"}, {goal_bound: true}, {blocks_agent: "agent-a"}, + {global_gate: true}, {task_class: "user_action"}, {status: "done"}]) { + assert.throws(() => plan(intent, {command: "update", role: "agent", todo: {status: "open", task_class: "advancement_task"}})); + } + assert.throws(() => plan({task_class: "user_action", status: "done"}), /cannot create completed/); + // A terminal historical record can be repaired without being admitted as an active gate. + assert.equal(plan({}, {command: "update", todo: {status: "done", task_class: "legacy"}}).status, "done"); +}); + +test("deferred state requires a supported condition and clears remain explicit", () => { + const todo = {task_class: "advancement_task", status: "deferred", resume_when: "capacity_available:network"}; + assert.equal(plan({}, {command: "update", role: "agent", todo}).effective_resume_when, todo.resume_when); + assert.throws(() => plan({clear_resume_when: true}, {command: "update", role: "agent", todo}), /requires --resume-when/); + const reopened = plan({status: "open", clear_resume_when: true}, {command: "update", role: "agent", todo}); + assert.equal(reopened.effective_resume_when, null); + assert.throws(() => plan({resume_when: "todo_done:todo_dependency", clear_resume_when: true}, {command: "update", role: "agent", todo}), /not both/); +}); + +test("resolved successor scope is checked without using draft defaults", () => { + const unbound = {bound_agent: null, goal_bound: false, blocks_agent: null, global_gate: false}; + assert.equal(userTodoScopeConflict("user_gate", unbound, 2), "gate_scope_missing"); + assert.equal(userTodoScopeConflict("user_action", unbound, 2), "binding_missing"); + assert.equal(userTodoScopeConflict("user_gate", {...unbound, global_gate: true}, 2), "global_binding_conflict"); + assert.equal(userTodoScopeConflict("user_gate", {...unbound, global_gate: true, goal_bound: true}, 2), null); + assert.equal(userTodoScopeConflict("user_gate", {...unbound, blocks_agent: "agent-b", bound_agent: "agent-a"}, 2), "agent_binding_conflict"); +}); + +test("malformed intent cannot turn a truthy string or an unknown field into scope", () => { + for (const intent of [{global_gate: "true"}, {goal_bound: 1}, {global_gat: true}]) { + assert.throws(() => plan({task_class: "user_gate", actor_agent_id: "agent-a", ...intent}), /boolean|does not own/); + } +}); From 2b4bd1a6f0606f8fd0ddc1539c26ad97ed9d5351 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 20:17:21 +0800 Subject: [PATCH 2/3] docs(todos): clarify gate impact and authoring migration checkpoint Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 6 ++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 5 +++++ .../typescript-control-plane-migration-v0.md | 15 ++++++++++++++ ...script-control-plane-migration-v0.zh-CN.md | 12 +++++++++++ docs/project-agent-todo-contract.md | 20 +++++++++++++++++-- 5 files changed, 56 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 12e7d5f247..660ac77277 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2524,6 +2524,12 @@ commands fail closed; they do not fall back to the old writer. #### Refactoring roadmap overview +Todo authoring scope and terminal successors now share the TS resolved-binding +invariant. Only explicit `global_gate` can widen blocking to all registered +agents; `goal_bound` grants no global-gate semantics. This consolidates T1 +admission rules without expanding native update fields, changing provider/profile +defaults, or releasing D1–D3 projection, real-backend, soak or promotion holds. + The original direction remains; execution cards expand these stages rather than cancel them: 1. **Close TS transactions and consumers.** Follow [T0–T3](typescript-control-plane-migration-v0.md#execution-cards-after-the-current-stack) to consolidate rules and delete duplicate decisions. 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 64977516ae..971dbb5828 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 @@ -2002,6 +2002,11 @@ backend、实时双向同步或按命令拆开的权威;晋升后不支持的 #### 重构主线总览 +Todo authoring scope 已与 terminal successor 共用 TS 最终绑定不变量;仅显式 +`global_gate` 可以扩大阻塞到全部注册 agent,`goal_bound` 不授予全局 gate 语义。 +这是 T1 准入规则收拢;不扩张 native update 字段权限、不改变 provider/profile 默认值, +也不解除 D1–D3 的投影、真实 backend、soak 或 promotion 条件。 + 以下规划保留原有方向;执行卡是它们的展开,不是替代或取消: 1. **闭合 TS 事务与 consumer。** 按 [T0–T3](typescript-control-plane-migration-v0.zh-CN.md#当前-stack-合入后的执行卡) 收口规则并删除重复决策。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 334f6d4281..854c8b0032 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -16,6 +16,21 @@ ## Current implementation checkpoint +Public Todo add/update now resolve role, continuation binding, gate scope and +deferred-condition requirements through `todos/authoring_scope.ts`. Python's +`write_policy.py` and duplicated scope selection in `todos.py` are retired; +the Markdown codec keeps only its early class-check adapter. Materialized +terminal successors share the resolved-scope invariant without draft inference. +Intentional corrections: explicit global/lane scope outranks author defaults; +explicit conflicting binding is rejected rather than overwritten; global gates +are never inferred from actor identity or `goal_bound`. Existing omitted scope, +completed-history repair and lifecycle/lease permission boundaries remain. + +This closes T1's authoring-scope prerequisite, not the whole update transaction. +Public metadata expansion, validation/effect closure and provider CAS/replay +integration remain T1/T2 work. Native update retains its text/note allowlist; +legacy codecs/locks/writers still have active callers and are not retired here. + A checked-in generator validates the language-neutral contract and emits deeply immutable Python/TypeScript bindings, including the native domain and projection sections. Both runtimes import these bindings; CI checks source 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 2691b06b68..7d46b754ac 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 @@ -15,6 +15,18 @@ ## 当前实现检查点 +公开 Todo add/update 现通过 `todos/authoring_scope.ts` 统一解析角色、continuation +绑定、gate 作用域与 deferred 条件要求。删除 Python `write_policy.py` 及 `todos.py` +重复的 scope 选择;Markdown codec 只保留早期 class 检查的适配调用。已物化的 terminal +successor 共用最终 scope 不变量,不执行草稿默认值推断。 +有意修正:显式全局/单 lane 作用域优先于作者默认值;显式绑定冲突拒绝而非静默覆盖; +不得从 actor 或 `goal_bound` 推断全局 gate。省略 scope 的更新、历史已完成记录修复、 +lifecycle/lease 权限边界保持。 + +这是 T1 的 authoring-scope 前置闭合,不是整个 update 事务完成。公开 metadata 扩展、 +validation/effect 闭合和 provider CAS/replay 汇合仍属于 T1/T2。Native update 继续 +保留 text/note allowlist;legacy codec/lock/writer 仍有实际 caller,本批不退役。 + 受检入的 generator 校验语言中立 contract,并生成深度不可变的 Python/TypeScript binding,覆盖原生 domain 与 projection section。两端 runtime 直接 import 生成物; CI 检查源数据一致性并拒绝陈旧生成物。这删除了重复 contract loader,但不改变 diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 714745be14..67f2b95e16 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -113,15 +113,31 @@ agent's broad prompt scope. Scope belongs in the automation prompt or sub-agent handoff; the agent uses that scope to decide which open todo it may claim. User-gate todos are different: when a user decision only unlocks one registered agent or lane, record the blocked agent explicitly with `blocks_agent` so quota -does not stop unrelated agents. For convenience, `todo add/update --role user +does not stop unrelated agents. For convenience, `todo add --role user --task-class user_gate --agent-id ` defaults `blocks_agent` to that agent -when `--blocks-agent` is omitted. In multi-agent goals, open `user_gate` todos +when neither an explicit `--blocks-agent` nor `--global-gate` is supplied. +Updates preserve omitted scope; changing the author does not retarget a gate. +In multi-agent goals, open `user_gate` todos must have exactly one explicit scope: either `blocks_agent=` for a lane-scoped decision or `global_gate=true` / `--global-gate` for a genuine goal-wide owner gate. Unscoped multi-agent user gates are an authoring error because every registered agent would otherwise see another lane's question as its own stop condition. +**Global gates have broad impact: they block every registered agent until +resolved.** Creation or widening to global scope requires explicit +`--global-gate`; it is never inferred from author identity, missing binding, +or `--goal-bound`. `--goal-bound` scopes continuation only and does not itself +block agents. Prefer `--blocks-agent ` for a lane-local decision. +With an explicit global gate, LoopX derives the necessary goal-wide +continuation binding without inventing a single-agent binding from the author. +Explicit contradictory flags are rejected, not silently overwritten. + +To narrow an existing global gate atomically, use `todo update` with +`--clear-global-gate --blocks-agent `. To widen a lane gate deliberately, +use `--clear-blocks-agent --global-gate`. Merely clearing scope in a multi-agent +Goal is rejected; it must not turn an ambiguous gate into a global one. + When a user gate only blocks one concrete action, add the blocked todo id with `unblocks_todo_id=`. When multiple todos share the same broad `action_kind`, use the schema-backed decision-scope fields instead of relying From 7b217323f789f1348e1e3d4d3b26981b2d99d620 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 9 Sep 2026 21:13:40 +0800 Subject: [PATCH 3/3] test(control-plane): preserve Node 22.6 compatibility Signed-off-by: huangruiteng --- tests/control_plane_ts/authority_store_conformance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index 930941a6d0..e3fc4cbd44 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -1108,7 +1108,9 @@ export function registerAuthorityStoreConformance( // Promise.all alone does not guarantee a CAS race: a late reader may // correctly reject the already-claimed Todo before reaching commit. // Hold the first two real reads so both transactions see the same head. - let releaseReaders!: () => void; // Promise executor assigns synchronously. + let releaseReaders: () => void = () => { + throw new Error("reader barrier was not initialized"); + }; const ready = new Promise((resolve) => { releaseReaders = resolve; }); let readers = 0; const originals = [store, contender].map((backend) => {