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
51 changes: 51 additions & 0 deletions docs/reference/handoff-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,42 @@ lease or change ownership policy to make the completion succeed. Other preview
failures retain their own diagnostics. This explains an unavailable operation;
it does not qualify App completion under `hard_lease`.

## Ordinary Agent CLI work

A CLI self-claim (`--claimed-by` equal to the explicit `--agent-id`) now prepares
execution in the same canonical transaction. In `hard_lease`, it acquires the
required scopes or reuses the caller's still-active grant. It renews only in the
last third of the grant's original TTL. `soft_claim` does not create a lease.
Assignment by another caller and explicit task-lease commands keep their existing
semantics; this does not change any Goal's policy.

```bash
loopx --format json todo claim --goal-id example-goal --todo-id todo_example \
--agent-id worker-a --claimed-by worker-a --claim-operation-id work-a
loopx --format json todo update --goal-id example-goal --todo-id todo_example \
--agent-id worker-a --claim-operation-id work-a --update-operation-id progress-a \
--note 'Validated progress'
loopx --format json todo complete --goal-id example-goal --todo-id todo_example \
--agent-id worker-a --claim-operation-id work-a --evidence evidence:validated-output
```

Claim readback supplies ready progress/completion commands in JSON and Markdown
with the original registry/runtime route. Normal self-claim output carries the
claim reference instead of raw lease counters or a duplicated receipt; explicit
lease commands retain their diagnostic output. Pre-upgrade plain-claim retries
recover only their original assignment and never become managed execution.
Keep any required original Turn identity and settlement.
Progress can renew the still-active execution in the **same** write transaction;
completion uses the existing terminal owner and release. Neither needs a separate
inspection or a copied lease key/version. The reference resolves only the execution
accepted by that claim: a later generation, foreign holder, expired grant, changed
policy or unavailable receipt cannot be borrowed or silently reacquired. An
explicit proof cannot be combined with the reference. Receipt recovery happens
before maintenance, so retrying an accepted operation does not renew or repeat it.
A read-only quota guard never claims or renews. Long-running delegated Hosts retain
their existing supervision; this CLI path does not supervise an idle external
process. The App's manual completion boundary described above is unchanged.

## Choose the policy independently of storage

Use `soft_claim` for an explicitly single executor without parallel takeover.
Expand Down Expand Up @@ -279,3 +315,18 @@ claim+lease 均不可执行,必须先审核迁移;合并式 claim+lease 仍
冲突不会得到不可执行的 acquire 建议;修改已租用工作的要求或状态须走对应 lifecycle。
提示不包含 execution key、不授予权限,也不修改 mode 或 provider。并发造成 version
变化时重新读取,不能绕过 CAS;dry-run 和拒绝路径不产生 Todo 或租约写入。

### Agent 普通 CLI 操作

CLI 由明确的 `--agent-id` 认领给自己时,会在同一 canonical 事务中准备执行;
`hard_lease` 自动取得任务要求的 scope,或复用自己尚有效的执行。仅在原租期最后
三分之一内续租,`soft_claim` 不创建租约,其他调用方分配任务和显式租约操作保持原义。
认领结果在 JSON 和 Markdown 中返回保留 registry/runtime 路由的进展与完成命令,
常规输出不再展开租约计数和重复回执,显式租约操作保留诊断读回。升级前旧认领只
恢复原来的分配,不被升级成新执行。新认领的进展与完成命令均携带原
`--claim-operation-id`,无需单独 inspect 或复制 key/version。进展写回按需在同一事务
续租,完成复用原终态 owner 及释放操作;必要的原 Turn 身份与结算仍须保留。
该引用只承接当初认领接受的执行,过期、换代、外部占用、策略变化或回执不可用时拒绝,
不自动借用或重新取得证明;显式证明不能与引用混用。原操作回执先于维护恢复,重试不
重复写入或续租。只读 quota guard 不做认领或续租;长期委派 Host 仍走已有监督,
本 CLI 路径不监督空闲外部进程,也不改变 App 手工完成的现有边界。
18 changes: 18 additions & 0 deletions loopx/cli_commands/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,19 +494,36 @@ def handle_todo_command(
claimed_by=args.claimed_by,
agent_id=args.agent_id,
claim_only=True,
manage_execution=(args.agent_id is not None and args.agent_id == args.claimed_by
and args.task_lease_idempotency_key is None and args.task_lease_expected_version is None),
claim_operation_id=args.claim_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),
)
if payload.get("ok") and payload.get("execution_managed") and not args.dry_run:
route = ["loopx", "--registry", str(registry_path), "--runtime-root",
str(resolve_runtime_root(load_registry(registry_path), runtime_root_arg)), "--format", "json"]
selected = ["--goal-id", args.goal_id, "--todo-id", args.todo_id,
"--agent-id", args.agent_id, "--claim-operation-id", payload["claim_operation_id"]]
payload["next_cli_actions"] = [shlex.join([*route, "todo", action, *selected, *extra]) for action, extra in (
("update", ["--update-operation-id", "<progress-operation-id>", "--note", "<progress>"]),
("complete", ["--evidence", "<validated-evidence>"]),
)]
payload["instruction"] = "Continue the task. Use this original claim reference for progress or completion; retain normal Turn settlement when applicable."
# Normal callers need the accepted task reference, not lease
# counters or a second inline copy of the native receipt.
for internal in ("lease", "lease_changed", "lease_idempotent", "original_receipt"):
payload.pop(internal, None)
elif args.todo_command == "update":
validate_todo_update_options(args)
payload = update_goal_todo(
registry_path=registry_path,
runtime_root_arg=runtime_root_arg,
goal_id=args.goal_id,
todo_id=args.todo_id,
claim_operation_id=args.claim_operation_id,
text=args.text,
priority=args.priority,
clear_priority=args.clear_priority,
Expand Down Expand Up @@ -667,6 +684,7 @@ def handle_todo_command(
runtime_root_arg=runtime_root_arg,
goal_id=args.goal_id,
todo_id=args.todo_id,
claim_operation_id=args.claim_operation_id,
role=args.role,
decision_outcome=args.decision_outcome,
evidence=args.evidence,
Expand Down
4 changes: 2 additions & 2 deletions loopx/cli_commands/todo_argument_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,8 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None:
raise ValueError("--update-operation-id is supported only by todo update")
if getattr(args, "update_expected_provider_revision", None) is not None and args.todo_command != "update":
raise ValueError("--update-expected-provider-revision 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 getattr(args, "claim_operation_id", None) is not None and args.todo_command not in {"claim", "update", "complete"}:
raise ValueError("--claim-operation-id is supported only by todo claim/update/complete")
if (
getattr(args, "completion_identity_key", None)
and args.todo_command != "complete"
Expand Down
2 changes: 1 addition & 1 deletion loopx/cli_commands/todo_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def register_todo_command(
todo_parser.add_argument(
"--claim-operation-id",
help=(
"For todo claim on promoted canonical authority only, reuse this public-safe "
"For claim/update/complete on canonical authority, reuse this public-safe "
"operation id across retries. Changed intent with the same id is rejected; "
"receipt replay proves historical acceptance, not current lease ownership. "
"Omit to retain a fresh operation id per invocation."
Expand Down
106 changes: 106 additions & 0 deletions loopx/control_plane/coordination/claim_execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** Ordinary task operations carry the accepted claim, not hand-copied lease
* counters. The canonical lifecycle/fence owners still decide every effect. */
import type {JsonObject} from "../effect_program.ts";
import type {AuthorityStore} from "./authority_store.ts";
import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthoritySha256,
requireAuthorityStoreId} from "./authority_store_codec.ts";
import {indexCoordinationProjection} from "./coordination_projection.ts";
import {canonicalTaskLease, canonicalTaskLeaseAcquireFacts} from "./task_lease_state.ts";
import {coordinationTodoWriteScopes} from "./todo_write_scopes.ts";
import {leaseEpoch, leaseVersion, leaseIsActive, normalizeTtl, type LeaseRecord} from "../work_items/task_lease_acquire.ts";
import {sameLeaseWorkspace} from "../work_items/task_lease_workspace.ts";
import {leaseWriteRepository} from "../work_items/task_lease_repository.ts";
import {evaluateTaskLeaseAcquireDecision, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts";
import {decideTaskLeaseLifecycle, materializeTaskLeaseLifecycle} from "../work_items/task_lease_lifecycle_decision.ts";

export function claimExecutionReference(id: unknown, key: unknown, version: unknown): string | undefined {
if (id == null) return undefined;
const value = requireAuthorityStoreId(id, "claim_operation_id");
if (key != null || version != null) throw new AuthorityStoreProtocolError(
"Use the original claim operation or explicit task-lease proof, not both");
return value;
}

type ExecutionInput = {goal_id: string; todo_id: string; actor_agent_id: string | null;
registered_agents: readonly string[]; now: Date};

/** Resolve only this accepted execution. No acquisition, adoption of a newer
* generation, or write retry is hidden behind a progress/completion request. */
export async function claimExecutionProof(store: AuthorityStore, head: JsonObject,
input: ExecutionInput, operationId: string, allowReleasedCloseout = false): Promise<{lease_idempotency_key: string; lease_expected_version: number} | null> {
const receipt = await store.readReceipt(operationId);
if (receipt.status !== "found" || receipt.receipts.length !== 1) {
throw new AuthorityStoreProtocolError("Original task claim unavailable; recover that claim before writing");
}
const original = receipt.receipts[0];
const result = canonicalAuthorityObject(original.result, "original task claim");
if (original.schema_version !== "loopx_coordination_todo_claim_receipt_v0" ||
original.operation_id !== operationId || original.goal_id !== input.goal_id ||
result.todo_id !== input.todo_id || result.claimed_by !== input.actor_agent_id ||
result.execution_managed !== true) throw new AuthorityStoreProtocolError(
"The claim reference does not name this actor's managed task execution");
// A soft claim has no execution lease. Its normal writer still checks policy.
if (result.handoff_mode !== "hard_lease") {
if (result.handoff_mode === "soft_claim" && head.handoff_mode === "soft_claim") return null;
throw new AuthorityStoreProtocolError("Task ownership policy changed; review a new task claim");
}
if (head.handoff_mode !== "hard_lease") throw new AuthorityStoreProtocolError("Task ownership policy changed; review the current task before continuing");
const accepted = canonicalTaskLease(canonicalAuthorityObject(result.lease, "accepted execution"), input.goal_id, input.todo_id);
const index = indexCoordinationProjection(head, input.goal_id);
const raw = index.leases.get(input.todo_id);
const current = raw && canonicalTaskLease(raw, input.goal_id, input.todo_id);
const retainedCloseout = allowReleasedCloseout && current?.status === "released" &&
index.todos.get(input.todo_id)?.status === "done";
if (accepted.owner !== input.actor_agent_id || !current || !(leaseIsActive(current, input.now) || retainedCloseout) || current.owner !== input.actor_agent_id || current.idempotency_key !== accepted.idempotency_key ||
leaseEpoch(current) !== leaseEpoch(accepted) || leaseVersion(current) < leaseVersion(accepted) ||
!sameLeaseWorkspace(current.write_workspace, accepted.write_workspace) ||
leaseWriteRepository(current.write_repository) !== leaseWriteRepository(accepted.write_repository)) {
throw new AuthorityStoreProtocolError("Original task execution changed; reconcile task ownership before a new claim");
}
return {lease_idempotency_key: String(current.idempotency_key), lease_expected_version: leaseVersion(current)};
}

/** Maintain only a still-active grant, using the normal lifecycle decision.
* Normal activity renews in the final third of its original TTL; an earlier
* read/write is not a renewal, and an expired grant cannot be resurrected. */
export function maintainClaimExecution(head: JsonObject, input: ExecutionInput, lease: LeaseRecord): LeaseRecord | null {
// Historical grants without an original TTL cannot justify a new duration.
if (typeof lease.acquire_ttl_seconds !== "number") return null;
const ttl = normalizeTtl(lease.acquire_ttl_seconds);
if (!leaseIsActive(lease, input.now) || new Date(String(lease.expires_at)).valueOf() - input.now.valueOf() > ttl * 1000 / 3) return null;
const facts = canonicalTaskLeaseAcquireFacts(indexCoordinationProjection(head, input.goal_id),
input.goal_id, input.todo_id, input.registered_agents, input.now);
const command = {operation: "renew" as const, owner: String(lease.owner),
idempotency_key: String(lease.idempotency_key), expected_version: leaseVersion(lease),
ttl_seconds: ttl, new_owner: null, new_idempotency_key: null};
const decision = decideTaskLeaseLifecycle({handoff_mode: String(head.handoff_mode),
registered_agents: input.registered_agents, todo: facts.todo, lease: facts.lease, command});
if (decision.outcome !== "apply") throw new AuthorityStoreProtocolError(`Task execution maintenance rejected: ${decision.code}`);
return materializeTaskLeaseLifecycle(lease, command, decision, input.now);
}

/** Atomic claim preparation, reusing acquire/lifecycle semantics and the
* caller's provider CAS. A foreign retained execution needs explicit recovery. */
export function prepareClaimExecution(head: JsonObject,
input: ExecutionInput & {operation_id: string; claimed_by: string}): {lease: LeaseRecord; changed: boolean} {
const index = indexCoordinationProjection(head, input.goal_id);
const facts = canonicalTaskLeaseAcquireFacts(index, input.goal_id, input.todo_id, input.registered_agents, input.now);
const current = facts.current;
if (current && current.owner !== input.claimed_by && current.status !== "released") {
throw new AuthorityStoreProtocolError("Another task execution is retained; its owner must release or hand it over");
}
if (current && leaseIsActive(current, input.now)) {
if (current.owner !== input.claimed_by) throw new AuthorityStoreProtocolError("Task is occupied by another actor");
const renewed = maintainClaimExecution(head, input, current);
return {lease: renewed ?? current, changed: renewed !== null};
}
const command = {owner: input.claimed_by,
idempotency_key: `claim-${canonicalAuthoritySha256({goal_id: input.goal_id, todo_id: input.todo_id,
owner: input.claimed_by, operation_id: input.operation_id})}`,
expected_version: current ? leaseVersion(current) : 0, ttl_seconds: normalizeTtl(null),
write_scopes: coordinationTodoWriteScopes(index.todos.get(input.todo_id)!)};
const decision = evaluateTaskLeaseAcquireDecision({handoff_mode: String(head.handoff_mode),
registered_agents: [...input.registered_agents], ...facts, command});
if (decision.outcome !== "apply") throw new AuthorityStoreProtocolError(`Task claim execution rejected: ${decision.code}`);
return {lease: materializeTaskLeaseAcquire(input, command, decision, input.now), changed: true};
}
5 changes: 4 additions & 1 deletion loopx/control_plane/coordination/local_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def claim_canonical_todo_if_promoted(
operation_id: str | None = None,
task_lease_idempotency_key: str | None = None,
task_lease_expected_version: int | None = None,
manage_execution: bool = False,
project: Path | None = None,
state_file: Path | None = None,
) -> dict[str, Any]:
Expand All @@ -163,7 +164,8 @@ def claim_canonical_todo_if_promoted(
result = effect_runtime_result(
LOCAL_COORDINATION_TODO_CLAIM_METHOD,
{
"schema_version": LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA,
"schema_version": ("loopx_local_coordination_todo_claim_request_v2" if manage_execution
else LOCAL_COORDINATION_TODO_CLAIM_WITNESSED_REQUEST_SCHEMA),
"runtime_root": str(runtime_root.expanduser().resolve(strict=False)),
"goal_id": goal_id,
"todo_id": todo_id,
Expand All @@ -177,6 +179,7 @@ def claim_canonical_todo_if_promoted(
if operation_id is not None
else f"todo-claim:{goal_id}:{todo_id}:{uuid4().hex}"
),
**({"manage_execution": True} if manage_execution else {}),
"lease_request": (
{
"idempotency_key": task_lease_idempotency_key,
Expand Down
Loading
Loading