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
12 changes: 12 additions & 0 deletions docs/reference/canonical-lease-renew.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ code-edit mode below. 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.

Scope comparison folds lexical path aliases such as `src//main.ts` and
`src/./main.ts`, while preserving slash and dot members inside glob character
classes. Fresh lease records use this canonical scope set. The original request
spellings remain part of the immutable acquisition identity, so an exact retry
of a retained request recovers its original receipt, including after renewal.
Equivalent strings in an accepted Todo declaration do not require a declaration
revision before atomic claim/acquire.

冲突比较会归并相对路径的词法别名,但保留 glob 字符类中的斜杠、点与字面量右括号。
新租约保存规范范围;原请求的拼写仍参与不可变请求身份,旧请求在升级或续租后按原参数
重试会恢复原回执与当前执行证明。已有 Todo 声明中的等价字符串不会要求用户先重录声明。

Old grants without `write_repository` (or with null) remain unknown and
conservatively overlap any repository for ordinary exclusive acquisition.
Reading or renewing them does not
Expand Down
14 changes: 9 additions & 5 deletions loopx/control_plane/coordination/task_lease_acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthorit
import {CoordinationCommandReceipt, commandReceiptResult} from "./command_receipt.ts";
import {indexCoordinationProjection, prepareCoordinationProjectionCommit} from "./coordination_projection.ts";
import {canonicalTaskLease} from "./task_lease_state.ts";
import {decideTaskLeaseAcquire, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts";
import {canonicalWriteScope, comparisonWriteScopes, decideTaskLeaseAcquire, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts";
import {acquisitionFacts, currentLeaseAcquisitionProof} from "./lease_acquisition_proof.ts";
import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts";
import {goalObjectiveWorkGuard} from "../goals/goal_objective.ts";
import {todoExecutionDependencyRejection} from "./todo_execution_dependency.ts";
import {normalizeGoalId, normalizeTodoId, normalizeOwner, normalizeIdempotencyKey,
normalizeWriteScopes, normalizeTtl, leaseEpoch, leaseVersion, leaseIsActive,
requireWriteScopes, normalizeTtl, leaseEpoch, leaseVersion, leaseIsActive,
TaskLeaseAcquireError} from "../work_items/task_lease_acquire.ts";

export interface CanonicalTaskLeaseAcquireInput {
Expand All @@ -33,7 +33,7 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
input = {...raw, goal_id: normalizeGoalId(raw.goal_id), todo_id: normalizeTodoId(raw.todo_id),
owner: normalizeOwner(raw.owner), idempotency_key: normalizeIdempotencyKey(raw.idempotency_key),
write_workspace: leaseWorkspace(raw.write_workspace),
write_scopes: normalizeWriteScopes(raw.write_scopes), ttl_seconds: normalizeTtl(raw.ttl_seconds),
write_scopes: requireWriteScopes(raw.write_scopes), ttl_seconds: normalizeTtl(raw.ttl_seconds),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 保留旧租约 acquire 请求的原始回执身份

File/SQLite:base 用 src//main.ts 首次 acquire,再按同一 key/expected-version=0/TTL/scope 重放成功;同一物理 provider 不变交给 head,完全原参数却 exit1/coordination_operation_identity_mismatch,原租约仍 active。 Separate comparison/new-record canonicalization from frozen historical request/receipt identity. Recover the existing original request and current proof without changing its immutable receipt or requiring a new execution. Create the lease with immutable base through real CLI on File/SQLite, then replay exact same key, expected0, TTL and original scope on head, also after renewal; require original_receipt unchanged/current generation returned and no extra event or lease.

registered_agents: raw.registered_agents.map(normalizeOwner)};
if (input.expected_version !== null && (!Number.isSafeInteger(input.expected_version) || input.expected_version < 0)) {
return failed("invalid_expected_version", "expected lease version must be a non-negative safe integer");
Expand All @@ -53,8 +53,12 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
decode(original) {
const payload = commandReceiptResult(original);
const lease = canonicalTaskLease(canonicalAuthorityObject(payload.fields.lease, "acquire receipt lease"), input.goal_id, input.todo_id);
const scopes = [...(lease.write_scopes ?? []) as string[]].sort();
if (!sameLeaseWorkspace(lease.write_workspace, input.write_workspace) || JSON.stringify(scopes) !== JSON.stringify([...input.write_scopes].sort()) ||
const retainedScopes = (lease.write_scopes ?? []) as string[];
if (retainedScopes.some(scope => canonicalWriteScope(scope) === null)) {
throw new AuthorityStoreProtocolError("acquire receipt contains an invalid write scope");
}
const scopes = comparisonWriteScopes(retainedScopes).sort();
if (!sameLeaseWorkspace(lease.write_workspace, input.write_workspace) || JSON.stringify(scopes) !== JSON.stringify(comparisonWriteScopes(input.write_scopes).sort()) ||
(lease.acquire_ttl_seconds != null && lease.acquire_ttl_seconds !== input.ttl_seconds) ||
(payload.changed && input.expected_version !== null && leaseVersion(lease) !== input.expected_version + 1)) {
throw new AuthorityStoreProtocolError("acquire receipt does not match its original parameters");
Expand Down
7 changes: 5 additions & 2 deletions loopx/control_plane/coordination/task_lease_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {normalizeTodoAgent} from "./todo_agents.ts";
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 {
comparisonWriteScopes,
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";

Expand Down Expand Up @@ -65,7 +68,7 @@ export function canonicalTaskLeaseAcquireFacts(index: ReturnType<typeof indexCoo
const active = leaseIsActive(candidate, now);
return [{todo_id: id, owner: String(candidate.owner), active,
effective: active && leaseOwnerRejection(canonicalLeaseTodoFact(index.todos.get(id)), String(candidate.owner), registered) === null,
write_scopes: (candidate.write_scopes ?? []) as string[], write_repository: leaseWriteRepository(candidate.write_repository), write_workspace: leaseWorkspace(candidate.write_workspace)}];
write_scopes: comparisonWriteScopes((candidate.write_scopes ?? []) as string[]), write_repository: leaseWriteRepository(candidate.write_repository), write_workspace: leaseWorkspace(candidate.write_workspace)}];
});
return {todo, lease, other_leases, current};
}
34 changes: 25 additions & 9 deletions loopx/control_plane/work_items/task_lease_acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {LeaseWorktreeObservationError, observeLeaseWorktree, type LeaseWorkspace
import {isCanonicalTodoId} from "../todos/identity.ts";
import {executeCanonicalTaskLeaseAcquire} from "../coordination/task_lease_acquire.ts";
import {withCanonicalTaskLeaseAuthority} from "./canonical_task_lease_lifecycle.ts";
import {evaluateTaskLeaseWriteScopesOverlap} from "./task_lease_acquire_decision.ts";
import {
canonicalWriteScope,
evaluateTaskLeaseWriteScopesOverlap,
} from "./task_lease_acquire_decision.ts";
import { ShadowManagementError } from "../coordination/shadow_management.ts";
import { parseIsoTimestamp } from "../runtime_timestamp.ts";
import { createHash } from "node:crypto";
Expand Down Expand Up @@ -35,8 +38,6 @@ export const TASK_LEASE_SCHEMA_VERSION = "task_lease_v0";

const DEFAULT_TTL_SECONDS = 45 * 60;
const MAX_TTL_SECONDS = 24 * 60 * 60;
const TODO_WRITE_SCOPE_MAX_CHARS = 160;

type FileState = "file" | "missing";
export type TodoFactField =
| "status"
Expand Down Expand Up @@ -232,7 +233,8 @@ export function normalizeIdempotencyKey(value: unknown): string {
return key;
}

export function normalizeWriteScopes(value: unknown): string[] {
function writeScopeCandidates(value: unknown): unknown[] {
if (value === null || value === undefined) return [];
let raw: unknown[];
if (Array.isArray(value)) {
raw = value;
Expand All @@ -241,15 +243,18 @@ export function normalizeWriteScopes(value: unknown): string[] {
} else {
raw = [value];
}
return raw;
}

/** Keep request spellings stable for immutable receipts and declared work. */
export function normalizeWriteScopes(value: unknown): string[] {
const raw = writeScopeCandidates(value);
const scopes: string[] = [];
const seen = new Set<string>();
for (const item of raw) {
const scope = compact(item);
if (
scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS ||
scope.startsWith("/") || scope.startsWith("~") ||
scope.split("/").includes("..") || /[\s<>]/u.test(scope) ||
seen.has(scope)
canonicalWriteScope(scope) === null || seen.has(scope)
) {
continue;
}
Expand All @@ -259,6 +264,17 @@ export function normalizeWriteScopes(value: unknown): string[] {
return scopes;
}

export function requireWriteScopes(value: unknown): string[] {
const raw = writeScopeCandidates(value);
if (raw.some(item => canonicalWriteScope(compact(item)) === null)) {
throw new TaskLeaseAcquireError(
"write_scopes must contain valid repository-relative scope tokens",
"invalid_write_scopes",
);
}
return normalizeWriteScopes(raw);
}

export function normalizeTtl(value: unknown): number {
const ttl = value === null || value === undefined
? DEFAULT_TTL_SECONDS
Expand Down Expand Up @@ -424,7 +440,7 @@ function decodeRequest(value: unknown): AcquireRequest {
owner: normalizeOwner(request.owner),
todo_id: normalizeTodoId(request.todo_id),
idempotency_key: normalizeIdempotencyKey(request.idempotency_key),
write_scopes: normalizeWriteScopes(request.write_scopes),
write_scopes: requireWriteScopes(request.write_scopes),
ttl_seconds: normalizeTtl(request.ttl_seconds),
expected_version: optionalInteger(request.expected_version, "expected_version"),
authority,
Expand Down
94 changes: 80 additions & 14 deletions loopx/control_plane/work_items/task_lease_acquire_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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 const TODO_WRITE_SCOPE_MAX_CHARS = 160;

export interface AcquireDecisionLease {
present: boolean;
active: boolean;
Expand Down Expand Up @@ -66,6 +68,70 @@ function stringValue(value: unknown, label: string): string {
return value;
}

function characterClassEnd(pattern: string, start: number): number {
let end = start + 1;
if (pattern[end] === "!") end += 1;
if (pattern[end] === "]") end += 1;
return pattern.indexOf("]", end);
}

/**
* Canonicalize one repository-relative scope without resolving it against a
* host filesystem. Glob character classes retain their own slash semantics.
*/
export function canonicalWriteScope(value: string): string | null {
const scope = value.trim();
if (
scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS ||
scope.startsWith("/") || scope.startsWith("~") || scope.includes("\\") ||
/[\s<>]/u.test(scope)
) {
return null;
}
const parts: string[] = [];
let start = 0;
for (let index = 0; index < scope.length; index += 1) {
if (scope[index] === "[") {
const closing = characterClassEnd(scope, index);
if (closing >= 0) {
index = closing;
continue;
}
}
if (scope[index] !== "/") continue;
parts.push(scope.slice(start, index));
start = index + 1;
}
parts.push(scope.slice(start));
const trailingSlash = scope.endsWith("/");
const canonical: string[] = [];
for (const part of parts) {
if (part.length === 0 || part === ".") continue;
if (part === "..") return null;
canonical.push(part);
}
if (canonical.length === 0) return trailingSlash ? "./" : null;
return `${canonical.join("/")}${trailingSlash ? "/" : ""}`;
}

/**
* Build a conflict-comparison view for persisted scopes without rewriting the
* retained record. An unreadable legacy scope blocks new work conservatively.
*/
export function comparisonWriteScopes(scopes: readonly string[]): string[] {
const canonical: string[] = [];
const seen = new Set<string>();
for (const raw of scopes) {
const scope = canonicalWriteScope(raw);
if (scope === null) return ["**"];
if (!seen.has(scope)) {
seen.add(scope);
canonical.push(scope);
}
}
return canonical;
}

function optionalInteger(value: unknown, label: string): number | null {
if (value === null || value === undefined) return null;
if (typeof value !== "number" || !Number.isInteger(value)) {
Expand All @@ -79,10 +145,7 @@ function classMatch(
start: number,
value: string,
): { end: number; matches: boolean } | null {
let end = start + 1;
if (pattern[end] === "!") end += 1;
if (pattern[end] === "]") end += 1;
end = pattern.indexOf("]", end);
const end = characterClassEnd(pattern, start);
if (end < 0) return null;
let body = pattern.slice(start + 1, end);
const negated = body.startsWith("!");
Expand Down Expand Up @@ -168,7 +231,8 @@ function scopePairOverlaps(left: string, right: string): boolean {

function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean {
if (left.length === 0 || right.length === 0) return false;
return left.some((a) => right.some((b) => scopePairOverlaps(a, b)));
const a = comparisonWriteScopes(left), b = comparisonWriteScopes(right);
return a.some((first) => b.some((second) => scopePairOverlaps(first, second)));
}

function decisionStringArray(value: unknown, label: string): string[] {
Expand Down Expand Up @@ -239,7 +303,9 @@ 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_scopes: comparisonWriteScopes(
decisionStringArray(lease.write_scopes, "lease.write_scopes"),
),
write_repository: leaseWriteRepository(lease.write_repository),
write_workspace: leaseWorkspace(lease.write_workspace),
acquire_ttl_seconds: optionalInteger(
Expand All @@ -264,10 +330,10 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput {
effective: decisionBoolean(lease.effective, `other_leases[${index}].effective`),
write_repository: leaseWriteRepository(lease.write_repository),
write_workspace: leaseWorkspace(lease.write_workspace),
write_scopes: decisionStringArray(
write_scopes: comparisonWriteScopes(decisionStringArray(
lease.write_scopes,
`other_leases[${index}].write_scopes`,
),
)),
};
});
return {
Expand All @@ -286,10 +352,10 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput {
"command.idempotency_key",
),
ttl_seconds: decisionInteger(command.ttl_seconds, "command.ttl_seconds"),
write_scopes: decisionStringArray(
write_scopes: comparisonWriteScopes(decisionStringArray(
command.write_scopes,
"command.write_scopes",
),
)),
write_workspace: leaseWorkspace(command.write_workspace),
expected_version: optionalInteger(
command.expected_version,
Expand Down Expand Up @@ -414,7 +480,7 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci
idempotency_key: command.idempotency_key,
version: actualVersion + 1,
lease_epoch: (lease?.lease_epoch ?? 0) + 1,
write_scopes: [...command.write_scopes],
write_scopes: comparisonWriteScopes(command.write_scopes),
...(workspace ? {write_workspace: workspace} : {}),
...(repository === null ? {} : {write_repository: repository}),
acquire_ttl_seconds: command.ttl_seconds,
Expand All @@ -423,8 +489,8 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci
}

function equalScopeSets(left: readonly string[], right: readonly string[]): boolean {
const a = [...new Set(left)].sort((first, second) => first.localeCompare(second));
const b = [...new Set(right)].sort((first, second) => first.localeCompare(second));
const a = comparisonWriteScopes(left).sort((first, second) => first.localeCompare(second));
const b = comparisonWriteScopes(right).sort((first, second) => first.localeCompare(second));
return a.length === b.length && a.every((value, index) => value === b[index]);
}

Expand All @@ -438,7 +504,7 @@ 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,
...(decision.next_lease.write_workspace ? {write_workspace: leaseWorkspace(decision.next_lease.write_workspace)} : {}),
write_scopes: [...command.write_scopes], acquire_ttl_seconds: command.ttl_seconds,
write_scopes: comparisonWriteScopes(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"),
Expand Down
Loading
Loading