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