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,526 @@
1
+ """Controlled retry of an aborted, released candidate under its original frozen intent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import asdict, replace
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .git_backend import inventory, is_ancestor, resolve_commit
12
+ from .lifecycle_history import _release_view
13
+ from .metadata_codec import integration_intent_to_dict
14
+ from .models import ControllerError, IntegrationIntent, MetadataSnapshot, RepositoryInventory
15
+ from .policy import load_policy
16
+ from .semantics import (
17
+ integration_intent_abandoned,
18
+ integration_prerequisite,
19
+ integration_queue_order,
20
+ unallocated_integration_conflict,
21
+ )
22
+ from .store import MetadataStore
23
+ from .transaction_scope import conflicts
24
+
25
+
26
+ def _digest(value: Any) -> str:
27
+ encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
28
+ return hashlib.sha256(encoded.encode()).hexdigest()
29
+
30
+
31
+ def _source_identity(intent: IntegrationIntent) -> dict[str, Any]:
32
+ fields = (
33
+ "intent_id",
34
+ "task_key",
35
+ "authority_key",
36
+ "module",
37
+ "owner",
38
+ "queue_sequence",
39
+ "enqueued_at",
40
+ "source_allocations",
41
+ "source_handoffs",
42
+ "source_branches",
43
+ "source_finalizer_record_ids",
44
+ "source_snapshot_ids",
45
+ "scope_paths",
46
+ "scope_digest",
47
+ )
48
+ payload = integration_intent_to_dict(intent)
49
+ return {field: payload[field] for field in fields}
50
+
51
+
52
+ def _sources(
53
+ repository: RepositoryInventory,
54
+ snapshot: MetadataSnapshot,
55
+ store: MetadataStore,
56
+ intent: IntegrationIntent,
57
+ *,
58
+ require_idle: bool = True,
59
+ ) -> dict[str, Any]:
60
+ from .lifecycle import _resolve_integration_sources, _scope_digest
61
+
62
+ observed = _resolve_integration_sources(
63
+ repository,
64
+ snapshot,
65
+ intent.source_allocations,
66
+ (),
67
+ intent.source_snapshot_ids,
68
+ )
69
+ expected = (
70
+ intent.source_allocations,
71
+ intent.source_handoffs,
72
+ intent.scope_paths,
73
+ intent.source_branches,
74
+ intent.source_finalizer_record_ids,
75
+ intent.source_snapshot_ids,
76
+ )
77
+ if observed != expected or _scope_digest(intent.scope_paths) != intent.scope_digest:
78
+ raise ControllerError("SOURCE_EVIDENCE_MISMATCH", "retry cannot replace source or scope")
79
+ evidence = {}
80
+ for allocation, branch, head in zip(
81
+ intent.source_allocations,
82
+ intent.source_branches,
83
+ intent.source_handoffs,
84
+ strict=True,
85
+ ):
86
+ record = snapshot.records[allocation]
87
+ if record.lifecycle not in {
88
+ "HANDOFF_READY",
89
+ "PRESERVED",
90
+ "INTEGRATING",
91
+ "PUBLISHED",
92
+ "RELEASED",
93
+ }:
94
+ raise ControllerError("SOURCE_EVIDENCE_MISMATCH", "source handoff is no longer frozen")
95
+ if resolve_commit(repository.canonical_path, f"refs/heads/{branch}") != head:
96
+ raise ControllerError("SOURCE_EVIDENCE_MISMATCH", "retained source branch drifted")
97
+ receipt = next(
98
+ (r for r in snapshot.release_receipts.values() if r.allocation_id == allocation), None
99
+ )
100
+ target = next(
101
+ (
102
+ item.path
103
+ for item in repository.worktrees
104
+ if snapshot.bindings.get(item.key) == allocation
105
+ ),
106
+ None,
107
+ )
108
+ if target is None and receipt is not None:
109
+ target = Path(receipt.target_path)
110
+ if target is None or conflicts(store, allocation=allocation, target=target):
111
+ raise ControllerError("BLOCK_TRANSACTION", "source has unresolved lifecycle effects")
112
+ if require_idle and (allocation in snapshot.writer_leases or record.external_bindings):
113
+ raise ControllerError("SOURCE_IN_USE", "source is leased or externally bound")
114
+ evidence[allocation] = _digest(asdict(record))
115
+ return evidence
116
+
117
+
118
+ def _failure(
119
+ repository: RepositoryInventory,
120
+ snapshot: MetadataSnapshot,
121
+ store: MetadataStore,
122
+ intent: IntegrationIntent,
123
+ ) -> dict[str, Any]:
124
+ if unallocated_integration_conflict(intent):
125
+ if any(r.intent_id == intent.intent_id for r in snapshot.publication_receipts.values()):
126
+ raise ControllerError("RETRY_ALREADY_PUBLISHED", "published intents cannot retry")
127
+ # An earlier attempt's candidate may remain only as a released record.
128
+ if any(r.role == "integration" and r.task_key == intent.task_key and r.lifecycle != "RELEASED"
129
+ for r in snapshot.records.values()):
130
+ raise ControllerError("RETRY_CANDIDATE_IN_USE", "unallocated failure has a live candidate")
131
+ return {
132
+ "kind": "unallocated_conflict",
133
+ "failure_event": intent.history[-1],
134
+ "failure_reason": intent.blocked_reason,
135
+ "failed_base_oid": resolve_commit(repository.canonical_path, intent.base_main_oid),
136
+ }
137
+ if (
138
+ intent.state != "ATTENTION_REQUIRED"
139
+ or intent.allocation_id is not None
140
+ or not intent.blocked_reason
141
+ or not intent.candidate_oid
142
+ or integration_intent_abandoned(intent)
143
+ ):
144
+ raise ControllerError(
145
+ "RETRY_NOT_ELIGIBLE", "retry requires an aborted, unbound failed intent"
146
+ )
147
+ if any(r.intent_id == intent.intent_id for r in snapshot.publication_receipts.values()):
148
+ raise ControllerError("RETRY_ALREADY_PUBLISHED", "published intents cannot retry")
149
+ if (
150
+ snapshot.publication_lease
151
+ and snapshot.publication_lease.candidate_oid == intent.candidate_oid
152
+ ):
153
+ raise ControllerError("PUBLICATION_LEASE_HELD", "intent still has publication authority")
154
+ matches = []
155
+ for path in sorted(store.completion_receipts_dir.glob("*.json")):
156
+ value = store._read_json(path)
157
+ if (
158
+ value.get("kind") == "integration_abort"
159
+ and value.get("intent_id") == intent.intent_id
160
+ and value.get("candidate_oid") == intent.candidate_oid
161
+ ):
162
+ matches.append((path, value))
163
+ if len(matches) != 1:
164
+ raise ControllerError(
165
+ "RETRY_FAILURE_EVIDENCE_MISSING", "one exact abort receipt is required"
166
+ )
167
+ path, abort = matches[0]
168
+ allocation = abort.get("allocation_id")
169
+ record = snapshot.records.get(allocation)
170
+ if (
171
+ abort.get("repository_id") != snapshot.repository_id
172
+ or abort.get("owner") != intent.owner
173
+ or abort.get("failure_reason") != intent.blocked_reason
174
+ or tuple(abort.get("source_allocations", [])) != intent.source_allocations
175
+ or tuple(abort.get("source_handoffs", [])) != intent.source_handoffs
176
+ or tuple(abort.get("source_branches", [])) != intent.source_branches
177
+ or tuple(abort.get("source_finalizer_record_ids", [])) != intent.source_finalizer_record_ids
178
+ or not any(event.get("event") == "PREPARATION_ABORTED" for event in intent.history)
179
+ or record is None
180
+ or record.role != "integration"
181
+ or record.lifecycle != "RELEASED"
182
+ or record.task_key != intent.task_key
183
+ or not any(
184
+ event.get("event") == "INTEGRATION_ABORTED" and event.get("owner") == intent.owner
185
+ for event in record.history
186
+ )
187
+ or record.task_commit != intent.candidate_oid
188
+ or allocation in snapshot.bindings.values()
189
+ or allocation in snapshot.writer_leases
190
+ or record.external_bindings
191
+ ):
192
+ raise ControllerError(
193
+ "RETRY_FAILURE_EVIDENCE_MISMATCH", "abort identity or candidate release drifted"
194
+ )
195
+ releases = [r for r in snapshot.release_receipts.values() if r.allocation_id == allocation]
196
+ if len(releases) != 1 or releases[0].released_head != intent.candidate_oid:
197
+ raise ControllerError(
198
+ "RETRY_RELEASE_EVIDENCE_MISSING", "failed candidate has no exact release"
199
+ )
200
+ _release_view(snapshot, releases[0], record, project=False)
201
+ if snapshot.release_plans[releases[0].plan_id].owner != intent.owner:
202
+ raise ControllerError("OWNER_MISMATCH", "failed candidate release owner differs")
203
+ if conflicts(store, allocation=allocation, target=Path(releases[0].target_path)):
204
+ raise ControllerError("BLOCK_TRANSACTION", "failed candidate has unfinished effects")
205
+ if any(
206
+ r.role == "integration" and r.task_key == intent.task_key and r.lifecycle != "RELEASED"
207
+ for r in snapshot.records.values()
208
+ ):
209
+ raise ControllerError(
210
+ "RETRY_CANDIDATE_IN_USE", "another candidate for this task remains live"
211
+ )
212
+ return {
213
+ "abort_receipt_id": abort["receipt_id"],
214
+ "abort_receipt_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
215
+ "release_receipt_id": releases[0].receipt_id,
216
+ "release_receipt_sha256": _digest(asdict(releases[0])),
217
+ "failed_allocation_id": allocation,
218
+ "failure_reason": intent.blocked_reason,
219
+ "failed_candidate_oid": intent.candidate_oid,
220
+ }
221
+
222
+
223
+ def _prerequisite(
224
+ repository: RepositoryInventory,
225
+ snapshot: MetadataSnapshot,
226
+ store: MetadataStore,
227
+ parent: IntegrationIntent,
228
+ child: IntegrationIntent,
229
+ ) -> dict[str, Any]:
230
+ from .lifecycle import _effective_intent_state, _scopes_overlap
231
+
232
+ if (
233
+ child.owner != parent.owner
234
+ or child.intent_id == parent.intent_id
235
+ or child.queue_sequence <= parent.queue_sequence
236
+ or parent.queue_sequence <= 0
237
+ or child.state not in {"QUEUED", "WAITING_SCOPE"}
238
+ or child.allocation_id is not None
239
+ or child.candidate_oid is not None
240
+ or integration_prerequisite(parent)
241
+ or integration_prerequisite(child)
242
+ or any(
243
+ integration_prerequisite(other) == child.intent_id
244
+ for other in snapshot.integration_intents.values()
245
+ )
246
+ or any(
247
+ integration_prerequisite(other) == parent.intent_id
248
+ for other in snapshot.integration_intents.values()
249
+ )
250
+ or any(r.intent_id == child.intent_id for r in snapshot.publication_receipts.values())
251
+ ):
252
+ raise ControllerError(
253
+ "INVALID_RETRY_PREREQUISITE", "prerequisite must be a separate unclaimed later intent"
254
+ )
255
+ if any(
256
+ other.intent_id != parent.intent_id
257
+ and other.allocation_id is not None
258
+ and _effective_intent_state(snapshot, other) not in {"RELEASED", "ABANDONED"}
259
+ and other.queue_sequence > parent.queue_sequence
260
+ and _scopes_overlap(other.scope_paths, child.scope_paths)
261
+ for other in snapshot.integration_intents.values()
262
+ ):
263
+ raise ControllerError(
264
+ "RETRY_PREREQUISITE_IN_USE",
265
+ "prerequisite cannot overtake an active overlapping candidate",
266
+ )
267
+ sources = _sources(repository, snapshot, store, child)
268
+ ancestors = []
269
+ for head in child.source_handoffs:
270
+ descendants = [
271
+ source
272
+ for source in parent.source_handoffs
273
+ if head != source and is_ancestor(repository.canonical_path, head, source)
274
+ ]
275
+ if not descendants:
276
+ raise ControllerError(
277
+ "INVALID_RETRY_PREREQUISITE",
278
+ "every prerequisite handoff must be a strict source ancestor",
279
+ )
280
+ ancestors.append({"ancestor": head, "descendants": descendants})
281
+ return {
282
+ "intent_id": child.intent_id,
283
+ "intent_sha256": _digest(integration_intent_to_dict(child)),
284
+ "source_identity": _source_identity(child),
285
+ "source_records": sources,
286
+ "ancestry": ancestors,
287
+ }
288
+
289
+
290
+ def _plan(
291
+ repository: RepositoryInventory,
292
+ snapshot: MetadataSnapshot,
293
+ store: MetadataStore,
294
+ intent: IntegrationIntent,
295
+ prerequisite_id: str | None,
296
+ reason: str,
297
+ ) -> dict[str, Any]:
298
+ from .lifecycle import _effective_intent_state
299
+
300
+ failure = _failure(repository, snapshot, store, intent)
301
+ source_records = _sources(repository, snapshot, store, intent)
302
+ policy = load_policy(repository.canonical_path)
303
+ base = resolve_commit(
304
+ repository.canonical_path,
305
+ f"refs/remotes/{policy.publication_remote}/{policy.publication_branch}",
306
+ )
307
+ pending_base = intent.candidate_oid == intent.base_main_oid and any(
308
+ event.get("pending_merge_base") == intent.candidate_oid
309
+ and event.get("pending_merge_source") in intent.source_handoffs
310
+ for event in intent.history
311
+ )
312
+ if (intent.candidate_oid and not pending_base
313
+ and is_ancestor(repository.canonical_path, intent.candidate_oid, base)):
314
+ raise ControllerError(
315
+ "RETRY_ALREADY_PUBLISHED", "failed candidate is already in publication base"
316
+ )
317
+ prerequisite = None
318
+ if prerequisite_id is not None:
319
+ child = snapshot.integration_intents.get(prerequisite_id)
320
+ if child is None:
321
+ raise ControllerError("MISSING_INTENT", "prerequisite intent is missing")
322
+ prerequisite = _prerequisite(repository, snapshot, store, intent, child)
323
+ else:
324
+ bound = integration_prerequisite(intent)
325
+ if bound:
326
+ child = snapshot.integration_intents.get(bound)
327
+ if child is None or _effective_intent_state(snapshot, child) != "RELEASED":
328
+ raise ControllerError(
329
+ "RETRY_PREREQUISITE_PENDING",
330
+ "prerequisite needs formal publication and release",
331
+ )
332
+ publications = [
333
+ r
334
+ for r in snapshot.publication_receipts.values()
335
+ if r.intent_id == child.intent_id
336
+ and r.status in {"REMOTE_VERIFIED", "RELEASED"}
337
+ and r.candidate_oid == child.candidate_oid
338
+ ]
339
+ if (
340
+ len(publications) != 1
341
+ or not child.candidate_oid
342
+ or not is_ancestor(repository.canonical_path, child.candidate_oid, base)
343
+ ):
344
+ raise ControllerError(
345
+ "RETRY_PREREQUISITE_PENDING", "prerequisite receipt is not absorbed into main"
346
+ )
347
+ released = [
348
+ r
349
+ for r in snapshot.release_receipts.values()
350
+ if r.allocation_id == child.allocation_id
351
+ ]
352
+ record = snapshot.records.get(child.allocation_id)
353
+ if len(released) != 1 or record is None:
354
+ raise ControllerError(
355
+ "RETRY_PREREQUISITE_PENDING", "prerequisite release proof is missing"
356
+ )
357
+ _release_view(snapshot, released[0], record, project=False)
358
+ prerequisite = {
359
+ "intent_id": child.intent_id,
360
+ "publication_receipt": asdict(publications[0]),
361
+ "release_receipt": asdict(released[0]),
362
+ }
363
+ value = {
364
+ "contract": "integration-retry/v1",
365
+ "repository_id": snapshot.repository_id,
366
+ "action": "bind_prerequisite" if prerequisite_id else "retry",
367
+ "reason": reason,
368
+ "intent_sha256": _digest(integration_intent_to_dict(intent)),
369
+ "previous_intent": {
370
+ key: value
371
+ for key, value in integration_intent_to_dict(intent).items()
372
+ if key != "history"
373
+ },
374
+ "source_identity": _source_identity(intent),
375
+ "source_records": source_records,
376
+ "publication_base_oid": base,
377
+ "failure_evidence": failure,
378
+ "prerequisite": prerequisite,
379
+ "queue_evidence": {
380
+ other.intent_id: _digest(integration_intent_to_dict(other))
381
+ for other in snapshot.integration_intents.values()
382
+ if _effective_intent_state(snapshot, other) not in {"RELEASED", "ABANDONED"}
383
+ },
384
+ }
385
+ return {**value, "plan_id": _digest(value)}
386
+
387
+
388
+ def retry_integration(
389
+ repository_path: Path,
390
+ *,
391
+ intent_id: str,
392
+ owner: str,
393
+ reason: str,
394
+ prerequisite_intent_id: str | None = None,
395
+ dry_run: bool = False,
396
+ expected_plan_id: str | None = None,
397
+ ) -> dict[str, Any]:
398
+ """Plan/CAS an append-only dependency decision or restart the original intent."""
399
+ from .controller import status
400
+ from .lifecycle import _blocking_scope_intent, _emit_lifecycle_event, _updated_intent
401
+
402
+ detail = reason.strip()
403
+ if not detail or len(detail) > 512 or "\x00" in detail:
404
+ raise ControllerError("INVALID_RETRY_REASON", "retry needs a short explicit reason")
405
+ if not dry_run and not expected_plan_id:
406
+ raise ControllerError("RETRY_PLAN_REQUIRED", "apply requires the exact dry-run plan id")
407
+ repository = inventory(repository_path)
408
+ store = MetadataStore(repository.common_dir)
409
+ replay = False
410
+ with store.lock(exclusive=not dry_run):
411
+ repository = inventory(repository_path)
412
+ snapshot = store._load_unlocked()
413
+ intent = snapshot.integration_intents.get(intent_id)
414
+ if intent is None:
415
+ raise ControllerError("MISSING_INTENT", "failed intent is missing")
416
+ if intent.owner != owner:
417
+ raise ControllerError("OWNER_MISMATCH", "retry must use the existing intent owner")
418
+ last = intent.history[-1] if intent.history else {}
419
+ if (
420
+ not dry_run
421
+ and last.get("plan_id") == expected_plan_id
422
+ and last.get("event") in {"RETRY_PREREQUISITE_BOUND", "PREPARATION_RETRY_QUEUED"}
423
+ ):
424
+ plan = last["retry_plan"]
425
+ if (
426
+ plan["reason"] != detail
427
+ or plan["source_identity"] != _source_identity(intent)
428
+ or (plan["action"] == "bind_prerequisite") != bool(prerequisite_intent_id)
429
+ or (
430
+ prerequisite_intent_id
431
+ and plan["prerequisite"]["intent_id"] != prerequisite_intent_id
432
+ )
433
+ ):
434
+ raise ControllerError("BLOCK_DRIFT", "retry replay request differs")
435
+ original = replace(
436
+ intent,
437
+ history=(
438
+ *intent.history[:-1],
439
+ {k: v for k, v in last.items() if k != "after_intent_sha256"},
440
+ ),
441
+ )
442
+ if (
443
+ _digest(integration_intent_to_dict(original)) != last.get("after_intent_sha256")
444
+ or _sources(repository, snapshot, store, intent) != plan["source_records"]
445
+ ):
446
+ raise ControllerError("BLOCK_DRIFT", "retry replay evidence changed")
447
+ replay = True
448
+ else:
449
+ plan = _plan(repository, snapshot, store, intent, prerequisite_intent_id, detail)
450
+ if expected_plan_id is not None and plan["plan_id"] != expected_plan_id:
451
+ raise ControllerError("BLOCK_DRIFT", "retry plan evidence changed; plan again")
452
+ if not dry_run:
453
+ if prerequisite_intent_id:
454
+ updated = _updated_intent(intent, "RETRY_PREREQUISITE_BOUND")
455
+ else:
456
+ updated = _updated_intent(
457
+ intent,
458
+ "PREPARATION_RETRY_QUEUED",
459
+ state="QUEUED",
460
+ attempt=intent.attempt + 1,
461
+ base_main_oid=None,
462
+ candidate_oid=None,
463
+ prepare_identity=None,
464
+ publication_identity=None,
465
+ recovery_identity=None,
466
+ prepared_receipt_id=None,
467
+ prepared_base_origin_main_oid=None,
468
+ prepared_commit_oid=None,
469
+ prepared_tree_oid=None,
470
+ prepared_scope_digest=None,
471
+ tests_receipt_id=None,
472
+ snapshot_receipt_id=None,
473
+ validation_evidence_id=None,
474
+ validated_oid=None,
475
+ equivalent_replays=[],
476
+ blocked_reason=None,
477
+ claimed_at=None,
478
+ validated_at=None,
479
+ published_at=None,
480
+ released_at=None,
481
+ )
482
+ blocker = _blocking_scope_intent(
483
+ snapshot, updated, repository_path=repository.canonical_path
484
+ )
485
+ if blocker:
486
+ updated = replace(
487
+ updated,
488
+ state="WAITING_SCOPE",
489
+ blocked_reason=f"scope overlaps earlier intent {blocker.intent_id}",
490
+ )
491
+ event = {
492
+ **updated.history[-1],
493
+ "to": updated.state,
494
+ "plan_id": plan["plan_id"],
495
+ "retry_plan": plan,
496
+ }
497
+ if prerequisite_intent_id:
498
+ event["prerequisite_intent_id"] = prerequisite_intent_id
499
+ intent = replace(updated, history=(*updated.history[:-1], event))
500
+ event["after_intent_sha256"] = _digest(integration_intent_to_dict(intent))
501
+ intent = replace(intent, history=(*intent.history[:-1], event))
502
+ store.replace_integration_intent(intent)
503
+ if not prerequisite_intent_id:
504
+ _emit_lifecycle_event(store, snapshot.repository_id, intent, "INTENT_QUEUED")
505
+ payload = status(repository_path)
506
+ payload["command"] = "integration-retry"
507
+ payload["decision"] = {
508
+ "code": "INTEGRATION_RETRY_PLAN"
509
+ if dry_run
510
+ else (
511
+ "INTEGRATION_PREREQUISITE_BOUND"
512
+ if prerequisite_intent_id
513
+ else "INTEGRATION_RETRY_QUEUED"
514
+ ),
515
+ "reason": "controlled retry retains original identity and failure evidence",
516
+ "intent_id": intent_id,
517
+ "state": intent.state,
518
+ "attempt": intent.attempt,
519
+ "queue_sequence": intent.queue_sequence,
520
+ "scope_digest": intent.scope_digest,
521
+ "plan_id": plan["plan_id"],
522
+ "replay": replay,
523
+ "execution_order": list(integration_queue_order(snapshot, intent)),
524
+ "plan": plan,
525
+ }
526
+ return payload
@@ -0,0 +1,162 @@
1
+ """Prove independent Handbook records from frozen Git objects, never working files.
2
+
3
+ This refines only the known keyed-document overlap. It does not choose merge results,
4
+ change declared ownership, or replace candidate validation and main publication CAS.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from .git_backend import _git
14
+ from .models import ControllerError
15
+
16
+ PROGRESS_PATH = "docs/handbook/business-progress.json"
17
+ _ATTRIBUTE = f"{PROGRESS_PATH} merge=qp-handbook-progress"
18
+ _COLLECTIONS = {
19
+ "tasks": "authority_key",
20
+ "development_lines": "id",
21
+ "modules": "id",
22
+ "decisions": "id",
23
+ }
24
+ _MAX_BYTES = 2 * 1024 * 1024
25
+ _OID = re.compile(r"[0-9a-f]{40}")
26
+
27
+
28
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
29
+ result: dict[str, object] = {}
30
+ for key, value in pairs:
31
+ if key in result:
32
+ raise ValueError("duplicate JSON key")
33
+ result[key] = value
34
+ return result
35
+
36
+
37
+ def _reject_constant(value: str) -> None:
38
+ raise ValueError(f"non-JSON constant: {value}")
39
+
40
+
41
+ def _document(raw: bytes) -> dict[str, object]:
42
+ value = json.loads(raw, object_pairs_hook=_unique_object, parse_constant=_reject_constant)
43
+ if (
44
+ not isinstance(value, dict)
45
+ or type(value.get("schema_version")) is not int
46
+ or value.get("schema_version") != 2
47
+ or value.get("repo_id") != "quant-platform"
48
+ or set(value) - {*_COLLECTIONS, "schema_version", "repo_id", "note"}
49
+ ):
50
+ raise ValueError("unsupported keyed document")
51
+ return value
52
+
53
+
54
+ def _rows(value: object, identity_key: str) -> dict[str, object]:
55
+ if not isinstance(value, list):
56
+ raise ValueError("expected keyed array")
57
+ rows: dict[str, object] = {}
58
+ for row in value:
59
+ if not isinstance(row, dict):
60
+ raise ValueError("expected keyed object")
61
+ identity = row.get(identity_key)
62
+ if not isinstance(identity, str) or not identity.strip() or identity in rows:
63
+ raise ValueError("missing or duplicate identity")
64
+ rows[identity] = row
65
+ return rows
66
+
67
+
68
+ def _changed_keys(before: bytes, after: bytes) -> set[tuple[str, str]]:
69
+ old, new = _document(before), _document(after)
70
+ if json.dumps(
71
+ {k: v for k, v in old.items() if k not in _COLLECTIONS}, sort_keys=True
72
+ ) != json.dumps({k: v for k, v in new.items() if k not in _COLLECTIONS}, sort_keys=True):
73
+ raise ValueError("document metadata changed")
74
+ touched: set[tuple[str, str]] = set()
75
+ for collection, identity_key in _COLLECTIONS.items():
76
+ left = _rows(old.get(collection, []), identity_key)
77
+ right = _rows(new.get(collection, []), identity_key)
78
+ if [key for key in left if key in right] != [key for key in right if key in left]:
79
+ raise ValueError("existing records reordered")
80
+ touched.update(
81
+ (collection, key)
82
+ for key in left.keys() | right.keys()
83
+ if json.dumps(left.get(key), sort_keys=True)
84
+ != json.dumps(right.get(key), sort_keys=True)
85
+ )
86
+ return touched
87
+
88
+
89
+ def _blob(repo: Path, commit: str, path: str, *, limit: int = _MAX_BYTES) -> bytes:
90
+ entry = _git(repo, "ls-tree", "-z", commit, "--", path).stdout
91
+ # Only a regular, tracked blob at the exact path can declare this protocol.
92
+ mode, kind, remainder = entry.split(b" ", 2)
93
+ oid, actual_path = remainder.split(b"\t", 1)
94
+ if mode != b"100644" or kind != b"blob" or actual_path != path.encode() + b"\0":
95
+ raise ValueError("unsupported Git document entry")
96
+ name = oid.decode("ascii")
97
+ if not _OID.fullmatch(name):
98
+ raise ValueError("invalid blob identity")
99
+ size = int(_git(repo, "cat-file", "-s", name).stdout)
100
+ if size < 0 or size > limit:
101
+ raise ValueError("document exceeds scope inspection limit")
102
+ raw = _git(repo, "cat-file", "blob", name).stdout
103
+ if len(raw) != size:
104
+ raise ValueError("blob size changed")
105
+ return raw
106
+
107
+
108
+ def _progress(repo: Path, commit: str) -> tuple[str, bytes]:
109
+ attributes = _blob(repo, commit, ".gitattributes", limit=64 * 1024).decode("utf-8")
110
+ if _ATTRIBUTE not in attributes.splitlines():
111
+ raise ValueError("keyed merge protocol is not declared")
112
+ return attributes, _blob(repo, commit, PROGRESS_PATH)
113
+
114
+
115
+ def independent_progress_scopes(
116
+ repo: Path,
117
+ left_paths: tuple[str, ...],
118
+ right_paths: tuple[str, ...],
119
+ left_sources: tuple[str, ...],
120
+ right_sources: tuple[str, ...],
121
+ ) -> bool:
122
+ """Unknown, broad or conflicting evidence retains the ordinary file fence.
123
+
124
+ The immutable source commits already belong to the existing integration intents.
125
+ Unique merge bases prove what each side actually changes, including source ancestry.
126
+ Old queued intents can use this proof without replacing their frozen evidence.
127
+ """
128
+ if not left_paths or not right_paths or not left_sources or not right_sources:
129
+ return False
130
+ overlaps = {
131
+ (left, right)
132
+ for left in left_paths
133
+ for right in right_paths
134
+ if left == right or left.startswith(right + "/") or right.startswith(left + "/")
135
+ }
136
+ if overlaps != {(PROGRESS_PATH, PROGRESS_PATH)}:
137
+ return False
138
+ if len(left_sources) > 8 or len(right_sources) > 8:
139
+ return False
140
+ if any(not _OID.fullmatch(source) for source in (*left_sources, *right_sources)):
141
+ return False
142
+ try:
143
+ for left in left_sources:
144
+ for right in right_sources:
145
+ bases = _git(repo, "merge-base", "--all", left, right).stdout.splitlines()
146
+ if len(bases) != 1:
147
+ return False
148
+ base = bases[0].decode("ascii")
149
+ if not _OID.fullmatch(base) or base in (left, right):
150
+ return False
151
+ attributes, original = _progress(repo, base)
152
+ left_attributes, left_document = _progress(repo, left)
153
+ right_attributes, right_document = _progress(repo, right)
154
+ if attributes != left_attributes or attributes != right_attributes:
155
+ return False
156
+ left_keys = _changed_keys(original, left_document)
157
+ right_keys = _changed_keys(original, right_document)
158
+ if left_keys & right_keys:
159
+ return False
160
+ return True
161
+ except (ControllerError, OSError, ValueError, RecursionError):
162
+ return False