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,893 @@
1
+ """Retention convergence: close checkouts nobody is using, without losing a byte.
2
+
3
+ Admission is resource based and never counts checkouts, so the population can only
4
+ stay bounded if unused checkouts leave again. Before this engine, every exit from
5
+ ``RETAINED`` was a manual, per-target command signed with an owner identity that
6
+ usually belonged to an agent session which no longer existed; retained checkouts
7
+ therefore accumulated without limit.
8
+
9
+ The engine runs under the repository's delegated reconciler identity and only
10
+ composes the existing guarded verbs: ``close-delivered``, ``complete-no-change``,
11
+ ``archive-retained`` and ``release``. It never touches a dirty, detached, bound,
12
+ leased or in-use checkout, never deletes anything that is not either reachable from
13
+ a retained branch or a registered reproducible ``.venv``, and never claims task
14
+ completion. Every physical removal still passes the unchanged release preflight and
15
+ writes the ordinary release receipt.
16
+
17
+ A frozen handoff normally leaves once its commit is published. When the policy opts
18
+ in with ``release_unintegrated_handoffs``, an integration-ready feature/audit/hotfix handoff
19
+ whose exact HEAD is retained by both its local and remote-tracking branch may leave
20
+ before integration; the integration queue then replays it from the retained branch.
21
+
22
+ Idle thresholds are dynamic. With ``live`` checkouts in use, the pressure reference
23
+ is ``live + standing_reserve``; while the registered population stays at or below
24
+ it, an unfinished checkout may idle for ``idle_hours_max``. Above it the threshold
25
+ shrinks with the square of the overshoot down to ``idle_hours_min``. Published and
26
+ no-change checkouts only wait ``grace_minutes``. Nothing here refuses admission.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import fcntl
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ from collections import Counter
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from .controller import envelope
43
+ from .git_backend import inventory, is_ancestor, resolve_commit
44
+ from .models import (
45
+ TERMINAL_INTEGRATION_STATES,
46
+ ControllerError,
47
+ MetadataRecord,
48
+ MetadataSnapshot,
49
+ WorktreeGitState,
50
+ )
51
+ from .owner_actions import owner_action
52
+ from .policy import load_policy
53
+ from .semantics import record_for as _record_for
54
+ from .store import MetadataStore
55
+ from .writer_lease import _referenced_paths, holder_is_live, process_group_is_live
56
+
57
+ TRIGGERS = ("manual", "admission", "background")
58
+
59
+ CLOSED_STATES = frozenset({"RELEASE_READY", "ARCHIVED", "NO_CHANGE_COMPLETED"})
60
+ CANDIDATE_STATES = frozenset({"ACTIVE", "RETAINED", "HANDOFF_READY"}) | CLOSED_STATES
61
+ CONVERGED_ROLES = frozenset({"feature", "audit"})
62
+
63
+ # Outcomes
64
+ LIVE = "IN_USE"
65
+ ATTENTION = "ATTENTION_REQUIRED"
66
+ WAITING = "IDLE_BELOW_THRESHOLD"
67
+ BLOCKED = "BLOCKED"
68
+ PLANNED = "WOULD_RELEASE"
69
+ RELEASED = "RELEASED"
70
+ SKIPPED = "OUT_OF_SCOPE"
71
+
72
+ # Summary order and wording; the reason names what actually kept checkouts on disk.
73
+ OUTCOME_LABELS = (
74
+ (PLANNED, "would be released"),
75
+ (LIVE, "in use"),
76
+ (ATTENTION, "need owner action"),
77
+ (BLOCKED, "blocked"),
78
+ (WAITING, "idle below threshold"),
79
+ (SKIPPED, "out of scope"),
80
+ )
81
+
82
+
83
+ def _parse_time(value: str | None) -> float | None:
84
+ if not value:
85
+ return None
86
+ try:
87
+ parsed = datetime.fromisoformat(value)
88
+ except ValueError:
89
+ return None
90
+ if parsed.tzinfo is None:
91
+ parsed = parsed.replace(tzinfo=UTC)
92
+ return parsed.timestamp()
93
+
94
+
95
+ def _admin_dir(item: WorktreeGitState) -> Path | None:
96
+ marker = Path(item.path) / ".git"
97
+ try:
98
+ text = marker.read_text(encoding="utf-8").strip()
99
+ except OSError:
100
+ return None
101
+ if not text.startswith("gitdir:"):
102
+ return None
103
+ admin = Path(text.split(":", 1)[1].strip())
104
+ return admin if admin.is_absolute() else (Path(item.path) / admin).resolve()
105
+
106
+
107
+ def last_activity(item: WorktreeGitState, record: MetadataRecord) -> float:
108
+ """Latest observable touch of the checkout: Git admin files or a lifecycle event.
109
+
110
+ Only clean checkouts are ever acted on, so every change that matters has passed
111
+ through the index, HEAD or its reflog.
112
+ """
113
+
114
+ stamps: list[float] = []
115
+ admin = _admin_dir(item)
116
+ if admin is not None:
117
+ for name in ("HEAD", "index", "logs/HEAD", "ORIG_HEAD", "FETCH_HEAD"):
118
+ try:
119
+ stamps.append((admin / name).stat().st_mtime)
120
+ except OSError:
121
+ continue
122
+ for value in (
123
+ record.created_at,
124
+ record.last_reviewed_at,
125
+ *(event.get("at") for event in record.history if isinstance(event, dict)),
126
+ ):
127
+ parsed = _parse_time(value if isinstance(value, str) else None)
128
+ if parsed is not None:
129
+ stamps.append(parsed)
130
+ return max(stamps) if stamps else 0.0
131
+
132
+
133
+ def idle_threshold_hours(policy_convergence: Any, *, registered: int, live: int) -> float:
134
+ reference = live + policy_convergence.standing_reserve
135
+ pressure = registered / reference if reference else float(registered)
136
+ if pressure <= 1:
137
+ return float(policy_convergence.idle_hours_max)
138
+ return max(
139
+ float(policy_convergence.idle_hours_min),
140
+ policy_convergence.idle_hours_max / (pressure * pressure),
141
+ )
142
+
143
+
144
+ def busy_paths(targets: set[str]) -> set[str]:
145
+ """Targets that any live process references, from one pass over ``/proc``.
146
+
147
+ Same evidence as ``writer_lease.paths_in_use`` (cwd, open files, mappings), but a
148
+ single scan serves every checkout instead of one full scan per checkout.
149
+ """
150
+
151
+ found: set[str] = set()
152
+ own = os.getpid()
153
+ for entry in os.listdir("/proc"):
154
+ if not entry.isdigit() or int(entry) == own:
155
+ continue
156
+ for candidate in _referenced_paths(int(entry)):
157
+ current = candidate
158
+ while current and current != os.sep:
159
+ if current in targets:
160
+ found.add(current)
161
+ break
162
+ current = os.path.dirname(current)
163
+ return found
164
+
165
+
166
+ def _in_use(
167
+ item: WorktreeGitState,
168
+ record: MetadataRecord,
169
+ snapshot: MetadataSnapshot,
170
+ busy: set[str],
171
+ ) -> str | None:
172
+ lease = snapshot.writer_leases.get(record.allocation_id)
173
+ if lease is not None:
174
+ if holder_is_live(lease.holder) or process_group_is_live(lease.holder):
175
+ return "writer lease holder is alive"
176
+ return "writer lease is recorded; writer reconciliation must retire it first"
177
+ if str(item.path) in busy:
178
+ return "a live process is using this checkout"
179
+ if record.external_bindings:
180
+ return "registered external or runtime bindings"
181
+ if (
182
+ snapshot.publication_lease
183
+ and snapshot.publication_lease.allocation_id == record.allocation_id
184
+ ):
185
+ return "a publication lease is in progress"
186
+ return None
187
+
188
+
189
+ def _is_candidate(record: MetadataRecord) -> bool:
190
+ """feature/audit in any candidate state, plus a frozen hotfix handoff.
191
+
192
+ A frozen hotfix handoff follows the same handoff routes as feature/audit; every
193
+ other hotfix state keeps its own flow.
194
+ """
195
+
196
+ if record.role in CONVERGED_ROLES:
197
+ return record.lifecycle in CANDIDATE_STATES
198
+ return record.role == "hotfix" and record.lifecycle == "HANDOFF_READY"
199
+
200
+
201
+ def _integration_bound(snapshot: MetadataSnapshot, record: MetadataRecord) -> bool:
202
+ from .lifecycle import _effective_intent_state
203
+
204
+ return any(
205
+ intent.allocation_id == record.allocation_id
206
+ or record.allocation_id in intent.source_allocations
207
+ for intent in snapshot.integration_intents.values()
208
+ if _effective_intent_state(snapshot, intent) not in TERMINAL_INTEGRATION_STATES
209
+ ) or any(
210
+ other.lifecycle != "RELEASED" and record.allocation_id in other.source_allocations
211
+ for other in snapshot.records.values()
212
+ )
213
+
214
+
215
+ def _published_oid(canonical: Path, remote: str, branch: str) -> str | None:
216
+ for ref in (f"refs/remotes/{remote}/{branch}", f"refs/heads/{branch}"):
217
+ try:
218
+ return resolve_commit(canonical, ref)
219
+ except ControllerError:
220
+ continue
221
+ return None
222
+
223
+
224
+ def _ref_oid(canonical: Path, ref: str) -> str | None:
225
+ try:
226
+ return resolve_commit(canonical, ref)
227
+ except ControllerError:
228
+ return None
229
+
230
+
231
+ def _unintegrated_handoff_hold(
232
+ canonical: Path, item: WorktreeGitState, record: MetadataRecord, remote: str
233
+ ) -> str | None:
234
+ """Why an unpublished frozen handoff must keep its checkout, or None if it may leave.
235
+
236
+ Leaving early is only safe when nothing but the checkout directory is lost: the
237
+ frozen identities all equal HEAD, the handoff is integration-ready (so the queue
238
+ can replay it from its branch), and both the local branch and the remote-tracking
239
+ branch retain exactly that HEAD.
240
+ """
241
+
242
+ if not any(event.get("event") == "HANDOFF_INTEGRATION_READY" for event in record.history):
243
+ return "handoff is not integration-ready"
244
+ identities = (record.task_commit, record.handoff_commit, record.finalizer_commit)
245
+ if not record.finalizer_record_id or any(identity != item.head for identity in identities):
246
+ return "task, handoff and Finalizer identities do not all equal HEAD"
247
+ if any(
248
+ (
249
+ record.quarantine_reason,
250
+ record.quarantine_observed_commit,
251
+ record.recovery_ref,
252
+ record.recovery_allocation_id,
253
+ )
254
+ ):
255
+ return "recovery lineage keeps its own flow"
256
+ if item.branch is None or _ref_oid(canonical, f"refs/heads/{item.branch}") != item.head:
257
+ return "local branch does not retain the frozen HEAD"
258
+ if _ref_oid(canonical, f"refs/remotes/{remote}/{item.branch}") != item.head:
259
+ return f"{remote}/{item.branch} does not retain the frozen HEAD; push it with Git Finalizer"
260
+ return None
261
+
262
+
263
+ def _route(
264
+ canonical: Path,
265
+ item: WorktreeGitState,
266
+ record: MetadataRecord,
267
+ published: str | None,
268
+ *,
269
+ remote: str,
270
+ release_unintegrated: bool,
271
+ ) -> tuple[str, str | None]:
272
+ """Pick the existing guarded verb that fits the recorded facts.
273
+
274
+ Returns the route and, for ``hold-handoff``, the reason the handoff stays.
275
+ """
276
+
277
+ if record.lifecycle in CLOSED_STATES:
278
+ return "release", None
279
+ if record.lifecycle == "HANDOFF_READY":
280
+ if (
281
+ published
282
+ and record.handoff_commit
283
+ and record.handoff_commit == item.head
284
+ and is_ancestor(canonical, record.handoff_commit, published)
285
+ ):
286
+ return "release-published-handoff", None
287
+ if not release_unintegrated:
288
+ return "hold-handoff", (
289
+ "frozen handoff is not integrated and no integration intent is queued; "
290
+ "integration-enqueue it, or enable release_unintegrated_handoffs"
291
+ )
292
+ hold = _unintegrated_handoff_hold(canonical, item, record, remote)
293
+ if hold is not None:
294
+ return "hold-handoff", f"unintegrated handoff keeps its checkout: {hold}"
295
+ return "release-retained-handoff", None
296
+ if record.handoff_commit or record.finalizer_record_id or record.finalizer_commit:
297
+ return "hold-handoff", "handoff evidence exists outside HANDOFF_READY"
298
+ if published and all(
299
+ is_ancestor(canonical, oid, published)
300
+ for oid in (item.head, record.base_commit, record.task_commit)
301
+ if oid
302
+ ):
303
+ return "close-delivered", None
304
+ if record.base_commit and item.head == resolve_commit(canonical, record.base_commit):
305
+ return "complete-no-change", None
306
+ return "archive-retained", None
307
+
308
+
309
+ def _require_intent_renewal_due(
310
+ repository_path: Path, item: WorktreeGitState, record: MetadataRecord
311
+ ) -> None:
312
+ """Refuse a new release intent while an unchanged handoff backs off.
313
+
314
+ Each pass otherwise creates a fresh intent for a checkout whose last one went
315
+ stale for a reason the pass cannot change, one intent per pass for as long as it
316
+ stays blocked. The same rule as stale-intent renewal applies: a changed record or
317
+ HEAD, or a first expiry, proceeds at once.
318
+ """
319
+ from .lifecycle import _release_intents_for_allocation, _renewal_due
320
+
321
+ canonical = inventory(repository_path)
322
+ snapshot = MetadataStore(canonical.common_dir).load()
323
+ intents = _release_intents_for_allocation(snapshot, record.allocation_id)
324
+ if any(intent.status in {"PENDING", "BLOCKED_RETRYABLE"} for intent in intents):
325
+ return # create_release_intent reuses the live intent
326
+ stale = [intent for intent in intents if intent.status == "STALE"]
327
+ if not stale:
328
+ return
329
+ # The backoff runs from the most recent time an intent of this allocation went stale.
330
+ latest = max(stale, key=lambda intent: (intent.updated_at, intent.created_at, intent.intent_id))
331
+ ttl = load_policy(canonical.canonical_path).release_intent_ttl_seconds
332
+ if not _renewal_due(snapshot, latest, record, item.head, datetime.now(UTC), ttl):
333
+ raise ControllerError(
334
+ "RENEWAL_BACKOFF",
335
+ f"unchanged handoff release intent is backing off after {latest.blocker or 'expiry'}",
336
+ )
337
+
338
+
339
+ def _register_venv(
340
+ repository_path: Path, item: WorktreeGitState, record: MetadataRecord
341
+ ) -> MetadataRecord:
342
+ """Classify an unregistered root ``.venv`` so the release preflight can decide on it."""
343
+
344
+ from .lifecycle import create_release_intent, register_disposable
345
+
346
+ venv = Path(item.path) / ".venv"
347
+ if (
348
+ not venv.is_dir()
349
+ or venv.is_symlink()
350
+ or any(resource["path"] == ".venv" for resource in record.disposable_resources)
351
+ ):
352
+ return record
353
+ if record.role == "hotfix":
354
+ # A hotfix environment is classified by its owner's attestation, which the
355
+ # reconciler must not sign on the owner's behalf.
356
+ raise ControllerError(
357
+ "RESOURCE_UNCLASSIFIED",
358
+ "hotfix handoff has an unregistered .venv; its owner classifies it with "
359
+ f"resource-register --creator handoff-owner-attestation:{record.owner}",
360
+ )
361
+ if record.lifecycle == "HANDOFF_READY":
362
+ # A frozen handoff only accepts resource classification behind a pending release
363
+ # intent, which binds its exact handoff/Finalizer identity; nothing else changes.
364
+ _require_intent_renewal_due(repository_path, item, record)
365
+ create_release_intent(
366
+ repository_path,
367
+ target=Path(item.path),
368
+ expected_record_version=record.record_version,
369
+ owner=record.owner,
370
+ trigger="capacity-pressure",
371
+ )
372
+ record = _reload(repository_path, item)
373
+ register_disposable(
374
+ repository_path,
375
+ target=Path(item.path),
376
+ expected_record_version=record.record_version,
377
+ owner=record.owner,
378
+ resource_path=".venv",
379
+ kind="python-venv",
380
+ reproducible=True,
381
+ creator="retention-convergence",
382
+ teardown_policy="on-release",
383
+ )
384
+ return _reload(repository_path, item)
385
+
386
+
387
+ def _reload(repository_path: Path, item: WorktreeGitState) -> MetadataRecord:
388
+ repository = inventory(repository_path)
389
+ snapshot = MetadataStore(repository.common_dir).load()
390
+ current = next(found for found in repository.worktrees if found.path == item.path)
391
+ record = _record_for(current, snapshot)
392
+ if record is None:
393
+ raise ControllerError("METADATA_MISSING", "allocation disappeared during convergence")
394
+ return record
395
+
396
+
397
+ def _close(
398
+ repository_path: Path, item: WorktreeGitState, record: MetadataRecord, route: str, actor: str
399
+ ) -> MetadataRecord:
400
+ from .delivered_closeout import archive_retained, close_delivered
401
+ from .lifecycle import complete_no_change
402
+
403
+ target = Path(item.path)
404
+ if route in {"release", "release-published-handoff", "release-retained-handoff"}:
405
+ return record
406
+ if route == "complete-no-change":
407
+ complete_no_change(
408
+ repository_path,
409
+ target=target,
410
+ expected_record_version=record.record_version,
411
+ owner=record.owner,
412
+ )
413
+ return _reload(repository_path, item)
414
+ verb = close_delivered if route == "close-delivered" else archive_retained
415
+ common = dict(
416
+ target=target,
417
+ expected_record_version=record.record_version,
418
+ owner_release=record.owner,
419
+ actor=actor,
420
+ )
421
+ planned = verb(repository_path, **common, dry_run=True)
422
+ verb(repository_path, **common, plan_id=planned["decision"]["plan_id"])
423
+ return _reload(repository_path, item)
424
+
425
+
426
+ def _pass_lock_path(common_dir: Path) -> Path:
427
+ state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
428
+ digest = hashlib.sha256(str(common_dir.resolve()).encode("utf-8")).hexdigest()[:24]
429
+ return state / "worktree-controller" / "convergence" / f"{digest}.lock"
430
+
431
+
432
+ def _pass_report_path(lock_path: Path) -> Path:
433
+ return lock_path.with_name(f"{lock_path.stem}.last-pass.json")
434
+
435
+
436
+ def _write_pass_report(lock_path: Path, report: dict[str, Any]) -> bool:
437
+ """Atomically replace the latest applied-pass report; False if it cannot be written.
438
+
439
+ The report is an operational observation for callers of detached passes. Release
440
+ receipts stay the only release evidence, so a missing report never implies success.
441
+ """
442
+
443
+ target = _pass_report_path(lock_path)
444
+ temporary = target.with_name(f"{target.name}.{os.getpid()}.tmp")
445
+ try:
446
+ temporary.write_text(json.dumps(report, sort_keys=True), encoding="utf-8")
447
+ os.replace(temporary, target)
448
+ except OSError:
449
+ temporary.unlink(missing_ok=True)
450
+ return False
451
+ return True
452
+
453
+
454
+ def _now_iso() -> str:
455
+ return datetime.now(UTC).isoformat(timespec="seconds")
456
+
457
+
458
+ def last_applied_pass(common_dir: Path) -> dict[str, Any] | None:
459
+ """The latest applied pass; a RUNNING report whose pass lock is free is INTERRUPTED."""
460
+
461
+ lock_path = _pass_lock_path(common_dir)
462
+ try:
463
+ report = json.loads(_pass_report_path(lock_path).read_text(encoding="utf-8"))
464
+ except (OSError, ValueError):
465
+ return None
466
+ if not isinstance(report, dict):
467
+ return None
468
+ if report.get("state") == "RUNNING":
469
+ try:
470
+ with lock_path.open("a+b") as handle:
471
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
472
+ except BlockingIOError:
473
+ return report
474
+ except OSError:
475
+ return report
476
+ report = {**report, "state": "INTERRUPTED"}
477
+ return report
478
+
479
+
480
+ def _pass_outcome(result: dict[str, Any]) -> dict[str, Any]:
481
+ decision = result["decision"]
482
+ return {
483
+ "code": decision["code"],
484
+ "reason": decision["reason"],
485
+ "outcome_counts": decision.get("outcome_counts", {}),
486
+ "released": [
487
+ {
488
+ "allocation_id": row["allocation_id"],
489
+ "receipt_id": row.get("receipt_id"),
490
+ "path": row["path"],
491
+ }
492
+ for row in decision.get("findings", [])
493
+ if row.get("outcome") == RELEASED
494
+ ],
495
+ }
496
+
497
+
498
+ def converge_retention(
499
+ repository_path: Path,
500
+ *,
501
+ actor: str,
502
+ trigger: str = "manual",
503
+ dry_run: bool = True,
504
+ now_at: float | None = None,
505
+ ) -> dict[str, Any]:
506
+ """Report, and optionally apply, retention convergence for one repository.
507
+
508
+ Applying passes are serialized per repository by a non-blocking host lock; a pass
509
+ that finds another one running returns ``CONVERGENCE_BUSY`` instead of racing it.
510
+ Every applying pass records RUNNING, then COMPLETED or FAILED, beside that lock so a
511
+ caller of a detached pass can see how it ended; a dry run reports the latest one.
512
+ """
513
+
514
+ common_dir = inventory(repository_path).common_dir
515
+ if dry_run:
516
+ result = _converge(
517
+ repository_path, actor=actor, trigger=trigger, dry_run=True, now_at=now_at
518
+ )
519
+ result["decision"]["last_applied_pass"] = last_applied_pass(common_dir)
520
+ return result
521
+ lock_path = _pass_lock_path(common_dir)
522
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
523
+ with lock_path.open("a+b") as handle:
524
+ try:
525
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
526
+ except BlockingIOError:
527
+ result = envelope("reconcile retention")
528
+ result["decision"] = {
529
+ "code": "CONVERGENCE_BUSY",
530
+ "reason": "another convergence pass is running for this repository",
531
+ "trigger": trigger,
532
+ }
533
+ return result
534
+ report: dict[str, Any] = {
535
+ "state": "RUNNING",
536
+ "trigger": trigger,
537
+ "actor": actor,
538
+ "pid": os.getpid(),
539
+ "started_at": _now_iso(),
540
+ }
541
+ recorded = _write_pass_report(lock_path, report)
542
+ try:
543
+ result = _converge(
544
+ repository_path, actor=actor, trigger=trigger, dry_run=False, now_at=now_at
545
+ )
546
+ except Exception as exc:
547
+ code = exc.code if isinstance(exc, ControllerError) else type(exc).__name__
548
+ _write_pass_report(
549
+ lock_path,
550
+ {
551
+ **report,
552
+ "state": "FAILED",
553
+ "finished_at": _now_iso(),
554
+ "code": code,
555
+ "reason": str(exc),
556
+ },
557
+ )
558
+ raise
559
+ recorded = (
560
+ _write_pass_report(
561
+ lock_path,
562
+ {
563
+ **report,
564
+ "state": "COMPLETED",
565
+ "finished_at": _now_iso(),
566
+ **_pass_outcome(result),
567
+ },
568
+ )
569
+ and recorded
570
+ )
571
+ if not recorded:
572
+ result["warnings"].append(
573
+ {
574
+ "code": "PASS_REPORT_UNAVAILABLE",
575
+ "message": "the applied-pass report could not be written; receipts remain authoritative",
576
+ }
577
+ )
578
+ return result
579
+
580
+
581
+ def _converge(
582
+ repository_path: Path,
583
+ *,
584
+ actor: str,
585
+ trigger: str,
586
+ dry_run: bool,
587
+ now_at: float | None,
588
+ ) -> dict[str, Any]:
589
+ from .lifecycle import release
590
+ from .reconciler import reconcile_writers
591
+
592
+ if trigger not in TRIGGERS:
593
+ raise ControllerError("INVALID_TRIGGER", f"unsupported convergence trigger: {trigger}")
594
+ if not actor.strip():
595
+ raise ControllerError("INVALID_ACTOR", "a convergence actor is required")
596
+ repository = inventory(repository_path)
597
+ policy = load_policy(repository.canonical_path)
598
+ settings = policy.convergence
599
+ if settings is None:
600
+ raise ControllerError(
601
+ "CONVERGENCE_UNSUPPORTED", "repository policy does not configure [convergence]"
602
+ )
603
+ if not dry_run:
604
+ if not settings.enabled:
605
+ raise ControllerError(
606
+ "CONVERGENCE_DISABLED", "repository policy does not enable retention convergence"
607
+ )
608
+ if actor != policy.reconciler_actor:
609
+ raise ControllerError(
610
+ "RECONCILER_ACTOR_MISMATCH",
611
+ "actor is not the delegated reconciler for this repository",
612
+ )
613
+ # Retire provably dead writers first so their checkouts can be evaluated now.
614
+ reconcile_writers(repository_path, actor=actor, trigger=trigger, dry_run=False)
615
+ repository = inventory(repository_path)
616
+
617
+ now_ts = now_at if now_at is not None else datetime.now(UTC).timestamp()
618
+ snapshot = MetadataStore(repository.common_dir).load()
619
+ published = _published_oid(
620
+ repository.canonical_path, policy.publication_remote, policy.publication_branch
621
+ )
622
+ linked = [item for item in repository.worktrees if not item.canonical]
623
+ busy_set = busy_paths({str(item.path) for item in linked})
624
+ rows: list[tuple[WorktreeGitState, MetadataRecord | None, str | None]] = []
625
+ live = 0
626
+ for item in linked:
627
+ record = _record_for(item, snapshot)
628
+ busy = _in_use(item, record, snapshot, busy_set) if record is not None else None
629
+ if (
630
+ busy is None
631
+ and record is not None
632
+ and record.role not in CONVERGED_ROLES
633
+ and record.lifecycle == "ACTIVE"
634
+ ):
635
+ busy = f"ACTIVE {record.role} allocation"
636
+ live += busy is not None
637
+ rows.append((item, record, busy))
638
+ threshold = idle_threshold_hours(settings, registered=len(linked), live=live)
639
+ grace = settings.grace_minutes / 60.0
640
+
641
+ findings: list[dict[str, Any]] = []
642
+ released = 0
643
+ for item, record, busy in rows:
644
+ row: dict[str, Any] = {
645
+ "path": str(item.path),
646
+ "branch": item.branch,
647
+ "allocation_id": record.allocation_id if record else None,
648
+ "owner": record.owner if record else None,
649
+ "lifecycle": record.lifecycle if record else None,
650
+ }
651
+ findings.append(row)
652
+ if record is None:
653
+ row.update(
654
+ outcome=ATTENTION,
655
+ reason="registered worktree has no controller record",
656
+ owner_action="run doctor, then adopt or recover the checkout through Controller",
657
+ )
658
+ continue
659
+ if busy is not None:
660
+ row.update(outcome=LIVE, reason=busy)
661
+ continue
662
+ if not _is_candidate(record):
663
+ row.update(
664
+ outcome=SKIPPED,
665
+ reason=f"role {record.role} / lifecycle {record.lifecycle} has its own flow",
666
+ )
667
+ continue
668
+ if item.prunable or item.locked or item.detached:
669
+ row.update(outcome=ATTENTION, reason="detached, locked or missing checkout")
670
+ continue
671
+ idle_hours = max(0.0, (now_ts - last_activity(item, record)) / 3600.0)
672
+ row["idle_hours"] = round(idle_hours, 2)
673
+ if not item.clean:
674
+ row.update(
675
+ outcome=ATTENTION,
676
+ reason="uncommitted work is never closed automatically; owner action required",
677
+ changes={
678
+ "staged": item.staged,
679
+ "modified": item.modified,
680
+ "untracked": item.untracked,
681
+ },
682
+ owner_action=_dirty_owner_action(item, record),
683
+ owner_action_request=owner_action(str(repository.canonical_path), {
684
+ "path": str(item.path), "head": item.head, "branch": item.branch,
685
+ **{key: getattr(record, key) for key in
686
+ ("allocation_id", "task_key", "authority_key", "owner", "record_version")},
687
+ }, reason="DIRTY_WORKTREE", required_actions=(
688
+ "review_task_files", "finalize_or_checkpoint", "preserve_unknown_files", "park_or_handoff")),
689
+ )
690
+ continue
691
+ if _integration_bound(snapshot, record):
692
+ row.update(outcome=BLOCKED, reason="integration lineage still references this checkout")
693
+ continue
694
+ route, hold = _route(
695
+ repository.canonical_path,
696
+ item,
697
+ record,
698
+ published,
699
+ remote=policy.publication_remote,
700
+ release_unintegrated=settings.release_unintegrated_handoffs,
701
+ )
702
+ row["route"] = route
703
+ if route == "hold-handoff":
704
+ row.update(outcome=BLOCKED, reason=hold)
705
+ continue
706
+ # An ACTIVE allocation with no lease, process, binding or Git activity is a
707
+ # session that ended without telling anyone (common for clients that do not
708
+ # use writer leases). It waits the full dynamic threshold, then is parked
709
+ # exactly as a finished session would have parked itself.
710
+ dormant = record.lifecycle == "ACTIVE"
711
+ # Population pressure only shortens how long already-parked work may linger.
712
+ # An ACTIVE lane may be a person or client resuming tomorrow without a lease, so
713
+ # it always gets the full idle_hours_max before it is parked.
714
+ if dormant:
715
+ required = float(settings.idle_hours_max)
716
+ elif route in {"archive-retained", "release-retained-handoff"}:
717
+ required = threshold
718
+ else:
719
+ required = grace
720
+ row["required_idle_hours"] = round(required, 2)
721
+ if idle_hours < required:
722
+ row.update(outcome=WAITING, reason="idle time is below the current threshold")
723
+ continue
724
+ if dry_run:
725
+ row.update(outcome=PLANNED, reason=f"{route} then guarded release")
726
+ continue
727
+ try:
728
+ current = record
729
+ if dormant:
730
+ from .lifecycle import park
731
+
732
+ park(
733
+ repository_path,
734
+ target=Path(item.path),
735
+ expected_record_version=current.record_version,
736
+ owner=current.owner,
737
+ )
738
+ current = _reload(repository_path, item)
739
+ row["parked"] = True
740
+ current = _register_venv(repository_path, item, current)
741
+ current = _close(repository_path, item, current, route, actor)
742
+ receipt = release(
743
+ repository_path,
744
+ target=Path(item.path),
745
+ expected_record_version=current.record_version,
746
+ owner_release=current.owner,
747
+ teardown_reproducible_venv=any(
748
+ resource["state"] == "registered" for resource in current.disposable_resources
749
+ ),
750
+ )
751
+ except ControllerError as exc:
752
+ row.update(outcome=BLOCKED, reason=str(exc), code=exc.code)
753
+ continue
754
+ released += 1
755
+ row.update(
756
+ outcome=RELEASED,
757
+ reason=f"{route} then guarded release",
758
+ receipt_id=receipt["decision"].get("release_receipt_id"),
759
+ release_plan_id=receipt["decision"].get("release_plan_id"),
760
+ )
761
+
762
+ result = envelope("reconcile retention")
763
+ counts = Counter(str(row["outcome"]) for row in findings)
764
+ attention = counts[ATTENTION]
765
+ result["decision"] = {
766
+ "code": "RETENTION_CONVERGED" if released else "NO_RETENTION_ACTION",
767
+ "reason": _summary(counts, released),
768
+ "outcome_counts": dict(sorted(counts.items())),
769
+ "actor": actor,
770
+ "trigger": trigger,
771
+ "dry_run": dry_run,
772
+ "registered": len(linked),
773
+ "live": live,
774
+ "pressure_reference": live + settings.standing_reserve,
775
+ "idle_threshold_hours": round(threshold, 2),
776
+ "grace_hours": round(grace, 2),
777
+ "released": released,
778
+ "would_release": sum(row["outcome"] == PLANNED for row in findings),
779
+ "attention_required": attention,
780
+ "findings": findings,
781
+ }
782
+ return result
783
+
784
+
785
+ def _summary(counts: Counter[str], released: int) -> str:
786
+ """Name what actually happened instead of blaming every protection on idle time."""
787
+
788
+ if not sum(counts.values()):
789
+ return "no linked checkout is registered"
790
+ head = (
791
+ f"{released} idle checkout(s) closed through guarded release"
792
+ if released
793
+ else "no checkout was released"
794
+ )
795
+ parts = [f"{counts[outcome]} {label}" for outcome, label in OUTCOME_LABELS if counts[outcome]]
796
+ return f"{head}; {', '.join(parts)}" if parts else head
797
+
798
+
799
+ def _dirty_owner_action(item: WorktreeGitState, record: MetadataRecord) -> str:
800
+ return (
801
+ f"owner {record.owner} (task {record.task_key}) reviews `git -C {item.path} status`, "
802
+ "then commits it through Git Finalizer or discards it explicitly, and ends with "
803
+ "park or handoff; the next pass closes the clean checkout"
804
+ )
805
+
806
+
807
+ ADMISSION_CONVERGENCE_INTERVAL_SECONDS = 300
808
+
809
+
810
+ def _recent_pass(common_dir: Path) -> dict[str, Any] | None:
811
+ """A running or recently started pass that an admission need not repeat."""
812
+ report = last_applied_pass(common_dir)
813
+ if not report:
814
+ return None
815
+ if report.get("state") == "RUNNING":
816
+ return {
817
+ "code": "CONVERGENCE_RUNNING",
818
+ "pid": report.get("pid"),
819
+ "started_at": report.get("started_at"),
820
+ }
821
+ try:
822
+ started = datetime.fromisoformat(str(report.get("started_at")))
823
+ except ValueError:
824
+ return None
825
+ if started.tzinfo is None:
826
+ return None
827
+ age = (datetime.now(UTC) - started).total_seconds()
828
+ if 0 <= age < ADMISSION_CONVERGENCE_INTERVAL_SECONDS:
829
+ return {
830
+ "code": "CONVERGENCE_RECENT",
831
+ "started_at": report.get("started_at"),
832
+ "interval_seconds": ADMISSION_CONVERGENCE_INTERVAL_SECONDS,
833
+ }
834
+ return None
835
+
836
+
837
+ def _spawn_detached(arguments: list[str]) -> subprocess.Popen[bytes]:
838
+ return subprocess.Popen(
839
+ arguments,
840
+ stdin=subprocess.DEVNULL,
841
+ stdout=subprocess.DEVNULL,
842
+ stderr=subprocess.DEVNULL,
843
+ start_new_session=True,
844
+ close_fds=True,
845
+ )
846
+
847
+
848
+ def admission_convergence(repository_path: Path) -> dict[str, Any] | None:
849
+ """Start a detached convergence pass after an admission; never block admission.
850
+
851
+ The pass runs in its own session so neither the caller's latency nor a caller that
852
+ is killed mid-way (tool timeouts) can interrupt a guarded release transaction.
853
+ A pass that is still running, or one started within the admission interval, is
854
+ not started again: back-to-back admissions would otherwise queue behind repeated
855
+ passes over the same unchanged checkouts.
856
+ """
857
+
858
+ try:
859
+ repository = inventory(repository_path)
860
+ policy = load_policy(repository.canonical_path)
861
+ if policy.convergence is None or not policy.convergence.enabled:
862
+ return None
863
+ recent = _recent_pass(repository.common_dir)
864
+ if recent is not None:
865
+ return recent
866
+ child = _spawn_detached(
867
+ [
868
+ sys.executable,
869
+ "-m",
870
+ "worktree_controller.cli",
871
+ "reconcile",
872
+ "retention",
873
+ "--repo",
874
+ str(repository.canonical_path),
875
+ "--actor",
876
+ policy.reconciler_actor,
877
+ "--trigger",
878
+ "admission",
879
+ "--apply",
880
+ "--json",
881
+ ]
882
+ )
883
+ except (ControllerError, OSError) as exc:
884
+ code = exc.code if isinstance(exc, ControllerError) else "OS_ERROR"
885
+ return {"code": "CONVERGENCE_SKIPPED", "reason": str(exc), "error": code}
886
+ # Starting is not finishing: the pass records its own RUNNING/COMPLETED/FAILED
887
+ # report, which `reconcile retention` (dry run) returns as last_applied_pass.
888
+ return {
889
+ "code": "CONVERGENCE_SCHEDULED",
890
+ "pid": child.pid,
891
+ "scheduled_at": _now_iso(),
892
+ "result": "reconcile retention --json (dry run) decision.last_applied_pass",
893
+ }