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,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
|
+
)
|