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
39 changes: 37 additions & 2 deletions docs/reference/canonical-lease-renew.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ 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.
different repository identities, or the explicit code-edit worktree mode below proves sibling checkout isolation. 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.

Expand All @@ -52,7 +52,42 @@ 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.

### 仓库相对路径的冲突边界
### Independent worktree code edits

For edits in an independent Git worktree on a promoted File/SQLite Goal, add
`--write-worktree` to standalone acquisition:

```bash
loopx --registry registry.json task-lease acquire \
--goal-id example-goal --todo-id todo_work --owner agent-a \
--idempotency-key worktree-edit-a --ttl-seconds 600 \
--write-scope 'src/**' --write-worktree "$PWD"
```

The TypeScript entrypoint verifies the Git root, origin against the Todo's
repository, machine identity and filesystem identity. Two grants in distinct
sibling worktrees on the same machine may overlap: acquisition returns
`integration_overlap_advisories` identifying the other Todo and paths, so their
owners can coordinate and validate the combined changes before merge. This is
cooperative code-edit coordination, not a filesystem access-control mechanism.
It does not authorize changing shared runtime data, Git administration, remote
branches, or merging. Use ordinary exclusive leases for those operations.

Same-worktree aliases, the same Todo, other machines or clones, and grants
without a verified workspace retain existing exclusion. Repository mismatch,
redirected paths and a non-worktree root fail closed. Verified machine discovery
currently supports macOS and Linux; other hosts retain ordinary leases. No
workspace path or machine identifier is stored directly: only opaque digests
and the existing public repository identity enter the private authority record.

Renewal preserves this identity. Acquire retries must use the same worktree,
scopes and execution key; an alias resolving to the same worktree is valid.
To change directories or return to ordinary exclusion, release the current
lease with its version and acquire a new execution key. Existing leases are
never retroactively reclassified; their holders can release and reacquire
explicitly. No automatic migration or grant expansion occurs.

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

新的 canonical 租约从权威 Todo 的 `task_repository` 冻结
`lease.write_repository`,不接受调用者覆盖,也不从 CLI 当前目录猜测。同一 Goal
Expand Down
15 changes: 13 additions & 2 deletions loopx/cli_commands/task_lease.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from collections.abc import Callable
from pathlib import Path

from ..control_plane.work_items.local_lease_record import TaskLeaseError
from ..control_plane.work_items.task_lease import (
TaskLeaseError,
inspect_task_lease,
release_task_lease,
renew_task_lease,
Expand Down Expand Up @@ -51,6 +51,13 @@ def render_task_lease_markdown(payload: dict[str, object]) -> str:
f"- write_repository: `{lease.get('write_repository') or 'unknown (conservative overlap)'}`",
]
)
advisories = payload.get("integration_overlap_advisories")
if isinstance(advisories, list) and advisories:
lines.append("- Independent worktree path overlap: coordinate changes and validate integration before merge.")
for row in advisories:
if not isinstance(row, dict):
continue
lines.append(f" - `{row['todo_id']}`: {', '.join(row['write_scopes'])}")
if payload.get("lease_path"):
lines.append(f"- lease_path: `{payload.get('lease_path')}`")
if payload.get("transfer_claim") is True:
Expand All @@ -74,7 +81,7 @@ def render_task_lease_markdown(payload: dict[str, object]) -> str:


def register_task_lease_command(
subparsers: argparse._SubParsersAction,
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
add_subcommand_format: Callable[[argparse.ArgumentParser], None],
) -> None:
parser = subparsers.add_parser(
Expand Down Expand Up @@ -106,6 +113,7 @@ def register_task_lease_command(
action="append",
help="Relative write scope protected by this lease, such as loopx/**. Repeatable.",
)
parser.add_argument("--write-worktree", help="Independent Git worktree root for code-edit scopes (canonical File/SQLite). Sibling worktree overlaps are advisory; shared-state leases stay exclusive.")
parser.add_argument(
"--expected-version",
type=int,
Expand All @@ -131,6 +139,8 @@ def handle_task_lease_command(
if args.command != "task-lease":
return None
try:
if args.write_worktree and args.task_lease_command != "acquire":
raise ValueError("--write-worktree is valid only for task-lease acquire")
if args.transfer_claim and args.task_lease_command != "transfer":
raise ValueError("--transfer-claim is valid only for task-lease transfer")
if _requires_owner(args) and not args.owner:
Expand All @@ -147,6 +157,7 @@ def handle_task_lease_command(
todo_id=args.todo_id,
idempotency_key=args.idempotency_key,
write_scopes=args.write_scopes,
**({"write_worktree": args.write_worktree} if args.write_worktree is not None else {}),
ttl_seconds=args.ttl_seconds,
expected_version=args.expected_version,
)
Expand Down
3 changes: 3 additions & 0 deletions loopx/control_plane/coordination/lease_acquisition_proof.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {sameLeaseWorkspace} from "../work_items/task_lease_workspace.ts";
/** Historical acquisition and current permission are different facts. Both
* standalone acquire and atomic claim/acquire return this current proof. */
import type {JsonObject} from "../effect_program.ts";
Expand Down Expand Up @@ -55,6 +56,8 @@ 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);
}
if (!sameLeaseWorkspace(current.write_workspace, original.write_workspace)) return failed(
"lease_workspace_divergence", "current worktree identity differs from its acquisition receipt", details);
const repositoryRejection = leaseWriteRepository(current.write_repository) !== leaseWriteRepository(original.write_repository)
? "lease_repository_divergence" : leaseRepositoryRejection(facts.todo, current);
if (repositoryRejection !== null) return failed(repositoryRejection,
Expand Down
13 changes: 11 additions & 2 deletions loopx/control_plane/coordination/task_lease_acquire.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {leaseWorkspace, sameLeaseWorkspace, type LeaseWorkspace} from "../work_items/task_lease_workspace.ts";
/** A lease acquisition is one full-head admission/CAS with a retained receipt.
* Unlike historical maintenance replay, success here must supply current proof. */
import type {JsonObject} from "../effect_program.ts";
Expand All @@ -16,6 +17,7 @@ import {normalizeGoalId, normalizeTodoId, normalizeOwner, normalizeIdempotencyKe
export interface CanonicalTaskLeaseAcquireInput {
goal_id: string; todo_id: string; owner: string; idempotency_key: string;
expected_version: number | null; ttl_seconds: number | null;
write_workspace?: LeaseWorkspace | null;
write_scopes: readonly string[]; registered_agents: readonly string[]; now: Date;
}

Expand All @@ -28,6 +30,7 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
try {
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),
registered_agents: raw.registered_agents.map(normalizeOwner)};
if (input.expected_version !== null && (!Number.isSafeInteger(input.expected_version) || input.expected_version < 0)) {
Expand All @@ -42,13 +45,14 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
const identity = {schema_version: "loopx_canonical_task_lease_acquire_receipt_v0",
operation_id: `lease-acquire:${canonicalAuthoritySha256(identityFields)}`, goal_id: input.goal_id,
request_sha256: canonicalAuthoritySha256({...identityFields, expected_version: input.expected_version,
...(input.write_workspace ? {write_workspace: input.write_workspace} : {}),
ttl_seconds: input.ttl_seconds, write_scopes: [...input.write_scopes].sort()})};
const receipt = new CoordinationCommandReceipt({result_schema: schema, identity, failure: failed,
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 (JSON.stringify(scopes) !== JSON.stringify([...input.write_scopes].sort()) ||
if (!sameLeaseWorkspace(lease.write_workspace, input.write_workspace) || JSON.stringify(scopes) !== JSON.stringify([...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 Expand Up @@ -76,6 +80,8 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
...facts, command: input});
if (decision.outcome === "rejected" || decision.outcome === "conflict") {
return failed(decision.code, `canonical task lease acquire rejected: ${decision.code}`, {
...(decision.code === "write_scope_conflict" ? {recommended_action:
"Coordinate with the listed holders to narrow scopes. For isolated code edits, both holders may release and reacquire with --write-worktree; existing grants remain exclusive. Never take over a foreign lease or use this mode for shared runtime state."} : {}),
handoff_mode: mode, expected_version: input.expected_version, actual_version: leaseVersion(facts.current),
...(facts.todo ? {todo_status: facts.todo.status, claimed_by: facts.todo.claimed_by, excluded_agents: [...facts.todo.excluded_agents]} : {}),
...(decision.conflict_indexes.length ? {conflicts: decision.conflict_indexes.map(i => facts.other_leases[i])} : {})});
Expand All @@ -92,7 +98,10 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra
operation_id: identity.operation_id, expected_provider_revision: head.provider_revision, projection: head.head,
mutations: [{kind: "lease_upsert", lease}]}) : {operation_id: identity.operation_id,
expected_provider_revision: head.provider_revision, next_projection: head.head, events: [], receipts: []};
commit.receipts = [{...identity, result: {changed, lease, handoff_mode: mode}}];
const overlaps = (decision.overlap_advisory_indexes ?? []).map(i => ({todo_id: facts.other_leases[i].todo_id, owner: facts.other_leases[i].owner ?? null,
write_scopes: facts.other_leases[i].write_scopes, reason: "independent_worktree_integration_overlap"}));
commit.receipts = [{...identity, result: {changed, lease, handoff_mode: mode,
...(overlaps.length ? {integration_overlap_advisories: overlaps} : {})}}];
await beforeCommit?.(lease);
committed = true;
const result = await receipt.commit(store, commit);
Expand Down
9 changes: 5 additions & 4 deletions loopx/control_plane/coordination/task_lease_state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {leaseWorkspace} from "../work_items/task_lease_workspace.ts";
/** Full canonical facts shared by lease acquisition, maintenance and Todo claim. */
import type {JsonObject} from "../effect_program.ts";
import {AuthorityStoreProtocolError} from "./authority_store_codec.ts";
Expand All @@ -20,7 +21,7 @@ export function canonicalTaskLease(value: JsonObject, goalId: string, todoId: st
normalizeOwner(value.owner) !== value.owner || normalizeIdempotencyKey(value.idempotency_key) !== value.idempotency_key) {
throw new AuthorityStoreProtocolError("canonical lease owner and execution key must be normalized strings");
}
leaseVersion(value); leaseEpoch(value);
leaseVersion(value); leaseEpoch(value); leaseWorkspace(value.write_workspace);
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) ||
Expand Down Expand Up @@ -52,15 +53,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[], write_repository: leaseWriteRepository(current.write_repository),
write_scopes: (current.write_scopes ?? []) as string[], write_repository: leaseWriteRepository(current.write_repository), write_workspace: leaseWorkspace(current.write_workspace),
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,
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_scopes: (candidate.write_scopes ?? []) as string[], write_repository: leaseWriteRepository(candidate.write_repository), write_workspace: leaseWorkspace(candidate.write_workspace)}];
});
return {todo, lease, other_leases, current};
}
15 changes: 13 additions & 2 deletions loopx/control_plane/work_items/task_lease_acquire.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {observeLeaseWorktree, type LeaseWorkspace} from "./task_lease_workspace.ts";
import {executeCanonicalTaskLeaseAcquire} from "../coordination/task_lease_acquire.ts";
import {withCanonicalTaskLeaseAuthority} from "./canonical_task_lease_lifecycle.ts";
import {evaluateTaskLeaseAcquireDecision, materializeTaskLeaseAcquire, type AcquireDecisionOtherLease} from "./task_lease_acquire_decision.ts";
import {evaluateTaskLeaseWriteScopesOverlap, evaluateTaskLeaseAcquireDecision, materializeTaskLeaseAcquire, type AcquireDecisionOtherLease} from "./task_lease_acquire_decision.ts";
import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts";
import { ShadowManagementError, requireShadowPrimaryWriteAllowed } from "../coordination/shadow_management.ts";
import { parseIsoTimestamp } from "../runtime_timestamp.ts";
Expand Down Expand Up @@ -80,6 +81,8 @@ export interface AuthorityFacts {

interface AcquireRequest {
canonical: boolean;
write_worktree: string | null;
write_workspace?: LeaseWorkspace | null;
runtime_root: string;
goal_id: string;
owner: string;
Expand Down Expand Up @@ -410,14 +413,15 @@ function decodeRequest(value: unknown): AcquireRequest {
// CLI: a missing authority projection is reported before unrelated fields.
if (canonical) {
const allowed = new Set(["schema_version", "runtime_root", "goal_id", "todo_id", "owner", "idempotency_key",
"ttl_seconds", "write_scopes", "expected_version", "authority"]);
"ttl_seconds", "write_scopes", "write_worktree", "expected_version", "authority"]);
const unsupported = Object.keys(request).find(key => !allowed.has(key));
if (unsupported) throw new TaskLeaseAcquireError(`canonical acquire does not accept ${unsupported}`, "invalid_canonical_acquire_request");
}
const rawAuthority = requireJsonObject(request.authority, "acquire authority");
const authority = decodeTaskLeaseAuthority(canonical ? {...rawAuthority, handoff_mode: "legacy", todos: [], todo_projection_error: null} : rawAuthority);
return {
canonical,
write_worktree: request.write_worktree == null ? null : stringValue(request.write_worktree, "write_worktree"),
runtime_root: stringValue(request.runtime_root, "runtime_root"),
goal_id: normalizeGoalId(request.goal_id),
owner: normalizeOwner(request.owner),
Expand Down Expand Up @@ -1019,6 +1023,13 @@ export async function executeTaskLeaseAcquire(
const context = executionContext(value);
try {
request = decodeRequest(value);
if (request.write_worktree !== null) {
if (!request.canonical || request.write_scopes.length === 0) throw new TaskLeaseAcquireError(
"--write-worktree requires canonical authority and nonempty code-edit scopes", "invalid_worktree_lease_request");
try { request.write_workspace = await observeLeaseWorktree(request.write_worktree, path =>
evaluateTaskLeaseWriteScopesOverlap({left: request.write_scopes, right: [path]}).overlap === true); }
catch { throw new TaskLeaseAcquireError("cannot verify independent Git worktree and origin for --write-worktree", "invalid_worktree_lease_request"); }
}
} catch (error) {
if (error instanceof TaskLeaseAcquireError || error instanceof ShadowManagementError || error instanceof LegacyCoordinationWriteError) {
return failureEnvelope(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from typing import Any

from ...history import load_registry
from ..projects.registry_codec import load_registry
from ...paths import resolve_runtime_root
from ..coordination.coordination_state_contract_generated import (
LOCAL_AUTHORITY_SHADOW_BINDING_SCHEMA,
Expand Down Expand Up @@ -365,6 +365,7 @@ def execute_native_task_lease_acquire(
ttl_seconds: int | None = None,
write_scopes: list[str] | None = None,
expected_version: int | None = None,
write_worktree: str | None = None,
_legacy_provider_projection: bool = False,
) -> dict[str, Any]:
"""Transport one compact acquire request to the native TypeScript owner."""
Expand Down Expand Up @@ -392,6 +393,10 @@ def execute_native_task_lease_acquire(
"idempotency_key": idempotency_key,
"ttl_seconds": ttl_seconds,
"write_scopes": list(write_scopes or []),
**({"write_worktree": (
str(Path(write_worktree).expanduser().absolute())
if write_worktree else write_worktree
)} if write_worktree is not None else {}),
"expected_version": expected_version,
"authority": authority,
}
Expand Down Expand Up @@ -589,6 +594,7 @@ def execute_native_task_lease_lifecycle(
fence_expected_lease_epoch: int | None = None,
fence_operation_id: str | None = None,
owner_pid: int | None = None,
write_worktree: str | None = None,
_legacy_provider_projection: bool = False,
_now: datetime | None = None,
) -> dict[str, Any]:
Expand Down
Loading
Loading