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,876 @@
1
+ """Repository-wide nonterminal mutation conflicts; retained evidence is not a lock."""
2
+
3
+ import hashlib
4
+ import os
5
+ import stat
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from uuid import UUID
9
+
10
+ from . import cleanup_evidence as e
11
+ from .models import ControllerError
12
+ from .store import MetadataStore, record_from_dict
13
+
14
+ CONTRACT = "controller-transaction-conflict/v1"
15
+
16
+ _OID_LENGTHS = frozenset({40, 64})
17
+ _HEX = frozenset("0123456789abcdef")
18
+
19
+
20
+ def is_atomic_temp(name: str) -> bool:
21
+ """True for ``store._atomic_json`` interrupted writes: ``.<target>.<8>.tmp``.
22
+
23
+ Such a file never became the authoritative record because its rename into
24
+ place did not complete, so journal and evidence scans skip it instead of
25
+ letting debris fence the whole repository.
26
+ """
27
+ return name.startswith(".") and name.endswith(".tmp")
28
+
29
+
30
+ def _require_flock(store: MetadataStore, *, assume_serialized: bool = False) -> None:
31
+ if not store.flock_held and not assume_serialized:
32
+ raise ControllerError(
33
+ "MISSING_REPOSITORY_LOCK",
34
+ "terminal transaction classification requires the held repository flock; "
35
+ "call it inside store.lock() or pass assume_serialized from a locked caller",
36
+ )
37
+
38
+
39
+ def _is_oid(value: str) -> bool:
40
+ return len(value) in _OID_LENGTHS and _HEX.issuperset(value)
41
+
42
+
43
+ def _files_ref_storage(common_dir: Path) -> bool:
44
+ """False unless refs live in the filesystem layout this classifier reads."""
45
+ try:
46
+ raw = (common_dir / "config").read_text(encoding="utf-8")
47
+ except FileNotFoundError:
48
+ return True
49
+ except (OSError, UnicodeError):
50
+ return False
51
+ section = ""
52
+ for line in raw.splitlines():
53
+ stripped = line.strip()
54
+ if stripped.startswith("[") and stripped.endswith("]"):
55
+ section = stripped[1:-1].split(maxsplit=1)[0].lower().strip('"')
56
+ elif "=" in stripped and section == "extensions":
57
+ key, _, value = stripped.partition("=")
58
+ if key.strip().lower() == "refstorage" and "reftable" in value.strip().lower():
59
+ return False
60
+ return True
61
+
62
+
63
+ def _branch_ref_state(common_dir: Path, ref: str) -> tuple[str | None, str]:
64
+ """Filesystem-only ref read: (oid, resolved) | (None, absent) | (None, uncertain).
65
+
66
+ Absence is only proven for the files backend when the loose path is truly
67
+ missing and packed-refs was scanned without a hit; a symref, a dangling
68
+ link, an unreadable file, or another ref backend is uncertainty, which
69
+ keeps a journal pending instead of proving a shape nobody observed.
70
+ """
71
+ if not _files_ref_storage(common_dir):
72
+ return None, "uncertain"
73
+ loose = common_dir.joinpath(*ref.split("/"))
74
+ try:
75
+ raw = loose.read_text(encoding="utf-8").strip()
76
+ except FileNotFoundError:
77
+ raw = None
78
+ except (OSError, UnicodeError):
79
+ return None, "uncertain"
80
+ if raw is not None:
81
+ return (raw, "resolved") if _is_oid(raw) else (None, "uncertain")
82
+ if loose.is_symlink():
83
+ return None, "uncertain"
84
+ try:
85
+ packed = (common_dir / "packed-refs").read_text(encoding="utf-8")
86
+ except FileNotFoundError:
87
+ return None, "absent"
88
+ except (OSError, UnicodeError):
89
+ return None, "uncertain"
90
+ for line in packed.splitlines():
91
+ if not line or line.startswith(("#", "^")):
92
+ continue
93
+ oid, _, name = line.partition(" ")
94
+ if name.strip() == ref:
95
+ return (oid, "resolved") if _is_oid(oid) else (None, "uncertain")
96
+ return None, "absent"
97
+
98
+
99
+ def _worktree_registration(common_dir: Path, target: Path) -> tuple[str | None, str | None, str]:
100
+ """Filesystem-only scan of linked-worktree admin entries for ``target``.
101
+
102
+ Returns (entry key, HEAD text, state) with state in registered/absent/
103
+ uncertain; two entries claiming one checkout is uncertainty.
104
+ """
105
+ records = common_dir / "worktrees"
106
+ if records.is_symlink():
107
+ return None, None, "uncertain"
108
+ if not records.is_dir():
109
+ return None, None, "absent"
110
+ found: tuple[str, str | None] | None = None
111
+ try:
112
+ entries = sorted(records.iterdir())
113
+ except OSError:
114
+ return None, None, "uncertain"
115
+ for entry in entries:
116
+ try:
117
+ value = (entry / "gitdir").read_text(encoding="utf-8").strip()
118
+ except FileNotFoundError:
119
+ continue
120
+ except (OSError, UnicodeError):
121
+ return None, None, "uncertain"
122
+ if value and Path(value).parent == target:
123
+ if found is not None:
124
+ return None, None, "uncertain"
125
+ try:
126
+ head = (entry / "HEAD").read_text(encoding="utf-8").strip()
127
+ except FileNotFoundError:
128
+ head = None
129
+ except (OSError, UnicodeError):
130
+ return None, None, "uncertain"
131
+ found = (entry.name, head)
132
+ if found is None:
133
+ return None, None, "absent"
134
+ return found[0], found[1], "registered"
135
+
136
+
137
+ def _target_state(target: Path) -> str:
138
+ try:
139
+ info = os.lstat(target)
140
+ except FileNotFoundError:
141
+ return "absent"
142
+ except OSError:
143
+ return "uncertain"
144
+ if stat.S_ISLNK(info.st_mode):
145
+ return "symlink"
146
+ return "directory" if stat.S_ISDIR(info.st_mode) else "present_other"
147
+
148
+
149
+ def _binding_for_key(store: MetadataStore, key: str) -> tuple[str | None, str]:
150
+ """The allocation a worktree key binds: (allocation, present) or state reason."""
151
+ path = store.bindings_dir / f"{key}.json"
152
+ try:
153
+ info = os.lstat(path)
154
+ except FileNotFoundError:
155
+ return None, "absent"
156
+ except OSError:
157
+ return None, "uncertain"
158
+ if not stat.S_ISREG(info.st_mode):
159
+ return None, "uncertain"
160
+ try:
161
+ value = dict(store._read_json(path))
162
+ except ControllerError:
163
+ return None, "uncertain"
164
+ allocation = value.get("allocation_id")
165
+ return (allocation if isinstance(allocation, str) else None), "present"
166
+
167
+
168
+ def _binding_allocation_for_entry(store: MetadataStore, entry_name: str) -> tuple[str | None, str]:
169
+ """Resolve the binding that GitBackend derives from a worktree admin entry."""
170
+ import hashlib
171
+
172
+ digest = hashlib.sha256(f"worktrees/{entry_name}".encode()).hexdigest()
173
+ return _binding_for_key(store, f"wt_{digest}")
174
+
175
+
176
+ def _allocation_bound_anywhere(store: MetadataStore, allocation: str) -> str:
177
+ """'bound', 'unbound' or 'uncertain' — full bindings scan, fail-closed."""
178
+ if store.bindings_dir.is_symlink():
179
+ return "uncertain"
180
+ if not store.bindings_dir.is_dir():
181
+ return "unbound"
182
+ try:
183
+ paths = sorted(store.bindings_dir.glob("*.json"))
184
+ except OSError:
185
+ return "uncertain"
186
+ for path in paths:
187
+ try:
188
+ value = dict(store._read_json(path))
189
+ except ControllerError:
190
+ return "uncertain"
191
+ if value.get("allocation_id") == allocation:
192
+ return "bound"
193
+ return "unbound"
194
+
195
+
196
+ def _journal_preconditions(
197
+ store: MetadataStore, transaction: str, command: str, phases: frozenset[str]
198
+ ) -> dict | None:
199
+ """Journal must exist, match this repository, and describe ``command``."""
200
+ try:
201
+ journal = dict(store._read_json(store.transactions_dir / f"{transaction}.json"))
202
+ except ControllerError:
203
+ return None
204
+ if (
205
+ journal.get("command") != command
206
+ or journal.get("phase") not in phases
207
+ or str(transaction) != journal.get("transaction_id")
208
+ or journal.get("schema_version") not in {1, 2, 3, 4}
209
+ or not journal.get("allocation_id")
210
+ or not isinstance(journal.get("target_path"), str)
211
+ or not Path(journal["target_path"]).is_absolute()
212
+ or not isinstance(journal.get("branch"), str)
213
+ or not journal.get("branch")
214
+ or not isinstance(journal.get("expected_head"), str)
215
+ or not _is_oid(journal["expected_head"])
216
+ ):
217
+ return None
218
+ try:
219
+ if journal.get("repository_id") != str(
220
+ dict(store._read_json(store.root / "repo.json"))["repository_id"]
221
+ ):
222
+ return None
223
+ except (ControllerError, KeyError):
224
+ return None
225
+ return journal
226
+
227
+
228
+ def _record_state(store: MetadataStore, allocation: str, expected_commit: str) -> str:
229
+ """'match' | 'mismatch' | 'absent' | 'uncertain' for the allocation's record."""
230
+ path = store.records_dir / f"{allocation}.json"
231
+ try:
232
+ info = os.lstat(path)
233
+ except FileNotFoundError:
234
+ return "absent"
235
+ except OSError:
236
+ return "uncertain"
237
+ if not stat.S_ISREG(info.st_mode):
238
+ return "uncertain"
239
+ try:
240
+ record = dict(store._read_json(path))
241
+ except ControllerError:
242
+ return "uncertain"
243
+ if record.get("allocation_id") != allocation:
244
+ return "uncertain"
245
+ return "match" if record.get("task_commit") == expected_commit else "mismatch"
246
+
247
+
248
+ class TransactionConflict(ControllerError):
249
+ def __init__(self, conflicts: list[dict]) -> None:
250
+ super().__init__("INTERRUPTED_TRANSACTION", "nonterminal repository transaction conflicts")
251
+ self.transaction_conflicts = conflicts
252
+
253
+
254
+ def _worktree_administratively_registered(common_dir: Path, target: Path) -> bool:
255
+ """True when Git still lists a linked worktree whose checkout is ``target``."""
256
+ records = common_dir / "worktrees"
257
+ if not records.is_dir():
258
+ return False
259
+ for entry in sorted(records.iterdir()):
260
+ try:
261
+ # The administrative gitdir holds the checkout's own .git pointer, so
262
+ # its parent is the working tree that registered the entry.
263
+ value = (entry / "gitdir").read_text(encoding="utf-8").strip()
264
+ except (OSError, UnicodeError):
265
+ continue
266
+ if value and Path(value).parent == target:
267
+ return True
268
+ return False
269
+
270
+
271
+ def release_evidence_terminal(store: MetadataStore, transaction: str) -> bool:
272
+ """True when a retained resource-clean journal is superseded by exact release evidence.
273
+
274
+ A cleanup whose own journal never finished can still be overtaken by a later
275
+ release that did: the allocation is RELEASED, exactly one receipt binds that
276
+ release to this allocation, target and record version, and the resource is
277
+ physically gone. That is the same immutable evidence the admission reconciler
278
+ already demands, so the journal stops fencing without being rewritten; any
279
+ missing or contradictory piece leaves it pending. Only the three directories
280
+ that carry that evidence are read, so this stays cheap inside pending().
281
+ """
282
+ try:
283
+ journal = dict(store._read_json(store.transactions_dir / f"{transaction}.json"))
284
+ except ControllerError:
285
+ return False
286
+ if (journal.get("command") != "resource-clean" or journal.get("phase") != "PREPARED"
287
+ or str(transaction) != journal.get("transaction_id")
288
+ or not journal.get("allocation_id")
289
+ or not isinstance(journal.get("target_path"), str)):
290
+ return False
291
+ target = Path(journal["target_path"])
292
+ if target.exists() or target.is_symlink():
293
+ return False
294
+ allocation = str(journal["allocation_id"])
295
+ record_path = store.records_dir / f"{allocation}.json"
296
+ if (store.writer_leases_dir / f"{allocation}.json").exists() or not record_path.is_file():
297
+ return False
298
+ try:
299
+ record = dict(store._read_json(record_path))
300
+ repository_id = str(dict(store._read_json(store.repo_file))["repository_id"])
301
+ except (ControllerError, KeyError):
302
+ return False
303
+ if (record.get("allocation_id") != allocation or record.get("lifecycle") != "RELEASED"
304
+ or journal.get("repository_id") != repository_id):
305
+ return False
306
+ if _worktree_administratively_registered(store.root.parents[1], target):
307
+ return False
308
+ matches = 0
309
+ for path in sorted(store.release_receipts_dir.glob("*.json")) if store.release_receipts_dir.is_dir() else ():
310
+ try:
311
+ receipt = dict(store._read_json(path))
312
+ except ControllerError:
313
+ continue
314
+ if (receipt.get("repository_id"), receipt.get("allocation_id"), receipt.get("target_path"),
315
+ receipt.get("released_record_version"), receipt.get("result")) == (
316
+ repository_id, allocation, str(target), record.get("record_version"), "RELEASED"):
317
+ matches += 1
318
+ return matches == 1
319
+
320
+
321
+ def _acquire_handoff_head(store: MetadataStore, journal: dict) -> str | None:
322
+ """A later frozen handoff can positively prove a completed acquisition."""
323
+ if journal["phase"] != "METADATA_WRITTEN":
324
+ return None
325
+ try:
326
+ record = record_from_dict(
327
+ store._read_json(store.records_dir / f"{journal['allocation_id']}.json")
328
+ )
329
+ first = record.history[0]
330
+ head = record.handoff_commit
331
+ if (
332
+ record.allocation_id != journal["allocation_id"]
333
+ or record.role not in {"feature", "audit"}
334
+ or record.lifecycle not in {"HANDOFF_READY", "PUBLISHED", "RELEASED"}
335
+ or record.base_commit != journal["expected_head"]
336
+ or not head
337
+ or head == journal["expected_head"]
338
+ or record.task_commit != head
339
+ or record.finalizer_commit != head
340
+ or not record.finalizer_record_id
341
+ or first.get("event") != "ACQUIRED"
342
+ or first.get("from") is not None
343
+ or first.get("to") != "ACTIVE"
344
+ or first.get("at") != record.created_at
345
+ or not any(event.get("event") == "HANDOFF_INTEGRATION_READY"
346
+ and event.get("to") == "HANDOFF_READY" for event in record.history)
347
+ ):
348
+ return None
349
+ from .git_backend import is_ancestor
350
+
351
+ return head if is_ancestor(store.root.parents[1], record.base_commit, head) else None
352
+ except (ControllerError, OSError, ValueError, KeyError, IndexError):
353
+ return None
354
+
355
+
356
+ def _acquire_retirement_evidence(store: MetadataStore, journal: dict, release: dict) -> bool:
357
+ """An exact retirement preserves release proof after its retained ref disappears.
358
+
359
+ A still-open authorization also admits settlement of that very deletion. It
360
+ does not settle the authorization, which keeps its independent rollout fence.
361
+ """
362
+ from .branch_retirement import _load_authorization, _load_plan
363
+
364
+ for path in sorted(store.branch_retirement_plans_dir.glob("*.json")):
365
+ try:
366
+ plan = _load_plan(store, path.stem)
367
+ identity = {
368
+ "repository_id": journal["repository_id"],
369
+ "allocation_id": journal["allocation_id"],
370
+ "branch": journal["branch"],
371
+ "expected_oid": release["released_head"],
372
+ }
373
+ if (
374
+ any(plan.get(key) != value for key, value in identity.items())
375
+ or plan.get("release_receipt_id") != release.get("receipt_id")
376
+ or plan.get("eligible") is not True
377
+ or plan.get("blockers") != []
378
+ ):
379
+ continue
380
+ receipt_path = store.branch_retirement_receipts_dir / f"{path.stem}-local.json"
381
+ if receipt_path.is_file():
382
+ receipt = dict(store._read_json(receipt_path))
383
+ if (
384
+ receipt.get("schema_version") == 2
385
+ and receipt.get("plan_id") == path.stem
386
+ and receipt.get("receipt_id")
387
+ == hashlib.sha256(f"{path.stem}:local".encode()).hexdigest()
388
+ and receipt.get("operation") == "local"
389
+ and receipt.get("branch") == identity["branch"]
390
+ and receipt.get("expected_oid") == identity["expected_oid"]
391
+ and receipt.get("expected_integrated_oid")
392
+ == plan.get("expected_integrated_oid")
393
+ and receipt.get("provenance") == "AUTHORIZED_EXECUTION"
394
+ and receipt.get("finalizer_result") == "REF_REMOVED_AT_EXPECTED_OID"
395
+ and receipt.get("observed_ref") == f"refs/heads/{identity['branch']}"
396
+ and receipt.get("observed_at_settle") is None
397
+ ):
398
+ return True
399
+ for grant_path in sorted(store.branch_retirement_authorizations_dir.glob("*.json")):
400
+ grant = _load_authorization(store, grant_path.stem)
401
+ if (
402
+ grant.get("plan_id") == path.stem
403
+ and all(grant.get(key) == value for key, value in identity.items())
404
+ and grant.get("operation") == "local"
405
+ and grant.get("ref") == f"refs/heads/{identity['branch']}"
406
+ and grant.get("expected_integrated_oid") == plan.get("expected_integrated_oid")
407
+ and grant.get("controller_state_digest") == plan.get("controller_state_digest")
408
+ and grant.get("minimum_revalidation", {}).get("ref_state") == "PRESENT_EXPECTED"
409
+ ):
410
+ return True
411
+ except (ControllerError, OSError, KeyError, TypeError, ValueError):
412
+ continue
413
+ return False
414
+
415
+
416
+ def _acquire_task_progress(store: MetadataStore, journal: dict, head: str) -> str | None:
417
+ """Prove creation survived task progress, without asserting task completion."""
418
+ if journal["phase"] != "METADATA_WRITTEN" or not isinstance(head, str) or not _is_oid(head):
419
+ return None
420
+ try:
421
+ record = record_from_dict(
422
+ store._read_json(store.records_dir / f"{journal['allocation_id']}.json")
423
+ )
424
+ first = record.history[0]
425
+ if (
426
+ record.allocation_id != journal["allocation_id"]
427
+ or record.role not in {"feature", "audit"}
428
+ or record.lifecycle not in {"ACTIVE", "RETAINED", "ARCHIVED", "RELEASED"}
429
+ or record.base_commit != journal["expected_head"]
430
+ or record.task_commit != journal["expected_head"]
431
+ or record.handoff_commit is not None
432
+ or record.finalizer_commit is not None
433
+ or record.finalizer_record_id is not None
434
+ or first.get("event") != "ACQUIRED"
435
+ or first.get("from") is not None
436
+ or first.get("to") != "ACTIVE"
437
+ or first.get("at") != record.created_at
438
+ ):
439
+ return None
440
+ from .git_backend import is_ancestor
441
+
442
+ if not is_ancestor(store.root.parents[1], journal["expected_head"], head):
443
+ return None
444
+ if record.lifecycle == "ARCHIVED":
445
+ from .delivered_closeout import _archive_receipt_binding
446
+
447
+ _archive_receipt_binding(
448
+ store, record, journal["repository_id"],
449
+ target=Path(journal["target_path"]), branch=journal["branch"], head=head,
450
+ )
451
+ return record.lifecycle
452
+ except (ControllerError, OSError, ValueError, KeyError, IndexError):
453
+ return None
454
+
455
+
456
+ def acquire_evidence_terminal(store: MetadataStore, transaction: str) -> str | None:
457
+ """Close an interrupted acquire only on positive current-state proof.
458
+
459
+ The completion shape is the whole effect this journal's allocation could
460
+ have produced: the target checkout directory exists and is administratively
461
+ registered, the binding under that worktree key names this allocation, the
462
+ record names this allocation with the acquired base as its task commit,
463
+ and the branch still exactly equals that base. It is admitted from
464
+ WORKTREE_ADDED or METADATA_WRITTEN, which covers the post-write record
465
+ step racing the journal phase. The no-effect shape is the total absence of
466
+ every one of those facts (plus the target itself), admitted from PREPARED
467
+ or from a WORKTREE_ADDED crash whose orphan the operator has fully cleaned
468
+ up. Any half-shape or unreadable element stays pending and fail-closed.
469
+ A later frozen handoff can advance that commit only with its original
470
+ ACQUIRED identity, unchanged base, matching frozen commits and ancestry.
471
+ An unfrozen ACTIVE/RETAINED feature or audit may advance its attached branch
472
+ with the original ACQUIRED identity, unchanged task/base and ancestry. This
473
+ proves acquisition only; its lifecycle and unfinished task remain untouched.
474
+ ARCHIVED progress additionally binds its immutable archive receipt. RELEASED
475
+ progress still requires exact release, absence and retained-ref/retirement proof.
476
+ A later guarded release supersedes METADATA_WRITTEN only when its exact
477
+ receipt, released record, retained branch (or exact retirement), and absent
478
+ checkout all agree.
479
+ """
480
+ journal = _journal_preconditions(
481
+ store, transaction, "acquire", frozenset({"PREPARED", "WORKTREE_ADDED", "METADATA_WRITTEN"})
482
+ )
483
+ if journal is None:
484
+ return None
485
+ common_dir = store.root.parents[1]
486
+ target = Path(journal["target_path"])
487
+ allocation = str(journal["allocation_id"])
488
+ branch_oid, branch_state = _branch_ref_state(common_dir, f"refs/heads/{journal['branch']}")
489
+ key, admin_head, registration = _worktree_registration(common_dir, target)
490
+ target_state = _target_state(target)
491
+ expected_head = journal["expected_head"]
492
+ handoff_head = None
493
+ if branch_state in {"resolved", "absent"} and branch_oid != expected_head:
494
+ handoff_head = _acquire_handoff_head(store, journal)
495
+ if handoff_head is not None:
496
+ expected_head = handoff_head
497
+ record_state = _record_state(store, allocation, expected_head)
498
+ if (
499
+ journal["phase"] in {"WORKTREE_ADDED", "METADATA_WRITTEN"}
500
+ and registration == "registered"
501
+ and target_state == "directory"
502
+ ):
503
+ binding_allocation, binding_state = _binding_allocation_for_entry(store, str(key))
504
+ if (
505
+ binding_state == "present"
506
+ and binding_allocation == allocation
507
+ and branch_state == "resolved"
508
+ and record_state == "match"
509
+ ):
510
+ attached = admin_head == f"ref: refs/heads/{journal['branch']}"
511
+ if branch_oid == expected_head and (handoff_head is None or attached):
512
+ return "ACQUIRE_HANDOFF_SUPERSEDED" if handoff_head else "ACQUIRE_EVIDENCE_SUPERSEDED"
513
+ if (handoff_head is None and attached
514
+ and _acquire_task_progress(store, journal, branch_oid)
515
+ in {"ACTIVE", "RETAINED", "ARCHIVED"}):
516
+ return "ACQUIRE_EVIDENCE_SUPERSEDED"
517
+ if (
518
+ journal["phase"] == "METADATA_WRITTEN"
519
+ and registration == "absent"
520
+ and target_state == "absent"
521
+ and branch_state in {"absent", "resolved"}
522
+ and record_state == "match"
523
+ and _allocation_bound_anywhere(store, allocation) == "unbound"
524
+ and not (store.writer_leases_dir / f"{allocation}.json").exists()
525
+ ):
526
+ try:
527
+ record = dict(store._read_json(store.records_dir / f"{allocation}.json"))
528
+ except ControllerError:
529
+ record = {}
530
+ if record.get("lifecycle") == "RELEASED":
531
+ matches = []
532
+ for path in sorted(store.release_receipts_dir.glob("*.json")):
533
+ try:
534
+ receipt = dict(store._read_json(path))
535
+ except ControllerError:
536
+ continue
537
+ if receipt.get("allocation_id") != allocation:
538
+ continue
539
+ released_head = receipt.get("released_head")
540
+ progressed = (
541
+ handoff_head is None
542
+ and _acquire_task_progress(store, journal, released_head) == "RELEASED"
543
+ )
544
+ if (
545
+ receipt.get("repository_id") == journal["repository_id"]
546
+ and receipt.get("target_path") == str(target)
547
+ and receipt.get("branch") == journal["branch"]
548
+ and (released_head == expected_head or progressed)
549
+ and (branch_state == "absent" or branch_oid == released_head)
550
+ and receipt.get("released_record_version") == record.get("record_version")
551
+ and receipt.get("result") == "RELEASED"
552
+ ):
553
+ matches.append(receipt)
554
+ else:
555
+ return None
556
+ if len(matches) == 1 and (
557
+ branch_state == "resolved" or _acquire_retirement_evidence(store, journal, matches[0])
558
+ ):
559
+ return "ACQUIRE_RELEASE_SUPERSEDED"
560
+ if (
561
+ journal["phase"] in {"PREPARED", "WORKTREE_ADDED"}
562
+ and registration == "absent"
563
+ and branch_state == "absent"
564
+ and target_state == "absent"
565
+ and record_state == "absent"
566
+ and _allocation_bound_anywhere(store, allocation) == "unbound"
567
+ ):
568
+ return "ACQUIRE_NO_EFFECT_SUPERSEDED"
569
+ return None
570
+
571
+
572
+ def integration_refresh_terminal(store: MetadataStore, transaction: str) -> str | None:
573
+ """Close an interrupted refresh on ref/record state, never on phase alone.
574
+
575
+ A detach that still dangles at the candidate, a worktree whose admin HEAD
576
+ no longer names its branch, a moved branch, or a record that disagrees with
577
+ the journal is a real effect only the operator can resolve, so every such
578
+ half-shape keeps the fence. The no-effect shape requires the checkout
579
+ present and attached with branch and record still at the journal's old
580
+ head; the completion shape requires branch and record both advanced to the
581
+ recorded candidate with the worktree reattached.
582
+ """
583
+ journal = _journal_preconditions(
584
+ store,
585
+ transaction,
586
+ "integration-refresh",
587
+ frozenset({"REFRESH_PREPARED", "DETACHED_AT_NEW_HEAD", "BRANCH_UPDATED"}),
588
+ )
589
+ if journal is None:
590
+ return None
591
+ new_head = journal.get("new_head")
592
+ if not isinstance(new_head, str) or not _is_oid(new_head):
593
+ return None
594
+ if not isinstance(journal.get("expected_record_version"), int):
595
+ return None
596
+ common_dir = store.root.parents[1]
597
+ target = Path(journal["target_path"])
598
+ allocation = str(journal["allocation_id"])
599
+ ref = f"refs/heads/{journal['branch']}"
600
+ _key, head, registration = _worktree_registration(common_dir, target)
601
+ if registration != "registered" or _target_state(target) != "directory":
602
+ return None
603
+ if head != f"ref: {ref}":
604
+ return None
605
+ branch_oid, branch_state = _branch_ref_state(common_dir, ref)
606
+ if branch_state != "resolved":
607
+ return None
608
+ if (
609
+ journal["phase"] == "BRANCH_UPDATED"
610
+ and branch_oid == new_head
611
+ and _record_state(store, allocation, new_head) == "match"
612
+ ):
613
+ return "REFRESH_EVIDENCE_SUPERSEDED"
614
+ if (
615
+ journal["phase"] in {"REFRESH_PREPARED", "DETACHED_AT_NEW_HEAD"}
616
+ and branch_oid == journal["expected_head"]
617
+ and _record_state(store, allocation, journal["expected_head"]) == "match"
618
+ ):
619
+ return "REFRESH_NO_EFFECT_SUPERSEDED"
620
+ return None
621
+
622
+
623
+ def registration_prune_terminal(store: MetadataStore, transaction: str) -> bool:
624
+ """A missing audit registration is terminal only with exact receipt and metadata."""
625
+ path = store.transactions_dir / f"{transaction}.json"
626
+ try:
627
+ journal = dict(store._read_json(path))
628
+ except ControllerError:
629
+ return False
630
+ if journal.get("command") != "registration-prune":
631
+ return False
632
+ allocation = journal.get("allocation_id")
633
+ target = journal.get("target_path")
634
+ key = journal.get("worktree_key")
635
+ version = journal.get("expected_record_version")
636
+ if (
637
+ journal.get("transaction_id") != transaction
638
+ or not isinstance(allocation, str)
639
+ or not isinstance(target, str)
640
+ or not Path(target).is_absolute()
641
+ or not isinstance(key, str)
642
+ or not isinstance(version, int)
643
+ or not isinstance(journal.get("admin_path"), str)
644
+ or not isinstance(journal.get("preserving_oid"), str)
645
+ or not _is_oid(journal["preserving_oid"])
646
+ or journal.get("phase") not in {"PREPARED", "WORKTREE_REMOVED", "RELEASED_RECORDED"}
647
+ ):
648
+ return False
649
+ common = store.root.parents[1]
650
+ if (
651
+ _target_state(Path(target)) != "absent"
652
+ or _target_state(Path(journal["admin_path"])) != "absent"
653
+ or _worktree_registration(common, Path(target))[2] != "absent"
654
+ or _binding_for_key(store, key)[1] != "absent"
655
+ or _allocation_bound_anywhere(store, allocation) != "unbound"
656
+ ):
657
+ return False
658
+ try:
659
+ record = dict(store._read_json(store.records_dir / f"{allocation}.json"))
660
+ receipt = dict(
661
+ store._read_json(store.registration_prune_receipts_dir / f"{transaction}.json")
662
+ )
663
+ except ControllerError:
664
+ return False
665
+ return (
666
+ record.get("allocation_id") == allocation
667
+ and record.get("lifecycle") == "RELEASED"
668
+ and record.get("owner") == "unassigned"
669
+ and record.get("record_version") == version + 1
670
+ and receipt.get("contract") == "registration-prune/v1"
671
+ and receipt.get("receipt_id") == transaction
672
+ and receipt.get("repository_id") == journal.get("repository_id")
673
+ and receipt.get("allocation_id") == allocation
674
+ and receipt.get("target_path") == target
675
+ and receipt.get("worktree_key") == key
676
+ and receipt.get("expected_head") == journal.get("expected_head")
677
+ and receipt.get("expected_record_version") == version
678
+ and receipt.get("released_record_version") == version + 1
679
+ and receipt.get("preserving_ref") == journal.get("preserving_ref")
680
+ and receipt.get("preserving_oid") == journal.get("preserving_oid")
681
+ and receipt.get("gc_plan_id") == journal.get("gc_plan_id")
682
+ and receipt.get("result") == "REGISTRATION_PRUNED"
683
+ )
684
+
685
+
686
+ def terminal_state(
687
+ store: MetadataStore,
688
+ transaction: str,
689
+ *,
690
+ verify_generations: bool = False,
691
+ assume_serialized: bool = False,
692
+ ) -> str | None:
693
+ """Only supported, exact immutable terminal evidence closes a retained journal.
694
+
695
+ Classification is authority: it may run only while the caller actually
696
+ holds the repository flock (or explicitly declares it is already serialized
697
+ by a locked caller), so no reader can classify a journal that a live writer
698
+ is still mid-flight updating.
699
+ """
700
+ _require_flock(store, assume_serialized=assume_serialized)
701
+ from .cleanup_attempts import typed_terminal
702
+ from .cleanup_recovery import completed
703
+ from .source_recovery import terminal_state as source_terminal_state
704
+
705
+ try:
706
+ if typed_terminal(store, transaction):
707
+ return "TYPED_ATTEMPT_SUPERSEDED"
708
+ if completed(store, transaction, verify_generations=verify_generations):
709
+ return "RECOVERY_CLEANED"
710
+ source_state = source_terminal_state(
711
+ store, transaction, verify_generation=verify_generations
712
+ )
713
+ if source_state is not None:
714
+ return source_state
715
+ if release_evidence_terminal(store, transaction):
716
+ return "RELEASE_EVIDENCE_SUPERSEDED"
717
+ acquire_state = acquire_evidence_terminal(store, transaction)
718
+ if acquire_state is not None:
719
+ return acquire_state
720
+ refresh_state = integration_refresh_terminal(store, transaction)
721
+ if refresh_state is not None:
722
+ return refresh_state
723
+ if registration_prune_terminal(store, transaction):
724
+ return "REGISTRATION_PRUNE_EVIDENCE_SUPERSEDED"
725
+ except (OSError, ValueError, TypeError, KeyError, IndexError) as exc:
726
+ raise ControllerError(
727
+ "INVALID_TRANSACTION", "terminal transaction evidence is invalid or unavailable"
728
+ ) from exc
729
+ return None
730
+
731
+
732
+ def terminal(
733
+ store: MetadataStore,
734
+ transaction: str,
735
+ *,
736
+ verify_generations: bool = False,
737
+ assume_serialized: bool = False,
738
+ ) -> bool:
739
+ return (
740
+ terminal_state(
741
+ store, transaction, verify_generations=verify_generations,
742
+ assume_serialized=assume_serialized,
743
+ )
744
+ is not None
745
+ )
746
+
747
+
748
+ def pending(
749
+ store: MetadataStore,
750
+ *,
751
+ assume_serialized: bool = False,
752
+ relevant: Callable[[dict], bool] | None = None,
753
+ ) -> list[dict]:
754
+ """Every journal identity is validated; terminal evidence only for relevant ones.
755
+
756
+ Terminal classification can re-verify large historical evidence, so a caller
757
+ that only consumes some journals names them with ``relevant`` instead of
758
+ filtering afterwards. The result equals the unfiltered result filtered by it.
759
+ """
760
+ _require_flock(store, assume_serialized=assume_serialized)
761
+ if not store.transactions_dir.exists():
762
+ return []
763
+ repository = store._read_json(store.root / "repo.json")["repository_id"]
764
+ result = []
765
+ for path in sorted(store.transactions_dir.iterdir()):
766
+ if is_atomic_temp(path.name):
767
+ continue
768
+ try:
769
+ value, _ = e.read(path)
770
+ if (
771
+ path.suffix != ".json"
772
+ or str(UUID(path.stem)) != path.stem
773
+ or value.get("transaction_id") != path.stem
774
+ or value.get("repository_id") != repository
775
+ or not value.get("allocation_id")
776
+ or not isinstance(value.get("target_path"), str)
777
+ or not Path(value["target_path"]).is_absolute()
778
+ or value.get("command")
779
+ not in {
780
+ "acquire",
781
+ "release",
782
+ "integration-refresh",
783
+ "resource-clean",
784
+ "registration-prune",
785
+ }
786
+ or value.get("schema_version") not in {1, 2, 3, 4}
787
+ or value.get("phase")
788
+ not in {
789
+ "PREPARED",
790
+ "TEARDOWN_PREPARED",
791
+ "VENV_REMOVAL_STARTED",
792
+ "VENV_REMOVED",
793
+ "WORKTREE_ADDED",
794
+ "METADATA_WRITTEN",
795
+ "WORKTREE_REMOVED",
796
+ "RELEASED_RECORDED",
797
+ "REFRESH_PREPARED",
798
+ "DETACHED_AT_NEW_HEAD",
799
+ "BRANCH_UPDATED",
800
+ }
801
+ ):
802
+ raise ValueError("unknown transaction identity or lifecycle")
803
+ except (OSError, ValueError, TypeError, KeyError, ControllerError) as exc:
804
+ raise TransactionConflict(
805
+ [{"journal": str(path), "lifecycle": "UNKNOWN", "reason": "INVALID_TRANSACTION"}]
806
+ ) from exc
807
+ if relevant is not None and not relevant(value):
808
+ continue
809
+ if not terminal(store, path.stem, assume_serialized=assume_serialized):
810
+ result.append(value)
811
+ return result
812
+
813
+
814
+ def conflicts(
815
+ store: MetadataStore,
816
+ *,
817
+ allocation: str | None,
818
+ target: Path,
819
+ assume_serialized: bool = False,
820
+ ) -> list[dict]:
821
+ """Persisted uncertainty fences its actual resource scope, not the repository.
822
+
823
+ The repository lock still serializes each mutation. Unknown journal identity
824
+ remains repository-wide through pending(); validated terminal evidence is not
825
+ an occupancy. Paths are compared without following potentially changed links.
826
+ """
827
+ def overlaps(row: dict) -> bool:
828
+ other = Path(row["target_path"])
829
+ return (row["allocation_id"] == allocation or target == other
830
+ or target in other.parents or other in target.parents)
831
+
832
+ return pending(store, assume_serialized=assume_serialized, relevant=overlaps)
833
+
834
+
835
+ def check_source(
836
+ store: MetadataStore,
837
+ allocation: str,
838
+ target: Path,
839
+ current: dict | None,
840
+ *,
841
+ assume_serialized: bool = False,
842
+ ) -> None:
843
+ """Admit only the exact current attempt within the affected resource scope."""
844
+ transactions = conflicts(
845
+ store, allocation=allocation, target=target, assume_serialized=assume_serialized
846
+ )
847
+ if current is not None:
848
+ expected = store.transactions_dir / f"{current['transaction_id']}.json"
849
+ actual, _ = e.read(expected)
850
+ if (
851
+ actual != current
852
+ or current.get("allocation_id") != allocation
853
+ or current.get("target_path") != str(target)
854
+ or current.get("command") != "resource-clean"
855
+ or current.get("cleanup_plan", {}).get("contract") != "tracked-source-cleanup/v1"
856
+ or current not in transactions
857
+ ):
858
+ raise TransactionConflict(
859
+ [{"journal": str(expected), "reason": "CURRENT_IDENTITY_MISMATCH"}]
860
+ )
861
+ blocked = [
862
+ {
863
+ "repository_id": row["repository_id"],
864
+ "allocation_id": row["allocation_id"],
865
+ "transaction_id": row["transaction_id"],
866
+ "target_path": row["target_path"],
867
+ "operation": row["command"],
868
+ "resource_scope": row.get("cleanup_plan", {}).get("contract"),
869
+ "lifecycle": "NONTERMINAL",
870
+ "phase": row["phase"],
871
+ }
872
+ for row in transactions
873
+ if row != current
874
+ ]
875
+ if blocked:
876
+ raise TransactionConflict(blocked)