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
43 changes: 43 additions & 0 deletions docs/reference/canonical-lease-renew.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,49 @@ uses all retained records, not the bounded operator display. Atomic
Standalone acquire uses requested scopes; atomic claim retains its existing
Todo-required scope intent. Neither operation expands a permission grant.

## Repository-relative scope identity

New canonical acquisitions freeze the canonical Todo's normalized
`task_repository` as `lease.write_repository`. There is no caller repository
override and no inference from the CLI working directory. Within one Goal,
overlapping relative paths conflict unless **both** execution grants have known,
different repository identities. Host/path case aliases remain overlapping.
The existing complete-head scan, owner eligibility, TTL, generations, CAS and
receipt identities are unchanged; an empty scope set still does not conflict.

Old grants without `write_repository` (or with null) remain unknown and
conservatively overlap any repository. Reading or renewing them does not
backfill a namespace from today's Todo. Fresh acquisition after legal retirement
can freeze the current Todo identity. Malformed frozen identities fail closed.
Renewal, transfer and release preserve the frozen value and historical receipts.
Current execution proof, acquire replay and inspection reject known repository
drift with `lease_repository_divergence`; cleanup still uses exact owner/key/version.
Changing retained work requirements is not a metadata-edit shortcut.

JSON inspection exposes `lease.write_repository`; Markdown lease readback also
shows the identity or `unknown (conservative overlap)`. This is a Goal-local,
logical repository mutex, not a physical filesystem/symlink alias check or a
cross-Goal lock. Unknown legacy storage remains conservative. No configuration,
provider promotion or automatic cross-agent dispatch is added. CLI and native
provider inspection cover this boundary; broader frontend/Lark collaboration
delivery remains separate work, not an end-to-end completion claim.

### 仓库相对路径的冲突边界

新的 canonical 租约从权威 Todo 的 `task_repository` 冻结
`lease.write_repository`,不接受调用者覆盖,也不从 CLI 当前目录猜测。同一 Goal
内,只有双方都是已知且不同的仓库,才隔离同名相对路径;大小写别名仍互斥。
完整 head 扫描、owner 资格、TTL、generation、CAS 与回执身份保持原规则,空 scope
仍不产生写冲突。旧记录缺少该字段或为 null 时保持未知、保守互斥,读回和续租不
回填;合法退役后的新执行才冻结当前仓库。损坏身份拒绝执行,续租、转交和释放
保留冻结值与原历史回执。当前执行证明、领取重放与 inspect 拒绝已知仓库漂移
(`lease_repository_divergence`);清理仍凭精确 owner/key/version,不能借 metadata
编辑替换已有执行契约。

JSON 与 Markdown 读回同一仓库字段或未知状态。这只是 Goal 内逻辑仓库互斥,
不识别物理目录、软链接别名,也不是跨 Goal 锁;不新增配置、promotion 或自动
委派。CLI 与 native provider 检查已覆盖该边界,完整前端/Lark 协作旅程仍需单独交付。

## Operate the current lease

Read the current canonical lease and use its owner, execution key and version:
Expand Down
2 changes: 2 additions & 0 deletions loopx/cli_commands/task_lease.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def render_task_lease_markdown(payload: dict[str, object]) -> str:
f"- status: `{lease.get('status')}`",
f"- expires_at: `{lease.get('expires_at')}`",
f"- write_scopes: `{', '.join(lease.get('write_scopes') or [])}`",
f"- write_repository: `{lease.get('write_repository') or 'unknown (conservative overlap)'}`",
]
)
if payload.get("lease_path"):
Expand All @@ -65,6 +66,7 @@ def render_task_lease_markdown(payload: dict[str, object]) -> str:
lines.append(
f" - `{conflict.get('todo_id')}` owner=`{conflict.get('owner')}` "
f"expires_at=`{conflict.get('expires_at')}` "
f"write_repository=`{conflict.get('write_repository') or 'unknown'}` "
f"write_scopes=`{', '.join(conflict.get('write_scopes') or [])}`"
)
append_operator_action_markdown(lines, payload)
Expand Down
5 changes: 5 additions & 0 deletions loopx/control_plane/coordination/lease_acquisition_proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {requireStringLiteral} from "../runtime_decode.ts";
import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts";
import {leaseEpoch, leaseVersion, leaseIsActive} from "../work_items/task_lease_acquire.ts";
import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts";
import {leaseRepositoryRejection, leaseWriteRepository} from "../work_items/task_lease_repository.ts";

interface AcquisitionIdentity {
goal_id: string; todo_id: string; owner: string; idempotency_key: string;
Expand Down Expand Up @@ -54,6 +55,10 @@ export async function currentLeaseAcquisitionProof<S extends string>(store: Auth
leaseVersion(current) < leaseVersion(original)) {
return failed("idempotency_key_reuse", "acquire receipt belongs to a retired execution; use a new execution key", details);
}
const repositoryRejection = leaseWriteRepository(current.write_repository) !== leaseWriteRepository(original.write_repository)
? "lease_repository_divergence" : leaseRepositoryRejection(facts.todo, current);
if (repositoryRejection !== null) return failed(repositoryRejection,
"current Todo repository differs from its frozen lease; reconcile through the owning lifecycle", details);
const acceptance = acceptanceWorkGuard(head.head, input.goal_id, input.todo_id);
if (acceptance !== null && !acceptance.allowed) {
return failed(String(acceptance.reason_code), `${String(acceptance.reason)} Inspect Goal acceptance and ask the owner to configure or rebind this Todo.`,
Expand Down
3 changes: 2 additions & 1 deletion loopx/control_plane/coordination/task_lease_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export async function executeCanonicalTaskLeaseLifecycle(store: AuthorityStore,
todo: canonicalLeaseTodoFact(claim.todo),
lease: lease ? {present: true, active: leaseIsActive(lease, input.now), status: String(lease.status),
owner: normalizeOwner(lease.owner), idempotency_key: normalizeIdempotencyKey(lease.idempotency_key),
version: leaseVersion(lease), lease_epoch: leaseEpoch(lease), write_scopes: (lease.write_scopes ?? []) as string[], acquire_ttl_seconds: null} : null,
version: leaseVersion(lease), lease_epoch: leaseEpoch(lease), write_scopes: (lease.write_scopes ?? []) as string[],
...(lease.write_repository == null ? {} : {write_repository: String(lease.write_repository)}), acquire_ttl_seconds: null} : null,
command});
if (decision.outcome === "rejected" || decision.outcome === "conflict") {
return {...failed(decision.code, `canonical task lease ${input.operation} rejected: ${decision.code}`),
Expand Down
4 changes: 4 additions & 0 deletions loopx/control_plane/coordination/task_lease_proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts";
import {TODO_WORK_REQUIREMENT_FIELDS} from "../todos/work_requirements.ts";
import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts";
import {leaseEpoch} from "../work_items/task_lease_acquire.ts";
import {leaseRepositoryRejection} from "../work_items/task_lease_repository.ts";
import {evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA} from "./todo_lifecycle_decision.ts";

export interface TaskLeaseProof {
Expand Down Expand Up @@ -123,6 +124,9 @@ export function todoUpdateLeaseRecovery(head: JsonObject, input: {
requires_flags: ["--task-lease-idempotency-key", "--task-lease-expected-version"],
proof_source: "current_owner_lease_readback"};
if (lease?.active) {
const repositoryRejection = leaseRepositoryRejection(facts.todo, lease);
if (repositoryRejection !== null) return {...base, action: "resolve_acquire_rejection",
reason_code: repositoryRejection, reason: "Todo repository differs from its frozen execution grant. Reconcile the owning lifecycle; inspection cannot grant replacement authority."};
const eligible = sameOwner && leaseOwnerRejection(facts.todo,
input.actor_agent_id, input.registered_agents) === null;
return {...base, action: eligible ? "inspect_current_proof" : "reconcile_lease_owner",
Expand Down
10 changes: 8 additions & 2 deletions loopx/control_plane/coordination/task_lease_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts";
import {leaseVersion, leaseEpoch, leaseInteger, leaseIsActive, normalizeOwner,
normalizeIdempotencyKey, TASK_LEASE_SCHEMA_VERSION, type LeaseRecord, type TodoFact} from "../work_items/task_lease_acquire.ts";
import type {AcquireDecisionInput} from "../work_items/task_lease_acquire_decision.ts";
import {normalizeTodoRepository} from "../todos/work_requirements.ts";
import {leaseWriteRepository} from "../work_items/task_lease_repository.ts";

export function canonicalTaskLease(value: JsonObject, goalId: string, todoId: string): LeaseRecord {
if ((value.schema_version !== undefined && value.schema_version !== TASK_LEASE_SCHEMA_VERSION) ||
Expand All @@ -19,6 +21,8 @@ export function canonicalTaskLease(value: JsonObject, goalId: string, todoId: st
throw new AuthorityStoreProtocolError("canonical lease owner and execution key must be normalized strings");
}
leaseVersion(value); leaseEpoch(value);
try { leaseWriteRepository(value.write_repository); }
catch { throw new AuthorityStoreProtocolError("canonical lease write_repository must be a canonical repository identity or null"); }
if (value.write_scopes !== undefined && (!Array.isArray(value.write_scopes) ||
value.write_scopes.some(scope => typeof scope !== "string"))) {
throw new AuthorityStoreProtocolError("canonical lease write_scopes must be strings");
Expand All @@ -33,6 +37,7 @@ export function canonicalLeaseTodoFact(todo: JsonObject | undefined): TodoFact |
const excluded = todo.excluded_agents ?? [];
if (!Array.isArray(excluded)) throw new AuthorityStoreProtocolError("Todo exclusions must be an array");
return {todo_id: String(todo.todo_id), status: String(todo.status),
task_repository: normalizeTodoRepository(todo.task_repository),
claimed_by: todo.claimed_by == null ? null : normalizeTodoAgent(todo.claimed_by, "todo.claimed_by"),
excluded_agents: excluded.map(value => normalizeTodoAgent(value, "todo.excluded_agents"))};
}
Expand All @@ -47,14 +52,15 @@ export function canonicalTaskLeaseAcquireFacts(index: ReturnType<typeof indexCoo
const lease = current === null ? null : {present: true, active: leaseIsActive(current, now),
status: String(current.status), owner: String(current.owner), idempotency_key: String(current.idempotency_key),
version: leaseVersion(current), lease_epoch: leaseEpoch(current),
write_scopes: (current.write_scopes ?? []) as string[], acquire_ttl_seconds: leaseInteger(current, "acquire_ttl_seconds")};
write_scopes: (current.write_scopes ?? []) as string[], write_repository: leaseWriteRepository(current.write_repository),
acquire_ttl_seconds: leaseInteger(current, "acquire_ttl_seconds")};
const other_leases = [...index.leases].flatMap(([id, rawLease]) => {
if (id === todoId) return [];
const candidate = canonicalTaskLease(rawLease, goalId, id);
const active = leaseIsActive(candidate, now);
return [{todo_id: id, active,
effective: active && leaseOwnerRejection(canonicalLeaseTodoFact(index.todos.get(id)), String(candidate.owner), registered) === null,
write_scopes: (candidate.write_scopes ?? []) as string[]}];
write_scopes: (candidate.write_scopes ?? []) as string[], write_repository: leaseWriteRepository(candidate.write_repository)}];
});
return {todo, lease, other_leases, current};
}
10 changes: 10 additions & 0 deletions loopx/control_plane/coordination/todo_lifecycle_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
requireStringLiteral,
} from "../runtime_decode.ts";
import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts";
import {normalizeTodoRepository} from "../todos/work_requirements.ts";
import {leaseWriteRepository, leaseRepositoryRejection} from "../work_items/task_lease_repository.ts";

export const COORDINATION_TODO_TERMINAL_DECISION_REQUEST_SCHEMA =
"loopx_coordination_todo_terminal_decision_request_v0";
Expand Down Expand Up @@ -40,6 +42,7 @@ interface DecisionScope extends JsonObject {
}

interface TodoFact extends JsonObject {
readonly task_repository?: string | null;
readonly todo_id: string;
readonly status: string;
readonly role: "user" | "agent";
Expand All @@ -54,6 +57,7 @@ interface TodoFact extends JsonObject {
}

interface LeaseFact extends JsonObject {
readonly write_repository?: string | null;
readonly present: boolean;
readonly active: boolean;
readonly status: string | null;
Expand Down Expand Up @@ -153,6 +157,7 @@ function todoFact(value: unknown, label: string): TodoFact {
const role = requireStringLiteral(todo.role, ["user", "agent"] as const, `${label}.role`);
return {
todo_id: requireNonEmptyString(todo.todo_id, `${label}.todo_id`),
task_repository: normalizeTodoRepository(todo.task_repository),
status: requireNonEmptyString(todo.status, `${label}.status`),
role,
task_class: optionalString(todo.task_class, `${label}.task_class`),
Expand Down Expand Up @@ -188,6 +193,7 @@ function leaseFact(value: unknown): LeaseFact | null {
version,
lease_epoch: epoch,
write_scopes: requireStringArray(lease.write_scopes ?? [], "lease.write_scopes"),
...(lease.write_repository == null ? {} : {write_repository: leaseWriteRepository(lease.write_repository)}),
acquire_ttl_seconds: optionalNonNegativeInteger(
lease.acquire_ttl_seconds,
"lease.acquire_ttl_seconds",
Expand Down Expand Up @@ -486,6 +492,10 @@ function terminalFence(
: "not_required",
});
}
const repositoryRejection = leaseRepositoryRejection(request.todo, lease);
if (repositoryRejection !== null) return result("rejected", repositoryRejection, {
authority_mode: authorityMode, lease_fence: "required",
});
if (request.lease_idempotency_key === null) {
return result("rejected", "lease_fence_required", {
authority_mode: authorityMode,
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/coordination/todo_terminal_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ async function commitTerminalResult(
function todoFact(todo: JsonObject): JsonObject {
return {
todo_id: todo.todo_id,
task_repository: todo.task_repository ?? null,
status: todo.status,
role: todo.role,
task_class: todo.task_class ?? null,
Expand Down Expand Up @@ -820,6 +821,7 @@ function leaseFact(lease: JsonObject | undefined, now: Date): JsonObject | null
version: leaseInteger(lease, "version") ?? 0,
lease_epoch: leaseEpoch(lease),
write_scopes: normalizeWriteScopes(lease.write_scopes),
...(lease.write_repository == null ? {} : {write_repository: lease.write_repository}),
acquire_ttl_seconds: leaseInteger(lease, "acquire_ttl_seconds"),
};
}
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/work_items/task_lease_acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface TodoFact {
task_class?: string | null;
bound_agent?: string | null;
blocks_agent?: string | null;
task_repository?: string | null;
/** Fields explicitly supplied by a legacy caller snapshot, if known. */
provided_fields?: readonly TodoFactField[];
}
Expand Down Expand Up @@ -97,6 +98,7 @@ export interface LeaseRecord extends JsonObject {
owner?: unknown;
idempotency_key?: unknown;
write_scopes?: unknown;
write_repository?: unknown;
acquire_ttl_seconds?: unknown;
version?: unknown;
lease_epoch?: unknown;
Expand Down
14 changes: 14 additions & 0 deletions loopx/control_plane/work_items/task_lease_acquire_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import {requireJsonObject} from "../runtime_decode.ts";
import type {JsonObject} from "../effect_program.ts";
import type {TodoFact, LeaseRecord} from "./task_lease_acquire.ts";
import {normalizeTodoRepository} from "../todos/work_requirements.ts";
import {leaseWriteRepository, leaseRepositoryRejection, repositoryScopesMayOverlap} from "./task_lease_repository.ts";

export interface AcquireDecisionLease {
present: boolean;
Expand All @@ -14,6 +16,7 @@ export interface AcquireDecisionLease {
version: number;
lease_epoch: number;
write_scopes: readonly string[];
write_repository?: string | null;
acquire_ttl_seconds: number | null;
}

Expand All @@ -22,6 +25,7 @@ export interface AcquireDecisionOtherLease {
active: boolean;
effective: boolean;
write_scopes: readonly string[];
write_repository?: string | null;
}

export interface AcquireDecisionInput {
Expand Down Expand Up @@ -206,6 +210,7 @@ function decodeDecisionTodo(value: unknown): TodoFact | null {
status: stringValue(todo.status, "todo.status"),
claimed_by: decisionNullableString(todo.claimed_by, "todo.claimed_by"),
excluded_agents: decisionStringArray(todo.excluded_agents, "todo.excluded_agents"),
task_repository: normalizeTodoRepository(todo.task_repository),
};
}

Expand All @@ -226,6 +231,7 @@ function decodeDecisionLease(value: unknown): AcquireDecisionLease | null {
version: decisionInteger(lease.version, "lease.version"),
lease_epoch: decisionInteger(lease.lease_epoch, "lease.lease_epoch"),
write_scopes: decisionStringArray(lease.write_scopes, "lease.write_scopes"),
write_repository: leaseWriteRepository(lease.write_repository),
acquire_ttl_seconds: optionalInteger(
lease.acquire_ttl_seconds,
"lease.acquire_ttl_seconds",
Expand All @@ -246,6 +252,7 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput {
todo_id: stringValue(lease.todo_id, `other_leases[${index}].todo_id`),
active: decisionBoolean(lease.active, `other_leases[${index}].active`),
effective: decisionBoolean(lease.effective, `other_leases[${index}].effective`),
write_repository: leaseWriteRepository(lease.write_repository),
write_scopes: decisionStringArray(
lease.write_scopes,
`other_leases[${index}].write_scopes`,
Expand Down Expand Up @@ -332,6 +339,8 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci
// The old wire effective hint is not authority over the supplied owner facts.
if (lease !== null && lease.present && lease.active &&
ownerRejection(input.todo, lease.owner, input.registered_agents) === null) {
const repositoryRejection = leaseRepositoryRejection(input.todo, lease);
if (repositoryRejection !== null) return acquireDecisionResult("rejected", repositoryRejection);
if (
lease.owner === command.owner &&
lease.idempotency_key === command.idempotency_key
Expand All @@ -354,8 +363,10 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci
) {
return acquireDecisionResult("rejected", "idempotency_key_reuse");
}
const repository = normalizeTodoRepository(input.todo?.task_repository);
const conflictIndexes = input.other_leases.flatMap((other, index) =>
other.active && other.effective &&
repositoryScopesMayOverlap(repository, other.write_repository) &&
writeScopesOverlap(command.write_scopes, other.write_scopes)
? [index]
: []
Expand All @@ -378,6 +389,7 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci
version: actualVersion + 1,
lease_epoch: (lease?.lease_epoch ?? 0) + 1,
write_scopes: [...command.write_scopes],
...(repository === null ? {} : {write_repository: repository}),
acquire_ttl_seconds: command.ttl_seconds,
},
});
Expand All @@ -399,6 +411,8 @@ export function materializeTaskLeaseAcquire(identity: {goal_id: string; todo_id:
return {schema_version: "task_lease_v0", goal_id: identity.goal_id, todo_id: identity.todo_id,
owner: command.owner, idempotency_key: command.idempotency_key,
write_scopes: [...command.write_scopes], acquire_ttl_seconds: command.ttl_seconds,
...(decision.next_lease.write_repository == null ? {} :
{write_repository: leaseWriteRepository(decision.next_lease.write_repository)}),
version: decisionInteger(decision.next_lease.version, "next_lease.version"),
lease_epoch: decisionInteger(decision.next_lease.lease_epoch, "next_lease.lease_epoch"),
acquired_at: at, updated_at: at,
Expand Down
Loading
Loading