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,430 @@
|
|
|
1
|
+
"""One deterministic reconciliation engine, shared by every trigger.
|
|
2
|
+
|
|
3
|
+
Admission, the background timer and the operator command all call
|
|
4
|
+
:func:`reconcile_writers`, so a writer slot is reclaimed under exactly one set of
|
|
5
|
+
rules no matter what asked for it.
|
|
6
|
+
|
|
7
|
+
The engine only ever answers one question: *can this allocation's writer
|
|
8
|
+
entitlement be proven inactive right now?* A proven-dead writer releases the slot
|
|
9
|
+
by moving ``ACTIVE -> RETAINED``. Nothing here completes a task, publishes,
|
|
10
|
+
finalizes, deletes a checkout or touches uncommitted work, and every outcome
|
|
11
|
+
carries the evidence that produced it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .controller import status
|
|
22
|
+
from .git_backend import inventory
|
|
23
|
+
from .lifecycle import _cancel_release_intents, _updated_record
|
|
24
|
+
from .models import (
|
|
25
|
+
ControllerError,
|
|
26
|
+
MetadataRecord,
|
|
27
|
+
MetadataSnapshot,
|
|
28
|
+
WriterReconciliationReceipt,
|
|
29
|
+
)
|
|
30
|
+
from .policy import load_policy
|
|
31
|
+
from .semantics import record_for as _record_for
|
|
32
|
+
from .store import WRITER_RECEIPT_SCHEMA_VERSION, MetadataStore
|
|
33
|
+
from .writer_lease import (
|
|
34
|
+
ProcessPathScan,
|
|
35
|
+
holder_is_live,
|
|
36
|
+
now,
|
|
37
|
+
paths_in_use,
|
|
38
|
+
process_group_is_live,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
TRIGGERS = ("manual", "admission", "background")
|
|
42
|
+
|
|
43
|
+
#: Outcomes that leave the allocation exactly as it was found.
|
|
44
|
+
BLOCKED = "BLOCKED"
|
|
45
|
+
LIVE = "LIVE_WRITER"
|
|
46
|
+
RECLAIMABLE = "RECLAIMABLE"
|
|
47
|
+
RECLAIMED = "RECLAIMED"
|
|
48
|
+
LEGACY = "LEGACY_UNVERIFIED"
|
|
49
|
+
ADOPTED = "LEGACY_LEASE_ADOPTED"
|
|
50
|
+
SKIPPED = "OUT_OF_WRITER_POOL"
|
|
51
|
+
STRANDED = "STRANDED_ENTITLEMENT"
|
|
52
|
+
STRANDED_CLEARED = "STRANDED_ENTITLEMENT_CLEARED"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _recovery_pending(record: MetadataRecord) -> bool:
|
|
56
|
+
return bool(
|
|
57
|
+
record.recovery_ref
|
|
58
|
+
or record.recovery_allocation_id
|
|
59
|
+
or record.lifecycle in {"RECOVERY_REQUIRED", "QUARANTINED"}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _integration_bound(snapshot: MetadataSnapshot, allocation_id: str) -> bool:
|
|
64
|
+
for intent in snapshot.integration_intents.values():
|
|
65
|
+
if intent.allocation_id == allocation_id:
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def classify(
|
|
71
|
+
item: Any,
|
|
72
|
+
record: MetadataRecord,
|
|
73
|
+
snapshot: MetadataSnapshot,
|
|
74
|
+
in_use: Callable[[Path], list[tuple[int, str]]] = paths_in_use,
|
|
75
|
+
) -> tuple[str, str, list[str]]:
|
|
76
|
+
"""Decide what may safely happen to one allocation, and why.
|
|
77
|
+
|
|
78
|
+
Ordering matters: every gate that could make reclamation unsafe is checked
|
|
79
|
+
before the writer evidence, so a blocked allocation reports the real obstacle
|
|
80
|
+
instead of a lease verdict that would never have been acted on.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
evidence: list[str] = []
|
|
84
|
+
|
|
85
|
+
if item.canonical:
|
|
86
|
+
return SKIPPED, "canonical is never reconciled", evidence
|
|
87
|
+
if record.role not in {"feature", "audit"}:
|
|
88
|
+
return SKIPPED, f"role {record.role} is outside the writer pool", evidence
|
|
89
|
+
if record.lifecycle == "RETAINED":
|
|
90
|
+
return SKIPPED, "already retained; writer slot is already free", evidence
|
|
91
|
+
if record.lifecycle != "ACTIVE":
|
|
92
|
+
return BLOCKED, f"lifecycle {record.lifecycle} is not reclaimable", evidence
|
|
93
|
+
if record.external_bindings:
|
|
94
|
+
evidence.append(f"external_bindings={list(record.external_bindings)}")
|
|
95
|
+
return BLOCKED, "registered external or runtime bindings", evidence
|
|
96
|
+
if snapshot.publication_lease is not None and (
|
|
97
|
+
snapshot.publication_lease.allocation_id == record.allocation_id
|
|
98
|
+
):
|
|
99
|
+
return BLOCKED, "a publication lease is in progress", evidence
|
|
100
|
+
if _integration_bound(snapshot, record.allocation_id):
|
|
101
|
+
return BLOCKED, "an integration intent still binds this allocation", evidence
|
|
102
|
+
if _recovery_pending(record):
|
|
103
|
+
return BLOCKED, "unresolved recovery state", evidence
|
|
104
|
+
if item.detached:
|
|
105
|
+
return BLOCKED, "detached worktree requires review", evidence
|
|
106
|
+
if item.locked or item.prunable:
|
|
107
|
+
return BLOCKED, "locked or prunable worktree requires review", evidence
|
|
108
|
+
|
|
109
|
+
# Writer evidence. A live process anywhere in the checkout outranks the lease
|
|
110
|
+
# record, so an unleased or externally started writer is never overlooked.
|
|
111
|
+
used = in_use(Path(str(item.path)))
|
|
112
|
+
if used:
|
|
113
|
+
evidence.append("live_paths=" + ",".join(f"{pid}:{cmd[:40]}" for pid, cmd in used[:3]))
|
|
114
|
+
return LIVE, "a live process is using this checkout", evidence
|
|
115
|
+
|
|
116
|
+
lease = snapshot.writer_leases.get(record.allocation_id)
|
|
117
|
+
if lease is None:
|
|
118
|
+
evidence.append("writer_lease=absent")
|
|
119
|
+
return (
|
|
120
|
+
LEGACY,
|
|
121
|
+
"no writer lease exists and this allocation predates leases; "
|
|
122
|
+
"absence is not proof of inactivity",
|
|
123
|
+
evidence,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
evidence.append(
|
|
127
|
+
f"writer_lease={lease.lease_id} holder={lease.holder.client_id}:{lease.holder.pid}"
|
|
128
|
+
)
|
|
129
|
+
if holder_is_live(lease.holder):
|
|
130
|
+
evidence.append("holder_process=alive")
|
|
131
|
+
return LIVE, "the lease holder process is still running", evidence
|
|
132
|
+
if process_group_is_live(lease.holder):
|
|
133
|
+
evidence.append("holder_process_group=alive")
|
|
134
|
+
return LIVE, "a surviving child of the lease holder is still running", evidence
|
|
135
|
+
|
|
136
|
+
evidence.append("holder_process=gone")
|
|
137
|
+
evidence.append("holder_process_group=gone")
|
|
138
|
+
# Retention relabels a checkout the ordinary pool already holds. It takes no
|
|
139
|
+
# capacity, so a full retained count can no longer strand a dead writer.
|
|
140
|
+
return (
|
|
141
|
+
RECLAIMABLE,
|
|
142
|
+
"the writer lease holder is gone; the task itself is untouched",
|
|
143
|
+
evidence,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def reconcile_writers(
|
|
148
|
+
repository_path: Path,
|
|
149
|
+
*,
|
|
150
|
+
actor: str,
|
|
151
|
+
trigger: str = "manual",
|
|
152
|
+
dry_run: bool = True,
|
|
153
|
+
adopt_legacy: bool = False,
|
|
154
|
+
only: tuple[str, ...] = (),
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Report, and optionally converge, writer entitlement across a repository.
|
|
157
|
+
|
|
158
|
+
``only`` restricts convergence to named task keys. Everything else is still
|
|
159
|
+
inspected and reported, but is left exactly as found. A one-time legacy
|
|
160
|
+
classification is rarely safe for a whole repository at once: the allocations
|
|
161
|
+
that can be positively tied to a live session, or that show recent work, have
|
|
162
|
+
to be excluded by name rather than swept up with the rest.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
if trigger not in TRIGGERS:
|
|
166
|
+
raise ControllerError("INVALID_TRIGGER", f"unsupported reconcile trigger: {trigger}")
|
|
167
|
+
if not actor.strip():
|
|
168
|
+
raise ControllerError("INVALID_ACTOR", "a reconciler actor is required")
|
|
169
|
+
|
|
170
|
+
repository_initial = inventory(repository_path)
|
|
171
|
+
store = MetadataStore(repository_initial.common_dir)
|
|
172
|
+
findings: list[dict[str, Any]] = []
|
|
173
|
+
reclaimed = 0
|
|
174
|
+
converged = 0
|
|
175
|
+
adopted = 0
|
|
176
|
+
|
|
177
|
+
with store.lock(exclusive=True):
|
|
178
|
+
repository = inventory(repository_path)
|
|
179
|
+
snapshot = store._load_unlocked()
|
|
180
|
+
policy = load_policy(repository.canonical_path)
|
|
181
|
+
if policy.reconciler_enabled and any(
|
|
182
|
+
record.owner == policy.reconciler_actor and record.lifecycle != "RELEASED"
|
|
183
|
+
for record in snapshot.records.values()
|
|
184
|
+
):
|
|
185
|
+
raise ControllerError(
|
|
186
|
+
"INVALID_POLICY", "the reconciler actor must be distinct from current task owners"
|
|
187
|
+
)
|
|
188
|
+
if not dry_run:
|
|
189
|
+
if policy.retained_slots < 1:
|
|
190
|
+
raise ControllerError(
|
|
191
|
+
"RETENTION_UNSUPPORTED",
|
|
192
|
+
"repository policy does not configure a retained pool",
|
|
193
|
+
)
|
|
194
|
+
if not policy.reconciler_enabled:
|
|
195
|
+
raise ControllerError(
|
|
196
|
+
"RECONCILER_DISABLED",
|
|
197
|
+
"repository policy does not delegate automatic reconciliation",
|
|
198
|
+
)
|
|
199
|
+
if actor != policy.reconciler_actor:
|
|
200
|
+
raise ControllerError(
|
|
201
|
+
"RECONCILER_ACTOR_MISMATCH",
|
|
202
|
+
"actor is not the delegated reconciler for this repository",
|
|
203
|
+
)
|
|
204
|
+
if adopt_legacy and not policy.reconciler_legacy_adoption:
|
|
205
|
+
raise ControllerError(
|
|
206
|
+
"LEGACY_ADOPTION_DISABLED",
|
|
207
|
+
"repository policy does not permit legacy writer adoption",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
retained_limit = policy.retained_slots
|
|
211
|
+
retained_used = sum(
|
|
212
|
+
1 for other in snapshot.records.values() if other.lifecycle == "RETAINED"
|
|
213
|
+
)
|
|
214
|
+
retained_before = retained_used
|
|
215
|
+
# Only registered checkouts occupy the pool. Counting every historical
|
|
216
|
+
# record would include released tasks and report nonsense capacity.
|
|
217
|
+
registered = [
|
|
218
|
+
found
|
|
219
|
+
for found in (_record_for(item, snapshot) for item in repository.worktrees)
|
|
220
|
+
if found is not None
|
|
221
|
+
]
|
|
222
|
+
writers_before = sum(
|
|
223
|
+
1
|
|
224
|
+
for other in registered
|
|
225
|
+
if other.role in {"feature", "audit"} and other.lifecycle != "RETAINED"
|
|
226
|
+
)
|
|
227
|
+
legacy_unverified = 0
|
|
228
|
+
# One process reading for the whole pass: this runs under the exclusive
|
|
229
|
+
# repository lock, and a reading per checkout held it for seconds.
|
|
230
|
+
process_scan = ProcessPathScan()
|
|
231
|
+
|
|
232
|
+
for item in repository.worktrees:
|
|
233
|
+
record = _record_for(item, snapshot)
|
|
234
|
+
if record is None:
|
|
235
|
+
findings.append(
|
|
236
|
+
{
|
|
237
|
+
"path": str(item.path),
|
|
238
|
+
"outcome": BLOCKED,
|
|
239
|
+
"reason": "worktree has no controller record",
|
|
240
|
+
"evidence": [],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
outcome, reason, evidence = classify(
|
|
246
|
+
item,
|
|
247
|
+
record,
|
|
248
|
+
snapshot,
|
|
249
|
+
process_scan.in_use,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# A legacy allocation is only ever converged by an explicit, one-time
|
|
253
|
+
# operator classification - never by the automatic triggers.
|
|
254
|
+
if outcome is LEGACY and adopt_legacy and (not only or record.task_key in only):
|
|
255
|
+
outcome = RECLAIMABLE
|
|
256
|
+
reason = "one-time legacy classification: no live writer evidence of any kind"
|
|
257
|
+
evidence.append("legacy_classification=adopted")
|
|
258
|
+
adopted += 1
|
|
259
|
+
|
|
260
|
+
if only and record.task_key not in only and outcome is RECLAIMABLE:
|
|
261
|
+
outcome = BLOCKED
|
|
262
|
+
reason = "excluded from this pass by an explicit task selection"
|
|
263
|
+
|
|
264
|
+
if outcome is LEGACY:
|
|
265
|
+
legacy_unverified += 1
|
|
266
|
+
|
|
267
|
+
applied = outcome
|
|
268
|
+
if outcome is RECLAIMABLE:
|
|
269
|
+
# counted for both a preview and a real pass, so the reported
|
|
270
|
+
# arithmetic is the same either way
|
|
271
|
+
retained_used += 1
|
|
272
|
+
converged += 1
|
|
273
|
+
if outcome is RECLAIMABLE and not dry_run:
|
|
274
|
+
_cancel_release_intents(
|
|
275
|
+
store,
|
|
276
|
+
snapshot,
|
|
277
|
+
record.allocation_id,
|
|
278
|
+
event="RECORD_CHANGED",
|
|
279
|
+
blocker="PARKED",
|
|
280
|
+
)
|
|
281
|
+
updated = _updated_record(record, "PARKED", lifecycle="RETAINED")
|
|
282
|
+
store.replace_record(updated)
|
|
283
|
+
store.remove_writer_lease(record.allocation_id)
|
|
284
|
+
receipt = WriterReconciliationReceipt(
|
|
285
|
+
schema_version=WRITER_RECEIPT_SCHEMA_VERSION,
|
|
286
|
+
receipt_id=str(uuid.uuid4()),
|
|
287
|
+
repository_id=snapshot.repository_id or "unknown",
|
|
288
|
+
allocation_id=record.allocation_id,
|
|
289
|
+
actor=actor,
|
|
290
|
+
owner=record.owner,
|
|
291
|
+
trigger=trigger,
|
|
292
|
+
outcome=RECLAIMED,
|
|
293
|
+
reason=reason,
|
|
294
|
+
evidence=tuple(evidence),
|
|
295
|
+
previous_lifecycle=record.lifecycle,
|
|
296
|
+
new_lifecycle="RETAINED",
|
|
297
|
+
reconciled_at=now(),
|
|
298
|
+
)
|
|
299
|
+
store.write_writer_receipt(receipt)
|
|
300
|
+
reclaimed += 1
|
|
301
|
+
applied = RECLAIMED
|
|
302
|
+
|
|
303
|
+
findings.append(
|
|
304
|
+
{
|
|
305
|
+
"task_key": record.task_key,
|
|
306
|
+
"path": str(item.path),
|
|
307
|
+
"role": record.role,
|
|
308
|
+
"owner": record.owner,
|
|
309
|
+
"lifecycle": record.lifecycle,
|
|
310
|
+
"outcome": applied,
|
|
311
|
+
"reason": reason,
|
|
312
|
+
"evidence": evidence,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
stranded, cleared = _converge_stranded_entitlement(
|
|
317
|
+
store,
|
|
318
|
+
snapshot,
|
|
319
|
+
findings,
|
|
320
|
+
actor=actor,
|
|
321
|
+
trigger=trigger,
|
|
322
|
+
dry_run=dry_run,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
payload = status(repository_path)
|
|
326
|
+
payload["command"] = "reconcile-writers"
|
|
327
|
+
payload["decision"] = {
|
|
328
|
+
"code": ("WRITERS_RECONCILED" if converged or stranded else "NO_WRITER_ACTION"),
|
|
329
|
+
"reason": (
|
|
330
|
+
f"{converged} writer slot(s) {'reclaimed' if not dry_run else 'would be reclaimed'}"
|
|
331
|
+
if converged
|
|
332
|
+
else "no allocation had a provably inactive writer"
|
|
333
|
+
),
|
|
334
|
+
"actor": actor,
|
|
335
|
+
"trigger": trigger,
|
|
336
|
+
"dry_run": dry_run,
|
|
337
|
+
"reclaimed": reclaimed,
|
|
338
|
+
"would_reclaim": converged,
|
|
339
|
+
"stranded_entitlement": stranded,
|
|
340
|
+
"stranded_entitlement_cleared": cleared,
|
|
341
|
+
"adopted": adopted,
|
|
342
|
+
"legacy_unverified": legacy_unverified,
|
|
343
|
+
"selection": list(only),
|
|
344
|
+
"writers_before": writers_before,
|
|
345
|
+
"writers_after": writers_before - converged,
|
|
346
|
+
"retained_before": retained_before,
|
|
347
|
+
"retained_after": retained_used,
|
|
348
|
+
"retained_limit": retained_limit,
|
|
349
|
+
"findings": findings,
|
|
350
|
+
}
|
|
351
|
+
return payload
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _converge_stranded_entitlement(
|
|
355
|
+
store: MetadataStore,
|
|
356
|
+
snapshot: MetadataSnapshot,
|
|
357
|
+
findings: list[dict[str, Any]],
|
|
358
|
+
*,
|
|
359
|
+
actor: str,
|
|
360
|
+
trigger: str,
|
|
361
|
+
dry_run: bool,
|
|
362
|
+
) -> tuple[int, int]:
|
|
363
|
+
"""Drop writer entitlement whose allocation has reached a terminal state.
|
|
364
|
+
|
|
365
|
+
A lease is only ever granted to an ACTIVE allocation, so once its allocation is
|
|
366
|
+
released or gone the lease can grant nothing. It is not harmless: every reader
|
|
367
|
+
of the lease directory, including the rollout gate, still sees a live holder and
|
|
368
|
+
reports a writer that no longer has a task. Liveness is deliberately not
|
|
369
|
+
consulted here, because the holder being alive says nothing about an entitlement
|
|
370
|
+
whose allocation is finished.
|
|
371
|
+
|
|
372
|
+
A parked, frozen or otherwise non-ACTIVE allocation is still someone's lane: it
|
|
373
|
+
can return to ACTIVE, and a live holder may be about to resume it, so its lease is
|
|
374
|
+
kept while the holder or its process group is alive. Once both are provably gone
|
|
375
|
+
the lease can grant nothing (resuming requires a new lease) and is swept too;
|
|
376
|
+
otherwise it would mark the checkout as in use forever. The allocation itself is
|
|
377
|
+
never touched.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
stranded = 0
|
|
381
|
+
cleared = 0
|
|
382
|
+
for allocation_id, lease in sorted(snapshot.writer_leases.items()):
|
|
383
|
+
record = snapshot.records.get(allocation_id)
|
|
384
|
+
if record is not None and record.lifecycle == "ACTIVE":
|
|
385
|
+
continue
|
|
386
|
+
if (
|
|
387
|
+
record is not None
|
|
388
|
+
and record.lifecycle != "RELEASED"
|
|
389
|
+
and (holder_is_live(lease.holder) or process_group_is_live(lease.holder))
|
|
390
|
+
):
|
|
391
|
+
continue
|
|
392
|
+
lifecycle = record.lifecycle if record is not None else "MISSING_RECORD"
|
|
393
|
+
stranded += 1
|
|
394
|
+
finding = {
|
|
395
|
+
"task_key": record.task_key if record is not None else None,
|
|
396
|
+
"path": lease.worktree_path,
|
|
397
|
+
"role": record.role if record is not None else None,
|
|
398
|
+
"owner": lease.owner,
|
|
399
|
+
"lifecycle": lifecycle,
|
|
400
|
+
"outcome": STRANDED,
|
|
401
|
+
"reason": (
|
|
402
|
+
f"terminal lifecycle {lifecycle} cannot hold writer entitlement"
|
|
403
|
+
if lifecycle in {"RELEASED", "MISSING_RECORD"}
|
|
404
|
+
else f"non-ACTIVE lifecycle {lifecycle} lease holder is gone"
|
|
405
|
+
),
|
|
406
|
+
"evidence": [f"writer_lease={lease.lease_id} holder={lease.holder.client_id}"],
|
|
407
|
+
}
|
|
408
|
+
if not dry_run:
|
|
409
|
+
store.remove_writer_lease(allocation_id)
|
|
410
|
+
store.write_writer_receipt(
|
|
411
|
+
WriterReconciliationReceipt(
|
|
412
|
+
schema_version=WRITER_RECEIPT_SCHEMA_VERSION,
|
|
413
|
+
receipt_id=str(uuid.uuid4()),
|
|
414
|
+
repository_id=snapshot.repository_id or "unknown",
|
|
415
|
+
allocation_id=allocation_id,
|
|
416
|
+
actor=actor,
|
|
417
|
+
owner=lease.owner,
|
|
418
|
+
trigger=trigger,
|
|
419
|
+
outcome=STRANDED_CLEARED,
|
|
420
|
+
reason=finding["reason"],
|
|
421
|
+
evidence=tuple(finding["evidence"]),
|
|
422
|
+
previous_lifecycle=lifecycle,
|
|
423
|
+
new_lifecycle=lifecycle,
|
|
424
|
+
reconciled_at=now(),
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
cleared += 1
|
|
428
|
+
finding["outcome"] = STRANDED_CLEARED
|
|
429
|
+
findings.append(finding)
|
|
430
|
+
return stranded, cleared
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Read-only, state-specific directions through the existing lifecycle contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .controller import envelope, status
|
|
9
|
+
from .git_backend import inventory
|
|
10
|
+
from .lifecycle_history import lifecycle_history
|
|
11
|
+
from .models import ControllerError
|
|
12
|
+
from .store import MetadataStore
|
|
13
|
+
|
|
14
|
+
CONTRACT = "recovery-guidance/v1"
|
|
15
|
+
REASON = "Preserve the failed candidate and reprepare the original frozen source"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def guide_argv(repository: str, path: str) -> list[str]:
|
|
19
|
+
return ["worktree-controller", "recovery-guide", "--repo", repository,
|
|
20
|
+
"--path", path, "--json"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def recovery_guide(repository_path: Path, *, target: Path | None = None,
|
|
24
|
+
intent_id: str | None = None) -> dict[str, Any]:
|
|
25
|
+
"""Observe one checkout or original intent; never authorize or execute a step.
|
|
26
|
+
|
|
27
|
+
Commands bind only the current observation. Later transitions must be observed
|
|
28
|
+
again; in particular, no future CAS or consumer-generated digest is emitted.
|
|
29
|
+
"""
|
|
30
|
+
if (target is None) == (intent_id is None):
|
|
31
|
+
raise ControllerError("INVALID_SELECTOR", "select exactly one path or integration intent")
|
|
32
|
+
repository = inventory(repository_path)
|
|
33
|
+
store = MetadataStore(repository.common_dir)
|
|
34
|
+
with store.lock(exclusive=False):
|
|
35
|
+
snapshot = store._load_unlocked()
|
|
36
|
+
observed = status(repository_path)
|
|
37
|
+
canonical = str(repository.canonical_path)
|
|
38
|
+
intent = None
|
|
39
|
+
item = None
|
|
40
|
+
if intent_id is not None:
|
|
41
|
+
intent = snapshot.integration_intents.get(intent_id)
|
|
42
|
+
if intent is None:
|
|
43
|
+
raise ControllerError("UNKNOWN_INTENT", "integration intent is not registered")
|
|
44
|
+
item = next((row for row in observed["worktrees"]
|
|
45
|
+
if row["allocation_id"] == intent.allocation_id), None)
|
|
46
|
+
if intent.allocation_id is not None and item is None and intent.state != "RELEASED":
|
|
47
|
+
raise ControllerError("UNKNOWN_TASK", "intent allocation has no registered checkout")
|
|
48
|
+
else:
|
|
49
|
+
resolved = str(target.expanduser().resolve(strict=True))
|
|
50
|
+
item = next((row for row in observed["worktrees"] if row["path"] == resolved), None)
|
|
51
|
+
if item is None or item["metadata_missing"]:
|
|
52
|
+
raise ControllerError("UNKNOWN_TASK", "path has no governed allocation")
|
|
53
|
+
intent = snapshot.integration_intents.get(item["integration_intent_id"])
|
|
54
|
+
|
|
55
|
+
result = envelope("recovery-guide")
|
|
56
|
+
result["repository"] = {"canonical_path": canonical,
|
|
57
|
+
"repository_id": snapshot.repository_id}
|
|
58
|
+
result["warnings"] = observed["warnings"]
|
|
59
|
+
decision: dict[str, Any] = {
|
|
60
|
+
"code": "RECOVERY_GUIDANCE", "contract": CONTRACT,
|
|
61
|
+
"execution_authority": False, "execution_guards_checked": False,
|
|
62
|
+
"mutation_performed": False, "task_completed": False,
|
|
63
|
+
"reobserve_after_each_step": True, "blockers": [], "next_steps": [],
|
|
64
|
+
"reason": "Inspect the current state before choosing a recovery action",
|
|
65
|
+
}
|
|
66
|
+
result["decision"] = decision
|
|
67
|
+
if intent is not None:
|
|
68
|
+
decision["intent"] = {field: getattr(intent, field) for field in
|
|
69
|
+
("intent_id", "owner", "state", "blocked_reason", "attempt",
|
|
70
|
+
"allocation_id", "queue_sequence", "scope_paths", "source_handoffs")}
|
|
71
|
+
|
|
72
|
+
def step(action: str, message: str, argv: list[str] | None = None) -> None:
|
|
73
|
+
value: dict[str, Any] = {"action": action, "message": message}
|
|
74
|
+
if argv is not None:
|
|
75
|
+
value["argv"] = argv
|
|
76
|
+
decision["next_steps"].append(value)
|
|
77
|
+
|
|
78
|
+
if item is None:
|
|
79
|
+
if intent.state == "RELEASED" and intent.allocation_id is not None:
|
|
80
|
+
history = lifecycle_history(repository_path, allocation_id=intent.allocation_id,
|
|
81
|
+
limit=1)["decision"]
|
|
82
|
+
closed = history["closed_releases"]
|
|
83
|
+
evidence = closed[0] if len(closed) == 1 else {}
|
|
84
|
+
publications = [delivery for delivery in evidence.get("deliveries", [])
|
|
85
|
+
if delivery["relationship"] == "candidate"
|
|
86
|
+
and delivery["integration_intent_id"] == intent.intent_id
|
|
87
|
+
and delivery["candidate_oid"] == intent.candidate_oid
|
|
88
|
+
and delivery["publication_status"] == "REMOTE_VERIFIED"]
|
|
89
|
+
exact = (not history["live_allocations"] and not history["incomplete_history"]
|
|
90
|
+
and not history["next_after"] and evidence.get("proof_status") == "COMPLETE"
|
|
91
|
+
and evidence.get("release_status") == "RELEASED"
|
|
92
|
+
and evidence.get("role") == "integration"
|
|
93
|
+
and all(evidence.get(key) == getattr(intent, key) for key in
|
|
94
|
+
("allocation_id", "owner", "task_key", "authority_key"))
|
|
95
|
+
and evidence.get("released_head") == intent.candidate_oid and bool(publications))
|
|
96
|
+
if exact:
|
|
97
|
+
decision["terminal_status"] = "PUBLISHED_RELEASED"
|
|
98
|
+
decision["terminal_evidence"] = {
|
|
99
|
+
"allocation_id": intent.allocation_id,
|
|
100
|
+
"candidate_oid": intent.candidate_oid,
|
|
101
|
+
"release_receipt_id": evidence["release_receipt_id"],
|
|
102
|
+
"publication_receipt_ids": sorted({p["publication_receipt_id"] for p in publications}),
|
|
103
|
+
}
|
|
104
|
+
decision["reason"] = "This integration was published and its checkout released; recovery is unnecessary"
|
|
105
|
+
else:
|
|
106
|
+
decision["blockers"].append("CLOSED_LIFECYCLE_EVIDENCE_INCOMPLETE")
|
|
107
|
+
decision["reason"] = "The released intent needs complete matching publication and release evidence"
|
|
108
|
+
step("inspect_history", "Inspect immutable lifecycle evidence; do not retry an already published integration",
|
|
109
|
+
["worktree-controller", "lifecycle-history", "--repo", canonical,
|
|
110
|
+
"--allocation-id", intent.allocation_id, "--json"])
|
|
111
|
+
return result
|
|
112
|
+
decision["reason"] = "The original intent has no live candidate; inspect its retry plan"
|
|
113
|
+
if intent.state == "ATTENTION_REQUIRED":
|
|
114
|
+
step("inspect_retry", "Require complete abort/release proof and unchanged source; "
|
|
115
|
+
"apply only the returned public plan_id with the identical request",
|
|
116
|
+
["worktree-controller", "integration-retry", "--repo", canonical,
|
|
117
|
+
"--intent-id", intent.intent_id, "--owner", intent.owner,
|
|
118
|
+
"--reason", REASON, "--dry-run", "--json"])
|
|
119
|
+
step("readmit", "After retry, claim the same intent through ordinary admission; "
|
|
120
|
+
"resolve conflicts explicitly and run fresh validation")
|
|
121
|
+
else:
|
|
122
|
+
step("inspect_queue", "Keep the original intent, source, scope and queue position",
|
|
123
|
+
["worktree-controller", "integration-queue", "--repo", canonical, "--json"])
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
decision["identity"] = {field: item[field] for field in
|
|
127
|
+
("allocation_id", "owner", "task_key", "authority_key", "path",
|
|
128
|
+
"branch", "head", "record_version", "lifecycle", "role")}
|
|
129
|
+
decision["inspect_argv"] = guide_argv(canonical, item["path"])
|
|
130
|
+
blockers = decision["blockers"]
|
|
131
|
+
for field, code in (("canonical", "CANONICAL_PROTECTED"),
|
|
132
|
+
("metadata_missing", "MISSING_METADATA"),
|
|
133
|
+
("dirty", "DIRTY_WORKTREE"), ("detached", "DETACHED_HEAD"),
|
|
134
|
+
("locked", "WORKTREE_LOCKED"), ("prunable", "WORKTREE_PRUNABLE"),
|
|
135
|
+
("external_bindings", "EXTERNAL_BINDINGS")):
|
|
136
|
+
if item.get(field):
|
|
137
|
+
blockers.append(code)
|
|
138
|
+
if item["allocation_id"] in snapshot.writer_leases:
|
|
139
|
+
blockers.append("WRITER_LEASE_PRESENT")
|
|
140
|
+
lease = snapshot.publication_lease
|
|
141
|
+
if lease is not None and lease.allocation_id == item["allocation_id"]:
|
|
142
|
+
blockers.append("PUBLICATION_LEASE_HELD")
|
|
143
|
+
from .transaction_scope import conflicts
|
|
144
|
+
|
|
145
|
+
if conflicts(store, allocation=item["allocation_id"], target=Path(item["path"])):
|
|
146
|
+
blockers.append("BLOCK_TRANSACTION")
|
|
147
|
+
if blockers:
|
|
148
|
+
decision["reason"] = "Preserve files and resolve the observed blockers with their recorded owner"
|
|
149
|
+
step("inspect_blockers", "End only the exact owned writer and preserve unknown files; "
|
|
150
|
+
"doctor checks integrity, not integration or publication eligibility",
|
|
151
|
+
["worktree-controller", "doctor", "--repo", canonical, "--json"])
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
common = ["--repo", canonical, "--path", item["path"], "--owner", item["owner"],
|
|
155
|
+
"--expected-record-version", str(item["record_version"])]
|
|
156
|
+
lifecycle = item["lifecycle"]
|
|
157
|
+
if item["role"] == "integration" and lifecycle in {"BLOCKED", "QUARANTINED"}:
|
|
158
|
+
if intent is not None:
|
|
159
|
+
from .integration_retry import _sources
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
_sources(repository, snapshot, store, intent, require_idle=False)
|
|
163
|
+
except ControllerError as error:
|
|
164
|
+
blockers.append(error.code)
|
|
165
|
+
decision["reason"] = str(error)
|
|
166
|
+
step("restore_source_evidence", "The recorded source and scope must be restored "
|
|
167
|
+
"by their owner; do not replace the original request")
|
|
168
|
+
return result
|
|
169
|
+
if lifecycle == "BLOCKED":
|
|
170
|
+
expected = item["task_commit"] or item["handoff_commit"] or item["base_commit"]
|
|
171
|
+
if expected != item["head"]:
|
|
172
|
+
blockers.append("CANDIDATE_HEAD_DRIFT")
|
|
173
|
+
decision["reason"] = "Unexpected commits require explicit quarantine and lineage recovery"
|
|
174
|
+
step("inspect_lineage", "Preserve the extra commits; use quarantine/recovery/externalize "
|
|
175
|
+
"after reviewing the latest legitimate commit")
|
|
176
|
+
return result
|
|
177
|
+
decision["reason"] = "BLOCKED cannot abort directly; first record quarantine of this exact candidate"
|
|
178
|
+
step("quarantine", "Equal expected and observed HEAD is valid; no synthetic commit is needed",
|
|
179
|
+
["worktree-controller", "quarantine", *common, "--expected-commit", expected,
|
|
180
|
+
"--reason", REASON, "--json"])
|
|
181
|
+
step("abort_then_release", "Reinspect QUARANTINED, record formal abort, then inspect "
|
|
182
|
+
"release guards; preserve unclassified artifacts before cleanup")
|
|
183
|
+
else:
|
|
184
|
+
decision["reason"] = "Quarantine preserves failure identity; formal abort is the next lifecycle step"
|
|
185
|
+
step("abort", "This records the failed preparation and preserves the original source",
|
|
186
|
+
["worktree-controller", "integration-abort", *common,
|
|
187
|
+
"--reason", REASON, "--json"])
|
|
188
|
+
elif lifecycle == "INTEGRATION_ABORTED":
|
|
189
|
+
decision["reason"] = "The failed attempt is recorded; inspect guarded release before retry"
|
|
190
|
+
step("inspect_release", "Unknown generated files need exact preservation and typed cleanup; "
|
|
191
|
+
"release retains the branch and must produce complete lifecycle evidence",
|
|
192
|
+
["worktree-controller", "release", "--repo", canonical, "--path", item["path"],
|
|
193
|
+
"--owner-release", item["owner"], "--expected-record-version",
|
|
194
|
+
str(item["record_version"]), "--dry-run", "--json"])
|
|
195
|
+
step("retry_original_intent", "After complete release, select the original intent with "
|
|
196
|
+
"recovery-guide --intent-id; retry requires its public plan and fresh admission")
|
|
197
|
+
elif lifecycle == "HANDOFF_READY":
|
|
198
|
+
decision["reason"] = "A frozen handoff needs exact Finalizer identity before session closeout"
|
|
199
|
+
step("check_session", "Run worktree-session-end --check with the real client/session/holder/run "
|
|
200
|
+
"and expected allocation IDs; read every row and summary.terminal_complete")
|
|
201
|
+
else:
|
|
202
|
+
decision["reason"] = "Unfinished work needs its existing Task checkpoint or a verified handoff"
|
|
203
|
+
step("preserve_task", "Finalize deliverable files through Git Finalizer, or save the existing "
|
|
204
|
+
"Task checkpoint and park with exact owner, HEAD and record version; preserve unknown files")
|
|
205
|
+
return result
|