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