worktree-controller 1.30.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,662 @@
1
+ """Writer leases: temporary write entitlement, separate from durable task identity.
2
+
3
+ An allocation records *who owns a task* and preserves its work. A writer lease
4
+ records *which live process may currently write through it*. Keeping the two apart
5
+ is what lets capacity be reclaimed without ever asserting that a task finished: a
6
+ lease can be proven dead while the allocation, its owner and its uncommitted work
7
+ stay exactly as they were.
8
+
9
+ Holder identity deliberately never rests on a pid alone. Linux reuses pids, so a
10
+ lease naming a recycled pid would read as live and permanently pin a writer slot.
11
+ The holder is the triple (boot id, pid, kernel start ticks), which no later process
12
+ can satisfy, optionally strengthened by a client_id session id.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import os
20
+ import re
21
+ import uuid
22
+ from dataclasses import asdict, replace
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .models import (
28
+ CANONICAL_ROLES,
29
+ FEATURE_AUTHORITY_ROLES,
30
+ RECOVERY_ROLES,
31
+ ControllerError,
32
+ MetadataRecord,
33
+ WriterHolder,
34
+ WriterLease,
35
+ )
36
+ from .store import WRITER_LEASE_SCHEMA_VERSION
37
+
38
+ CLIENT_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
39
+ HOLDER_RESOLVER_VERSION = 1
40
+ _BOOT_ID_FILE = Path("/proc/sys/kernel/random/boot_id")
41
+ RUNTIME_BINDING_PREFIX = "runtime:process-v1:"
42
+
43
+
44
+ def runtime_binding(value: str) -> tuple[WriterHolder, str | None] | None:
45
+ """Decode a typed occupancy within the existing external-binding registry."""
46
+ if not value.startswith(RUNTIME_BINDING_PREFIX):
47
+ return None
48
+ def unique(pairs):
49
+ result = {}
50
+ for key, item in pairs:
51
+ if key in result:
52
+ raise ValueError("duplicate runtime identity field")
53
+ result[key] = item
54
+ return result
55
+
56
+ try:
57
+ data = json.loads(value[len(RUNTIME_BINDING_PREFIX):], object_pairs_hook=unique)
58
+ if set(data) != {"holder", "lease_id", "kind"} or data["kind"] not in {"agent", "test", "preview", "background", "command"}:
59
+ raise ValueError("invalid runtime binding")
60
+ identity = data["holder"]
61
+ if set(identity) != {"pid", "boot_id", "start_ticks", "client_id", "session_id", "run_id"}:
62
+ raise ValueError("incomplete runtime holder")
63
+ if any(type(identity[key]) is not int or identity[key] < 1 for key in ("pid", "start_ticks")):
64
+ raise ValueError("invalid runtime holder")
65
+ if not isinstance(identity["client_id"], str) or not CLIENT_ID_PATTERN.fullmatch(identity["client_id"]):
66
+ raise ValueError("invalid runtime client")
67
+ for key in ("session_id", "run_id"):
68
+ if identity[key] is not None and (not isinstance(identity[key], str) or not identity[key] or "\x00" in identity[key]):
69
+ raise ValueError("invalid runtime identity")
70
+ uuid.UUID(identity["boot_id"])
71
+ if data["lease_id"] is not None:
72
+ uuid.UUID(data["lease_id"])
73
+ return WriterHolder(**identity), data["lease_id"]
74
+ except (ValueError, KeyError, TypeError, AttributeError) as exc:
75
+ raise ControllerError("INVALID_RUNTIME_BINDING", "runtime occupancy identity is malformed") from exc
76
+
77
+
78
+ def assert_runtime_clear(target: Path, holders: tuple[WriterHolder, ...]) -> None:
79
+ """Clear this execution's occupancy; allocation release still checks all references."""
80
+ from .environment import _current_process_identity, runtime_authority_evidence
81
+
82
+ if any(holder_is_live(holder) or process_group_is_live(holder) for holder in holders):
83
+ raise ControllerError("RUNTIME_OCCUPIED", "runtime holder is still alive")
84
+ evidence = runtime_authority_evidence(target, holders=holders)
85
+ observed = []
86
+ for reference in evidence["observed_references"]:
87
+ process = _current_process_identity(Path("/proc") / str(reference["pid"]))
88
+ # A still-open human terminal can predate this entire execution. Kernel
89
+ # creation order proves it is not a descendant of any declared holder.
90
+ # Newer, reused or unobservable identities remain blocking. The allocation
91
+ # cleanup path retains this positive reference without this exclusion.
92
+ if (not holders or process is None or any(holder.boot_id != boot_id()
93
+ or process.start_ticks >= holder.start_ticks for holder in holders)):
94
+ observed.append(reference)
95
+ if observed or any(evidence[key] for key in ("managed_references", "managed_visibility_uncertainties", "scan_uncertainties")):
96
+ raise ControllerError("RUNTIME_OCCUPIED", "runtime remains until managed and observed use is clear")
97
+
98
+
99
+ def foreign_runtime_bindings(record: MetadataRecord, holder: WriterHolder, lease_id: str | None) -> tuple[str, ...]:
100
+ """One owner's runtime occupancy does not fence its own write entitlement."""
101
+ foreign = []
102
+ for value in record.external_bindings:
103
+ declared = runtime_binding(value)
104
+ # A descendant already admitted by the formal transfer operation may keep
105
+ # running after the root writer exits. It shares occupancy, not ownership.
106
+ if (declared is None or lease_id != declared[1]
107
+ or (holder.client_id, holder.session_id, holder.run_id)
108
+ != (declared[0].client_id, declared[0].session_id, declared[0].run_id)):
109
+ foreign.append(value)
110
+ return tuple(foreign)
111
+
112
+
113
+ def writer_requirement(role: str) -> str:
114
+ """Classify declared roles, never Controller errors or future unknown roles.
115
+
116
+ NOT_APPLICABLE grants no write authority: integration, recovery, publication
117
+ and control-plane operations retain their dedicated write/lifecycle gates.
118
+ """
119
+ if role in FEATURE_AUTHORITY_ROLES:
120
+ return "REQUIRED"
121
+ if role in CANONICAL_ROLES | RECOVERY_ROLES:
122
+ return "NOT_APPLICABLE"
123
+ raise ControllerError("INVALID_ROLE", f"no writer applicability contract for role {role}")
124
+
125
+
126
+ def now() -> str:
127
+ return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
128
+
129
+
130
+ def boot_id() -> str:
131
+ """Identify this boot, so a lease can never survive a reboot into a reused pid."""
132
+ try:
133
+ value = _BOOT_ID_FILE.read_text(encoding="utf-8").strip()
134
+ except OSError:
135
+ value = ""
136
+ if not value or value == "unknown-boot":
137
+ raise ControllerError("INVALID_HOLDER", "boot identity is unavailable")
138
+ return value
139
+
140
+
141
+ def start_ticks(pid: int) -> int | None:
142
+ """Kernel start time for ``pid``, or None when the process is gone.
143
+
144
+ Field 22 of ``/proc/<pid>/stat`` counts clock ticks since boot. The comm field
145
+ can contain spaces and parentheses, so the tail is parsed after the final ')'.
146
+ """
147
+ try:
148
+ raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
149
+ except (FileNotFoundError, ProcessLookupError):
150
+ return None
151
+ except OSError as exc:
152
+ raise ControllerError("INVALID_HOLDER", f"process {pid} cannot be inspected") from exc
153
+ cut = raw.rfind(")")
154
+ if cut == -1:
155
+ raise ControllerError("INVALID_HOLDER", "invalid process stat")
156
+ fields = raw[cut + 2 :].split()
157
+ if len(fields) < 20:
158
+ raise ControllerError("INVALID_HOLDER", "incomplete process stat")
159
+ try:
160
+ return int(fields[19])
161
+ except ValueError as exc:
162
+ raise ControllerError("INVALID_HOLDER", "invalid process start ticks") from exc
163
+
164
+
165
+ def resolve_holder(
166
+ selector: str, *, client_id: str, session_id: str | None = None, run_id: str | None = None
167
+ ) -> WriterHolder:
168
+ """Version 1 resolver shared by CLI, hooks and wrappers; never match shell arguments."""
169
+ if selector.startswith("ancestor:"):
170
+ name = selector.removeprefix("ancestor:")
171
+ if not name or Path(name).name != name:
172
+ raise ControllerError("INVALID_HOLDER", "an ancestor program name is required")
173
+ pid = os.getppid()
174
+ for _ in range(32):
175
+ if pid <= 1:
176
+ break
177
+ try:
178
+ before = start_ticks(pid)
179
+ argv0 = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0", 1)[0]
180
+ executable = Path(os.readlink(f"/proc/{pid}/exe"))
181
+ raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
182
+ parent = int(raw[raw.rfind(")") + 2 :].split()[1])
183
+ except (OSError, ValueError, IndexError) as exc:
184
+ raise ControllerError("INVALID_HOLDER", "ancestor identity unavailable") from exc
185
+ if before is None or start_ticks(pid) != before:
186
+ raise ControllerError("INVALID_HOLDER", "ancestor process changed")
187
+ if Path(os.fsdecode(argv0)).name == name or executable.name == name:
188
+ holder = describe_holder(
189
+ pid, client_id=client_id, session_id=session_id, run_id=run_id
190
+ )
191
+ if holder.start_ticks != before:
192
+ raise ControllerError("INVALID_HOLDER", "ancestor process changed")
193
+ return holder
194
+ pid = parent
195
+ raise ControllerError("INVALID_HOLDER", f"no ancestor process runs {name!r}")
196
+ if not selector.isdecimal():
197
+ raise ControllerError("INVALID_HOLDER", "holder must be a pid or ancestor:<program>")
198
+ return describe_holder(int(selector), client_id=client_id, session_id=session_id, run_id=run_id)
199
+
200
+
201
+ def same_holder(left: WriterHolder, right: WriterHolder) -> bool:
202
+ """Optional session/run identity cannot be omitted to inherit another entitlement."""
203
+ return left == right
204
+
205
+
206
+ def describe_holder(
207
+ pid: int,
208
+ *,
209
+ client_id: str,
210
+ session_id: str | None = None,
211
+ run_id: str | None = None,
212
+ ) -> WriterHolder:
213
+ if not isinstance(client_id, str) or not CLIENT_ID_PATTERN.fullmatch(client_id) or pid <= 0:
214
+ raise ControllerError(
215
+ "INVALID_HOLDER", "writer client_id must be a bounded opaque identifier"
216
+ )
217
+ ticks = start_ticks(pid)
218
+ if ticks is None:
219
+ raise ControllerError("INVALID_HOLDER", f"process {pid} is not running")
220
+ return WriterHolder(
221
+ client_id=client_id,
222
+ pid=pid,
223
+ boot_id=boot_id(),
224
+ start_ticks=ticks,
225
+ session_id=session_id,
226
+ run_id=run_id,
227
+ )
228
+
229
+
230
+ def holder_is_live(holder: WriterHolder) -> bool:
231
+ """True only when the exact original process is still running.
232
+
233
+ A different boot, a vanished pid, or a pid whose start time no longer matches
234
+ all mean the original holder is gone - never that its task finished.
235
+ """
236
+ if not holder.boot_id or holder.boot_id == "unknown-boot":
237
+ raise ControllerError("INVALID_HOLDER", "recorded boot identity is unknown")
238
+ if holder.boot_id != boot_id():
239
+ return False
240
+ return start_ticks(holder.pid) == holder.start_ticks
241
+
242
+
243
+ def process_group_is_live(holder: WriterHolder) -> bool:
244
+ """True when the holder's process group still has a member running.
245
+
246
+ A wrapper can die while the real work continues in a surviving child, so the
247
+ group is checked before a lease is treated as abandoned.
248
+ """
249
+ if holder.boot_id != boot_id():
250
+ return False
251
+ ticks = start_ticks(holder.pid)
252
+ if ticks is not None and ticks != holder.start_ticks:
253
+ # A reused pid's new group is not evidence about the original holder.
254
+ return False
255
+ try:
256
+ pgid = os.getpgid(holder.pid)
257
+ except (ProcessLookupError, PermissionError, OSError):
258
+ pgid = holder.pid
259
+ for entry in os.listdir("/proc"):
260
+ if not entry.isdigit():
261
+ continue
262
+ candidate = int(entry)
263
+ try:
264
+ if os.getpgid(candidate) == pgid:
265
+ return True
266
+ except (ProcessLookupError, PermissionError, OSError):
267
+ continue
268
+ return False
269
+
270
+
271
+ def own_ancestry() -> set[int]:
272
+ """This process and its ancestors: the caller asking for an action is not a user of the checkout."""
273
+ chain, pid = set(), os.getpid()
274
+ while pid > 1 and pid not in chain:
275
+ chain.add(pid)
276
+ try:
277
+ stat = Path(f"/proc/{pid}/stat").read_text()
278
+ except OSError:
279
+ break
280
+ pid = int(stat.rsplit(")", 1)[1].split()[1])
281
+ return chain
282
+
283
+
284
+ def paths_in_use(target: Path) -> list[tuple[int, str]]:
285
+ """Live processes whose cwd, open files, or mappings sit inside ``target``.
286
+
287
+ This is the evidence that covers a session holding no lease at all, so a
288
+ legacy or externally started writer is never mistaken for an absent one.
289
+ """
290
+ return ProcessPathScan().in_use(target)
291
+
292
+
293
+ class ProcessPathScan:
294
+ """One ``/proc`` reading shared by every target checked in a single pass.
295
+
296
+ Reading each process's cwd, descriptors and mappings is the expensive part, so a
297
+ pass that checks many checkouts reads them once, on first use, instead of once
298
+ per checkout. Each answer is the same as a fresh ``paths_in_use`` over that
299
+ reading; the reading is as old as the pass, like any other pass-time evidence.
300
+ """
301
+
302
+ def __init__(self) -> None:
303
+ self._references: list[tuple[int, list[str]]] | None = None
304
+
305
+ def in_use(self, target: Path) -> list[tuple[int, str]]:
306
+ root = str(target)
307
+ prefix = root + os.sep
308
+ return [
309
+ (pid, _command(pid))
310
+ for pid, candidates in self._read_references()
311
+ if any(candidate == root or candidate.startswith(prefix) for candidate in candidates)
312
+ ]
313
+
314
+ def _read_references(self) -> list[tuple[int, list[str]]]:
315
+ if self._references is None:
316
+ self._references = [
317
+ (pid, _referenced_paths(pid))
318
+ for pid in (int(entry) for entry in os.listdir("/proc") if entry.isdigit())
319
+ if pid != os.getpid()
320
+ ]
321
+ return self._references
322
+
323
+ def referenced_paths_in_use(self, target: Path) -> list[tuple[int, list[str]]]:
324
+ """Reuse the same scan without reading process command-line arguments."""
325
+ root = str(target)
326
+ prefix = root + os.sep
327
+ result = []
328
+ for pid, candidates in self._read_references():
329
+ matches = [path for path in candidates if path == root or path.startswith(prefix)]
330
+ if matches:
331
+ result.append((pid, matches))
332
+ return result
333
+
334
+
335
+ def _referenced_paths(pid: int) -> list[str]:
336
+ seen: list[str] = []
337
+ with contextlib.suppress(OSError):
338
+ seen.append(os.path.realpath(f"/proc/{pid}/cwd"))
339
+ try:
340
+ for handle in os.listdir(f"/proc/{pid}/fd"):
341
+ try:
342
+ seen.append(os.path.realpath(f"/proc/{pid}/fd/{handle}"))
343
+ except OSError:
344
+ continue
345
+ except OSError:
346
+ pass
347
+ try:
348
+ with open(f"/proc/{pid}/maps", encoding="utf-8") as stream:
349
+ for line in stream:
350
+ parts = line.rstrip("\n").split(" ", 5)
351
+ if len(parts) == 6 and parts[5].startswith("/"):
352
+ seen.append(parts[5].strip())
353
+ except OSError:
354
+ pass
355
+ return seen
356
+
357
+
358
+ def _command(pid: int) -> str:
359
+ try:
360
+ raw = Path(f"/proc/{pid}/cmdline").read_bytes()
361
+ except OSError:
362
+ return "<unknown>"
363
+ return raw.replace(b"\0", b" ").decode("utf-8", "replace").strip()[:120]
364
+
365
+
366
+ def new_lease(
367
+ *,
368
+ repository_id: str,
369
+ allocation_id: str,
370
+ worktree_path: str,
371
+ owner: str,
372
+ holder: WriterHolder,
373
+ ttl_seconds: int | None,
374
+ ) -> WriterLease:
375
+ stamp = now()
376
+ expires_at = None
377
+ if ttl_seconds is not None:
378
+ if ttl_seconds <= 0:
379
+ raise ControllerError("INVALID_HOLDER", "lease ttl must be positive")
380
+ moment = datetime.fromisoformat(stamp)
381
+ expires_at = moment.fromtimestamp(
382
+ moment.timestamp() + ttl_seconds, tz=moment.tzinfo
383
+ ).isoformat(timespec="seconds")
384
+ return WriterLease(
385
+ schema_version=WRITER_LEASE_SCHEMA_VERSION,
386
+ lease_id=str(uuid.uuid4()),
387
+ repository_id=repository_id,
388
+ allocation_id=allocation_id,
389
+ worktree_path=worktree_path,
390
+ owner=owner,
391
+ holder=holder,
392
+ acquired_at=stamp,
393
+ renewed_at=stamp,
394
+ expires_at=expires_at,
395
+ record_version=1,
396
+ )
397
+
398
+
399
+ def lease_is_expired(lease: WriterLease, *, at: str | None = None) -> bool:
400
+ """Expiry means the writer claim needs verification - never that work is done."""
401
+ if lease.expires_at is None:
402
+ return False
403
+ moment = datetime.fromisoformat(at or now())
404
+ return datetime.fromisoformat(lease.expires_at) < moment
405
+
406
+
407
+ def acquire_writer_lease(
408
+ repository_path: Path,
409
+ *,
410
+ target: Path,
411
+ owner: str | None = None,
412
+ client_id: str,
413
+ holder_pid: int,
414
+ session_id: str | None = None,
415
+ run_id: str | None = None,
416
+ ttl_seconds: int | None = None,
417
+ holder: WriterHolder | None = None,
418
+ expected_record_version: int | None = None,
419
+ observe: bool = True,
420
+ ) -> dict[str, Any]:
421
+ """Claim write entitlement for one live process, without touching ownership."""
422
+
423
+ from .controller import envelope
424
+ from .controller import status as _status
425
+ from .git_backend import inventory as _inventory
426
+ from .policy import load_policy
427
+ from .semantics import assert_record_version as _cas
428
+ from .semantics import managed_record as _managed_record
429
+ from .store import MetadataStore as _Store
430
+
431
+ repository_initial = _inventory(repository_path)
432
+ try:
433
+ selected = _inventory(target)
434
+ except OSError as exc:
435
+ raise ControllerError("INVALID_TARGET", "writer path is unavailable") from exc
436
+ if selected.common_dir != repository_initial.common_dir:
437
+ raise ControllerError("UNREGISTERED_WORKTREE", "writer path belongs to another repository")
438
+ target = selected.requested_path
439
+ store = _Store(repository_initial.common_dir)
440
+ if not store.lock_file.is_file() or store.lock_file.is_symlink():
441
+ raise ControllerError(
442
+ "INVALID_METADATA", "governed writer acquisition requires its repository lock"
443
+ )
444
+ with store.lock(exclusive=True):
445
+ policy = load_policy(repository_initial.canonical_path)
446
+ if policy.path.is_symlink():
447
+ raise ControllerError("INVALID_POLICY", "policy marker is a symlink")
448
+ snapshot = store._load_unlocked()
449
+ if snapshot.repository_id is None:
450
+ raise ControllerError("INVALID_METADATA", "governed repository identity is missing")
451
+ item, record = _managed_record(_inventory(repository_path), snapshot, target)
452
+ if expected_record_version is not None:
453
+ _cas(record, expected_record_version)
454
+ if item.detached or item.locked or item.prunable:
455
+ raise ControllerError("INVALID_TARGET", "writer checkout requires review")
456
+ # A wrapper at a process boundary cannot know the owner string; being able to
457
+ # run in the governed checkout is the entitlement. A caller that does declare
458
+ # an owner is still held to it, so a declared mismatch fails closed.
459
+ if owner is not None and record.owner != owner:
460
+ raise ControllerError(
461
+ "OWNER_MISMATCH", "writer lease owner does not match the allocation owner"
462
+ )
463
+ observed = describe_holder(
464
+ holder_pid, client_id=client_id, session_id=session_id, run_id=run_id
465
+ )
466
+ if holder is not None and not same_holder(holder, observed):
467
+ raise ControllerError("WRITER_LEASE_MISMATCH", "holder changed during acquisition")
468
+ holder = observed
469
+ if ttl_seconds is not None and ttl_seconds <= 0:
470
+ raise ControllerError("INVALID_HOLDER", "lease ttl must be positive")
471
+ if writer_requirement(record.role) == "NOT_APPLICABLE":
472
+ payload = envelope("writer-lease-acquire")
473
+ payload["decision"] = {
474
+ "admission": "NOT_APPLICABLE",
475
+ "code": "DEDICATED_ROLE_GATE",
476
+ "role": record.role,
477
+ "allocation_id": record.allocation_id,
478
+ "reason": "no ordinary writer entitlement; dedicated operation gates still apply",
479
+ }
480
+ return payload
481
+ existing = snapshot.writer_leases.get(record.allocation_id)
482
+ if foreign_runtime_bindings(record, holder, existing.lease_id if existing else None):
483
+ raise ControllerError("EXTERNAL_BINDING", "runtime bindings prevent a new writer")
484
+ if record.lifecycle != "ACTIVE":
485
+ raise ControllerError(
486
+ "INVALID_LIFECYCLE",
487
+ f"lifecycle {record.lifecycle} cannot take a writer lease; resume it first",
488
+ )
489
+ if existing is not None and not same_holder(existing.holder, holder) and (
490
+ holder_is_live(existing.holder) or process_group_is_live(existing.holder) or paths_in_use(item.path)
491
+ ):
492
+ raise ControllerError("WRITER_LEASE_HELD", "another live process already holds this allocation")
493
+ if existing is not None:
494
+ same = same_holder(existing.holder, holder)
495
+ if (existing.repository_id, existing.worktree_path, existing.owner) != (
496
+ snapshot.repository_id,
497
+ str(item.path),
498
+ record.owner,
499
+ ):
500
+ raise ControllerError("WRITER_LEASE_MISMATCH", "lease allocation identity drifted")
501
+ if same:
502
+ if lease_is_expired(existing) and ttl_seconds is None:
503
+ raise ControllerError(
504
+ "WRITER_LEASE_EXPIRED", "expired lease requires explicit renewal ttl"
505
+ )
506
+ renewed = replace(
507
+ existing, renewed_at=now(), record_version=existing.record_version + 1
508
+ )
509
+ if ttl_seconds is not None:
510
+ renewed = replace(
511
+ renewed,
512
+ expires_at=_shift(renewed.renewed_at, ttl_seconds),
513
+ )
514
+ store.write_writer_lease(renewed)
515
+ lease = renewed
516
+ code = "WRITER_LEASE_RENEWED"
517
+ elif (
518
+ holder_is_live(existing.holder)
519
+ or process_group_is_live(existing.holder)
520
+ or paths_in_use(item.path)
521
+ ):
522
+ raise ControllerError(
523
+ "WRITER_LEASE_HELD",
524
+ "another live process already holds the writer lease for this allocation",
525
+ )
526
+ else:
527
+ lease = new_lease(
528
+ repository_id=snapshot.repository_id or "unknown",
529
+ allocation_id=record.allocation_id,
530
+ worktree_path=str(item.path),
531
+ owner=record.owner,
532
+ holder=holder,
533
+ ttl_seconds=ttl_seconds,
534
+ )
535
+ store.write_writer_lease(lease)
536
+ code = "WRITER_LEASE_TAKEN_OVER"
537
+ else:
538
+ lease = new_lease(
539
+ repository_id=snapshot.repository_id or "unknown",
540
+ allocation_id=record.allocation_id,
541
+ worktree_path=str(item.path),
542
+ owner=record.owner,
543
+ holder=holder,
544
+ ttl_seconds=ttl_seconds,
545
+ )
546
+ store.write_writer_lease(lease)
547
+ code = "WRITER_LEASE_ACQUIRED"
548
+ payload = _status(repository_path) if observe else envelope("writer-lease-acquire")
549
+ payload["command"] = "writer-lease-acquire"
550
+ payload["decision"] = {
551
+ "code": code,
552
+ "admission": "GRANTED",
553
+ "reason": "writer entitlement bound to a verifiable live process",
554
+ "lease_id": lease.lease_id,
555
+ "allocation_id": lease.allocation_id,
556
+ "owner": lease.owner,
557
+ "holder": asdict(lease.holder),
558
+ "holder_resolver_version": HOLDER_RESOLVER_VERSION,
559
+ "expires_at": lease.expires_at,
560
+ }
561
+ return payload
562
+
563
+
564
+ def _shift(stamp: str, seconds: int) -> str:
565
+ moment = datetime.fromisoformat(stamp)
566
+ return moment.fromtimestamp(moment.timestamp() + seconds, tz=moment.tzinfo).isoformat(
567
+ timespec="seconds"
568
+ )
569
+
570
+
571
+ def release_writer_lease(
572
+ repository_path: Path,
573
+ *,
574
+ target: Path,
575
+ holder_pid: int | None = None,
576
+ lease_id: str | None = None,
577
+ holder: WriterHolder | None = None,
578
+ ) -> dict[str, Any]:
579
+ """Give up write entitlement. This says nothing about whether the task finished."""
580
+
581
+ from .controller import status as _status
582
+ from .git_backend import inventory as _inventory
583
+ from .semantics import managed_record as _managed_record
584
+ from .store import MetadataStore as _Store
585
+
586
+ if holder is None:
587
+ raise ControllerError("WRITER_LEASE_MISMATCH", "complete holder identity is required")
588
+ if holder_pid is not None and holder_pid != holder.pid:
589
+ raise ControllerError("WRITER_LEASE_MISMATCH", "conflicting holder pid")
590
+ repository_initial = _inventory(repository_path)
591
+ store = _Store(repository_initial.common_dir)
592
+ removed = False
593
+ exited = False
594
+ reason = "no writer lease was held"
595
+ with store.lock(exclusive=True):
596
+ snapshot = store._load_unlocked()
597
+ item, record = _managed_record(_inventory(repository_path), snapshot, target)
598
+ lease = snapshot.writer_leases.get(record.allocation_id)
599
+ if lease is not None:
600
+ if lease_id is not None and lease.lease_id != lease_id:
601
+ raise ControllerError(
602
+ "WRITER_LEASE_MISMATCH", "lease id does not match the recorded lease"
603
+ )
604
+ if not same_holder(lease.holder, holder):
605
+ raise ControllerError(
606
+ "WRITER_LEASE_MISMATCH", "exact live holder identity does not match the lease"
607
+ )
608
+ exited = not holder_is_live(holder)
609
+ if exited and (
610
+ lease_id is None
611
+ or process_group_is_live(holder)
612
+ or any(pid not in own_ancestry() for pid, _ in paths_in_use(item.path))
613
+ ):
614
+ # A host releases after its runtime reaped the session; the exact recorded
615
+ # identity and lease ID prove it is that holder's own lease. Surviving
616
+ # children, or any other process still inside the checkout, keep it.
617
+ raise ControllerError(
618
+ "WRITER_LEASE_MISMATCH",
619
+ "an exited holder's lease needs its exact lease ID and no surviving children",
620
+ )
621
+ removed = store.remove_writer_lease(record.allocation_id)
622
+ reason = (
623
+ "exited holder's writer entitlement released; allocation and work untouched"
624
+ if exited
625
+ else "writer entitlement released; allocation and work untouched"
626
+ )
627
+
628
+ payload = _status(repository_path)
629
+ payload["command"] = "writer-lease-release"
630
+ payload["decision"] = {
631
+ "code": "WRITER_LEASE_RELEASED" if removed else "NO_WRITER_LEASE",
632
+ "reason": reason,
633
+ "allocation_id": record.allocation_id,
634
+ "holder_exited": exited,
635
+ }
636
+ return payload
637
+
638
+
639
+ def check_writer_entitlement(
640
+ repository_id: str | None,
641
+ record: MetadataRecord,
642
+ target: Path,
643
+ lease: WriterLease | None,
644
+ holder: WriterHolder | None,
645
+ ) -> None:
646
+ """Check the current entitlement from the same snapshot as write authority."""
647
+ if lease is None or holder is None:
648
+ raise ControllerError("WRITER_LEASE_REQUIRED", "an exact current writer lease is required")
649
+ if foreign_runtime_bindings(record, holder, lease.lease_id):
650
+ raise ControllerError("EXTERNAL_BINDING", "runtime bindings fence writer entitlement")
651
+ if (
652
+ lease.repository_id != repository_id
653
+ or lease.allocation_id != record.allocation_id
654
+ or lease.worktree_path != str(target)
655
+ or lease.owner != record.owner
656
+ or not same_holder(lease.holder, holder)
657
+ or not holder_is_live(holder)
658
+ or lease_is_expired(lease)
659
+ ):
660
+ raise ControllerError(
661
+ "WRITER_LEASE_MISMATCH", "writer entitlement is stale or belongs to another holder"
662
+ )