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,358 @@
1
+ """Read-only projections of existing allocation and immutable release evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .controller import envelope
11
+ from .git_backend import inventory, resolve_repository
12
+ from .models import (
13
+ ControllerError,
14
+ MetadataRecord,
15
+ MetadataSnapshot,
16
+ PreparedCandidateReceipt,
17
+ PublicationReceipt,
18
+ ReleaseReceipt,
19
+ )
20
+ from .store import MetadataStore
21
+
22
+ CONTRACT = "closed-lifecycle/v1"
23
+ QUERY_VERSION = 1
24
+
25
+
26
+ def _conflict(message: str) -> None:
27
+ raise ControllerError("HISTORY_EVIDENCE_CONFLICT", message)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class _DeliveryIndex:
32
+ candidates: dict[str, list[PublicationReceipt]]
33
+ sources: dict[tuple[str, str], list[PublicationReceipt]]
34
+ preparations: dict[tuple[str, str, str, str], list[PreparedCandidateReceipt]]
35
+
36
+
37
+ def _delivery_index(snapshot: MetadataSnapshot) -> _DeliveryIndex:
38
+ candidates: dict[str, list[PublicationReceipt]] = defaultdict(list)
39
+ sources: dict[tuple[str, str], list[PublicationReceipt]] = defaultdict(list)
40
+ preparations: dict[tuple[str, str, str, str], list[PreparedCandidateReceipt]] = defaultdict(list)
41
+ for publication in sorted(snapshot.publication_receipts.values(), key=lambda p: p.receipt_id):
42
+ candidates[publication.allocation_id].append(publication)
43
+ for identity in {
44
+ (result["source_allocation_id"], result["source_handoff_oid"])
45
+ for result in publication.source_results
46
+ }:
47
+ sources[identity].append(publication)
48
+ for prepared in sorted(snapshot.prepared_candidate_receipts.values(), key=lambda p: p.receipt_id):
49
+ preparations[
50
+ (
51
+ prepared.intent_id,
52
+ prepared.allocation_id,
53
+ prepared.candidate_oid,
54
+ prepared.validation_evidence_id,
55
+ )
56
+ ].append(prepared)
57
+ return _DeliveryIndex(dict(candidates), dict(sources), dict(preparations))
58
+
59
+
60
+ def _delivery(
61
+ snapshot: MetadataSnapshot,
62
+ receipt: ReleaseReceipt,
63
+ *,
64
+ index: _DeliveryIndex | None = None,
65
+ project: bool = True,
66
+ ) -> list[dict[str, Any]]:
67
+ index = index if index is not None else _delivery_index(snapshot)
68
+ publications = {
69
+ p.receipt_id: p
70
+ for p in index.candidates.get(receipt.allocation_id, ())
71
+ }
72
+ publications.update(
73
+ (p.receipt_id, p)
74
+ for p in index.sources.get((receipt.allocation_id, receipt.released_head), ())
75
+ )
76
+ deliveries = []
77
+ for publication in sorted(publications.values(), key=lambda p: p.receipt_id):
78
+ direct = publication.allocation_id == receipt.allocation_id
79
+ source_evidence = [
80
+ result
81
+ for result in publication.source_results
82
+ if result["source_allocation_id"] == receipt.allocation_id
83
+ and result["source_handoff_oid"] == receipt.released_head
84
+ ]
85
+ source = bool(source_evidence)
86
+ if not direct and not source:
87
+ continue
88
+ if publication.repository_id != receipt.repository_id:
89
+ _conflict("publication receipt belongs to another repository")
90
+ if direct and publication.candidate_oid != receipt.released_head:
91
+ _conflict("released integration HEAD differs from its publication receipt")
92
+ prepared_receipts = index.preparations.get(
93
+ (
94
+ publication.intent_id,
95
+ publication.allocation_id,
96
+ publication.candidate_oid,
97
+ publication.validation_evidence_id,
98
+ ),
99
+ [],
100
+ )
101
+ # Keep every matching immutable preparation; ambiguous evidence is never guessed.
102
+ for prepared in prepared_receipts:
103
+ if prepared.repository_id != receipt.repository_id:
104
+ _conflict("prepared receipt belongs to another repository")
105
+ if not project:
106
+ continue
107
+ prepared = prepared_receipts[0] if len(prepared_receipts) == 1 else None
108
+ deliveries.append(
109
+ {
110
+ "integration_intent_id": publication.intent_id,
111
+ "integration_allocation_id": publication.allocation_id,
112
+ "relationship": "candidate" if direct else "source",
113
+ "source_absorption_evidence": source_evidence,
114
+ "candidate_oid": publication.candidate_oid,
115
+ "publication_receipt_id": publication.receipt_id,
116
+ "publication_receipt": asdict(publication),
117
+ "publication_status": publication.status,
118
+ "published_at": publication.remote_verified_at,
119
+ "publisher": publication.holder,
120
+ "validation_evidence_id": publication.validation_evidence_id,
121
+ "prepared_candidate_receipt": asdict(prepared) if prepared else None,
122
+ "prepared_candidate_receipts": [asdict(p) for p in prepared_receipts],
123
+ "finalizer_record_id": publication.finalizer_record_id,
124
+ }
125
+ )
126
+ return deliveries
127
+
128
+
129
+ def _release_view(
130
+ snapshot: MetadataSnapshot,
131
+ receipt: ReleaseReceipt,
132
+ record: MetadataRecord | None,
133
+ *,
134
+ index: _DeliveryIndex | None = None,
135
+ project: bool = True,
136
+ ) -> dict[str, Any]:
137
+ plan = snapshot.release_plans.get(receipt.plan_id)
138
+ if receipt.repository_id != snapshot.repository_id:
139
+ _conflict("release receipt belongs to another repository")
140
+ if plan is None:
141
+ raise ControllerError("HISTORY_EVIDENCE_INCOMPLETE", "release receipt has no release plan")
142
+ for field, expected in {
143
+ "repository_id": receipt.repository_id,
144
+ "allocation_id": receipt.allocation_id,
145
+ "release_intent_id": receipt.release_intent_id,
146
+ "target_path": receipt.target_path,
147
+ "branch": receipt.branch,
148
+ "expected_head": receipt.released_head,
149
+ "expected_record_version": receipt.expected_record_version,
150
+ "trigger": receipt.trigger,
151
+ }.items():
152
+ if getattr(plan, field) != expected:
153
+ _conflict(f"release plan and receipt disagree on {field}")
154
+ release_intent = snapshot.release_intents.get(receipt.release_intent_id or "")
155
+ if release_intent is not None and (
156
+ release_intent.repository_id != receipt.repository_id
157
+ or release_intent.allocation_id != receipt.allocation_id
158
+ or release_intent.owner != plan.owner
159
+ or release_intent.authority_key != plan.authority_key
160
+ ):
161
+ _conflict("release intent identity differs from its release plan")
162
+ if record is not None and (
163
+ record.record_version != receipt.released_record_version
164
+ or record.lifecycle != "RELEASED"
165
+ or record.authority_key != plan.authority_key
166
+ or (release_intent is not None and record.task_key != release_intent.task_key)
167
+ ):
168
+ _conflict("closed allocation identity differs from its release receipt")
169
+ deliveries = _delivery(snapshot, receipt, index=index, project=project)
170
+ if not project:
171
+ return {}
172
+ single = deliveries[0] if len(deliveries) == 1 else None
173
+ task_key = release_intent.task_key if release_intent else record.task_key if record else None
174
+ return {
175
+ "boundary": "CLOSED",
176
+ "allocation_id": receipt.allocation_id,
177
+ "authority_key": plan.authority_key,
178
+ "task_key": task_key,
179
+ "owner": plan.owner,
180
+ "role": plan.role,
181
+ "branch": receipt.branch,
182
+ "worktree_path": receipt.target_path,
183
+ "released_head": receipt.released_head,
184
+ "candidate_oid": single["candidate_oid"] if single else None,
185
+ "integration_intent_id": single["integration_intent_id"] if single else None,
186
+ "release_status": receipt.result,
187
+ "published_at": single["published_at"] if single else None,
188
+ "released_at": receipt.released_at,
189
+ "release_receipt_id": receipt.receipt_id,
190
+ "release_plan_id": receipt.plan_id,
191
+ "release_intent_id": receipt.release_intent_id,
192
+ "validation_evidence_id": single["validation_evidence_id"] if single else None,
193
+ "finalizer_record_id": single["finalizer_record_id"] if single else None,
194
+ "source_finalizer_record_id": record.finalizer_record_id if record else None,
195
+ "source_finalizer_commit": record.finalizer_commit if record else None,
196
+ "deliveries": deliveries,
197
+ "proof_status": "COMPLETE" if task_key else "INCOMPLETE",
198
+ "evidence_sources": {
199
+ "identity": "release-plans",
200
+ "task": "release-intents" if release_intent else "records" if record else None,
201
+ "release": "release-receipts",
202
+ "publication": "publication-receipts" if deliveries else None,
203
+ "validation": "prepared-candidate-receipts"
204
+ if any(d["prepared_candidate_receipts"] for d in deliveries)
205
+ else None,
206
+ },
207
+ }
208
+
209
+
210
+ def lifecycle_history(
211
+ repository_path: Path,
212
+ *,
213
+ allocation_id: str | None = None,
214
+ authority_key: str | None = None,
215
+ task_key: str | None = None,
216
+ integration_intent_id: str | None = None,
217
+ release_receipt_id: str | None = None,
218
+ limit: int = 100,
219
+ after: str | None = None,
220
+ ) -> dict[str, Any]:
221
+ """Query existing evidence without new records, remote access, or lifecycle mutation.
222
+
223
+ Closed entries are keyed by immutable release receipt id; live entries are a separate
224
+ observation and cannot prove historical ownership or authorize final_result.
225
+ """
226
+ query = {
227
+ "allocation_id": allocation_id,
228
+ "authority_key": authority_key,
229
+ "task_key": task_key,
230
+ "integration_intent_id": integration_intent_id,
231
+ "release_receipt_id": release_receipt_id,
232
+ }
233
+ if sum(value is not None for value in query.values()) != 1:
234
+ raise ControllerError("INVALID_HISTORY_QUERY", "exactly one history selector is required")
235
+ if (
236
+ isinstance(limit, bool)
237
+ or not isinstance(limit, int)
238
+ or not 1 <= limit <= 1000
239
+ or any(
240
+ value is not None and (not value.strip() or "\x00" in value) for value in query.values()
241
+ )
242
+ or (after is not None and (not after.strip() or "\x00" in after))
243
+ ):
244
+ raise ControllerError("INVALID_HISTORY_QUERY", "invalid selector or limit (1..1000)")
245
+ _, common = resolve_repository(repository_path)
246
+ store = MetadataStore(common)
247
+ with store.lock(exclusive=False):
248
+ snapshot = store._load_unlocked()
249
+ repository = inventory(repository_path)
250
+ wanted = None
251
+ if integration_intent_id is not None:
252
+ intent = snapshot.integration_intents.get(integration_intent_id)
253
+ wanted = set(intent.source_allocations) | {intent.allocation_id} if intent else set()
254
+ for publication in snapshot.publication_receipts.values():
255
+ if publication.intent_id == integration_intent_id:
256
+ wanted.add(publication.allocation_id)
257
+ wanted.update(r["source_allocation_id"] for r in publication.source_results)
258
+ if release_receipt_id is not None:
259
+ receipt = snapshot.release_receipts.get(release_receipt_id)
260
+ wanted = {receipt.allocation_id} if receipt else set()
261
+
262
+ def matches(identity: dict[str, Any]) -> bool:
263
+ return (
264
+ (wanted is None or identity["allocation_id"] in wanted)
265
+ and (allocation_id is None or identity["allocation_id"] == allocation_id)
266
+ and (authority_key is None or identity["authority_key"] == authority_key)
267
+ and (task_key is None or identity["task_key"] == task_key)
268
+ )
269
+
270
+ closed = []
271
+ matching_count = 0
272
+ index = None
273
+ for receipt in sorted(snapshot.release_receipts.values(), key=lambda r: r.receipt_id):
274
+ if release_receipt_id is not None and receipt.receipt_id != release_receipt_id:
275
+ continue
276
+ record = snapshot.records.get(receipt.allocation_id)
277
+ plan = snapshot.release_plans.get(receipt.plan_id)
278
+ release_intent = snapshot.release_intents.get(receipt.release_intent_id or "")
279
+ identity = {
280
+ "allocation_id": receipt.allocation_id,
281
+ "authority_key": plan.authority_key
282
+ if plan
283
+ else record.authority_key
284
+ if record
285
+ else None,
286
+ "task_key": release_intent.task_key
287
+ if release_intent
288
+ else record.task_key
289
+ if record
290
+ else None,
291
+ }
292
+ if matches(identity) and (after is None or receipt.receipt_id > after):
293
+ matching_count += 1
294
+ if index is None:
295
+ index = _delivery_index(snapshot)
296
+ project = matching_count <= limit
297
+ # Validate every match, including those beyond this page, without
298
+ # serializing their complete evidence into discarded documents.
299
+ row = _release_view(snapshot, receipt, record, index=index, project=project)
300
+ if project:
301
+ closed.append(row)
302
+ live = []
303
+ if release_receipt_id is None:
304
+ for item in repository.worktrees:
305
+ record = snapshot.records.get(snapshot.bindings.get(item.key, ""))
306
+ if record is not None and matches(asdict(record)):
307
+ live.append(
308
+ {
309
+ "boundary": "LIVE",
310
+ "allocation_id": record.allocation_id,
311
+ "authority_key": record.authority_key,
312
+ "task_key": record.task_key,
313
+ "owner": record.owner,
314
+ "lifecycle": record.lifecycle,
315
+ "branch": item.branch,
316
+ "worktree_path": str(item.path),
317
+ "head": item.head,
318
+ }
319
+ )
320
+ receipted = {r.allocation_id for r in snapshot.release_receipts.values()}
321
+ incomplete = (
322
+ [
323
+ {
324
+ "allocation_id": r.allocation_id,
325
+ "authority_key": r.authority_key,
326
+ "task_key": r.task_key,
327
+ "recorded_lifecycle": r.lifecycle,
328
+ "proof_status": "INCOMPLETE",
329
+ "reason": "no formal release receipt",
330
+ }
331
+ for r in sorted(snapshot.records.values(), key=lambda r: r.allocation_id)
332
+ if r.lifecycle == "RELEASED"
333
+ and r.allocation_id not in receipted
334
+ and matches(asdict(r))
335
+ ]
336
+ if release_receipt_id is None
337
+ else []
338
+ )
339
+ payload = envelope("lifecycle-history")
340
+ payload["repository"] = {
341
+ "repository_id": snapshot.repository_id,
342
+ "canonical_path": str(repository.canonical_path),
343
+ "common_dir": str(common),
344
+ "metadata_path": str(store.root),
345
+ }
346
+ payload["warnings"] = list(snapshot.warnings)
347
+ payload["decision"] = {
348
+ "code": "LIFECYCLE_HISTORY",
349
+ "contract": CONTRACT,
350
+ "query_version": QUERY_VERSION,
351
+ "query": query,
352
+ "live_allocations": sorted(live, key=lambda row: row["allocation_id"]),
353
+ "closed_releases": closed,
354
+ "incomplete_history": incomplete,
355
+ "next_after": closed[-1]["release_receipt_id"] if matching_count > limit else None,
356
+ "mutation_performed": False,
357
+ }
358
+ return payload
@@ -0,0 +1,234 @@
1
+ """Read-only ownership and session observations over existing Controller facts.
2
+
3
+ These findings do not transition lifecycle state or establish release eligibility.
4
+ A lease renewal is the only available heartbeat proxy; absence of a visible holder
5
+ does not prove that its task or descendant processes have ended.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from collections.abc import Callable
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import Any
14
+
15
+ from .models import (
16
+ ControllerError,
17
+ MetadataRecord,
18
+ MetadataSnapshot,
19
+ RepositoryInventory,
20
+ WorktreeGitState,
21
+ WorktreePolicy,
22
+ WriterHolder,
23
+ )
24
+ from .writer_lease import holder_is_live, runtime_binding
25
+
26
+
27
+ def _holder_status(
28
+ holder: WriterHolder,
29
+ *,
30
+ namespace_healthy: bool,
31
+ probe: Callable[[WriterHolder], bool],
32
+ ) -> str:
33
+ if not namespace_healthy:
34
+ return "UNKNOWN"
35
+ try:
36
+ return "LIVE" if probe(holder) else "NOT_VISIBLE"
37
+ except (ControllerError, OSError, ValueError):
38
+ return "UNKNOWN"
39
+
40
+
41
+ def _timestamp(value: str) -> datetime | None:
42
+ try:
43
+ parsed = datetime.fromisoformat(value)
44
+ except ValueError:
45
+ return None
46
+ return parsed if parsed.tzinfo is not None else None
47
+
48
+
49
+ def observe_worktree(
50
+ item: WorktreeGitState,
51
+ record: MetadataRecord | None,
52
+ snapshot: MetadataSnapshot,
53
+ policy: WorktreePolicy,
54
+ *,
55
+ now_at: datetime | None = None,
56
+ holder_probe: Callable[[WriterHolder], bool] | None = None,
57
+ namespace_healthy: bool = True,
58
+ ) -> dict[str, Any]:
59
+ """Describe recorded ownership and best-effort holder visibility, without mutation.
60
+
61
+ ``NOT_VISIBLE`` is limited to this process's /proc view. It never asserts that
62
+ a session, its process group, runtime bindings, or the task has ended.
63
+ """
64
+ observed_at = now_at or datetime.now(UTC)
65
+ if observed_at.tzinfo is None:
66
+ raise ValueError("observation time must include a timezone")
67
+ probe = holder_probe or holder_is_live
68
+ allocation_id = snapshot.bindings.get(item.key)
69
+ lease = snapshot.writer_leases.get(allocation_id) if allocation_id else None
70
+ stale_facts: list[str] = []
71
+ orphan_facts: list[str] = []
72
+ integrity_facts: list[str] = []
73
+ if allocation_id is None:
74
+ orphan_facts.append("UNMANAGED_WORKTREE")
75
+ elif record is None:
76
+ orphan_facts.append("MISSING_RECORD")
77
+ elif record.lifecycle == "RELEASED":
78
+ orphan_facts.append("RELEASED_REGISTRATION")
79
+
80
+ review_status = "UNKNOWN"
81
+ if record is not None:
82
+ reviewed = _timestamp(record.last_reviewed_at)
83
+ if reviewed is not None:
84
+ review_status = (
85
+ "OVERDUE"
86
+ if observed_at - reviewed >= timedelta(days=policy.stale_warning_days)
87
+ else "CURRENT"
88
+ )
89
+ if review_status == "OVERDUE":
90
+ stale_facts.append("REVIEW_OVERDUE")
91
+
92
+ holder_status = "UNKNOWN"
93
+ session: dict[str, Any] = {
94
+ "source": "NONE",
95
+ "client_id": None,
96
+ "session_id": None,
97
+ "run_id": None,
98
+ "holder_pid": None,
99
+ }
100
+ lease_observation: dict[str, Any] = {
101
+ "present": lease is not None,
102
+ "lease_id": None,
103
+ "owner": None,
104
+ "acquired_at": None,
105
+ "renewed_at": None,
106
+ "expires_at": None,
107
+ "status": "ABSENT",
108
+ }
109
+ heartbeat = {"source": "NONE", "last_at": None, "independent": False}
110
+ if lease is not None:
111
+ holder = lease.holder
112
+ holder_status = _holder_status(holder, namespace_healthy=namespace_healthy, probe=probe)
113
+ session = {
114
+ "source": "WRITER_LEASE",
115
+ "client_id": holder.client_id,
116
+ "session_id": holder.session_id,
117
+ "run_id": holder.run_id,
118
+ "holder_pid": holder.pid,
119
+ }
120
+ if lease.expires_at is None:
121
+ expiry_status = "NO_TTL"
122
+ elif (expiry := _timestamp(lease.expires_at)) is None:
123
+ expiry_status = "UNKNOWN"
124
+ elif expiry < observed_at:
125
+ expiry_status = "EXPIRED"
126
+ stale_facts.append("LEASE_EXPIRED")
127
+ else:
128
+ expiry_status = "CURRENT"
129
+ lease_observation = {
130
+ "present": True,
131
+ "lease_id": lease.lease_id,
132
+ "owner": lease.owner,
133
+ "acquired_at": lease.acquired_at,
134
+ "renewed_at": lease.renewed_at,
135
+ "expires_at": lease.expires_at,
136
+ "status": expiry_status,
137
+ }
138
+ heartbeat = {
139
+ "source": "LEASE_RENEWAL",
140
+ "last_at": lease.renewed_at,
141
+ "independent": False,
142
+ }
143
+ if holder_status == "NOT_VISIBLE":
144
+ stale_facts.append("HOLDER_NOT_VISIBLE")
145
+ if record is None or record.lifecycle == "RELEASED":
146
+ orphan_facts.append("STRANDED_WRITER_LEASE")
147
+
148
+ runtime_sessions: list[dict[str, Any]] = []
149
+ if record is not None:
150
+ for binding in record.external_bindings:
151
+ try:
152
+ decoded = runtime_binding(binding)
153
+ except ControllerError:
154
+ integrity_facts.append("INVALID_RUNTIME_BINDING")
155
+ continue
156
+ if decoded is None:
157
+ continue
158
+ holder, bound_lease_id = decoded
159
+ runtime_sessions.append(
160
+ {
161
+ "source": "RUNTIME_BINDING",
162
+ "client_id": holder.client_id,
163
+ "session_id": holder.session_id,
164
+ "run_id": holder.run_id,
165
+ "holder_pid": holder.pid,
166
+ "lease_id": bound_lease_id,
167
+ "occupancy": "RECORDED",
168
+ }
169
+ )
170
+
171
+ return {
172
+ "allocation_id": allocation_id,
173
+ "owner": record.owner if record else None,
174
+ "lifecycle": record.lifecycle if record else None,
175
+ "session": session,
176
+ "runtime_sessions": runtime_sessions,
177
+ "lease": lease_observation,
178
+ "heartbeat": heartbeat,
179
+ "status": {
180
+ "owner": "UNKNOWN"
181
+ if record is None
182
+ else "UNASSIGNED"
183
+ if record.owner == "unassigned"
184
+ else "ASSIGNED",
185
+ "holder": holder_status,
186
+ "review": review_status,
187
+ "host_session": "UNKNOWN",
188
+ },
189
+ "stale_facts": stale_facts,
190
+ "orphan_facts": orphan_facts,
191
+ "integrity_facts": integrity_facts,
192
+ "advisory_only": True,
193
+ }
194
+
195
+
196
+ def observe_repository_orphans(
197
+ repository: RepositoryInventory, snapshot: MetadataSnapshot
198
+ ) -> list[dict[str, str]]:
199
+ """Find structural registry/metadata orphans; make no cleanup decision."""
200
+ findings: list[dict[str, str]] = []
201
+ items = {item.key: item for item in repository.worktrees}
202
+ keys_by_allocation: defaultdict[str, list[str]] = defaultdict(list)
203
+ for key, allocation_id in sorted(snapshot.bindings.items()):
204
+ keys_by_allocation[allocation_id].append(key)
205
+ if key not in items:
206
+ findings.append(
207
+ {"code": "ORPHAN_BINDING", "allocation_id": allocation_id, "worktree_key": key}
208
+ )
209
+ if allocation_id not in snapshot.records:
210
+ findings.append(
211
+ {"code": "MISSING_RECORD", "allocation_id": allocation_id, "worktree_key": key}
212
+ )
213
+ for allocation_id, record in sorted(snapshot.records.items()):
214
+ keys = keys_by_allocation.get(allocation_id, [])
215
+ if not keys and record.lifecycle != "RELEASED":
216
+ findings.append({"code": "ORPHAN_RECORD", "allocation_id": allocation_id})
217
+ elif len(keys) > 1:
218
+ findings.append({"code": "DUPLICATE_BINDING", "allocation_id": allocation_id})
219
+ for item in repository.worktrees:
220
+ if snapshot.bindings.get(item.key) not in snapshot.records:
221
+ findings.append({"code": "UNMANAGED_WORKTREE", "path": str(item.path)})
222
+ for allocation_id in sorted(snapshot.writer_leases):
223
+ record = snapshot.records.get(allocation_id)
224
+ if record is None or record.lifecycle == "RELEASED":
225
+ findings.append({"code": "STRANDED_WRITER_LEASE", "allocation_id": allocation_id})
226
+ return sorted(
227
+ findings,
228
+ key=lambda row: (
229
+ row["code"],
230
+ row.get("allocation_id", ""),
231
+ row.get("worktree_key", ""),
232
+ row.get("path", ""),
233
+ ),
234
+ )