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,459 @@
1
+ """Semantic evidence checks for task, integration, and publication state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+ from .git_backend import (
10
+ changed_paths,
11
+ commits_with_parents,
12
+ inventory,
13
+ is_ancestor,
14
+ resolve_commit,
15
+ trusted_publication_base,
16
+ )
17
+ from .models import (
18
+ ControllerError,
19
+ IntegrationIntent,
20
+ MetadataRecord,
21
+ MetadataSnapshot,
22
+ RepositoryInventory,
23
+ WorktreeGitState,
24
+ WorktreePolicy,
25
+ )
26
+ from .policy import load_policy
27
+
28
+ EVIDENCE_LIFECYCLES = frozenset({"HANDOFF_READY", "PRESERVED", "INTEGRATING", "PUBLISHED"})
29
+
30
+
31
+ def record_for(item: WorktreeGitState, snapshot: MetadataSnapshot) -> MetadataRecord | None:
32
+ allocation_id = snapshot.bindings.get(item.key)
33
+ if allocation_id is None:
34
+ return None
35
+ return snapshot.records.get(allocation_id)
36
+
37
+
38
+ def managed_record(
39
+ repository: RepositoryInventory,
40
+ snapshot: MetadataSnapshot,
41
+ target: Path,
42
+ ) -> tuple[WorktreeGitState, MetadataRecord]:
43
+ """Resolve a Git registration against the caller's already locked snapshot."""
44
+ try:
45
+ resolved = target.expanduser().resolve(strict=True)
46
+ except OSError as exc:
47
+ raise ControllerError("INVALID_TARGET", f"target path is unavailable: {target}") from exc
48
+ for item in repository.worktrees:
49
+ if item.path.exists() and item.path.resolve(strict=True) == resolved:
50
+ record = record_for(item, snapshot)
51
+ if record is None:
52
+ raise ControllerError("NEEDS_ADOPTION", "registered worktree is not adopted")
53
+ return item, record
54
+ raise ControllerError("UNREGISTERED_WORKTREE", f"target is not registered: {resolved}")
55
+
56
+
57
+ def assert_record_version(
58
+ record: MetadataRecord,
59
+ expected_record_version: int,
60
+ expected_allocation_id: str | None = None,
61
+ ) -> None:
62
+ if expected_allocation_id is not None and record.allocation_id != expected_allocation_id:
63
+ raise ControllerError("IDENTITY_MISMATCH", "target allocation changed")
64
+ if expected_record_version != record.record_version:
65
+ raise ControllerError(
66
+ "CAS_MISMATCH",
67
+ f"expected record version {expected_record_version}, observed {record.record_version}",
68
+ )
69
+
70
+
71
+ def integration_intent_abandoned(intent: IntegrationIntent) -> bool:
72
+ """Recognize the explicit terminal event without forgiving unexplained drift."""
73
+ return (
74
+ intent.state == "ATTENTION_REQUIRED"
75
+ and intent.allocation_id is None
76
+ and bool(intent.blocked_reason)
77
+ and bool(intent.history)
78
+ and intent.history[-1].get("event") == "PREPARATION_ABANDONED"
79
+ )
80
+
81
+
82
+ def _current_attempt(intent: IntegrationIntent) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
83
+ """Split history at the last formal retry: (earlier attempts, current attempt)."""
84
+ history = list(intent.history)
85
+ starts = [index for index, event in enumerate(history)
86
+ if event.get("event") == "PREPARATION_RETRY_QUEUED"]
87
+ if not starts:
88
+ return [], history
89
+ return history[: starts[-1]], history[starts[-1]:]
90
+
91
+
92
+ def unallocated_integration_conflict(intent: IntegrationIntent) -> bool:
93
+ """Recognize the producer's pre-allocation failure, never infer a missing release.
94
+
95
+ Only the current attempt must be unclaimed. An earlier attempt counts only when
96
+ it ended in a formal abort before the retry; the retry path itself rechecks that
97
+ its candidate was released.
98
+ """
99
+ earlier, current = _current_attempt(intent)
100
+ return (
101
+ intent.state == "ATTENTION_REQUIRED"
102
+ and intent.allocation_id is None
103
+ and intent.candidate_oid is None
104
+ and (intent.revision == 0 or bool(earlier))
105
+ and intent.claimed_at is None
106
+ and intent.prepared_receipt_id is None
107
+ and intent.validated_oid is None
108
+ and intent.publication_identity is None
109
+ and intent.published_at is None
110
+ and intent.released_at is None
111
+ and bool(intent.base_main_oid)
112
+ and bool(intent.blocked_reason)
113
+ and bool(current)
114
+ and current[-1].get("event") == "INTEGRATION_CONFLICT_BLOCKED"
115
+ and not any(event.get("event") == "CANDIDATE_CLAIMED" for event in current)
116
+ and (
117
+ not any(event.get("event") == "CANDIDATE_CLAIMED" for event in earlier)
118
+ or any(event.get("event") == "PREPARATION_ABORTED" for event in earlier)
119
+ )
120
+ )
121
+
122
+
123
+ def integration_prerequisite(intent: IntegrationIntent) -> str | None:
124
+ """Read the producer's append-only prerequisite decision, without changing enqueue identity."""
125
+ matches = [event for event in intent.history
126
+ if event.get("event") == "RETRY_PREREQUISITE_BOUND"]
127
+ if len(matches) > 1:
128
+ raise ControllerError("INVALID_METADATA", "intent has multiple retry prerequisites")
129
+ if not matches:
130
+ return None
131
+ value = matches[0].get("prerequisite_intent_id")
132
+ if not isinstance(value, str) or not value or value == intent.intent_id:
133
+ raise ControllerError("INVALID_METADATA", "invalid retry prerequisite identity")
134
+ return value
135
+
136
+
137
+ def integration_queue_order(
138
+ snapshot: MetadataSnapshot, intent: IntegrationIntent,
139
+ ) -> tuple[int, int | str, int, str]:
140
+ """A verified prerequisite inherits only its failed parent's scheduling position."""
141
+ parents = [parent for parent in snapshot.integration_intents.values()
142
+ if integration_prerequisite(parent) == intent.intent_id]
143
+ if len(parents) > 1:
144
+ raise ControllerError("INVALID_METADATA", "prerequisite belongs to multiple failed intents")
145
+ anchor = parents[0] if parents else intent
146
+ if parents and (anchor.queue_sequence <= 0 or anchor.queue_sequence >= intent.queue_sequence
147
+ or anchor.owner != intent.owner or integration_prerequisite(intent)):
148
+ raise ControllerError("INVALID_METADATA", "invalid prerequisite scheduling relationship")
149
+ phase = 0 if parents else 1
150
+ if anchor.queue_sequence > 0:
151
+ return (1, anchor.queue_sequence, phase, intent.intent_id)
152
+ return (0, anchor.enqueued_at, phase, intent.intent_id)
153
+
154
+
155
+ def normalize_scope_paths(values: tuple[str, ...]) -> tuple[str, ...]:
156
+ normalized: set[str] = set()
157
+ for raw in values:
158
+ if not raw or "\x00" in raw or "\\" in raw:
159
+ raise ControllerError("INVALID_SCOPE", f"scope path is unsafe: {raw!r}")
160
+ path = PurePosixPath(raw)
161
+ if (
162
+ path.is_absolute()
163
+ or not path.parts
164
+ or any(part in {"", ".", ".."} for part in path.parts)
165
+ ):
166
+ raise ControllerError("INVALID_SCOPE", f"scope path is unsafe: {raw}")
167
+ value = path.as_posix().rstrip("/")
168
+ if value == ".git" or value.startswith(".git/"):
169
+ raise ControllerError("INVALID_SCOPE", "Git administrative paths cannot be scoped")
170
+ normalized.add(value)
171
+ return tuple(sorted(normalized))
172
+
173
+
174
+ def path_in_scopes(path: str, scopes: tuple[str, ...]) -> bool:
175
+ return any(path == scope or path.startswith(f"{scope}/") for scope in scopes)
176
+
177
+
178
+ def integration_source_evidence(
179
+ snapshot: MetadataSnapshot, source_allocations: tuple[str, ...]
180
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
181
+ if not source_allocations:
182
+ raise ControllerError(
183
+ "MISSING_INTEGRATION_SOURCE",
184
+ "integration candidates require at least one integration-ready source allocation",
185
+ )
186
+ source_heads: list[str] = []
187
+ scopes: set[str] = set()
188
+ for allocation_id in source_allocations:
189
+ source = snapshot.records.get(allocation_id)
190
+ if source is None:
191
+ raise ControllerError(
192
+ "MISSING_SOURCE_ALLOCATION", f"source allocation is missing: {allocation_id}"
193
+ )
194
+ integration_ready = any(
195
+ event.get("event") == "HANDOFF_INTEGRATION_READY" for event in source.history
196
+ )
197
+ if (
198
+ not integration_ready
199
+ or not source.handoff_commit
200
+ or not source.finalizer_record_id
201
+ or not source.finalizer_commit
202
+ or source.finalizer_commit != source.handoff_commit
203
+ ):
204
+ raise ControllerError(
205
+ "SOURCE_EVIDENCE_MISMATCH",
206
+ f"source allocation lacks matching integration-ready evidence: {allocation_id}",
207
+ )
208
+ if not source.scope_paths:
209
+ raise ControllerError(
210
+ "MISSING_SOURCE_SCOPE", f"source allocation has no path scope: {allocation_id}"
211
+ )
212
+ source_heads.append(source.handoff_commit)
213
+ scopes.update(source.scope_paths)
214
+ return tuple(source_heads), tuple(sorted(scopes))
215
+
216
+
217
+ def integration_intent_evidence(
218
+ snapshot: MetadataSnapshot, record: MetadataRecord
219
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
220
+ matches = [
221
+ intent
222
+ for intent in snapshot.integration_intents.values()
223
+ if intent.allocation_id == record.allocation_id
224
+ ]
225
+ if len(matches) > 1:
226
+ raise ControllerError("INVALID_METADATA", "multiple intents bind one integration candidate")
227
+ if not matches:
228
+ return integration_source_evidence(snapshot, record.source_allocations)
229
+ intent = matches[0]
230
+ if intent.source_allocations != record.source_allocations:
231
+ raise ControllerError(
232
+ "INTEGRATION_INTENT_MISMATCH",
233
+ "candidate source allocations differ from its frozen integration intent",
234
+ )
235
+ if intent.scope_paths != record.scope_paths:
236
+ raise ControllerError(
237
+ "INTEGRATION_SCOPE_MISMATCH",
238
+ "candidate scope differs from its frozen integration intent",
239
+ )
240
+ if intent.candidate_oid is not None and intent.candidate_oid != record.task_commit:
241
+ raise ControllerError(
242
+ "INTEGRATION_INTENT_MISMATCH",
243
+ "candidate revision differs from its integration intent",
244
+ )
245
+ return intent.source_handoffs, intent.scope_paths
246
+
247
+
248
+ def integration_replay_evidence(
249
+ snapshot: MetadataSnapshot, record: MetadataRecord
250
+ ) -> tuple[dict[str, str], ...]:
251
+ matches = [
252
+ intent
253
+ for intent in snapshot.integration_intents.values()
254
+ if intent.allocation_id == record.allocation_id
255
+ ]
256
+ if len(matches) > 1:
257
+ raise ControllerError("INVALID_METADATA", "multiple intents bind one integration candidate")
258
+ return matches[0].equivalent_replays if matches else ()
259
+
260
+
261
+ def publication_ref(repo: Path, policy: WorktreePolicy) -> str | None:
262
+ """Only the policy's formal remote-tracking ref can attest publication."""
263
+ if not policy.present or policy.policy_schema_version < 2:
264
+ return None
265
+ candidate = f"refs/remotes/{policy.publication_remote}/{policy.publication_branch}"
266
+ try:
267
+ resolve_commit(repo, candidate)
268
+ except ControllerError:
269
+ return None
270
+ return candidate
271
+
272
+
273
+ def attribution_base(repo: Path, base_commit: str, head: str, formal_ref: str | None) -> str:
274
+ policy = load_policy(inventory(repo).canonical_path)
275
+ if formal_ref is None or formal_ref != publication_ref(repo, policy):
276
+ return base_commit
277
+ return trusted_publication_base(repo, base_commit, head, formal_ref)
278
+
279
+
280
+ def path_scope_issues(
281
+ repo: Path, record: MetadataRecord, head: str, *, publication_ref: str | None = None
282
+ ) -> tuple[dict[str, str], ...]:
283
+ if not record.base_commit or not record.scope_paths:
284
+ return ()
285
+ base = (
286
+ attribution_base(repo, record.base_commit, head, publication_ref)
287
+ if publication_ref
288
+ else record.base_commit
289
+ )
290
+ outside = tuple(
291
+ path
292
+ for path in changed_paths(repo, base, head)
293
+ if not path_in_scopes(path, record.scope_paths)
294
+ )
295
+ if not outside:
296
+ return ()
297
+ return (
298
+ {
299
+ "code": "PATH_SCOPE_VIOLATION",
300
+ "message": "candidate changes paths outside the recorded task scope",
301
+ "allocation_id": record.allocation_id,
302
+ "path": outside[0],
303
+ },
304
+ )
305
+
306
+
307
+ def evidence_head_issues(
308
+ item: WorktreeGitState, record: MetadataRecord
309
+ ) -> tuple[dict[str, str], ...]:
310
+ issues: list[dict[str, str]] = []
311
+ check_task = record.role == "integration" or record.lifecycle in EVIDENCE_LIFECYCLES
312
+ if check_task:
313
+ if record.task_commit is None:
314
+ issues.append(
315
+ {
316
+ "code": "MISSING_TASK_COMMIT",
317
+ "message": "task evidence is not bound to a commit",
318
+ "allocation_id": record.allocation_id,
319
+ }
320
+ )
321
+ elif record.task_commit != item.head:
322
+ issues.append(
323
+ {
324
+ "code": "TASK_HEAD_MISMATCH",
325
+ "message": "recorded task commit does not equal worktree HEAD",
326
+ "allocation_id": record.allocation_id,
327
+ "expected": record.task_commit,
328
+ "observed": item.head,
329
+ }
330
+ )
331
+ if record.handoff_commit is not None and record.handoff_commit != item.head:
332
+ issues.append(
333
+ {
334
+ "code": "HANDOFF_HEAD_MISMATCH",
335
+ "message": "recorded handoff commit does not equal worktree HEAD",
336
+ "allocation_id": record.allocation_id,
337
+ "expected": record.handoff_commit,
338
+ "observed": item.head,
339
+ }
340
+ )
341
+ if record.finalizer_record_id and record.finalizer_commit is None:
342
+ issues.append(
343
+ {
344
+ "code": "MISSING_FINALIZER_COMMIT",
345
+ "message": "Finalizer record is not bound to a commit",
346
+ "allocation_id": record.allocation_id,
347
+ }
348
+ )
349
+ elif record.finalizer_commit is not None and record.finalizer_commit != item.head:
350
+ issues.append(
351
+ {
352
+ "code": "FINALIZER_HEAD_MISMATCH",
353
+ "message": "recorded Finalizer commit does not equal worktree HEAD",
354
+ "allocation_id": record.allocation_id,
355
+ "expected": record.finalizer_commit,
356
+ "observed": item.head,
357
+ }
358
+ )
359
+ return tuple(issues)
360
+
361
+
362
+ def integration_candidate_issues(
363
+ item: WorktreeGitState,
364
+ record: MetadataRecord,
365
+ snapshot: MetadataSnapshot,
366
+ *,
367
+ require_task_match: bool = True,
368
+ base_commit: str | None = None,
369
+ ) -> tuple[dict[str, str], ...]:
370
+ issues: list[dict[str, str]] = []
371
+ if require_task_match:
372
+ issues.extend(evidence_head_issues(item, record))
373
+ base_value = base_commit or record.base_commit
374
+ if base_value is None:
375
+ issues.append(
376
+ {
377
+ "code": "MISSING_INTEGRATION_BASE",
378
+ "message": "integration candidate has no recorded base commit",
379
+ "allocation_id": record.allocation_id,
380
+ }
381
+ )
382
+ return tuple(issues)
383
+ try:
384
+ base = resolve_commit(item.path, base_value)
385
+ if not is_ancestor(item.path, base, item.head):
386
+ issues.append(
387
+ {
388
+ "code": "INTEGRATION_BASE_DIVERGED",
389
+ "message": "integration candidate no longer contains its recorded base",
390
+ "allocation_id": record.allocation_id,
391
+ }
392
+ )
393
+ return tuple(issues)
394
+ source_heads, derived_scopes = integration_intent_evidence(snapshot, record)
395
+ replay_evidence = integration_replay_evidence(snapshot, record)
396
+ except ControllerError as exc:
397
+ issues.append(
398
+ {
399
+ "code": exc.code,
400
+ "message": str(exc),
401
+ "allocation_id": record.allocation_id,
402
+ }
403
+ )
404
+ return tuple(issues)
405
+ if tuple(record.scope_paths) != derived_scopes:
406
+ issues.append(
407
+ {
408
+ "code": "INTEGRATION_SCOPE_MISMATCH",
409
+ "message": "candidate scope does not equal the union of source handoff scopes",
410
+ "allocation_id": record.allocation_id,
411
+ }
412
+ )
413
+ source_allocations = tuple(record.source_allocations)
414
+ replay_by_source = {replay["source_allocation_id"]: replay for replay in replay_evidence}
415
+ lineage_heads = list(source_heads)
416
+ for source_allocation, source_head in zip(source_allocations, source_heads, strict=True):
417
+ if is_ancestor(item.path, source_head, item.head):
418
+ continue
419
+ replay = replay_by_source.get(source_allocation)
420
+ if replay is None:
421
+ issues.append(
422
+ {
423
+ "code": "SOURCE_NOT_ABSORBED",
424
+ "message": "candidate lacks source ancestry or equivalent replay evidence",
425
+ "allocation_id": record.allocation_id,
426
+ "source_allocation_id": source_allocation,
427
+ }
428
+ )
429
+ continue
430
+ replay_oid = resolve_commit(item.path, replay["replay_oid"])
431
+ if not is_ancestor(item.path, replay_oid, item.head):
432
+ issues.append(
433
+ {
434
+ "code": "EQUIVALENT_REPLAY_DRIFT",
435
+ "message": "equivalent replay commit is not contained by the candidate",
436
+ "allocation_id": record.allocation_id,
437
+ "source_allocation_id": source_allocation,
438
+ }
439
+ )
440
+ continue
441
+ lineage_heads.append(replay_oid)
442
+ novel_commits: list[str] = []
443
+ for topology in commits_with_parents(item.path, base, item.head):
444
+ commit = topology[0]
445
+ if len(topology) >= 3:
446
+ continue
447
+ if not any(is_ancestor(item.path, commit, lineage_head) for lineage_head in lineage_heads):
448
+ novel_commits.append(commit)
449
+ if novel_commits:
450
+ issues.append(
451
+ {
452
+ "code": "COMMIT_SCOPE_VIOLATION",
453
+ "message": "candidate contains a non-merge commit outside source handoff lineage",
454
+ "allocation_id": record.allocation_id,
455
+ "commit": novel_commits[0],
456
+ }
457
+ )
458
+ issues.extend(path_scope_issues(item.path, replace(record, base_commit=base), item.head))
459
+ return tuple(issues)