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