From ee027d1957c62e2cc7a492b053e8282df29aa2e6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:18:05 +0800 Subject: [PATCH 1/2] fix(task-lease): scope canonical writes by frozen repository Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/canonical-lease-renew.md | 43 ++++++++ loopx/cli_commands/task_lease.py | 2 + .../coordination/lease_acquisition_proof.ts | 5 + .../coordination/task_lease_lifecycle.ts | 3 +- .../coordination/task_lease_proof.ts | 4 + .../coordination/task_lease_state.ts | 10 +- .../coordination/todo_lifecycle_decision.ts | 10 ++ .../coordination/todo_terminal_lifecycle.ts | 2 + .../work_items/task_lease_acquire.ts | 2 + .../work_items/task_lease_acquire_decision.ts | 14 +++ .../work_items/task_lease_inspection.ts | 5 +- .../task_lease_lifecycle_decision.ts | 8 ++ .../work_items/task_lease_repository.ts | 28 ++++++ .../test_canonical_lease_inspection.py | 39 +++++++- .../lease_acquisition_conformance.ts | 98 +++++++++++++++++++ .../task_lease_inspection.test.ts | 8 ++ .../task_lease_repository.test.ts | 53 ++++++++++ 17 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 loopx/control_plane/work_items/task_lease_repository.ts create mode 100644 tests/control_plane_ts/task_lease_repository.test.ts diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index bdad520203..bce405faa6 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -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: diff --git a/loopx/cli_commands/task_lease.py b/loopx/cli_commands/task_lease.py index 2c1eb17b15..da01101087 100644 --- a/loopx/cli_commands/task_lease.py +++ b/loopx/cli_commands/task_lease.py @@ -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"): @@ -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) diff --git a/loopx/control_plane/coordination/lease_acquisition_proof.ts b/loopx/control_plane/coordination/lease_acquisition_proof.ts index 75e31790aa..816171d6e3 100644 --- a/loopx/control_plane/coordination/lease_acquisition_proof.ts +++ b/loopx/control_plane/coordination/lease_acquisition_proof.ts @@ -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; @@ -54,6 +55,10 @@ export async function currentLeaseAcquisitionProof(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.`, diff --git a/loopx/control_plane/coordination/task_lease_lifecycle.ts b/loopx/control_plane/coordination/task_lease_lifecycle.ts index 95c02f61b5..6fc2803f34 100644 --- a/loopx/control_plane/coordination/task_lease_lifecycle.ts +++ b/loopx/control_plane/coordination/task_lease_lifecycle.ts @@ -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}`), diff --git a/loopx/control_plane/coordination/task_lease_proof.ts b/loopx/control_plane/coordination/task_lease_proof.ts index adcaf712ce..8b7025cbf4 100644 --- a/loopx/control_plane/coordination/task_lease_proof.ts +++ b/loopx/control_plane/coordination/task_lease_proof.ts @@ -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 { @@ -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", diff --git a/loopx/control_plane/coordination/task_lease_state.ts b/loopx/control_plane/coordination/task_lease_state.ts index 1d44325797..d9a9b7bc84 100644 --- a/loopx/control_plane/coordination/task_lease_state.ts +++ b/loopx/control_plane/coordination/task_lease_state.ts @@ -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) || @@ -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"); @@ -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"))}; } @@ -47,14 +52,15 @@ export function canonicalTaskLeaseAcquireFacts(index: ReturnType { 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}; } diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index fe489b7530..40712b08dc 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -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"; @@ -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"; @@ -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; @@ -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`), @@ -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", @@ -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, diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index f6e1d14b76..947a2df720 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -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, @@ -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"), }; } diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index 8a2b998040..770fcbfd6d 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -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[]; } @@ -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; diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index 0fbd3c248c..c070766566 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -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; @@ -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; } @@ -22,6 +25,7 @@ export interface AcquireDecisionOtherLease { active: boolean; effective: boolean; write_scopes: readonly string[]; + write_repository?: string | null; } export interface AcquireDecisionInput { @@ -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), }; } @@ -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", @@ -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`, @@ -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 @@ -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] : [] @@ -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, }, }); @@ -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, diff --git a/loopx/control_plane/work_items/task_lease_inspection.ts b/loopx/control_plane/work_items/task_lease_inspection.ts index ea03ac84f9..688e7b07fb 100644 --- a/loopx/control_plane/work_items/task_lease_inspection.ts +++ b/loopx/control_plane/work_items/task_lease_inspection.ts @@ -12,6 +12,7 @@ import {decodeTaskLeaseAuthority, leaseIsActive, normalizeGoalId, normalizeTodoI readLease, revalidateAuthoritySources, TaskLeaseAcquireError, TASK_LEASE_SCHEMA_VERSION, type LeaseRecord, type TodoFact} from "./task_lease_acquire.ts"; import {leaseOwnerConstraint} from "./task_lease_eligibility.ts"; +import {leaseRepositoryRejection} from "./task_lease_repository.ts"; export const TASK_LEASE_INSPECT_REQUEST = "loopx_task_lease_inspect_request_v0"; @@ -82,10 +83,12 @@ export async function inspectTaskLease(value: unknown, if (!Number.isFinite(at.valueOf())) throw new TaskLeaseAcquireError("invalid inspection clock", "invalid_inspection_clock"); const timeActive = leaseIsActive(lease, at); const needsProjection = !canonical && input.phase === "lease_record" && timeActive; - const constraint = !timeActive || lease === null || needsProjection ? null + const ownerConstraint = !timeActive || lease === null || needsProjection ? null : authority.todo_projection_error !== null ? {effective: false, reason: authority.todo_projection_error.code} : leaseOwnerConstraint(todo, typeof lease.owner === "string" ? lease.owner : null, authority.registered_agents); + const repositoryRejection = canonical && ownerConstraint?.effective === true ? leaseRepositoryRejection(todo, lease) : null; + const constraint = repositoryRejection === null ? ownerConstraint : {effective: false, reason: repositoryRejection}; // Both registration and route must still describe the source we inspected. // No lock is held and no promise is made about later commits or expiry. await revalidateAuthoritySources(authority.source_receipts); diff --git a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts index 4db058de86..bc9eb64ced 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts @@ -3,6 +3,8 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { type JsonObject } from "../effect_program.ts"; import { requireJsonObject } from "../runtime_decode.ts"; import {leaseEpoch, utcIsoformat, type LeaseRecord} from "./task_lease_acquire.ts"; +import {normalizeTodoRepository} from "../todos/work_requirements.ts"; +import {leaseWriteRepository, leaseRepositoryRejection} from "./task_lease_repository.ts"; export const TASK_LEASE_LIFECYCLE_DECISION_OPERATIONS = [ "renew", @@ -18,6 +20,7 @@ export type TaskLeaseLifecycleDecisionOutcome = | "rejected"; export interface TaskLeaseLifecycleDecisionTodo { + task_repository?: string | null; todo_id: string; status: string; claimed_by: string | null; @@ -25,6 +28,7 @@ export interface TaskLeaseLifecycleDecisionTodo { } export interface TaskLeaseLifecycleDecisionLease { + write_repository?: string | null; present: boolean; active: boolean; status: string | null; @@ -123,6 +127,7 @@ function decodeTodo(value: unknown): TaskLeaseLifecycleDecisionTodo | null { const todo = requireJsonObject(value, "task lease lifecycle decision todo"); return { todo_id: stringValue(todo.todo_id, "todo.todo_id"), + task_repository: normalizeTodoRepository(todo.task_repository), status: stringValue(todo.status, "todo.status"), claimed_by: nullableString(todo.claimed_by, "todo.claimed_by"), excluded_agents: stringArray(todo.excluded_agents, "todo.excluded_agents"), @@ -144,6 +149,7 @@ function decodeLease(value: unknown): TaskLeaseLifecycleDecisionLease | null { version: integerValue(lease.version, "lease.version"), lease_epoch: integerValue(lease.lease_epoch, "lease.lease_epoch"), write_scopes: stringArray(lease.write_scopes, "lease.write_scopes"), + ...(lease.write_repository == null ? {} : {write_repository: leaseWriteRepository(lease.write_repository)}), acquire_ttl_seconds: optionalInteger( lease.acquire_ttl_seconds, "lease.acquire_ttl_seconds", @@ -233,6 +239,8 @@ export function decideTaskLeaseLifecycle( input.registered_agents, ); if (rejection !== null) return result("rejected", rejection); + const repositoryRejection = leaseRepositoryRejection(input.todo, lease); + if (repositoryRejection !== null) return result("rejected", repositoryRejection); } const actualVersion = lease !== null && lease.present ? lease.version : 0; if (actualVersion !== command.expected_version) { diff --git a/loopx/control_plane/work_items/task_lease_repository.ts b/loopx/control_plane/work_items/task_lease_repository.ts new file mode 100644 index 0000000000..591216ca3e --- /dev/null +++ b/loopx/control_plane/work_items/task_lease_repository.ts @@ -0,0 +1,28 @@ +/** Repository-relative scope identity. Unknown historical grants stay global. + * This is a Goal-local logical mutex, not a physical filesystem alias fence. */ +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {normalizeTodoRepository} from "../todos/work_requirements.ts"; + +export function leaseWriteRepository(value: unknown): string | null { + if (value == null) return null; + const repository = normalizeTodoRepository(value, "lease.write_repository"); + if (repository === null || repository !== value) { + throw new EffectRuntimeRequestError("lease.write_repository must be a canonical repository identity or null"); + } + return repository; +} + +export function repositoryScopesMayOverlap(left: unknown, right: unknown): boolean { + const a = leaseWriteRepository(left), b = leaseWriteRepository(right); + // Case aliases must not create a second lock on case-insensitive hosts. + return a === null || b === null || a.toLowerCase() === b.toLowerCase(); +} + +export function leaseRepositoryRejection(todo: {task_repository?: unknown} | null | undefined, + lease: {write_repository?: unknown} | null | undefined): string | null { + const frozen = leaseWriteRepository(lease?.write_repository); + if (frozen === null) return null; + const current = normalizeTodoRepository(todo?.task_repository); + return current !== null && frozen.toLowerCase() === current.toLowerCase() + ? null : "lease_repository_divergence"; +} diff --git a/tests/control_plane/test_canonical_lease_inspection.py b/tests/control_plane/test_canonical_lease_inspection.py index 5b603b60b8..ba53566605 100644 --- a/tests/control_plane/test_canonical_lease_inspection.py +++ b/tests/control_plane/test_canonical_lease_inspection.py @@ -17,7 +17,7 @@ TODO = "todo_current" -def _fixture(root: Path, provider: str, *, retained: bool = True, excluded: bool = False, todo_patch=None, lease_patch=None): +def _fixture(root: Path, provider: str, *, retained: bool = True, excluded: bool = False, todo_patch=None, lease_patch=None, peer: bool = False): runtime = root / "runtime" state = root / "ACTIVE_GOAL_STATE.md" state.write_text("---\nhandoff_mode: soft_claim\n---\n# Obsolete display\n") @@ -34,7 +34,10 @@ def _fixture(root: Path, provider: str, *, retained: bool = True, excluded: bool "expires_at": "2099-01-01T00:00:00Z", "lease_epoch": 3, "version": 2} todo.update(todo_patch or {}) lease.update(lease_patch or {}) - projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=[todo], + todos = [todo] + if peer: + todos.append({**todo, "todo_id": "todo_peer", "task_repository": "git:github.com/team/b"}) + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=todos, leases=[lease] if retained else [], handoff_mode="hard_lease") # Preserve retained provider history even when a legacy capture would omit it. projection["leases"] = [lease] if retained else [] @@ -220,3 +223,35 @@ def release_during_projection(**_): assert result["active"] is False assert result["lease"]["status"] == "released" assert "todo_projection_required" not in result + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_repository_scoped_cli_acquire_and_json_markdown_readback(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _, obsolete = _fixture(tmp_path, provider, retained=False, peer=True, + todo_patch={"task_repository": "git:github.com/team/a", "required_write_scopes": ["tests/**"]}) + obsolete_before = obsolete.read_bytes() + + def cli(action, todo, *args, output="json"): + process = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--format", output, "task-lease", action, "--goal-id", GOAL, "--todo-id", todo, *args], + capture_output=True, text=True, timeout=30) + assert process.returncode == 0, process.stderr + process.stdout + return json.loads(process.stdout) if output == "json" else process.stdout + + grants = [] + for todo, agent, name in [(TODO, "agent-a", "a"), ("todo_peer", "agent-b", "b")]: + acquired = cli("acquire", todo, "--owner", agent, "--idempotency-key", f"repository-{name}", + "--expected-version", "0", "--ttl-seconds", "120", "--write-scope", "tests/**") + grants.append((acquired, name)) + for acquired, name in grants: + assert acquired["lease"]["write_repository"] == f"git:github.com/team/{name}" + assert acquired["lease"]["write_scopes"] == ["tests/**"] + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL, include_leases=True) + for todo, name in [(TODO, "a"), ("todo_peer", "b")]: + observed = cli("inspect", todo) + assert observed["active"] is True + assert observed["lease"]["write_repository"] == f"git:github.com/team/{name}" + assert f"write_repository: `git:github.com/team/{name}`" in cli("inspect", todo, output="markdown") + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL, include_leases=True) == before + assert obsolete.read_bytes() == obsolete_before diff --git a/tests/control_plane_ts/lease_acquisition_conformance.ts b/tests/control_plane_ts/lease_acquisition_conformance.ts index b43f6caaa8..a34d08967e 100644 --- a/tests/control_plane_ts/lease_acquisition_conformance.ts +++ b/tests/control_plane_ts/lease_acquisition_conformance.ts @@ -9,6 +9,8 @@ import {executeCoordinationTodoClaim} from "../../loopx/control_plane/coordinati import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; import {productionScaleLeaseAcquisitionFixture} from "./production_scale_coordination_fixture.ts"; import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; +import {evaluateCanonicalTaskLeaseProof} from "../../loopx/control_plane/coordination/task_lease_proof.ts"; +import {prepareCoordinationProjectionCommit} from "../../loopx/control_plane/coordination/coordination_projection.ts"; async function loaded(store: AuthorityStore) { const result = await store.loadAuthority(); @@ -171,4 +173,100 @@ export function registerLeaseAcquisitionConformance(provider: string, factory: A assert.equal(result.status, "applied", JSON.stringify(result)); assert.equal((result.lease as JsonObject).version, 1); }); + + for (const [kind, repository, frozen, allowed] of [ + ["different", "git:github.com/team/a", "git:github.com/team/b", true], + ["same", "git:github.com/team/a", "git:github.com/team/a", false], + ["case-alias", "git:github.com/Team/A", "git:github.com/team/a", false], + ["legacy-holder", "git:github.com/team/a", null, false], + ["unknown-requester", null, "git:github.com/team/b", false], + ] as const) { + test(`${provider} repository namespace ${kind} retains complete-scan admission`, async t => { + const {store, request} = await setup(t, "native", (p, target, conflict) => { + (p.todos as JsonObject[]).find(r => r.todo_id === target)!.task_repository = repository; + // Even a known current Todo cannot backfill an old unknown grant. + (p.todos as JsonObject[]).find(r => r.todo_id === conflict)!.task_repository = "git:github.com/team/b"; + const holder = (p.leases as JsonObject[]).find(r => r.todo_id === conflict)!; + holder.write_scopes = ["lease-admission/shared/**"]; + if (frozen !== null) holder.write_repository = frozen; + }); + const before = await loaded(store), result = await acquire(store, request); + if (allowed) { + assert.equal(result.status, "applied", JSON.stringify(result)); + assert.equal((result.lease as JsonObject).write_repository, repository); + } else { + assert.equal(result.reason_code, "write_scope_conflict", JSON.stringify(result)); + assert.deepEqual(await loaded(store), before); + } + }); + } + + test(`${provider} independent repositories acquire same paths and preserve frozen lineage`, async t => { + const {store, contender, request, fixture} = await setup(t, "native", (p, target, conflict) => { + p.leases = []; + (p.todos as JsonObject[]).find(r => r.todo_id === target)!.task_repository = "https://github.com/team/a.git"; + const other = (p.todos as JsonObject[]).find(r => r.todo_id === conflict)!; + other.task_repository = "git:github.com/team/b"; other.claimed_by = null; + other.required_write_scopes = ["tests/**"]; + }); + const first = await acquire(store, {...request, write_scopes: ["tests/**"]}); + assert.equal(first.status, "applied", JSON.stringify(first)); + assert.equal((first.lease as JsonObject).write_repository, "git:github.com/team/a"); + const second = await executeCoordinationTodoClaim(contender, {goal_id: request.goal_id, + todo_id: fixture.acquisition.conflict_todo_id, claimed_by: "agent-b", actor_agent_id: "agent-b", + expected_role: "agent", registered_agents: request.registered_agents, operation_id: "second-repository-claim", + lease_request: {idempotency_key: "repository-b", expected_version: 0, ttl_seconds: 600}, dry_run: false, now: request.now}); + assert.equal(second.status, "applied", JSON.stringify(second)); + assert.equal((second.lease as JsonObject).write_repository, "git:github.com/team/b"); + assert.deepEqual((second.lease as JsonObject).write_scopes, ["tests/**"]); + const maintenance = {...request, operation: "renew" as const, expected_version: 1}; + const renewed = await mutate(store, maintenance); + assert.equal(renewed.status, "applied"); + const replay = await acquire(contender, {...request, write_scopes: ["tests/**"]}); + assert.equal(replay.status, "replayed"); assert.deepEqual(replay.original_receipt, first.original_receipt); + assert.deepEqual(replay.lease, renewed.lease); + const transfer = await mutate(store, {...maintenance, operation: "transfer", expected_version: 2, + new_owner: "agent-b", new_idempotency_key: "transferred-a"}); + assert.equal(transfer.status, "applied"); + const release = await mutate(store, {...maintenance, operation: "release", owner: "agent-b", + idempotency_key: "transferred-a", expected_version: 3, ttl_seconds: null}); + assert.equal(release.status, "applied"); + for (const result of [renewed, transfer, release]) assert.equal((result.lease as JsonObject).write_repository, "git:github.com/team/a"); + const head = await loaded(store); + assert.equal((head.head.leases as JsonObject[]).filter(r => r.status === "active").length, 1); + }); + + test(`${provider} repository drift rejects current proof and renewal without rewriting receipt`, async t => { + const {store, contender, request} = await setup(t, "native", (p, target) => { + (p.todos as JsonObject[]).find(r => r.todo_id === target)!.task_repository = "git:github.com/team/a"; + }); + const first = await acquire(store, request); + assert.equal(first.status, "applied"); + const head = await loaded(store), next = structuredClone(head.head); + const todo = (next.todos as JsonObject[]).find(r => r.todo_id === request.todo_id)!; + todo.task_repository = "git:github.com/team/b"; + assert.equal((await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: request.goal_id, + expected_provider_revision: head.provider_revision, operation_id: "synthetic-repository-drift", + projection: head.head, mutations: [{kind: "todo_upsert", todo}]}))).status, "applied"); + const before = await loaded(store); + const replay = await acquire(contender, request); + assert.equal(replay.reason_code, "lease_repository_divergence", JSON.stringify(replay)); + assert.deepEqual(replay.original_receipt, first.original_receipt); + assert.equal(evaluateCanonicalTaskLeaseProof({todo, lease: first.lease as JsonObject, + handoff_mode: "hard_lease", actor_agent_id: request.owner, registered_agents: request.registered_agents, + lease_idempotency_key: request.idempotency_key, lease_expected_version: 1, now: request.now}).code, "lease_repository_divergence"); + assert.equal((await mutate(store, {...request, operation: "renew", expected_version: 1})).reason_code, "lease_repository_divergence"); + assert.deepEqual(await loaded(store), before); + // Cleanup still uses the exact holder/key/version, not the current repository. + assert.equal((await mutate(store, {...request, operation: "release", expected_version: 1, ttl_seconds: null})).status, "applied"); + }); + + test(`${provider} malformed frozen repository fails closed with no mutation`, async t => { + const {store, request} = await setup(t, "native", (p, _target, conflict) => { + (p.leases as JsonObject[]).find(r => r.todo_id === conflict)!.write_repository = "https://github.com/team/b.git"; + }); + const before = await loaded(store); + assert.equal((await acquire(store, request)).reason_code, "invalid_canonical_acquire_state"); + assert.deepEqual(await loaded(store), before); + }); } diff --git a/tests/control_plane_ts/task_lease_inspection.test.ts b/tests/control_plane_ts/task_lease_inspection.test.ts index 39591bb013..a3bdce99c5 100644 --- a/tests/control_plane_ts/task_lease_inspection.test.ts +++ b/tests/control_plane_ts/task_lease_inspection.test.ts @@ -59,6 +59,13 @@ async function fixture(t: test.TestContext, provider: Provider, schema: "native" ]; // Canonical heads prohibit orphan leases; legacy files may retain them. if (provider !== "legacy") cases.splice(cases.findIndex(item => item.name === "orphan"), 1); + if (provider !== "legacy") cases.push( + {name: "repository-bound", todo: {task_repository: "git:github.com/team/a"}, + lease: {write_repository: "git:github.com/team/a"}, active: true}, + {name: "repository-drift", todo: {task_repository: "git:github.com/team/b"}, + lease: {write_repository: "git:github.com/team/a"}, active: false, + constraint: {effective: false, reason: "lease_repository_divergence"}}, + ); const todos = [...originalTodos], leases = [...originalLeases]; for (const item of cases) { if (item.todo !== null) todos.push({...todo, todo_id: `todo_inspect_${item.name}`, ...item.todo}); @@ -121,6 +128,7 @@ for (const provider of ["legacy", "file", "sqlite", "postgresql"] as const) { assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(result.active, item.active, item.name); assert.deepEqual(result.executor_constraint, item.constraint, item.name); + if (item.name === "repository-bound") assert.equal((result.lease as JsonObject).write_repository, "git:github.com/team/a"); if (store && before?.status === "loaded") { assert.equal(result.source_authority, `${provider}_v0`); assert.equal(result.provider_revision, before.provider_revision); diff --git a/tests/control_plane_ts/task_lease_repository.test.ts b/tests/control_plane_ts/task_lease_repository.test.ts new file mode 100644 index 0000000000..e121b92635 --- /dev/null +++ b/tests/control_plane_ts/task_lease_repository.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {leaseWriteRepository, leaseRepositoryRejection, repositoryScopesMayOverlap} from "../../loopx/control_plane/work_items/task_lease_repository.ts"; +import {evaluateTaskLeaseAcquireDecision, materializeTaskLeaseAcquire} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; +import {evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/todo_lifecycle_decision.ts"; + +const repository = "git:github.com/team/a"; +function input(task_repository: string | null = repository, other_repository: string | null = "git:github.com/team/b") { + return {handoff_mode: "hard_lease", registered_agents: ["agent-a", "agent-b"], + todo: {todo_id: "todo_a", status: "open", claimed_by: "agent-a", excluded_agents: [], task_repository}, lease: null, + other_leases: [{todo_id: "todo_b", active: true, effective: true, write_scopes: ["tests/**"], write_repository: other_repository}], + command: {owner: "agent-a", idempotency_key: "turn-a", ttl_seconds: 60, expected_version: 0, write_scopes: ["tests/**"]}}; +} + +test("repository scopes require two distinct known identities and never infer historical namespaces", () => { + assert.equal(repositoryScopesMayOverlap(repository, "git:github.com/team/b"), false); + for (const alias of [repository, "git:github.com/Team/A", null, undefined]) assert.equal(repositoryScopesMayOverlap(repository, alias), true); + assert.equal(leaseRepositoryRejection({task_repository: repository}, {}), null); + assert.equal(leaseRepositoryRejection({task_repository: null}, {write_repository: repository}), "lease_repository_divergence"); + assert.equal(leaseRepositoryRejection({task_repository: "https://github.com/team/a.git"}, {write_repository: repository}), null); +}); + +test("frozen namespace codec rejects malformed, noncanonical and empty identities", () => { + for (const value of ["", " ", "https://github.com/team/a.git", "git:github.com/team/../a", false, [], "git:github.com/team\\a"]) { + assert.throws(() => leaseWriteRepository(value)); + assert.throws(() => evaluateTaskLeaseAcquireDecision({...input(), other_leases: [{...input().other_leases[0], write_repository: value}]})); + } +}); + +test("only canonical Todo facts determine a new namespace; caller fields cannot override it", () => { + const request = input("git@github.com:team/a.git"); + const decision = evaluateTaskLeaseAcquireDecision({...request, write_repository: "git:github.com/team/evil", + command: {...request.command, task_repository: "git:github.com/team/evil", write_repository: "git:github.com/team/evil"}}); + assert.equal(decision.outcome, "apply"); + const lease = materializeTaskLeaseAcquire({goal_id: "goal", todo_id: "todo_a"}, request.command, decision, new Date("2026-09-27T00:00:00Z")); + assert.equal(lease.write_repository, repository); + const unknown = input(null); unknown.other_leases = []; + assert.equal(Object.hasOwn(evaluateTaskLeaseAcquireDecision(unknown).next_lease!, "write_repository"), false); + const noScopes = {...input(null), command: {...input().command, write_scopes: []}}; + assert.equal(evaluateTaskLeaseAcquireDecision(noScopes).outcome, "apply"); +}); + +test("terminal execution fence rejects repository drift even with the exact holder key/version", () => { + const request = {schema_version: COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, + todo: {...input().todo, role: "agent", task_class: "implementation", task_repository: "git:github.com/team/b"}, + registered_agents: ["agent-a"], actor_agent_id: "agent-a", handoff_mode: "hard_lease", + lease: {present: true, active: true, status: "active", owner: "agent-a", idempotency_key: "turn-a", + version: 1, lease_epoch: 1, write_scopes: ["tests/**"], acquire_ttl_seconds: 60, write_repository: repository}, + lease_idempotency_key: "turn-a", lease_expected_version: 1, allow_user_gate_auto_acquire: false, + delegated_authority: false, require_active_when_fence_supplied: true}; + assert.equal(evaluateCoordinationTerminalFence(request).code, "lease_repository_divergence"); + assert.equal(evaluateCoordinationTerminalFence({...request, todo: {...request.todo, task_repository: repository}}).code, "terminal_fence_verified"); +}); From 036e2e17fb7cf06c0583e3743a8b9ccd6f588f92 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:23:00 +0800 Subject: [PATCH 2/2] test(task-lease): verify repository reconciliation resumes work Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../lease_acquisition_conformance.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/control_plane_ts/lease_acquisition_conformance.ts b/tests/control_plane_ts/lease_acquisition_conformance.ts index a34d08967e..2181bd303d 100644 --- a/tests/control_plane_ts/lease_acquisition_conformance.ts +++ b/tests/control_plane_ts/lease_acquisition_conformance.ts @@ -258,7 +258,17 @@ export function registerLeaseAcquisitionConformance(provider: string, factory: A assert.equal((await mutate(store, {...request, operation: "renew", expected_version: 1})).reason_code, "lease_repository_divergence"); assert.deepEqual(await loaded(store), before); // Cleanup still uses the exact holder/key/version, not the current repository. - assert.equal((await mutate(store, {...request, operation: "release", expected_version: 1, ttl_seconds: null})).status, "applied"); + const released = await mutate(store, {...request, operation: "release", expected_version: 1, ttl_seconds: null}); + assert.equal(released.status, "applied"); + assert.equal((released.lease as JsonObject).version, 1); + const recovered = await acquire(contender, {...request, idempotency_key: "repository-recovered", expected_version: 1}); + assert.equal(recovered.status, "applied", JSON.stringify(recovered)); + assert.equal((recovered.lease as JsonObject).write_repository, "git:github.com/team/b"); + assert.equal(evaluateCanonicalTaskLeaseProof({todo, lease: recovered.lease as JsonObject, + handoff_mode: "hard_lease", actor_agent_id: request.owner, registered_agents: request.registered_agents, + lease_idempotency_key: "repository-recovered", lease_expected_version: 2, now: request.now}).code, "terminal_fence_verified"); + assert.equal((await acquire(store, request)).reason_code, "idempotency_key_reuse"); + assert.deepEqual((await acquire(store, request)).original_receipt, first.original_receipt); }); test(`${provider} malformed frozen repository fails closed with no mutation`, async t => {