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
10 changes: 10 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,16 @@

## Current implementation checkpoint

Provider-first text/note updates now accept the active execution key and lease
version through the existing terminal fence, with automatic acquisition and
delegated overrides disabled. The same provider revision guards the edit and
receipt; the lease is never mutated. Explicit `--update-operation-id` supports
CLI retries with unchanged proof and intent, including historical replay after
expiry or transfer. Missing/stale proof and historical inactive leases fail
closed. No-proof receipt fingerprints remain compatible. This is the bounded
#4105 lease-fence slice, not full T1 metadata or T2 effect closure; legacy updates
without these options remain unchanged. See the [Todo contract](../../project-agent-todo-contract.md#lease-fenced-canonical-textnote-updates).

Monitor metadata authoring and poll transitions now share `todos/monitor_metadata.ts`.
Public update composes that owner inside its existing field-plan request; cadence
calculation stays in-process instead of making two additional scheduler RPCs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@

## 当前实现检查点

Provider-first text/note 更新现可携带当前执行 key 和租约版本,复用 terminal fence,
禁用自动获取及委托覆盖。修改和回执受同一个 provider revision 保护,租约不变。
显式 `--update-operation-id` 支持同凭证、同内容的 CLI 重试,过期或转交后仍可回放
历史回执。缺失/陈旧凭证及历史非活跃租约拒绝;无凭证的旧回执指纹保持兼容。
这是 #4105 的租约 fence 切片,不是完整 T1 metadata 或 T2 effect 闭合;不带新选项
的 legacy 更新不变。用法见 [Todo 合同](../../project-agent-todo-contract.md#lease-fenced-canonical-textnote-updates)。

Monitor metadata authoring 与 poll transition 现共用 `todos/monitor_metadata.ts`。
公开 update 在已有 field-plan 请求内组合该 owner;cadence 在进程内计算,不再额外
调用两次 scheduler RPC。删除 Python 的 observation/replay/counter/scope/boundedness
Expand Down
28 changes: 28 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -919,3 +919,31 @@ The fourth verifies concurrent todo writers wait on the active-state lock and
preserve both claim metadata and unrelated updates.
The fifth verifies per-todo `required_capabilities`, including multiple P0/P1
candidate selection, bridge repair, and owner-gated capability misses.

### Lease-fenced canonical text/note updates

After explicit canonical-authority promotion, the active lease holder can edit
only `text` and `note` using the existing execution key and current lease version:

```bash
loopx todo update --goal-id <goal> --todo-id <todo> --agent-id <agent> \
--text 'Correct task description' --note 'Correction context' \
--task-lease-idempotency-key <execution-key> --task-lease-expected-version <version> \
--update-operation-id <stable-update-id>
```

Reuse the update id, execution proof and edit intent after a lost response.
The original receipt can be replayed after lease expiry or transfer; it grants no
current execution authority. Changed proof or edit intent with that id conflicts.
Omitting the update id preserves a fresh id per CLI invocation. Preview writes
nothing and does not consume the id. Updates preserve the lease exactly: they
cannot acquire, renew, release or transfer it. Missing, stale or expired proof
fails closed. These options do not enable promotion or a legacy Markdown fallback;
legacy updates without the new options retain their existing behavior.

显式切换到 canonical authority 后,当前租约持有者可使用执行 key 和当前租约版本
修改 `text`/`note`。响应丢失后复用相同 `--update-operation-id`、凭证和修改内容;
历史回执可在租约过期或转交后回放,但不授予当前执行权。相同 ID 搭配不同凭证或
内容会冲突;省略 ID 则每次 CLI 调用生成新 ID。Preview 不写入、不消耗 ID。
更新不获取、续期、释放或转交租约;缺失、陈旧或过期凭证拒绝。此入口不自动
promotion,也不回退 Markdown;不带新选项的 legacy 更新保持原行为。
3 changes: 3 additions & 0 deletions loopx/cli_commands/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ def handle_todo_command(
if value is not None
},
clear_claim=bool(args.clear_claim),
update_operation_id=args.update_operation_id,
task_lease_idempotency_key=args.task_lease_idempotency_key,
task_lease_expected_version=args.task_lease_expected_version,
**_todo_path_args(args),
dry_run=bool(args.dry_run),
)
Expand Down
7 changes: 5 additions & 2 deletions loopx/cli_commands/todo_argument_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
("--follow-up", "followups"),
("--todo-id", "todo_id"),
("--claim-operation-id", "claim_operation_id"),
("--update-operation-id", "update_operation_id"),
("--turn-instance-id", "turn_instance_id"),
("--completion-identity-key", "completion_identity_key"),
("--replan-obligation-id", "replan_obligation_id"),
Expand Down Expand Up @@ -509,6 +510,8 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None:
raise ValueError(
"--turn-instance-id is supported only by todo complete settlement"
)
if getattr(args, "update_operation_id", None) is not None and args.todo_command != "update":
raise ValueError("--update-operation-id is supported only by todo update")
if getattr(args, "claim_operation_id", None) is not None and args.todo_command != "claim":
raise ValueError("--claim-operation-id is supported only by todo claim")
if (
Expand All @@ -526,15 +529,15 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None:
"--replan-obligation-id is supported only by todo add"
)
if (
args.todo_command not in {"claim", "complete", "supersede"}
args.todo_command not in {"claim", "update", "complete", "supersede"}
and (
args.task_lease_idempotency_key
or args.task_lease_expected_version is not None
)
):
raise ValueError(
"--task-lease-idempotency-key and --task-lease-expected-version "
"are supported only by todo claim, todo complete, and todo supersede"
"are supported only by todo claim, todo update, todo complete, and todo supersede"
)
if args.capability_binding_ref and args.todo_command != "add":
raise ValueError(
Expand Down
8 changes: 6 additions & 2 deletions loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def register_todo_command(
help="For capture-followups, append one public-safe agent follow-up todo. Repeat up to the requested batch.",
)
todo_parser.add_argument("--todo-id", help="Structured todo id from status/quota, such as todo_ab12cd34ef56.")
todo_parser.add_argument(
"--update-operation-id",
help="For promoted text/note update, reuse this operation id after a lost response; changed intent is rejected.",
)
todo_parser.add_argument(
"--claim-operation-id",
help=(
Expand Down Expand Up @@ -279,7 +283,7 @@ def register_todo_command(
"--task-lease-idempotency-key",
help=(
"For todo claim on promoted hard-lease authority, atomically acquire "
"the canonical lease and claim; for complete and supersede, prove the "
"the canonical lease and claim; for promoted text/note update, complete and supersede, prove the "
"execution instance that owns the active lease."
),
)
Expand All @@ -288,7 +292,7 @@ def register_todo_command(
type=int,
help=(
"For promoted todo claim, optionally compare-and-set the canonical "
"lease version; for complete and supersede, supply the active lease "
"lease version; for promoted text/note update, complete and supersede, supply the active lease "
"version when it is effective."
),
)
Expand Down
3 changes: 3 additions & 0 deletions loopx/control_plane/coordination/local_authority_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,9 @@ export async function updateLocalCoordinationTodo(
registered_agents: input.registered_agents.map((agent) =>
claimAgentValue(agent, "registered agent")),
operation_id: requireAuthorityStoreId(input.operation_id, "operation id"),
lease_idempotency_key: input.lease_idempotency_key == null ? null :
requireAuthorityStoreId(input.lease_idempotency_key, "lease_idempotency_key"),
lease_expected_version: optionalNonNegativeSafeInteger(input.lease_expected_version, "lease_expected_version"),
patch: requireJsonObject(input.patch, "Todo update patch"),
clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")),
dry_run: input.dry_run as boolean,
Expand Down
66 changes: 59 additions & 7 deletions loopx/control_plane/coordination/todo_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
} from "./coordination_projection.ts";
import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts";

import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA }
from "./todo_lifecycle_decision.ts";
import { leaseEpoch } from "../work_items/task_lease_acquire.ts";
import { parseIsoTimestamp } from "../runtime_timestamp.ts";

export const COORDINATION_TODO_UPDATE_REQUEST_SCHEMA =
"loopx_local_coordination_todo_update_request_v0";
export const COORDINATION_TODO_UPDATE_RESULT_SCHEMA =
Expand All @@ -39,6 +44,8 @@ export interface CoordinationTodoUpdateInput {
readonly clear_fields: readonly string[];
readonly dry_run: boolean;
readonly now: Date;
readonly lease_idempotency_key?: string | null;
readonly lease_expected_version?: number | null;
}

export type CoordinationTodoUpdateResult = JsonObject & {
Expand All @@ -56,6 +63,14 @@ function isFailure(value: JsonObject): value is CoordinationTodoUpdateResult {
}

function normalizeInput(raw: CoordinationTodoUpdateInput): CoordinationTodoUpdateInput {
const key = raw.lease_idempotency_key ?? null;
const version = raw.lease_expected_version ?? null;
if (key !== null && (typeof key !== "string" || !key.trim() || key !== key.trim())) {
throw new AuthorityStoreProtocolError("lease_idempotency_key must be a non-empty unpadded string");
}
if (version !== null && (!Number.isSafeInteger(version) || version < 0)) {
throw new AuthorityStoreProtocolError("lease_expected_version must be a non-negative safe integer");
}
const patch = canonicalAuthorityObject(raw.patch, "Todo update patch");
const clearFields = raw.clear_fields.map((field, index) =>
requireAuthorityStoreId(field, `clear_fields[${index}]`));
Expand All @@ -82,7 +97,7 @@ function normalizeInput(raw: CoordinationTodoUpdateInput): CoordinationTodoUpdat
if (!(raw.now instanceof Date) || Number.isNaN(raw.now.valueOf())) {
throw new AuthorityStoreProtocolError("now must be a valid Date");
}
return {...raw,
return {...raw, lease_idempotency_key: key, lease_expected_version: version,
goal_id: requireAuthorityStoreId(raw.goal_id, "goal id"),
todo_id: requireAuthorityStoreId(raw.todo_id, "todo id"),
operation_id: requireAuthorityStoreId(raw.operation_id, "operation id"),
Expand Down Expand Up @@ -122,7 +137,13 @@ function updateRequestSha(input: CoordinationTodoUpdateInput): string {
return canonicalAuthoritySha256({goal_id: input.goal_id,
todo_id: input.todo_id, expected_role: input.expected_role,
actor_agent_id: input.actor_agent_id, patch: input.patch,
clear_fields: input.clear_fields, dry_run: input.dry_run});
clear_fields: input.clear_fields, dry_run: input.dry_run,
// Preserve receipt identity for pre-proof requests already persisted in v0.
...(input.lease_idempotency_key != null || input.lease_expected_version != null ? {
lease_idempotency_key: input.lease_idempotency_key,
lease_expected_version: input.lease_expected_version,
} : {}),
});
}

function loadUpdateTarget(
Expand Down Expand Up @@ -166,11 +187,42 @@ function targetRejection(
if (todo.claimed_by && todo.claimed_by !== input.actor_agent_id) {
return failure("update_owner_mismatch", "Todo update cannot edit another claim owner's work");
}
// Lease-bearing updates need an execution-instance fence in addition to the
// actor identity. Until the native request carries that proof, fail closed.
if (![undefined, "legacy", "soft_claim"].includes(head.handoff_mode as string | undefined) ||
leases.has(input.todo_id)) {
return failure("update_lease_unsupported", "lease-bearing Todo updates are not yet supported");
const lease = leases.get(input.todo_id);
const mode = head.handoff_mode === undefined ? "legacy" : head.handoff_mode;
if (typeof mode !== "string" || !["legacy", "soft_claim", "hard_lease"].includes(mode)) {
return failure("invalid_handoff_mode", "canonical handoff mode is invalid");
}
if (lease !== undefined || mode === "hard_lease" ||
input.lease_idempotency_key != null || input.lease_expected_version != null) {
try {
const expires = lease === undefined ? null :
typeof lease.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null;
if (lease?.status === "active" && expires === null) {
return failure("invalid_coordination_projection", "active lease expiry is invalid");
}
const fence = evaluateCoordinationTerminalFence({
schema_version: COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA,
todo, registered_agents: input.registered_agents, actor_agent_id: input.actor_agent_id,
// A historical lease never licenses an unfenced edit. No acquisition or override.
handoff_mode: lease !== undefined ? "hard_lease" : mode,
lease: lease === undefined ? null : {...lease, present: true,
active: lease.status === "active" && expires !== null && expires > input.now,
lease_epoch: leaseEpoch(lease)},
lease_idempotency_key: input.lease_idempotency_key ?? null,
lease_expected_version: input.lease_expected_version ?? null,
allow_user_gate_auto_acquire: false, delegated_authority: false,
require_active_when_fence_supplied: true,
});
if (fence.outcome !== "apply") {
return failure(String(fence.code), "Todo update requires the current active lease execution proof");
}
if (lease !== undefined && todo.claimed_by !== input.actor_agent_id) {
return failure("update_owner_mismatch", "Leased Todo update requires the current claim owner");
}
} catch (error) {
return failure("invalid_coordination_projection",
error instanceof Error ? error.message : "invalid lease facts");
}
}
return null;
}
Expand Down
6 changes: 5 additions & 1 deletion loopx/control_plane/todos/provider_compatibility_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def edit_canonical_todo_if_promoted(
actor_agent_id: str | None, role: str | None, text: str | None,
note: str | None, dry_run: bool,
project: Path | None = None, state_file: Path | None = None,
operation_id: str | None = None, task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
) -> dict[str, Any] | None:
canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id)
if canonical is None:
Expand Down Expand Up @@ -60,7 +62,9 @@ def edit_canonical_todo_if_promoted(
"runtime_root": str(runtime_root.resolve()), "goal_id": goal_id,
"todo_id": todo_id, "role": role, "actor_agent_id": actor_agent_id,
"registered_agents": registered_agent_ids_from_registry(registry_path, goal_id),
"operation_id": f"todo-update:{uuid4().hex}",
"operation_id": operation_id if operation_id is not None else f"todo-update:{uuid4().hex}",
"lease_idempotency_key": task_lease_idempotency_key,
"lease_expected_version": task_lease_expected_version,
"patch": patch, "clear_fields": [], "dry_run": dry_run,
"observed_at": now_local(),
})
Expand Down
10 changes: 9 additions & 1 deletion loopx/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@ def update_goal_todo(
clear_claim: bool = False,
claim_only: bool = False,
claim_operation_id: str | None = None,
update_operation_id: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
project: Path | None = None,
Expand Down Expand Up @@ -1067,7 +1068,7 @@ def update_goal_todo(
raise ValueError(
"--task-lease-expected-version requires --task-lease-idempotency-key"
)
if task_lease_idempotency_key is not None and not promoted_claim:
if task_lease_idempotency_key is not None and claim_only and not promoted_claim:
raise ValueError(
"--task-lease-idempotency-key on todo claim requires promoted canonical authority; no legacy write attempted"
)
Expand Down Expand Up @@ -1130,9 +1131,16 @@ def update_goal_todo(
goal_id=goal_id, todo_id=normalize_todo_id(todo_id) or todo_id,
actor_agent_id=agent_id, role=role, text=text, note=note, dry_run=dry_run,
project=project, state_file=state_file,
operation_id=update_operation_id,
task_lease_idempotency_key=task_lease_idempotency_key,
task_lease_expected_version=task_lease_expected_version,
)
if canonical_edit is not None:
return canonical_edit
if update_operation_id is not None or (not claim_only and (
task_lease_idempotency_key is not None or task_lease_expected_version is not None
)):
raise ValueError("update operation id and lease proof require promoted text/note-only update; no legacy write attempted")
resolved_project, resolved_state_file = resolve_todo_state_path(
registry_path=registry_path,
goal_id=goal_id,
Expand Down
26 changes: 26 additions & 0 deletions tests/control_plane/test_local_coordination_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,32 @@ def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership(
assert after["todos"][0]["claimed_by"] == "agent-a"
assert not state_file.exists()

edit = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry",
str(registry_path), "todo", "update", "--goal-id", "goal-a", "--todo-id",
"todo_atomic_claim", "--agent-id", "agent-a", "--text", "Correct leased task",
"--note", "Updated note", "--update-operation-id", "cli-leased-edit",
"--task-lease-idempotency-key", "turn:atomic-cli-claim",
"--task-lease-expected-version", str(applied["lease"]["version"])]
def invoke_edit(argv):
return subprocess.run(argv, capture_output=True, text=True, timeout=30)
preview_edit = invoke_edit([*edit, "--dry-run"])
assert preview_edit.returncode == 0, preview_edit.stdout + preview_edit.stderr
assert json.loads(preview_edit.stdout)["status"] == "planned"
assert list_goal_todos(registry_path=registry_path, goal_id="goal-a") == after
first_edit = invoke_edit(edit)
assert first_edit.returncode == 0, first_edit.stdout + first_edit.stderr
assert json.loads(first_edit.stdout)["status"] == "applied"
retry_edit = invoke_edit(edit)
assert retry_edit.returncode == 0, retry_edit.stdout + retry_edit.stderr
assert json.loads(retry_edit.stdout)["status"] == "replayed"
changed_edit = invoke_edit([*edit, "--note", "Different intent"])
assert changed_edit.returncode != 0
final = list_goal_todos(registry_path=registry_path, goal_id="goal-a")
assert final["todos"][0]["text"] == "Correct leased task"
assert final["todos"][0]["note"] == "Updated note"
assert final["todos"][0]["claimed_by"] == "agent-a"
assert not state_file.exists()


def test_promoted_terminal_lifecycle_commits_successors_and_archive_natively(
tmp_path: Path,
Expand Down
Loading