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