Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-合入后的执行卡) 收口规则并删除重复决策。
Expand Down
15 changes: 15 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,但不改变
Expand Down
20 changes: 18 additions & 2 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>` 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=<registered-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 <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 <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=<todo_id>`. When multiple todos share the same broad
`action_kind`, use the schema-backed decision-scope fields instead of relying
Expand Down
10 changes: 10 additions & 0 deletions examples/shared-goal-authority-e2e/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
8 changes: 5 additions & 3 deletions loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 17 additions & 31 deletions loopx/control_plane/coordination/todo_terminal_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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];
Expand Down Expand Up @@ -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]);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 41 additions & 0 deletions loopx/control_plane/todos/authoring_scope.py
Original file line number Diff line number Diff line change
@@ -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="")
Loading