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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- 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)
|