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,1974 @@
|
|
|
1
|
+
"""Controller decisions over Git inventory and repository-local metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .git_backend import commit_tree_oid, git_state_fingerprint, inventory, resolve_commit
|
|
13
|
+
from .lifecycle_observation import observe_repository_orphans, observe_worktree
|
|
14
|
+
from .models import (
|
|
15
|
+
ACTIVE_AUTHORITY_STATES,
|
|
16
|
+
CANONICAL_ROLES,
|
|
17
|
+
EMERGENCY_ROLES,
|
|
18
|
+
FEATURE_AUTHORITY_ROLES,
|
|
19
|
+
LIFECYCLE_STATES,
|
|
20
|
+
ORDINARY_ROLES,
|
|
21
|
+
RECOVERY_ROLES,
|
|
22
|
+
SCHEMA_VERSION,
|
|
23
|
+
WORKTREE_ROLES,
|
|
24
|
+
ControllerError,
|
|
25
|
+
MetadataRecord,
|
|
26
|
+
MetadataSnapshot,
|
|
27
|
+
RepositoryInventory,
|
|
28
|
+
WorktreeGitState,
|
|
29
|
+
WorktreePolicy,
|
|
30
|
+
WriterHolder,
|
|
31
|
+
)
|
|
32
|
+
from .mount_diagnostics import diagnose_mounts, mount_warnings
|
|
33
|
+
from .policy import load_policy
|
|
34
|
+
from .semantics import (
|
|
35
|
+
EVIDENCE_LIFECYCLES,
|
|
36
|
+
evidence_head_issues,
|
|
37
|
+
integration_candidate_issues,
|
|
38
|
+
integration_intent_abandoned,
|
|
39
|
+
integration_queue_order,
|
|
40
|
+
normalize_scope_paths,
|
|
41
|
+
path_scope_issues,
|
|
42
|
+
publication_ref,
|
|
43
|
+
unallocated_integration_conflict,
|
|
44
|
+
)
|
|
45
|
+
from .semantics import record_for as _record_for
|
|
46
|
+
from .store import TIMEZONE, MetadataStore, new_record
|
|
47
|
+
from .writer_lease import check_writer_entitlement
|
|
48
|
+
|
|
49
|
+
WRITE_OPERATIONS = frozenset({"feature-write", "integration-write", "recovery", "finalization"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def envelope(command: str) -> dict[str, Any]:
|
|
53
|
+
from . import __version__
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"tool_version": __version__,
|
|
57
|
+
"schema_version": SCHEMA_VERSION,
|
|
58
|
+
"command": command,
|
|
59
|
+
"repository": None,
|
|
60
|
+
"decision": None,
|
|
61
|
+
"worktrees": [],
|
|
62
|
+
"warnings": [],
|
|
63
|
+
"errors": [],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _warning(code: str, message: str, **details: str) -> dict[str, str]:
|
|
68
|
+
return {"code": code, "message": message, **details}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _retirement_execution_findings(
|
|
72
|
+
store: MetadataStore,
|
|
73
|
+
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
|
74
|
+
"""Outstanding retirement grants and removals nobody can attest.
|
|
75
|
+
|
|
76
|
+
A grant is live work in someone else's hands, so it is reported without being
|
|
77
|
+
called a failure. An abandoned removal is different: the ref is gone and the
|
|
78
|
+
Controller never observed the authorized transition, so it must stay visible
|
|
79
|
+
instead of leaving ``branch-retirement-status`` at PARTIAL with no explanation.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
directory = store.branch_retirement_authorizations_dir
|
|
83
|
+
if not directory.is_dir():
|
|
84
|
+
return [], []
|
|
85
|
+
outstanding: list[dict[str, str]] = []
|
|
86
|
+
unattested: list[dict[str, str]] = []
|
|
87
|
+
for path in sorted(directory.iterdir()):
|
|
88
|
+
if not path.name.endswith(".json") or not path.is_file():
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
value = dict(store._read_json(path))
|
|
92
|
+
except ControllerError:
|
|
93
|
+
unattested.append(
|
|
94
|
+
{"path": path.name, "code": "INVALID_RETIREMENT_EXECUTION_RECORD"}
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
if path.name.endswith("-abandoned.json"):
|
|
98
|
+
if value.get("classification") == "ABANDONED_UNATTESTED_REMOVAL":
|
|
99
|
+
unattested.append(
|
|
100
|
+
{
|
|
101
|
+
"authorization_id": str(value.get("authorization_id", path.stem)),
|
|
102
|
+
"plan_id": str(value.get("plan_id", "")),
|
|
103
|
+
"operation": str(value.get("operation", "")),
|
|
104
|
+
"ref": str(value.get("ref", "")),
|
|
105
|
+
"code": "RETIREMENT_REMOVAL_UNATTESTED",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
outstanding.append(
|
|
110
|
+
{
|
|
111
|
+
"authorization_id": str(value.get("authorization_id", path.stem)),
|
|
112
|
+
"plan_id": str(value.get("plan_id", "")),
|
|
113
|
+
"operation": str(value.get("operation", "")),
|
|
114
|
+
"branch": str(value.get("branch", "")),
|
|
115
|
+
"ref": str(value.get("ref", "")),
|
|
116
|
+
"expires_at": str(value.get("expires_at", "")),
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
return outstanding, unattested
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _lock_warnings(store: MetadataStore) -> list[dict[str, str]]:
|
|
123
|
+
"""The repository lock arbitrates every writer, so its shape is authority."""
|
|
124
|
+
|
|
125
|
+
def _populated(directory: Path) -> bool:
|
|
126
|
+
try:
|
|
127
|
+
return next(directory.iterdir(), None) is not None
|
|
128
|
+
except OSError:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
if not os.path.lexists(store.lock_file):
|
|
132
|
+
if store.repo_file.exists() or any(
|
|
133
|
+
directory.is_dir() and _populated(directory)
|
|
134
|
+
for directory in (store.records_dir, store.transactions_dir)
|
|
135
|
+
):
|
|
136
|
+
return [
|
|
137
|
+
_warning(
|
|
138
|
+
"REPOSITORY_LOCK_MISSING",
|
|
139
|
+
"repository lock file is missing; mutations fail closed until a bootstrap "
|
|
140
|
+
"command recreates it",
|
|
141
|
+
path=str(store.lock_file),
|
|
142
|
+
)
|
|
143
|
+
]
|
|
144
|
+
return []
|
|
145
|
+
try:
|
|
146
|
+
lock_shape_valid = stat.S_ISREG(os.lstat(store.lock_file).st_mode)
|
|
147
|
+
except OSError:
|
|
148
|
+
return [] # Environment diagnosis owns inaccessible paths.
|
|
149
|
+
if not lock_shape_valid:
|
|
150
|
+
return [
|
|
151
|
+
_warning(
|
|
152
|
+
"REPOSITORY_LOCK_INVALID",
|
|
153
|
+
"repository lock is not a regular file; controller authority requires review",
|
|
154
|
+
path=str(store.lock_file),
|
|
155
|
+
)
|
|
156
|
+
]
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _environment_first(error: ControllerError, diagnosis: dict[str, Any]) -> ControllerError:
|
|
161
|
+
"""Keep access failures ahead of lock authority until the environment is known good."""
|
|
162
|
+
code = diagnosis["code"]
|
|
163
|
+
if code != "HEALTHY" and error.code in {
|
|
164
|
+
"REPOSITORY_LOCK_INVALID",
|
|
165
|
+
"REPOSITORY_LOCK_ACCESS_UNKNOWN",
|
|
166
|
+
"MISSING_REPOSITORY_LOCK",
|
|
167
|
+
"FILESYSTEM_PERMISSION",
|
|
168
|
+
}:
|
|
169
|
+
facts = ", ".join(diagnosis["facts"]) or "access could not be confirmed"
|
|
170
|
+
return ControllerError(code, f"repository environment diagnosis: {facts}")
|
|
171
|
+
if error.code == "REPOSITORY_LOCK_ACCESS_UNKNOWN":
|
|
172
|
+
return ControllerError("UNKNOWN", str(error))
|
|
173
|
+
return error
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _environment_warnings(diagnostics: dict[str, Any]) -> list[dict[str, str]]:
|
|
177
|
+
warnings = mount_warnings(diagnostics)
|
|
178
|
+
code = diagnostics["environment"]["code"]
|
|
179
|
+
if code != "HEALTHY":
|
|
180
|
+
warnings = [row for row in warnings if row["code"] != "REPOSITORY_LOCK_SHAPE_INVALID"]
|
|
181
|
+
if code == "UNKNOWN":
|
|
182
|
+
warnings.append(
|
|
183
|
+
_warning(
|
|
184
|
+
"ENVIRONMENT_UNKNOWN",
|
|
185
|
+
"repository mount or namespace access could not be confirmed",
|
|
186
|
+
severity="degraded",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
elif code == "FILESYSTEM_PERMISSION":
|
|
190
|
+
warnings.append(
|
|
191
|
+
_warning(
|
|
192
|
+
"FILESYSTEM_PERMISSION",
|
|
193
|
+
"repository filesystem access is restricted",
|
|
194
|
+
severity="blocking",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return warnings
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _primary_diagnosis(
|
|
201
|
+
environment: dict[str, Any], findings: list[dict[str, str]], *, failed: bool
|
|
202
|
+
) -> str:
|
|
203
|
+
if environment["code"] != "HEALTHY":
|
|
204
|
+
return str(environment["code"])
|
|
205
|
+
codes = {row["code"] for row in findings}
|
|
206
|
+
if "FILESYSTEM_PERMISSION" in codes:
|
|
207
|
+
return "FILESYSTEM_PERMISSION"
|
|
208
|
+
if "UNKNOWN" in codes:
|
|
209
|
+
return "UNKNOWN"
|
|
210
|
+
if codes & {
|
|
211
|
+
"REPOSITORY_LOCK_INVALID",
|
|
212
|
+
"REPOSITORY_LOCK_SHAPE_INVALID",
|
|
213
|
+
"REPOSITORY_LOCK_MISSING",
|
|
214
|
+
"MISSING_REPOSITORY_LOCK",
|
|
215
|
+
}:
|
|
216
|
+
return "CONTROLLER_LOCK_AUTHORITY"
|
|
217
|
+
return "CONTROLLER_METADATA" if failed else "NONE"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _observed_worktrees(
|
|
221
|
+
repository: RepositoryInventory,
|
|
222
|
+
policy: WorktreePolicy,
|
|
223
|
+
snapshot: MetadataSnapshot,
|
|
224
|
+
*,
|
|
225
|
+
namespace_healthy: bool,
|
|
226
|
+
) -> list[dict[str, Any]]:
|
|
227
|
+
worktrees = []
|
|
228
|
+
for item in repository.worktrees:
|
|
229
|
+
record = _record_for(item, snapshot)
|
|
230
|
+
row = _worktree_payload(item, record, snapshot)
|
|
231
|
+
row["lifecycle_observation"] = observe_worktree(
|
|
232
|
+
item, record, snapshot, policy, namespace_healthy=namespace_healthy
|
|
233
|
+
)
|
|
234
|
+
worktrees.append(row)
|
|
235
|
+
return worktrees
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _intent_for_allocation(snapshot: MetadataSnapshot, allocation_id: str | None) -> Any | None:
|
|
239
|
+
if allocation_id is None:
|
|
240
|
+
return None
|
|
241
|
+
matches = [
|
|
242
|
+
intent
|
|
243
|
+
for intent in snapshot.integration_intents.values()
|
|
244
|
+
if intent.allocation_id == allocation_id
|
|
245
|
+
]
|
|
246
|
+
if len(matches) > 1:
|
|
247
|
+
raise ControllerError("INVALID_METADATA", "multiple intents bind one allocation")
|
|
248
|
+
return matches[0] if matches else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _effective_intent_state(snapshot: MetadataSnapshot, intent: Any) -> str:
|
|
252
|
+
reconciliations = [
|
|
253
|
+
receipt
|
|
254
|
+
for receipt in snapshot.integration_reconciliation_receipts.values()
|
|
255
|
+
if receipt.intent_id == intent.intent_id
|
|
256
|
+
and receipt.result == "STALE_INTENT_TERMINAL_ACKNOWLEDGED"
|
|
257
|
+
]
|
|
258
|
+
if len(reconciliations) > 1:
|
|
259
|
+
raise ControllerError(
|
|
260
|
+
"INVALID_METADATA", "integration intent has multiple terminal reconciliations"
|
|
261
|
+
)
|
|
262
|
+
if reconciliations:
|
|
263
|
+
return reconciliations[0].effective_terminal_state
|
|
264
|
+
return "ABANDONED" if integration_intent_abandoned(intent) else intent.state
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _integration_intent_order(intent: Any) -> tuple[int, int | str, str]:
|
|
268
|
+
if intent.queue_sequence > 0:
|
|
269
|
+
return (1, intent.queue_sequence, intent.intent_id)
|
|
270
|
+
return (0, intent.enqueued_at, intent.intent_id)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _release_intent_for_allocation(
|
|
274
|
+
snapshot: MetadataSnapshot, allocation_id: str | None
|
|
275
|
+
) -> Any | None:
|
|
276
|
+
if allocation_id is None:
|
|
277
|
+
return None
|
|
278
|
+
matches = sorted(
|
|
279
|
+
(
|
|
280
|
+
intent
|
|
281
|
+
for intent in snapshot.release_intents.values()
|
|
282
|
+
if intent.allocation_id == allocation_id
|
|
283
|
+
),
|
|
284
|
+
key=lambda intent: (intent.updated_at, intent.intent_id),
|
|
285
|
+
reverse=True,
|
|
286
|
+
)
|
|
287
|
+
active = [intent for intent in matches if intent.status in {"PENDING", "BLOCKED_RETRYABLE"}]
|
|
288
|
+
if len(active) > 1:
|
|
289
|
+
raise ControllerError("INVALID_METADATA", "multiple active release intents bind allocation")
|
|
290
|
+
return active[0] if active else matches[0] if matches else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def capacity_accounting(
|
|
294
|
+
repository: RepositoryInventory,
|
|
295
|
+
policy: WorktreePolicy,
|
|
296
|
+
snapshot: MetadataSnapshot,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Report inventory and declared ownership/occupancy without probing processes.
|
|
299
|
+
|
|
300
|
+
Resource admission treats the legacy checkout/role limits as statistics. A
|
|
301
|
+
writer lease is ownership evidence, including when its holder has died; only
|
|
302
|
+
an existing runtime binding declares execution occupancy until formal clear.
|
|
303
|
+
Neither count proves process liveness or business completion.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
registered = [
|
|
307
|
+
(item, record)
|
|
308
|
+
for item in repository.worktrees
|
|
309
|
+
if (record := _record_for(item, snapshot)) is not None
|
|
310
|
+
]
|
|
311
|
+
managed = [(item, record) for item, record in registered if not item.canonical]
|
|
312
|
+
ordinary = [(item, record) for item, record in managed if record.role in ORDINARY_ROLES]
|
|
313
|
+
writers = [record for _item, record in ordinary if record.lifecycle != "RETAINED"]
|
|
314
|
+
retained = [record for _item, record in ordinary if record.lifecycle == "RETAINED"]
|
|
315
|
+
leased = set(snapshot.writer_leases)
|
|
316
|
+
runtime_occupied = sum(
|
|
317
|
+
any(binding.startswith("runtime:") for binding in record.external_bindings)
|
|
318
|
+
for _item, record in registered
|
|
319
|
+
)
|
|
320
|
+
cleanup_pending = {
|
|
321
|
+
intent.allocation_id
|
|
322
|
+
for intent in snapshot.release_intents.values()
|
|
323
|
+
if intent.status in {"PENDING", "BLOCKED_RETRYABLE"}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
"physical_used": len(repository.worktrees),
|
|
327
|
+
"physical_limit": policy.hard_limit,
|
|
328
|
+
"normal_limit": policy.normal_limit,
|
|
329
|
+
"ordinary_physical": len(ordinary),
|
|
330
|
+
"ordinary_limit": policy.feature_audit_slots + policy.retained_slots,
|
|
331
|
+
"writers_used": len(writers),
|
|
332
|
+
"writer_limit": policy.feature_audit_slots,
|
|
333
|
+
"retained_used": len(retained),
|
|
334
|
+
"integration_used": sum(1 for _item, record in managed if record.role == "integration"),
|
|
335
|
+
"integration_limit": policy.integration_candidate_limit,
|
|
336
|
+
"emergency_used": sum(1 for _item, record in managed if record.role in EMERGENCY_ROLES),
|
|
337
|
+
"emergency_limit": policy.emergency_slots,
|
|
338
|
+
"recovery_used": sum(1 for _item, record in managed if record.role in RECOVERY_ROLES),
|
|
339
|
+
"recovery_limit": policy.recovery_reserve,
|
|
340
|
+
# Legacy allocation statistics do not grant or reject resource admission.
|
|
341
|
+
"legacy_reserved": sum(1 for record in writers if record.allocation_id not in leased),
|
|
342
|
+
"writer_leases_recorded": sum(record.allocation_id in leased for _item, record in registered),
|
|
343
|
+
"runtime_occupied": runtime_occupied,
|
|
344
|
+
"live_executions": runtime_occupied,
|
|
345
|
+
"live_executions_semantics": "runtime-binding occupancy; process liveness is not observed",
|
|
346
|
+
"runtime_bound": sum(1 for _item, record in managed if record.external_bindings),
|
|
347
|
+
"cleanup_pending": sum(
|
|
348
|
+
1 for _item, record in managed if record.allocation_id in cleanup_pending
|
|
349
|
+
),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def governance_capacity(
|
|
354
|
+
repository: RepositoryInventory,
|
|
355
|
+
policy: WorktreePolicy,
|
|
356
|
+
snapshot: MetadataSnapshot,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Observe declared checkout limits without changing resource admission."""
|
|
359
|
+
ordinary = [
|
|
360
|
+
record
|
|
361
|
+
for item in repository.worktrees
|
|
362
|
+
if not item.canonical
|
|
363
|
+
and (record := _record_for(item, snapshot)) is not None
|
|
364
|
+
and record.role in ORDINARY_ROLES
|
|
365
|
+
]
|
|
366
|
+
used = {
|
|
367
|
+
"active": sum(record.lifecycle == "ACTIVE" for record in ordinary),
|
|
368
|
+
"retained": sum(record.lifecycle == "RETAINED" for record in ordinary),
|
|
369
|
+
"total": len(repository.worktrees),
|
|
370
|
+
}
|
|
371
|
+
limits = {
|
|
372
|
+
"active_limit": policy.feature_audit_slots,
|
|
373
|
+
"retained_limit": policy.retained_slots,
|
|
374
|
+
"total_limit": policy.hard_limit,
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
"mode": "observe",
|
|
378
|
+
"scope": "active/retained: feature and audit linked worktrees; total: all Git registered worktrees",
|
|
379
|
+
"used": used,
|
|
380
|
+
"limits": limits,
|
|
381
|
+
"over_limit": {
|
|
382
|
+
name: used[name] > limits[f"{name}_limit"] for name in used
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _repository_payload(
|
|
388
|
+
repository: RepositoryInventory,
|
|
389
|
+
policy: WorktreePolicy,
|
|
390
|
+
snapshot: MetadataSnapshot,
|
|
391
|
+
) -> dict[str, Any]:
|
|
392
|
+
count = len(repository.worktrees)
|
|
393
|
+
retained = sum(
|
|
394
|
+
1
|
|
395
|
+
for item in repository.worktrees
|
|
396
|
+
if (record := _record_for(item, snapshot)) is not None and record.lifecycle == "RETAINED"
|
|
397
|
+
)
|
|
398
|
+
accounting = capacity_accounting(repository, policy, snapshot)
|
|
399
|
+
queued = sorted(
|
|
400
|
+
(
|
|
401
|
+
intent
|
|
402
|
+
for intent in snapshot.integration_intents.values()
|
|
403
|
+
if _effective_intent_state(snapshot, intent) in {"QUEUED", "WAITING_SCOPE"}
|
|
404
|
+
),
|
|
405
|
+
key=lambda value: integration_queue_order(snapshot, value),
|
|
406
|
+
)
|
|
407
|
+
return {
|
|
408
|
+
"repository_id": snapshot.repository_id,
|
|
409
|
+
"canonical_path": str(repository.canonical_path),
|
|
410
|
+
"common_dir": str(repository.common_dir),
|
|
411
|
+
"requested_path": str(repository.requested_path),
|
|
412
|
+
"worktree_count": count,
|
|
413
|
+
"governance_capacity": governance_capacity(repository, policy, snapshot),
|
|
414
|
+
"admission_strategy": "resource-budget-v1" if policy.resource_admission else "legacy-slots",
|
|
415
|
+
"checkout_limits_enforced": not policy.resource_admission,
|
|
416
|
+
"normal_limit": policy.normal_limit,
|
|
417
|
+
"hard_limit": policy.hard_limit,
|
|
418
|
+
"normal_slots_remaining": max(policy.normal_limit - (count - retained), 0),
|
|
419
|
+
"hard_slots_remaining": max(policy.hard_limit - count, 0),
|
|
420
|
+
**accounting,
|
|
421
|
+
# Retained work is bounded by the ordinary pool it shares, so the remaining
|
|
422
|
+
# room is the pool's, not a separate quota's.
|
|
423
|
+
"retained_limit": accounting["ordinary_limit"],
|
|
424
|
+
"retained_slots_remaining": max(
|
|
425
|
+
accounting["ordinary_limit"] - accounting["ordinary_physical"], 0
|
|
426
|
+
),
|
|
427
|
+
"ordinary_slots_remaining": max(
|
|
428
|
+
accounting["ordinary_limit"] - accounting["ordinary_physical"], 0
|
|
429
|
+
),
|
|
430
|
+
"writer_slots_remaining": max(accounting["writer_limit"] - accounting["writers_used"], 0),
|
|
431
|
+
"integration_candidate_limit": policy.integration_candidate_limit,
|
|
432
|
+
"integration_prepare_limit": policy.integration_prepare_limit,
|
|
433
|
+
"main_publish_limit": policy.main_publish_limit,
|
|
434
|
+
"recovery_reserve": policy.recovery_reserve,
|
|
435
|
+
"feature_or_audit_limit": policy.feature_audit_slots,
|
|
436
|
+
"emergency_limit": policy.emergency_slots,
|
|
437
|
+
"integration_queue_depth": len(queued),
|
|
438
|
+
"oldest_integration_enqueued_at": queued[0].enqueued_at if queued else None,
|
|
439
|
+
"publication_remote": policy.publication_remote,
|
|
440
|
+
"publication_branch": policy.publication_branch,
|
|
441
|
+
"automatic_feature_audit_release": policy.automatic_feature_audit_release,
|
|
442
|
+
"automatic_integration_continuation": policy.automatic_integration_continuation,
|
|
443
|
+
"preserved_auto_release": policy.preserved_auto_release,
|
|
444
|
+
"release_intent_ttl_seconds": policy.release_intent_ttl_seconds,
|
|
445
|
+
"active_release_intent_count": sum(
|
|
446
|
+
intent.status in {"PENDING", "BLOCKED_RETRYABLE"}
|
|
447
|
+
for intent in snapshot.release_intents.values()
|
|
448
|
+
),
|
|
449
|
+
"publication_lease": (
|
|
450
|
+
{
|
|
451
|
+
"lease_id": snapshot.publication_lease.lease_id,
|
|
452
|
+
"repository_id": snapshot.publication_lease.repository_id,
|
|
453
|
+
"allocation_id": snapshot.publication_lease.allocation_id,
|
|
454
|
+
"owner": snapshot.publication_lease.holder,
|
|
455
|
+
"holder": snapshot.publication_lease.holder,
|
|
456
|
+
"run_id": snapshot.publication_lease.run_id,
|
|
457
|
+
"remote": snapshot.publication_lease.remote,
|
|
458
|
+
"branch": snapshot.publication_lease.branch,
|
|
459
|
+
"target_ref": snapshot.publication_lease.target_ref,
|
|
460
|
+
"candidate_head": snapshot.publication_lease.candidate_oid,
|
|
461
|
+
"candidate_oid": snapshot.publication_lease.candidate_oid,
|
|
462
|
+
"remote_head": snapshot.publication_lease.expected_main_oid,
|
|
463
|
+
"expected_main_oid": snapshot.publication_lease.expected_main_oid,
|
|
464
|
+
"finalizer_record_id": snapshot.publication_lease.validation_evidence_id,
|
|
465
|
+
"validation_evidence_id": snapshot.publication_lease.validation_evidence_id,
|
|
466
|
+
"acquired_at": snapshot.publication_lease.acquired_at,
|
|
467
|
+
"expires_at": snapshot.publication_lease.expires_at,
|
|
468
|
+
}
|
|
469
|
+
if snapshot.publication_lease
|
|
470
|
+
else None
|
|
471
|
+
),
|
|
472
|
+
"policy_path": str(policy.path),
|
|
473
|
+
"policy_present": policy.present,
|
|
474
|
+
"metadata_path": str(MetadataStore(repository.common_dir).root),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _worktree_payload(
|
|
479
|
+
item: WorktreeGitState,
|
|
480
|
+
record: MetadataRecord | None,
|
|
481
|
+
snapshot: MetadataSnapshot | None = None,
|
|
482
|
+
) -> dict[str, Any]:
|
|
483
|
+
from .owner_actions import owner_action
|
|
484
|
+
|
|
485
|
+
action = None
|
|
486
|
+
if record is not None and not item.clean and not item.canonical:
|
|
487
|
+
action = owner_action(str(item.path), {
|
|
488
|
+
"path": str(item.path), "head": item.head, "branch": item.branch,
|
|
489
|
+
**{key: getattr(record, key) for key in
|
|
490
|
+
("allocation_id", "task_key", "authority_key", "owner", "record_version")},
|
|
491
|
+
}, reason="DIRTY_WORKTREE", required_actions=(
|
|
492
|
+
"review_task_files", "finalize_or_checkpoint", "preserve_unknown_files", "park_or_handoff"))
|
|
493
|
+
intent = (
|
|
494
|
+
_intent_for_allocation(snapshot, record.allocation_id)
|
|
495
|
+
if snapshot is not None and record is not None
|
|
496
|
+
else None
|
|
497
|
+
)
|
|
498
|
+
release_intent = (
|
|
499
|
+
_release_intent_for_allocation(snapshot, record.allocation_id)
|
|
500
|
+
if snapshot is not None and record is not None
|
|
501
|
+
else None
|
|
502
|
+
)
|
|
503
|
+
return {
|
|
504
|
+
"kind": "canonical" if item.canonical else "secondary",
|
|
505
|
+
"canonical": item.canonical,
|
|
506
|
+
"path": str(item.path),
|
|
507
|
+
"branch": item.branch,
|
|
508
|
+
"detached": item.detached,
|
|
509
|
+
"head": item.head,
|
|
510
|
+
"clean": item.clean,
|
|
511
|
+
"dirty": not item.clean,
|
|
512
|
+
"changes": {
|
|
513
|
+
"staged": item.staged,
|
|
514
|
+
"modified": item.modified,
|
|
515
|
+
"untracked": item.untracked,
|
|
516
|
+
},
|
|
517
|
+
"locked": item.locked,
|
|
518
|
+
"lock_reason": item.lock_reason,
|
|
519
|
+
"prunable": item.prunable,
|
|
520
|
+
"prunable_reason": item.prunable_reason,
|
|
521
|
+
"metadata_missing": record is None,
|
|
522
|
+
"allocation_id": record.allocation_id if record else None,
|
|
523
|
+
"task_key": record.task_key if record else None,
|
|
524
|
+
"authority_key": record.authority_key if record else None,
|
|
525
|
+
"module": record.module if record else None,
|
|
526
|
+
"role": record.role if record else None,
|
|
527
|
+
"owner": record.owner if record else None,
|
|
528
|
+
"lifecycle": record.lifecycle if record else None,
|
|
529
|
+
"record_version": record.record_version if record else None,
|
|
530
|
+
"owner_action_request": action,
|
|
531
|
+
"wait_checkpoint": (
|
|
532
|
+
record.history[-1].get("checkpoint")
|
|
533
|
+
if record and record.lifecycle == "RETAINED" and record.history else None
|
|
534
|
+
),
|
|
535
|
+
"created_at": record.created_at if record else None,
|
|
536
|
+
"last_reviewed_at": record.last_reviewed_at if record else None,
|
|
537
|
+
"handoff_commit": record.handoff_commit if record else None,
|
|
538
|
+
"finalizer_record_id": record.finalizer_record_id if record else None,
|
|
539
|
+
"base_commit": record.base_commit if record else None,
|
|
540
|
+
"task_commit": record.task_commit if record else None,
|
|
541
|
+
"finalizer_commit": record.finalizer_commit if record else None,
|
|
542
|
+
"scope_paths": list(record.scope_paths) if record else [],
|
|
543
|
+
"source_allocations": list(record.source_allocations) if record else [],
|
|
544
|
+
"integration_intent_id": intent.intent_id if intent else None,
|
|
545
|
+
"integration_intent_state": (
|
|
546
|
+
_effective_intent_state(snapshot, intent)
|
|
547
|
+
if intent is not None and snapshot is not None
|
|
548
|
+
else None
|
|
549
|
+
),
|
|
550
|
+
"integration_intent_raw_state": intent.state if intent else None,
|
|
551
|
+
"source_handoff_oids": list(intent.source_handoffs) if intent else [],
|
|
552
|
+
"candidate_revision": intent.revision if intent else None,
|
|
553
|
+
"candidate_oid": intent.candidate_oid if intent else None,
|
|
554
|
+
"validation_evidence_id": intent.validation_evidence_id if intent else None,
|
|
555
|
+
"validated_oid": intent.validated_oid if intent else None,
|
|
556
|
+
"quarantine_expected_commit": (record.quarantine_expected_commit if record else None),
|
|
557
|
+
"quarantine_observed_commit": (record.quarantine_observed_commit if record else None),
|
|
558
|
+
"recovery_allocation_id": record.recovery_allocation_id if record else None,
|
|
559
|
+
"recovery_ref": record.recovery_ref if record else None,
|
|
560
|
+
"disposable_resources": (
|
|
561
|
+
[dict(resource) for resource in record.disposable_resources] if record else []
|
|
562
|
+
),
|
|
563
|
+
"external_bindings": list(record.external_bindings) if record else [],
|
|
564
|
+
"release_intent_id": release_intent.intent_id if release_intent else None,
|
|
565
|
+
"release_intent_status": release_intent.status if release_intent else None,
|
|
566
|
+
"release_intent_blocker": release_intent.blocker if release_intent else None,
|
|
567
|
+
"release_intent_expires_at": release_intent.expires_at if release_intent else None,
|
|
568
|
+
"release_eligible": False,
|
|
569
|
+
"release_note": "not inferred from cleanliness; release guards are required",
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _metadata_warnings(
|
|
574
|
+
repository: RepositoryInventory,
|
|
575
|
+
policy: WorktreePolicy,
|
|
576
|
+
snapshot: MetadataSnapshot,
|
|
577
|
+
) -> list[dict[str, str]]:
|
|
578
|
+
warnings = [dict(item) for item in snapshot.warnings]
|
|
579
|
+
live_keys = {item.key for item in repository.worktrees}
|
|
580
|
+
bound_allocations: defaultdict[str, list[str]] = defaultdict(list)
|
|
581
|
+
for key, allocation_id in snapshot.bindings.items():
|
|
582
|
+
bound_allocations[allocation_id].append(key)
|
|
583
|
+
if key not in live_keys:
|
|
584
|
+
warnings.append(
|
|
585
|
+
_warning(
|
|
586
|
+
"ORPHAN_BINDING",
|
|
587
|
+
"metadata binding has no registered worktree",
|
|
588
|
+
worktree_key=key,
|
|
589
|
+
allocation_id=allocation_id,
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
if allocation_id not in snapshot.records:
|
|
593
|
+
warnings.append(
|
|
594
|
+
_warning(
|
|
595
|
+
"MISSING_RECORD",
|
|
596
|
+
"binding references a missing metadata record",
|
|
597
|
+
worktree_key=key,
|
|
598
|
+
allocation_id=allocation_id,
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
for allocation_id in snapshot.records:
|
|
602
|
+
keys = bound_allocations.get(allocation_id, [])
|
|
603
|
+
if not keys and snapshot.records[allocation_id].lifecycle != "RELEASED":
|
|
604
|
+
warnings.append(
|
|
605
|
+
_warning(
|
|
606
|
+
"ORPHAN_RECORD",
|
|
607
|
+
"metadata record is not bound to a registered worktree",
|
|
608
|
+
allocation_id=allocation_id,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
elif len(keys) > 1:
|
|
612
|
+
warnings.append(
|
|
613
|
+
_warning(
|
|
614
|
+
"DUPLICATE_BINDING",
|
|
615
|
+
"one allocation is bound to multiple worktrees",
|
|
616
|
+
allocation_id=allocation_id,
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
active_by_authority: defaultdict[str, list[MetadataRecord]] = defaultdict(list)
|
|
621
|
+
active_by_task: defaultdict[str, list[MetadataRecord]] = defaultdict(list)
|
|
622
|
+
for item in repository.worktrees:
|
|
623
|
+
record = _record_for(item, snapshot)
|
|
624
|
+
if record is None:
|
|
625
|
+
warnings.append(
|
|
626
|
+
_warning(
|
|
627
|
+
"UNMANAGED_WORKTREE",
|
|
628
|
+
"registered worktree has no controller metadata",
|
|
629
|
+
path=str(item.path),
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
continue
|
|
633
|
+
if record.owner == "unassigned":
|
|
634
|
+
warnings.append(
|
|
635
|
+
_warning(
|
|
636
|
+
"UNOWNED_WORKTREE",
|
|
637
|
+
"worktree owner is explicitly unassigned",
|
|
638
|
+
path=str(item.path),
|
|
639
|
+
allocation_id=record.allocation_id,
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
if record.lifecycle in ACTIVE_AUTHORITY_STATES:
|
|
643
|
+
active_by_task[record.task_key].append(record)
|
|
644
|
+
if record.role in FEATURE_AUTHORITY_ROLES:
|
|
645
|
+
active_by_authority[record.authority_key].append(record)
|
|
646
|
+
try:
|
|
647
|
+
reviewed = datetime.fromisoformat(record.last_reviewed_at)
|
|
648
|
+
age = datetime.now(TIMEZONE) - reviewed.astimezone(TIMEZONE)
|
|
649
|
+
if age.days >= policy.stale_warning_days:
|
|
650
|
+
warnings.append(
|
|
651
|
+
_warning(
|
|
652
|
+
"STALE_REVIEW",
|
|
653
|
+
"worktree metadata review is stale; lifecycle is not auto-expired",
|
|
654
|
+
path=str(item.path),
|
|
655
|
+
allocation_id=record.allocation_id,
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
except ValueError:
|
|
659
|
+
warnings.append(
|
|
660
|
+
_warning(
|
|
661
|
+
"INVALID_REVIEW_TIME",
|
|
662
|
+
"last_reviewed_at cannot be interpreted",
|
|
663
|
+
allocation_id=record.allocation_id,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
for task_key, records in active_by_task.items():
|
|
667
|
+
if len(records) > 1:
|
|
668
|
+
warnings.append(
|
|
669
|
+
_warning(
|
|
670
|
+
"DUPLICATE_TASK",
|
|
671
|
+
"multiple active allocations use the same task key",
|
|
672
|
+
task_key=task_key,
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
for authority_key, records in active_by_authority.items():
|
|
676
|
+
if len(records) > 1:
|
|
677
|
+
warnings.append(
|
|
678
|
+
_warning(
|
|
679
|
+
"DUPLICATE_AUTHORITY",
|
|
680
|
+
"multiple active feature allocations use the same authority",
|
|
681
|
+
authority_key=authority_key,
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
if policy.policy_schema_version >= 2:
|
|
685
|
+
managed = [
|
|
686
|
+
record
|
|
687
|
+
for item in repository.worktrees
|
|
688
|
+
if not item.canonical and (record := _record_for(item, snapshot)) is not None
|
|
689
|
+
]
|
|
690
|
+
capacity_checks = (
|
|
691
|
+
(
|
|
692
|
+
"INTEGRATION_CAPACITY_VIOLATION",
|
|
693
|
+
sum(record.role == "integration" and record.lifecycle not in {"PUBLISHED", "RELEASED"}
|
|
694
|
+
for record in managed),
|
|
695
|
+
policy.integration_candidate_limit,
|
|
696
|
+
),
|
|
697
|
+
(
|
|
698
|
+
"FEATURE_CAPACITY_VIOLATION",
|
|
699
|
+
sum(
|
|
700
|
+
record.role in {"feature", "audit"} and record.lifecycle != "RETAINED"
|
|
701
|
+
for record in managed
|
|
702
|
+
),
|
|
703
|
+
policy.feature_audit_slots,
|
|
704
|
+
),
|
|
705
|
+
(
|
|
706
|
+
# Retained work is bounded by the ordinary pool it shares with writers,
|
|
707
|
+
# not by a separate physical sub-quota.
|
|
708
|
+
"ORDINARY_POOL_VIOLATION",
|
|
709
|
+
sum(record.role in ORDINARY_ROLES for record in managed),
|
|
710
|
+
policy.feature_audit_slots + policy.retained_slots,
|
|
711
|
+
),
|
|
712
|
+
(
|
|
713
|
+
"EMERGENCY_CAPACITY_VIOLATION",
|
|
714
|
+
sum(record.role in EMERGENCY_ROLES for record in managed),
|
|
715
|
+
policy.emergency_slots,
|
|
716
|
+
),
|
|
717
|
+
(
|
|
718
|
+
"RECOVERY_CAPACITY_VIOLATION",
|
|
719
|
+
sum(record.role in RECOVERY_ROLES for record in managed),
|
|
720
|
+
policy.recovery_reserve,
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
for code, observed, limit in capacity_checks:
|
|
724
|
+
if policy.resource_admission and code != "INTEGRATION_CAPACITY_VIOLATION":
|
|
725
|
+
continue
|
|
726
|
+
if observed > limit:
|
|
727
|
+
warnings.append(
|
|
728
|
+
_warning(
|
|
729
|
+
code,
|
|
730
|
+
"role-specific worktree capacity is exceeded",
|
|
731
|
+
observed=str(observed),
|
|
732
|
+
limit=str(limit),
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
if not policy.present:
|
|
736
|
+
warnings.append(_warning("MISSING_POLICY", "repository worktree policy is missing"))
|
|
737
|
+
return warnings
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def status(repository_path: Path, *, namespace_pid: int | None = None) -> dict[str, Any]:
|
|
741
|
+
result = envelope("status")
|
|
742
|
+
repository = inventory(repository_path)
|
|
743
|
+
diagnostics = diagnose_mounts(repository, namespace_pid=namespace_pid)
|
|
744
|
+
diagnosis = diagnostics["environment"]
|
|
745
|
+
result["mount_diagnostics"] = diagnostics
|
|
746
|
+
result["environment_diagnosis"] = diagnosis
|
|
747
|
+
policy = load_policy(repository.canonical_path)
|
|
748
|
+
store = MetadataStore(repository.common_dir)
|
|
749
|
+
try:
|
|
750
|
+
snapshot = store.load()
|
|
751
|
+
except ControllerError as exc:
|
|
752
|
+
raise _environment_first(exc, diagnosis) from exc
|
|
753
|
+
result["repository"] = _repository_payload(repository, policy, snapshot)
|
|
754
|
+
result["worktrees"] = _observed_worktrees(
|
|
755
|
+
repository, policy, snapshot, namespace_healthy=diagnosis["code"] == "HEALTHY"
|
|
756
|
+
)
|
|
757
|
+
result["orphan_observations"] = observe_repository_orphans(repository, snapshot)
|
|
758
|
+
result["warnings"] = (
|
|
759
|
+
_environment_warnings(diagnostics)
|
|
760
|
+
+ _metadata_warnings(repository, policy, snapshot)
|
|
761
|
+
+ (_lock_warnings(store) if diagnosis["code"] == "HEALTHY" else [])
|
|
762
|
+
)
|
|
763
|
+
return result
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _resolve_target(
|
|
767
|
+
repository: RepositoryInventory, target: Path | None
|
|
768
|
+
) -> WorktreeGitState | None:
|
|
769
|
+
if target is None:
|
|
770
|
+
return None
|
|
771
|
+
try:
|
|
772
|
+
resolved = target.expanduser().resolve(strict=True)
|
|
773
|
+
except OSError as exc:
|
|
774
|
+
raise ControllerError("INVALID_TARGET", f"target path is unavailable: {target}") from exc
|
|
775
|
+
for item in repository.worktrees:
|
|
776
|
+
if item.path.resolve(strict=True) == resolved:
|
|
777
|
+
return item
|
|
778
|
+
raise ControllerError("UNREGISTERED_WORKTREE", f"target is not registered: {resolved}")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def plan(
|
|
782
|
+
repository_path: Path,
|
|
783
|
+
*,
|
|
784
|
+
task_key: str,
|
|
785
|
+
authority_key: str | None,
|
|
786
|
+
module: str,
|
|
787
|
+
role: str,
|
|
788
|
+
target: Path | None = None,
|
|
789
|
+
) -> dict[str, Any]:
|
|
790
|
+
repository = inventory(repository_path)
|
|
791
|
+
policy = load_policy(repository.canonical_path)
|
|
792
|
+
snapshot = MetadataStore(repository.common_dir).load()
|
|
793
|
+
return _plan_from_snapshot(repository, policy, snapshot, task_key=task_key,
|
|
794
|
+
authority_key=authority_key, module=module, role=role, target=target)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _plan_from_snapshot(
|
|
798
|
+
repository: RepositoryInventory,
|
|
799
|
+
policy: WorktreePolicy,
|
|
800
|
+
snapshot: MetadataSnapshot,
|
|
801
|
+
*,
|
|
802
|
+
task_key: str,
|
|
803
|
+
authority_key: str | None,
|
|
804
|
+
module: str,
|
|
805
|
+
role: str,
|
|
806
|
+
target: Path | None = None,
|
|
807
|
+
) -> dict[str, Any]:
|
|
808
|
+
"""Evaluate read-only planning; acquire still rechecks under its own lock."""
|
|
809
|
+
result = envelope("plan")
|
|
810
|
+
warnings = _metadata_warnings(repository, policy, snapshot)
|
|
811
|
+
result["repository"] = _repository_payload(repository, policy, snapshot)
|
|
812
|
+
result["worktrees"] = [
|
|
813
|
+
_worktree_payload(item, _record_for(item, snapshot), snapshot)
|
|
814
|
+
for item in repository.worktrees
|
|
815
|
+
]
|
|
816
|
+
result["warnings"] = warnings
|
|
817
|
+
requested = {
|
|
818
|
+
"task_key": task_key,
|
|
819
|
+
"authority_key": authority_key,
|
|
820
|
+
"module": module,
|
|
821
|
+
"role": role,
|
|
822
|
+
"target": str(target.expanduser()) if target else None,
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
def decide(code: str, reason: str, **extra: Any) -> dict[str, Any]:
|
|
826
|
+
result["decision"] = {
|
|
827
|
+
"code": code,
|
|
828
|
+
"reason": reason,
|
|
829
|
+
"request": requested,
|
|
830
|
+
"capacity": accounting,
|
|
831
|
+
**extra,
|
|
832
|
+
}
|
|
833
|
+
return result
|
|
834
|
+
|
|
835
|
+
accounting = capacity_accounting(repository, policy, snapshot)
|
|
836
|
+
if not task_key.strip() or not module.strip() or role not in WORKTREE_ROLES:
|
|
837
|
+
return decide("UNKNOWN", "task_key, module, and a supported role are required")
|
|
838
|
+
if policy.require_authority_key and (authority_key is None or not authority_key.strip()):
|
|
839
|
+
return decide("UNKNOWN", "authority_key is required by repository policy")
|
|
840
|
+
if not policy.present:
|
|
841
|
+
return decide("BLOCK_DRIFT", "repository policy is missing")
|
|
842
|
+
capacity_drift = {
|
|
843
|
+
"INTEGRATION_CAPACITY_VIOLATION",
|
|
844
|
+
"FEATURE_CAPACITY_VIOLATION",
|
|
845
|
+
"EMERGENCY_CAPACITY_VIOLATION",
|
|
846
|
+
"RECOVERY_CAPACITY_VIOLATION",
|
|
847
|
+
"ORDINARY_POOL_VIOLATION",
|
|
848
|
+
}
|
|
849
|
+
if any(warning["code"] in capacity_drift for warning in warnings):
|
|
850
|
+
return decide("BLOCK_DRIFT", "role-specific capacity already exceeds policy")
|
|
851
|
+
target_item = _resolve_target(repository, target)
|
|
852
|
+
if target_item and target_item.canonical and role not in CANONICAL_ROLES:
|
|
853
|
+
return decide("BLOCK_CANONICAL", "canonical is protected from ordinary feature work")
|
|
854
|
+
|
|
855
|
+
active: list[tuple[WorktreeGitState, MetadataRecord]] = []
|
|
856
|
+
unmanaged: list[WorktreeGitState] = []
|
|
857
|
+
for item in repository.worktrees:
|
|
858
|
+
record = _record_for(item, snapshot)
|
|
859
|
+
if record is None:
|
|
860
|
+
unmanaged.append(item)
|
|
861
|
+
elif record.lifecycle in ACTIVE_AUTHORITY_STATES:
|
|
862
|
+
active.append((item, record))
|
|
863
|
+
|
|
864
|
+
task_matches = [(item, record) for item, record in active if record.task_key == task_key]
|
|
865
|
+
if task_matches:
|
|
866
|
+
item, record = task_matches[0]
|
|
867
|
+
return decide(
|
|
868
|
+
"REUSE",
|
|
869
|
+
"an active allocation already owns this task",
|
|
870
|
+
reuse={"path": str(item.path), "allocation_id": record.allocation_id},
|
|
871
|
+
)
|
|
872
|
+
if unmanaged and policy.require_adoption:
|
|
873
|
+
return decide(
|
|
874
|
+
"NEEDS_ADOPTION",
|
|
875
|
+
"all registered worktrees must be adopted before allocating another authority",
|
|
876
|
+
unmanaged_paths=[str(item.path) for item in unmanaged],
|
|
877
|
+
)
|
|
878
|
+
if authority_key and policy.unique_active_feature_authority:
|
|
879
|
+
authority_matches = [
|
|
880
|
+
(item, record)
|
|
881
|
+
for item, record in active
|
|
882
|
+
if record.authority_key == authority_key and record.role in FEATURE_AUTHORITY_ROLES
|
|
883
|
+
]
|
|
884
|
+
if authority_matches:
|
|
885
|
+
item, record = authority_matches[0]
|
|
886
|
+
return decide(
|
|
887
|
+
"BLOCK_DUPLICATE_AUTHORITY",
|
|
888
|
+
"an active feature allocation already owns this authority",
|
|
889
|
+
conflict={
|
|
890
|
+
"path": str(item.path),
|
|
891
|
+
"allocation_id": record.allocation_id,
|
|
892
|
+
"task_key": record.task_key,
|
|
893
|
+
},
|
|
894
|
+
)
|
|
895
|
+
preparing = sum(
|
|
896
|
+
record.role == "integration" and record.lifecycle not in {"PUBLISHED", "RELEASED"}
|
|
897
|
+
for item in repository.worktrees
|
|
898
|
+
if not item.canonical and (record := _record_for(item, snapshot)) is not None
|
|
899
|
+
)
|
|
900
|
+
if role == "integration" and preparing >= policy.integration_candidate_limit:
|
|
901
|
+
return decide("BLOCK_INTEGRATION_SLOT", "integration preparation concurrency is occupied")
|
|
902
|
+
from .resource_admission import AdmissionLedger, allocation_request
|
|
903
|
+
|
|
904
|
+
canonical = next(item for item in repository.worktrees if item.canonical)
|
|
905
|
+
request = allocation_request(repository, snapshot.repository_id, policy,
|
|
906
|
+
target=target.expanduser().absolute() if target is not None else Path.home() / "worktrees" / repository.canonical_path.name / task_key,
|
|
907
|
+
base_oid=canonical.head, arguments={"task_key": task_key, "authority_key": authority_key})
|
|
908
|
+
budget = AdmissionLedger().plan([request])
|
|
909
|
+
available = "CREATE_EMERGENCY_ALLOWED" if role in EMERGENCY_ROLES else "CREATE_RECOVERY_ALLOWED" if role in RECOVERY_ROLES else "CREATE_ALLOWED"
|
|
910
|
+
return decide("RESOURCE_WAIT" if budget["blockers"] else available,
|
|
911
|
+
"target filesystem resource budget evaluated; checkout counts are statistical",
|
|
912
|
+
resource_admission=budget)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def guard_write(
|
|
916
|
+
repository_path: Path,
|
|
917
|
+
*,
|
|
918
|
+
target: Path,
|
|
919
|
+
task_key: str,
|
|
920
|
+
authority_key: str,
|
|
921
|
+
owner: str,
|
|
922
|
+
operation: str,
|
|
923
|
+
holder: WriterHolder | None = None,
|
|
924
|
+
observe: bool = True,
|
|
925
|
+
state: RepositoryInventory | None = None,
|
|
926
|
+
snapshot: MetadataSnapshot | None = None,
|
|
927
|
+
) -> dict[str, Any]:
|
|
928
|
+
"""Authorize one declared write class against the selected allocation.
|
|
929
|
+
|
|
930
|
+
This is a trusted-agent workflow gate, not same-UID process isolation. It is intentionally
|
|
931
|
+
read-only: lifecycle mutation remains owned by the dedicated Controller commands.
|
|
932
|
+
A caller already holding one repository inventory and metadata snapshot (under
|
|
933
|
+
its repository lock) passes them, so the decision uses that same snapshot.
|
|
934
|
+
"""
|
|
935
|
+
|
|
936
|
+
result = status(repository_path) if observe else envelope("write-guard")
|
|
937
|
+
result["command"] = "write-guard"
|
|
938
|
+
repository = state if state is not None else inventory(repository_path)
|
|
939
|
+
store = MetadataStore(repository.common_dir)
|
|
940
|
+
if snapshot is None:
|
|
941
|
+
snapshot = store.load()
|
|
942
|
+
item = _resolve_target(repository, target)
|
|
943
|
+
requested = {
|
|
944
|
+
"path": str(target.expanduser()),
|
|
945
|
+
"task_key": task_key,
|
|
946
|
+
"authority_key": authority_key,
|
|
947
|
+
"owner": owner,
|
|
948
|
+
"operation": operation,
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
def decide(code: str, reason: str, **extra: Any) -> dict[str, Any]:
|
|
952
|
+
result["decision"] = {"code": code, "reason": reason, "request": requested, **extra}
|
|
953
|
+
return result
|
|
954
|
+
|
|
955
|
+
if item is None:
|
|
956
|
+
return decide("BLOCK_UNREGISTERED_WORKTREE", "write target is not a registered worktree")
|
|
957
|
+
record = _record_for(item, snapshot)
|
|
958
|
+
if record is None:
|
|
959
|
+
return decide("BLOCK_UNMANAGED_WORKTREE", "write target has no Controller allocation")
|
|
960
|
+
if not store.lock_file.is_file() or store.lock_file.is_symlink():
|
|
961
|
+
return decide("BLOCK_INVALID_METADATA", "governed write requires its repository lock")
|
|
962
|
+
if operation not in WRITE_OPERATIONS:
|
|
963
|
+
return decide("BLOCK_UNKNOWN_WRITE_OPERATION", "write operation is not supported")
|
|
964
|
+
if record.owner != owner:
|
|
965
|
+
return decide(
|
|
966
|
+
"BLOCK_OWNER_MISMATCH",
|
|
967
|
+
"declared owner does not own the selected allocation",
|
|
968
|
+
allocation_id=record.allocation_id,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
# Recovery must remain available after task/head drift; its lifecycle commands perform the
|
|
972
|
+
# stronger CAS, cleanliness, lineage, and scope checks before any metadata mutation.
|
|
973
|
+
if operation == "recovery":
|
|
974
|
+
if record.role not in {"integration", "recovery", "preservation"}:
|
|
975
|
+
return decide(
|
|
976
|
+
"BLOCK_RECOVERY_ROLE",
|
|
977
|
+
"recovery entry is limited to integration and recovery allocations",
|
|
978
|
+
allocation_id=record.allocation_id,
|
|
979
|
+
)
|
|
980
|
+
if record.lifecycle in {"PUBLISHED", "RELEASED"}:
|
|
981
|
+
return decide(
|
|
982
|
+
"BLOCK_RECOVERY_LIFECYCLE",
|
|
983
|
+
f"lifecycle {record.lifecycle} cannot enter recovery",
|
|
984
|
+
allocation_id=record.allocation_id,
|
|
985
|
+
)
|
|
986
|
+
return decide(
|
|
987
|
+
"WRITE_ALLOWED",
|
|
988
|
+
"explicit recovery entry is allowed; lifecycle commands remain authoritative",
|
|
989
|
+
allocation_id=record.allocation_id,
|
|
990
|
+
allocation_task_key=record.task_key,
|
|
991
|
+
lifecycle=record.lifecycle,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
if record.task_key != task_key:
|
|
995
|
+
return decide(
|
|
996
|
+
"BLOCK_TASK_IDENTITY_MISMATCH",
|
|
997
|
+
"declared task does not match the selected allocation",
|
|
998
|
+
allocation_id=record.allocation_id,
|
|
999
|
+
allocation_task_key=record.task_key,
|
|
1000
|
+
)
|
|
1001
|
+
if record.authority_key != authority_key:
|
|
1002
|
+
return decide(
|
|
1003
|
+
"BLOCK_AUTHORITY_IDENTITY_MISMATCH",
|
|
1004
|
+
"declared authority does not match the selected allocation",
|
|
1005
|
+
allocation_id=record.allocation_id,
|
|
1006
|
+
allocation_authority_key=record.authority_key,
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
if operation == "feature-write":
|
|
1010
|
+
if record.role == "integration":
|
|
1011
|
+
return decide(
|
|
1012
|
+
"BLOCK_INTEGRATION_FEATURE_WRITE",
|
|
1013
|
+
"integration allocations cannot host feature implementation",
|
|
1014
|
+
allocation_id=record.allocation_id,
|
|
1015
|
+
lifecycle=record.lifecycle,
|
|
1016
|
+
next_action="allocate or reuse a feature worktree; use recovery if drift already exists",
|
|
1017
|
+
)
|
|
1018
|
+
if record.role not in FEATURE_AUTHORITY_ROLES:
|
|
1019
|
+
return decide(
|
|
1020
|
+
"BLOCK_FEATURE_WRITE_ROLE",
|
|
1021
|
+
f"role {record.role} cannot host ordinary feature implementation",
|
|
1022
|
+
allocation_id=record.allocation_id,
|
|
1023
|
+
)
|
|
1024
|
+
if record.lifecycle != "ACTIVE":
|
|
1025
|
+
return decide(
|
|
1026
|
+
"BLOCK_FEATURE_WRITE_LIFECYCLE",
|
|
1027
|
+
f"lifecycle {record.lifecycle} is frozen for feature writes",
|
|
1028
|
+
allocation_id=record.allocation_id,
|
|
1029
|
+
)
|
|
1030
|
+
try:
|
|
1031
|
+
check_writer_entitlement(
|
|
1032
|
+
snapshot.repository_id,
|
|
1033
|
+
record,
|
|
1034
|
+
item.path,
|
|
1035
|
+
snapshot.writer_leases.get(record.allocation_id),
|
|
1036
|
+
holder,
|
|
1037
|
+
)
|
|
1038
|
+
except ControllerError as exc:
|
|
1039
|
+
return decide(f"BLOCK_{exc.code}", str(exc), allocation_id=record.allocation_id)
|
|
1040
|
+
return decide(
|
|
1041
|
+
"WRITE_ALLOWED",
|
|
1042
|
+
"active feature allocation matches the declared task authority",
|
|
1043
|
+
allocation_id=record.allocation_id,
|
|
1044
|
+
lifecycle=record.lifecycle,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
if operation == "integration-write":
|
|
1048
|
+
if record.role != "integration":
|
|
1049
|
+
return decide(
|
|
1050
|
+
"BLOCK_INTEGRATION_WRITE_ROLE",
|
|
1051
|
+
"integration-only writes require an integration allocation",
|
|
1052
|
+
allocation_id=record.allocation_id,
|
|
1053
|
+
)
|
|
1054
|
+
if record.lifecycle == "HANDOFF_READY":
|
|
1055
|
+
drift = evidence_head_issues(item, record)
|
|
1056
|
+
if drift:
|
|
1057
|
+
return decide(
|
|
1058
|
+
"BLOCK_RECOVERY_REQUIRED",
|
|
1059
|
+
"HANDOFF_READY integration HEAD drift requires quarantine/recovery",
|
|
1060
|
+
allocation_id=record.allocation_id,
|
|
1061
|
+
finding=drift[0],
|
|
1062
|
+
next_action="quarantine and externalize the drift before further writes",
|
|
1063
|
+
)
|
|
1064
|
+
intent = _intent_for_allocation(snapshot, record.allocation_id)
|
|
1065
|
+
if intent is not None and intent.state == "READY_TO_PUBLISH":
|
|
1066
|
+
return decide(
|
|
1067
|
+
"BLOCK_VALIDATED_CANDIDATE_FROZEN",
|
|
1068
|
+
"validated integration candidates can only refresh, publish, or enter recovery",
|
|
1069
|
+
allocation_id=record.allocation_id,
|
|
1070
|
+
lifecycle=record.lifecycle,
|
|
1071
|
+
validation_evidence_id=intent.validation_evidence_id,
|
|
1072
|
+
)
|
|
1073
|
+
if intent is not None and intent.state == "NEEDS_REPREPARE":
|
|
1074
|
+
return decide(
|
|
1075
|
+
"BLOCK_REPREPARE_REQUIRED",
|
|
1076
|
+
"stale candidates must be rebuilt from frozen sources before further writes",
|
|
1077
|
+
allocation_id=record.allocation_id,
|
|
1078
|
+
lifecycle=record.lifecycle,
|
|
1079
|
+
next_action="run integration-refresh; old prepared evidence is no longer eligible",
|
|
1080
|
+
)
|
|
1081
|
+
if record.lifecycle != "ACTIVE":
|
|
1082
|
+
return decide(
|
|
1083
|
+
"BLOCK_INTEGRATION_WRITE_LIFECYCLE",
|
|
1084
|
+
f"lifecycle {record.lifecycle} is frozen for integration writes",
|
|
1085
|
+
allocation_id=record.allocation_id,
|
|
1086
|
+
next_action="use integration-refresh/recovery; ordinary writes remain fenced",
|
|
1087
|
+
)
|
|
1088
|
+
return decide(
|
|
1089
|
+
"WRITE_ALLOWED",
|
|
1090
|
+
"declared integration task matches the integration allocation",
|
|
1091
|
+
allocation_id=record.allocation_id,
|
|
1092
|
+
lifecycle=record.lifecycle,
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
if record.lifecycle in {"QUARANTINED", "RECOVERY_REQUIRED", "RELEASED"}:
|
|
1096
|
+
return decide(
|
|
1097
|
+
"BLOCK_FINALIZATION_LIFECYCLE",
|
|
1098
|
+
f"lifecycle {record.lifecycle} requires its dedicated recovery/release command",
|
|
1099
|
+
allocation_id=record.allocation_id,
|
|
1100
|
+
)
|
|
1101
|
+
if record.lifecycle in {"HANDOFF_READY", "PRESERVED"}:
|
|
1102
|
+
return decide(
|
|
1103
|
+
"BLOCK_HANDOFF_WRITE_FENCE",
|
|
1104
|
+
f"lifecycle {record.lifecycle} is frozen after handoff",
|
|
1105
|
+
allocation_id=record.allocation_id,
|
|
1106
|
+
next_action="explicitly reopen the allocation before any new controlled write",
|
|
1107
|
+
)
|
|
1108
|
+
if record.role in FEATURE_AUTHORITY_ROLES:
|
|
1109
|
+
if record.lifecycle != "ACTIVE":
|
|
1110
|
+
return decide("BLOCK_FINALIZATION_LIFECYCLE", "writer allocation is not active")
|
|
1111
|
+
try:
|
|
1112
|
+
check_writer_entitlement(
|
|
1113
|
+
snapshot.repository_id,
|
|
1114
|
+
record,
|
|
1115
|
+
item.path,
|
|
1116
|
+
snapshot.writer_leases.get(record.allocation_id),
|
|
1117
|
+
holder,
|
|
1118
|
+
)
|
|
1119
|
+
except ControllerError as exc:
|
|
1120
|
+
return decide(f"BLOCK_{exc.code}", str(exc), allocation_id=record.allocation_id)
|
|
1121
|
+
return decide(
|
|
1122
|
+
"WRITE_ALLOWED",
|
|
1123
|
+
"declared finalization task matches the allocation",
|
|
1124
|
+
allocation_id=record.allocation_id,
|
|
1125
|
+
lifecycle=record.lifecycle,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def adopt(
|
|
1130
|
+
repository_path: Path,
|
|
1131
|
+
*,
|
|
1132
|
+
target: Path,
|
|
1133
|
+
task_key: str,
|
|
1134
|
+
authority_key: str,
|
|
1135
|
+
module: str,
|
|
1136
|
+
role: str,
|
|
1137
|
+
owner: str,
|
|
1138
|
+
lifecycle: str,
|
|
1139
|
+
base_commit: str | None = None,
|
|
1140
|
+
task_commit: str | None = None,
|
|
1141
|
+
scope_paths: tuple[str, ...] = (),
|
|
1142
|
+
source_allocations: tuple[str, ...] = (),
|
|
1143
|
+
external_bindings: tuple[str, ...] = (),
|
|
1144
|
+
) -> dict[str, Any]:
|
|
1145
|
+
result = envelope("adopt")
|
|
1146
|
+
if role not in WORKTREE_ROLES:
|
|
1147
|
+
raise ControllerError("INVALID_ROLE", f"unsupported role: {role}")
|
|
1148
|
+
if lifecycle not in LIFECYCLE_STATES:
|
|
1149
|
+
raise ControllerError("INVALID_LIFECYCLE", f"unsupported lifecycle: {lifecycle}")
|
|
1150
|
+
repository_before = inventory(repository_path)
|
|
1151
|
+
target_item = _resolve_target(repository_before, target)
|
|
1152
|
+
if target_item is None:
|
|
1153
|
+
raise ControllerError("INVALID_TARGET", "adopt target is required")
|
|
1154
|
+
if target_item.canonical and role not in CANONICAL_ROLES:
|
|
1155
|
+
raise ControllerError("BLOCK_CANONICAL", "canonical requires a control-plane role")
|
|
1156
|
+
normalized_scopes = normalize_scope_paths(scope_paths)
|
|
1157
|
+
base_oid = resolve_commit(target_item.path, base_commit) if base_commit else None
|
|
1158
|
+
task_oid = resolve_commit(target_item.path, task_commit) if task_commit else None
|
|
1159
|
+
before = git_state_fingerprint(repository_before)
|
|
1160
|
+
store = MetadataStore(repository_before.common_dir)
|
|
1161
|
+
created = False
|
|
1162
|
+
with store.lock(exclusive=True, create=True):
|
|
1163
|
+
repository_locked = inventory(repository_path)
|
|
1164
|
+
locked_item = _resolve_target(repository_locked, target)
|
|
1165
|
+
if locked_item is None or locked_item.key != target_item.key:
|
|
1166
|
+
raise ControllerError("BLOCK_DRIFT", "worktree identity changed during adoption")
|
|
1167
|
+
snapshot = store._load_unlocked()
|
|
1168
|
+
allocation_id = snapshot.bindings.get(locked_item.key)
|
|
1169
|
+
if allocation_id is not None:
|
|
1170
|
+
record = snapshot.records.get(allocation_id)
|
|
1171
|
+
if record is None:
|
|
1172
|
+
raise ControllerError("BLOCK_DRIFT", "binding references a missing record")
|
|
1173
|
+
expected = (
|
|
1174
|
+
task_key.strip(),
|
|
1175
|
+
authority_key.strip(),
|
|
1176
|
+
module.strip(),
|
|
1177
|
+
role,
|
|
1178
|
+
owner.strip(),
|
|
1179
|
+
lifecycle,
|
|
1180
|
+
base_oid,
|
|
1181
|
+
task_oid,
|
|
1182
|
+
normalized_scopes,
|
|
1183
|
+
tuple(source_allocations),
|
|
1184
|
+
)
|
|
1185
|
+
actual = (
|
|
1186
|
+
record.task_key,
|
|
1187
|
+
record.authority_key,
|
|
1188
|
+
record.module,
|
|
1189
|
+
record.role,
|
|
1190
|
+
record.owner,
|
|
1191
|
+
record.lifecycle,
|
|
1192
|
+
record.base_commit,
|
|
1193
|
+
record.task_commit,
|
|
1194
|
+
record.scope_paths,
|
|
1195
|
+
record.source_allocations,
|
|
1196
|
+
)
|
|
1197
|
+
if actual != expected:
|
|
1198
|
+
raise ControllerError(
|
|
1199
|
+
"ADOPTION_CONFLICT",
|
|
1200
|
+
"worktree is already adopted with different metadata; use lifecycle commands",
|
|
1201
|
+
)
|
|
1202
|
+
else:
|
|
1203
|
+
store.initialize_repository()
|
|
1204
|
+
record = new_record(
|
|
1205
|
+
task_key=task_key,
|
|
1206
|
+
authority_key=authority_key,
|
|
1207
|
+
module=module,
|
|
1208
|
+
role=role,
|
|
1209
|
+
owner=owner,
|
|
1210
|
+
lifecycle=lifecycle,
|
|
1211
|
+
base_commit=base_oid,
|
|
1212
|
+
task_commit=task_oid,
|
|
1213
|
+
scope_paths=normalized_scopes,
|
|
1214
|
+
source_allocations=tuple(source_allocations),
|
|
1215
|
+
external_bindings=external_bindings,
|
|
1216
|
+
)
|
|
1217
|
+
store.write_record(
|
|
1218
|
+
locked_item.key,
|
|
1219
|
+
record,
|
|
1220
|
+
registration=None if locked_item.canonical else locked_item.git_dir,
|
|
1221
|
+
)
|
|
1222
|
+
created = True
|
|
1223
|
+
repository_after = inventory(repository_path)
|
|
1224
|
+
after = git_state_fingerprint(repository_after)
|
|
1225
|
+
if before != after:
|
|
1226
|
+
raise ControllerError(
|
|
1227
|
+
"GIT_STATE_CHANGED", "Git refs, index, worktrees, or status changed during adoption"
|
|
1228
|
+
)
|
|
1229
|
+
policy = load_policy(repository_after.canonical_path)
|
|
1230
|
+
snapshot_after = store.load()
|
|
1231
|
+
result["repository"] = _repository_payload(repository_after, policy, snapshot_after)
|
|
1232
|
+
result["worktrees"] = [
|
|
1233
|
+
_worktree_payload(item, _record_for(item, snapshot_after), snapshot_after)
|
|
1234
|
+
for item in repository_after.worktrees
|
|
1235
|
+
]
|
|
1236
|
+
result["warnings"] = _metadata_warnings(repository_after, policy, snapshot_after)
|
|
1237
|
+
result["decision"] = {
|
|
1238
|
+
"code": "ADOPTED" if created else "ALREADY_ADOPTED",
|
|
1239
|
+
"reason": "controller metadata created" if created else "metadata already matched",
|
|
1240
|
+
"allocation_id": record.allocation_id,
|
|
1241
|
+
"path": str(target_item.path),
|
|
1242
|
+
}
|
|
1243
|
+
return result
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _semantic_warnings(
|
|
1247
|
+
repository: RepositoryInventory, snapshot: MetadataSnapshot, policy: WorktreePolicy
|
|
1248
|
+
) -> list[dict[str, str]]:
|
|
1249
|
+
warnings: list[dict[str, str]] = []
|
|
1250
|
+
items_by_allocation: dict[str, tuple[WorktreeGitState, MetadataRecord]] = {}
|
|
1251
|
+
for item in repository.worktrees:
|
|
1252
|
+
record = _record_for(item, snapshot)
|
|
1253
|
+
if record is None:
|
|
1254
|
+
continue
|
|
1255
|
+
items_by_allocation[record.allocation_id] = (item, record)
|
|
1256
|
+
if record.role == "integration":
|
|
1257
|
+
issues = list(integration_candidate_issues(item, record, snapshot))
|
|
1258
|
+
else:
|
|
1259
|
+
issues = list(evidence_head_issues(item, record))
|
|
1260
|
+
if record.lifecycle in EVIDENCE_LIFECYCLES:
|
|
1261
|
+
issues.extend(
|
|
1262
|
+
path_scope_issues(
|
|
1263
|
+
item.path,
|
|
1264
|
+
record,
|
|
1265
|
+
item.head,
|
|
1266
|
+
publication_ref=publication_ref(item.path, policy),
|
|
1267
|
+
)
|
|
1268
|
+
)
|
|
1269
|
+
warnings.extend(issues)
|
|
1270
|
+
if record.lifecycle == "RELEASE_READY":
|
|
1271
|
+
from .delivered_closeout import verify_receipt
|
|
1272
|
+
|
|
1273
|
+
try:
|
|
1274
|
+
verify_receipt(MetadataStore(repository.common_dir), item, record, snapshot.repository_id)
|
|
1275
|
+
except ControllerError as exc:
|
|
1276
|
+
warnings.append(_warning(exc.code, str(exc), allocation_id=record.allocation_id))
|
|
1277
|
+
if record.lifecycle == "ARCHIVED":
|
|
1278
|
+
from .delivered_closeout import verify_archive_receipt
|
|
1279
|
+
|
|
1280
|
+
try:
|
|
1281
|
+
verify_archive_receipt(
|
|
1282
|
+
MetadataStore(repository.common_dir), item, record, snapshot.repository_id
|
|
1283
|
+
)
|
|
1284
|
+
except ControllerError as exc:
|
|
1285
|
+
warnings.append(_warning(exc.code, str(exc), allocation_id=record.allocation_id))
|
|
1286
|
+
if (
|
|
1287
|
+
issues
|
|
1288
|
+
and record.role == "integration"
|
|
1289
|
+
and record.lifecycle not in {"QUARANTINED", "RECOVERY_REQUIRED"}
|
|
1290
|
+
):
|
|
1291
|
+
warnings.append(
|
|
1292
|
+
_warning(
|
|
1293
|
+
"RECOVERY_REQUIRED",
|
|
1294
|
+
"integration semantic drift requires explicit quarantine and recovery",
|
|
1295
|
+
allocation_id=record.allocation_id,
|
|
1296
|
+
path=str(item.path),
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
if record.lifecycle in {"QUARANTINED", "RECOVERY_REQUIRED"}:
|
|
1300
|
+
warnings.append(
|
|
1301
|
+
_warning(
|
|
1302
|
+
record.lifecycle,
|
|
1303
|
+
"allocation is held for explicit governance recovery",
|
|
1304
|
+
allocation_id=record.allocation_id,
|
|
1305
|
+
path=str(item.path),
|
|
1306
|
+
)
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
lifecycle_for_intent = {
|
|
1310
|
+
"PREPARING_INTEGRATION": {"ACTIVE"},
|
|
1311
|
+
"READY_TO_PUBLISH": {"HANDOFF_READY"},
|
|
1312
|
+
"NEEDS_REPREPARE": {"ACTIVE"},
|
|
1313
|
+
"PUBLISHING": {"INTEGRATING"},
|
|
1314
|
+
"RECOVERY_REQUIRED": {"QUARANTINED", "RECOVERY_REQUIRED", "RELEASED"},
|
|
1315
|
+
"ATTENTION_REQUIRED": {"BLOCKED"},
|
|
1316
|
+
"REMOTE_VERIFIED": {"PUBLISHED", "RELEASED"},
|
|
1317
|
+
"RELEASED": {"RELEASED"},
|
|
1318
|
+
}
|
|
1319
|
+
for intent in snapshot.integration_intents.values():
|
|
1320
|
+
effective_state = _effective_intent_state(snapshot, intent)
|
|
1321
|
+
if effective_state in {"QUEUED", "WAITING_SCOPE", "ABANDONED"}:
|
|
1322
|
+
continue
|
|
1323
|
+
if unallocated_integration_conflict(intent):
|
|
1324
|
+
warnings.append(_warning(
|
|
1325
|
+
"INTEGRATION_CONFLICT_PENDING",
|
|
1326
|
+
"pre-allocation conflict needs owner retry and explicit resolution",
|
|
1327
|
+
severity="informational", intent_id=intent.intent_id,
|
|
1328
|
+
))
|
|
1329
|
+
continue
|
|
1330
|
+
record = (
|
|
1331
|
+
snapshot.records.get(intent.allocation_id) if intent.allocation_id is not None else None
|
|
1332
|
+
)
|
|
1333
|
+
if record is None:
|
|
1334
|
+
warnings.append(
|
|
1335
|
+
_warning(
|
|
1336
|
+
"INTEGRATION_INTENT_DRIFT",
|
|
1337
|
+
"claimed integration intent has no allocation record",
|
|
1338
|
+
intent_id=intent.intent_id,
|
|
1339
|
+
)
|
|
1340
|
+
)
|
|
1341
|
+
continue
|
|
1342
|
+
if effective_state == "RELEASED" and intent.state != "RELEASED":
|
|
1343
|
+
warnings.append(
|
|
1344
|
+
_warning(
|
|
1345
|
+
"INTEGRATION_INTENT_RECONCILED",
|
|
1346
|
+
"immutable reconciliation makes historical intent drift non-blocking",
|
|
1347
|
+
severity="informational",
|
|
1348
|
+
intent_id=intent.intent_id,
|
|
1349
|
+
allocation_id=record.allocation_id,
|
|
1350
|
+
)
|
|
1351
|
+
)
|
|
1352
|
+
continue
|
|
1353
|
+
expected_lifecycles = lifecycle_for_intent.get(effective_state, set())
|
|
1354
|
+
if (
|
|
1355
|
+
record.role != "integration"
|
|
1356
|
+
or record.task_key != intent.task_key
|
|
1357
|
+
or record.authority_key != intent.authority_key
|
|
1358
|
+
or record.source_allocations != intent.source_allocations
|
|
1359
|
+
or record.scope_paths != intent.scope_paths
|
|
1360
|
+
or record.lifecycle not in expected_lifecycles
|
|
1361
|
+
):
|
|
1362
|
+
warnings.append(
|
|
1363
|
+
_warning(
|
|
1364
|
+
"INTEGRATION_INTENT_DRIFT",
|
|
1365
|
+
"integration intent and candidate allocation identity differ",
|
|
1366
|
+
intent_id=intent.intent_id,
|
|
1367
|
+
allocation_id=record.allocation_id,
|
|
1368
|
+
)
|
|
1369
|
+
)
|
|
1370
|
+
if intent.candidate_oid != record.task_commit:
|
|
1371
|
+
warnings.append(
|
|
1372
|
+
_warning(
|
|
1373
|
+
"INTEGRATION_INTENT_DRIFT",
|
|
1374
|
+
"integration intent candidate OID differs from allocation evidence",
|
|
1375
|
+
intent_id=intent.intent_id,
|
|
1376
|
+
allocation_id=record.allocation_id,
|
|
1377
|
+
)
|
|
1378
|
+
)
|
|
1379
|
+
if intent.schema_version >= 3 and effective_state in {
|
|
1380
|
+
"READY_TO_PUBLISH",
|
|
1381
|
+
"PUBLISHING",
|
|
1382
|
+
"REMOTE_VERIFIED",
|
|
1383
|
+
"RELEASED",
|
|
1384
|
+
}:
|
|
1385
|
+
prepared = (
|
|
1386
|
+
snapshot.prepared_candidate_receipts.get(intent.prepared_receipt_id)
|
|
1387
|
+
if intent.prepared_receipt_id is not None
|
|
1388
|
+
else None
|
|
1389
|
+
)
|
|
1390
|
+
matched = items_by_allocation.get(record.allocation_id)
|
|
1391
|
+
live_tree_drift = (
|
|
1392
|
+
matched is not None
|
|
1393
|
+
and matched[0].head == intent.prepared_commit_oid
|
|
1394
|
+
and commit_tree_oid(matched[0].path, matched[0].head) != intent.prepared_tree_oid
|
|
1395
|
+
)
|
|
1396
|
+
if (
|
|
1397
|
+
prepared is None
|
|
1398
|
+
or prepared.repository_id != snapshot.repository_id
|
|
1399
|
+
or prepared.intent_id != intent.intent_id
|
|
1400
|
+
or prepared.allocation_id != record.allocation_id
|
|
1401
|
+
or prepared.attempt != intent.attempt
|
|
1402
|
+
or prepared.base_origin_main_oid != intent.prepared_base_origin_main_oid
|
|
1403
|
+
or prepared.candidate_oid != intent.prepared_commit_oid
|
|
1404
|
+
or prepared.tree_oid != intent.prepared_tree_oid
|
|
1405
|
+
or prepared.scope_digest != intent.prepared_scope_digest
|
|
1406
|
+
or prepared.prepare_identity != intent.prepare_identity
|
|
1407
|
+
or prepared.validation_evidence_id != intent.validation_evidence_id
|
|
1408
|
+
or prepared.tests_receipt_id != intent.tests_receipt_id
|
|
1409
|
+
or prepared.snapshot_receipt_id != intent.snapshot_receipt_id
|
|
1410
|
+
or live_tree_drift
|
|
1411
|
+
):
|
|
1412
|
+
warnings.append(
|
|
1413
|
+
_warning(
|
|
1414
|
+
"PREPARED_RECEIPT_DRIFT",
|
|
1415
|
+
"prepared candidate receipt no longer matches intent and tree evidence",
|
|
1416
|
+
severity="blocking",
|
|
1417
|
+
intent_id=intent.intent_id,
|
|
1418
|
+
allocation_id=record.allocation_id,
|
|
1419
|
+
)
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
# Historical publications join release evidence by exact identity, once per snapshot.
|
|
1423
|
+
released_publications = {
|
|
1424
|
+
(release_receipt.allocation_id, release_receipt.released_record_version,
|
|
1425
|
+
plan.publication_receipt_id)
|
|
1426
|
+
for release_receipt in snapshot.release_receipts.values()
|
|
1427
|
+
if (plan := snapshot.release_plans.get(release_receipt.plan_id)) is not None
|
|
1428
|
+
}
|
|
1429
|
+
receipts_by_allocation: defaultdict[str, int] = defaultdict(int)
|
|
1430
|
+
for receipt in snapshot.publication_receipts.values():
|
|
1431
|
+
receipts_by_allocation[receipt.allocation_id] += 1
|
|
1432
|
+
record = snapshot.records.get(receipt.allocation_id)
|
|
1433
|
+
expected_lifecycle = "RELEASED" if receipt.status == "RELEASED" else "PUBLISHED"
|
|
1434
|
+
identity_drift = (
|
|
1435
|
+
receipt.repository_id != snapshot.repository_id
|
|
1436
|
+
or record is None
|
|
1437
|
+
or record.task_commit != receipt.candidate_oid
|
|
1438
|
+
or record.finalizer_record_id != receipt.finalizer_record_id
|
|
1439
|
+
)
|
|
1440
|
+
if not identity_drift and record is not None and record.lifecycle != expected_lifecycle:
|
|
1441
|
+
immutable_release_evidence = (
|
|
1442
|
+
record.allocation_id, record.record_version, receipt.receipt_id
|
|
1443
|
+
) in released_publications
|
|
1444
|
+
if immutable_release_evidence and record.lifecycle == "RELEASED":
|
|
1445
|
+
continue
|
|
1446
|
+
reconciliations = [
|
|
1447
|
+
reconciliation
|
|
1448
|
+
for reconciliation in snapshot.reconciliation_receipts.values()
|
|
1449
|
+
if reconciliation.source_receipt_id == receipt.receipt_id
|
|
1450
|
+
and reconciliation.allocation_id == record.allocation_id
|
|
1451
|
+
and reconciliation.observed_record_version == record.record_version
|
|
1452
|
+
and reconciliation.result == "RELEASE_STATE_ACKNOWLEDGED"
|
|
1453
|
+
]
|
|
1454
|
+
if len(reconciliations) == 1 and record.lifecycle == "RELEASED":
|
|
1455
|
+
warnings.append(
|
|
1456
|
+
_warning(
|
|
1457
|
+
"PUBLICATION_RECEIPT_RECONCILED",
|
|
1458
|
+
"immutable publication receipt drift has reconciliation evidence",
|
|
1459
|
+
severity="informational",
|
|
1460
|
+
receipt_id=receipt.receipt_id,
|
|
1461
|
+
allocation_id=receipt.allocation_id,
|
|
1462
|
+
)
|
|
1463
|
+
)
|
|
1464
|
+
continue
|
|
1465
|
+
if (
|
|
1466
|
+
not reconciliations
|
|
1467
|
+
and receipt.status == "REMOTE_VERIFIED"
|
|
1468
|
+
and receipt.lease_released_at is not None
|
|
1469
|
+
and record.lifecycle == "RELEASED"
|
|
1470
|
+
):
|
|
1471
|
+
warnings.append(
|
|
1472
|
+
_warning(
|
|
1473
|
+
"PUBLICATION_RECEIPT_DRIFT",
|
|
1474
|
+
"released historical receipt needs idempotent reconciliation",
|
|
1475
|
+
severity="degraded",
|
|
1476
|
+
receipt_id=receipt.receipt_id,
|
|
1477
|
+
allocation_id=receipt.allocation_id,
|
|
1478
|
+
)
|
|
1479
|
+
)
|
|
1480
|
+
continue
|
|
1481
|
+
if identity_drift or (record is not None and record.lifecycle != expected_lifecycle):
|
|
1482
|
+
warnings.append(
|
|
1483
|
+
_warning(
|
|
1484
|
+
"PUBLICATION_RECEIPT_DRIFT",
|
|
1485
|
+
"publication receipt no longer matches repository and candidate evidence",
|
|
1486
|
+
severity="blocking",
|
|
1487
|
+
receipt_id=receipt.receipt_id,
|
|
1488
|
+
allocation_id=receipt.allocation_id,
|
|
1489
|
+
)
|
|
1490
|
+
)
|
|
1491
|
+
for allocation_id, count in receipts_by_allocation.items():
|
|
1492
|
+
if count > 1:
|
|
1493
|
+
warnings.append(
|
|
1494
|
+
_warning(
|
|
1495
|
+
"PUBLICATION_RECEIPT_DRIFT",
|
|
1496
|
+
"one candidate allocation has multiple publication receipts",
|
|
1497
|
+
severity="blocking",
|
|
1498
|
+
allocation_id=allocation_id,
|
|
1499
|
+
count=str(count),
|
|
1500
|
+
)
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
lease = snapshot.publication_lease
|
|
1504
|
+
if lease is not None:
|
|
1505
|
+
if lease.repository_id not in {
|
|
1506
|
+
snapshot.repository_id,
|
|
1507
|
+
"00000000-0000-0000-0000-000000000000",
|
|
1508
|
+
}:
|
|
1509
|
+
warnings.append(
|
|
1510
|
+
_warning(
|
|
1511
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1512
|
+
"publication lease repository identity differs",
|
|
1513
|
+
allocation_id=lease.allocation_id,
|
|
1514
|
+
)
|
|
1515
|
+
)
|
|
1516
|
+
try:
|
|
1517
|
+
if datetime.fromisoformat(lease.expires_at) <= datetime.now(TIMEZONE):
|
|
1518
|
+
warnings.append(
|
|
1519
|
+
_warning(
|
|
1520
|
+
"PUBLICATION_LEASE_EXPIRED",
|
|
1521
|
+
"publication lease expired and requires remote-fact recovery",
|
|
1522
|
+
allocation_id=lease.allocation_id,
|
|
1523
|
+
)
|
|
1524
|
+
)
|
|
1525
|
+
except ValueError:
|
|
1526
|
+
warnings.append(
|
|
1527
|
+
_warning(
|
|
1528
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1529
|
+
"publication lease expiry is invalid",
|
|
1530
|
+
allocation_id=lease.allocation_id,
|
|
1531
|
+
)
|
|
1532
|
+
)
|
|
1533
|
+
matched = items_by_allocation.get(lease.allocation_id)
|
|
1534
|
+
if matched is None:
|
|
1535
|
+
warnings.append(
|
|
1536
|
+
_warning(
|
|
1537
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1538
|
+
"publication lease references no registered allocation",
|
|
1539
|
+
allocation_id=lease.allocation_id,
|
|
1540
|
+
)
|
|
1541
|
+
)
|
|
1542
|
+
else:
|
|
1543
|
+
item, record = matched
|
|
1544
|
+
if (
|
|
1545
|
+
record.lifecycle != "INTEGRATING"
|
|
1546
|
+
or item.head != lease.candidate_oid
|
|
1547
|
+
or record.finalizer_record_id != lease.validation_evidence_id
|
|
1548
|
+
):
|
|
1549
|
+
warnings.append(
|
|
1550
|
+
_warning(
|
|
1551
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1552
|
+
"publication lease no longer matches candidate evidence",
|
|
1553
|
+
allocation_id=lease.allocation_id,
|
|
1554
|
+
)
|
|
1555
|
+
)
|
|
1556
|
+
else:
|
|
1557
|
+
warnings.append(
|
|
1558
|
+
_warning(
|
|
1559
|
+
"ACTIVE_PUBLICATION_LEASE",
|
|
1560
|
+
"main publication authority is currently leased",
|
|
1561
|
+
allocation_id=lease.allocation_id,
|
|
1562
|
+
)
|
|
1563
|
+
)
|
|
1564
|
+
try:
|
|
1565
|
+
tracking_head = resolve_commit(
|
|
1566
|
+
repository.canonical_path,
|
|
1567
|
+
f"refs/remotes/{lease.remote}/{lease.branch}",
|
|
1568
|
+
)
|
|
1569
|
+
except ControllerError:
|
|
1570
|
+
tracking_head = None
|
|
1571
|
+
if tracking_head != lease.expected_main_oid:
|
|
1572
|
+
warnings.append(
|
|
1573
|
+
_warning(
|
|
1574
|
+
"PUBLICATION_REMOTE_DRIFT",
|
|
1575
|
+
"local publication tracking ref changed after lease acquisition",
|
|
1576
|
+
allocation_id=lease.allocation_id,
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
for allocation_id, (_item, record) in items_by_allocation.items():
|
|
1580
|
+
if record.lifecycle == "INTEGRATING" and (
|
|
1581
|
+
lease is None or lease.allocation_id != allocation_id
|
|
1582
|
+
):
|
|
1583
|
+
warnings.append(
|
|
1584
|
+
_warning(
|
|
1585
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1586
|
+
"integrating lifecycle has no matching publication lease",
|
|
1587
|
+
allocation_id=allocation_id,
|
|
1588
|
+
)
|
|
1589
|
+
)
|
|
1590
|
+
return warnings
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def doctor(repository_path: Path, *, namespace_pid: int | None = None) -> dict[str, Any]:
|
|
1594
|
+
result = envelope("doctor")
|
|
1595
|
+
try:
|
|
1596
|
+
repository = inventory(repository_path)
|
|
1597
|
+
except ControllerError as exc:
|
|
1598
|
+
result["environment_diagnosis"] = {
|
|
1599
|
+
"code": "UNKNOWN",
|
|
1600
|
+
"facts": ["GIT_INVENTORY_UNAVAILABLE"],
|
|
1601
|
+
}
|
|
1602
|
+
result["errors"].append({"code": exc.code, "message": str(exc)})
|
|
1603
|
+
result["decision"] = {
|
|
1604
|
+
"code": "FAIL",
|
|
1605
|
+
"reason": "Git inventory is unavailable; mount and lock authority cannot be confirmed",
|
|
1606
|
+
"primary_diagnosis": "UNKNOWN",
|
|
1607
|
+
}
|
|
1608
|
+
return result
|
|
1609
|
+
diagnostics = diagnose_mounts(repository, namespace_pid=namespace_pid)
|
|
1610
|
+
diagnosis = diagnostics["environment"]
|
|
1611
|
+
result["mount_diagnostics"] = diagnostics
|
|
1612
|
+
result["environment_diagnosis"] = diagnosis
|
|
1613
|
+
result["warnings"] = _environment_warnings(diagnostics)
|
|
1614
|
+
try:
|
|
1615
|
+
policy = load_policy(repository.canonical_path)
|
|
1616
|
+
except ControllerError as exc:
|
|
1617
|
+
result["errors"].append({"code": exc.code, "message": str(exc)})
|
|
1618
|
+
from .policy import default_policy
|
|
1619
|
+
|
|
1620
|
+
policy = default_policy(repository.canonical_path)
|
|
1621
|
+
store = MetadataStore(repository.common_dir)
|
|
1622
|
+
try:
|
|
1623
|
+
snapshot = store.load()
|
|
1624
|
+
except ControllerError as exc:
|
|
1625
|
+
error = _environment_first(exc, diagnosis)
|
|
1626
|
+
result["repository"] = {
|
|
1627
|
+
"canonical_path": str(repository.canonical_path),
|
|
1628
|
+
"common_dir": str(repository.common_dir),
|
|
1629
|
+
"worktree_count": len(repository.worktrees),
|
|
1630
|
+
"normal_limit": policy.normal_limit,
|
|
1631
|
+
"hard_limit": policy.hard_limit,
|
|
1632
|
+
"metadata_path": str(store.root),
|
|
1633
|
+
}
|
|
1634
|
+
result["errors"].append({"code": error.code, "message": str(error)})
|
|
1635
|
+
result["decision"] = {
|
|
1636
|
+
"code": "FAIL",
|
|
1637
|
+
"reason": "repository environment or controller metadata is unavailable",
|
|
1638
|
+
"primary_diagnosis": _primary_diagnosis(
|
|
1639
|
+
diagnosis, result["warnings"] + result["errors"], failed=True
|
|
1640
|
+
),
|
|
1641
|
+
}
|
|
1642
|
+
return result
|
|
1643
|
+
warnings = _metadata_warnings(repository, policy, snapshot)
|
|
1644
|
+
result["repository"] = _repository_payload(repository, policy, snapshot)
|
|
1645
|
+
result["worktrees"] = _observed_worktrees(
|
|
1646
|
+
repository, policy, snapshot, namespace_healthy=diagnosis["code"] == "HEALTHY"
|
|
1647
|
+
)
|
|
1648
|
+
result["orphan_observations"] = observe_repository_orphans(repository, snapshot)
|
|
1649
|
+
result["warnings"] += (
|
|
1650
|
+
warnings
|
|
1651
|
+
+ _semantic_warnings(repository, snapshot, policy)
|
|
1652
|
+
+ (_lock_warnings(store) if diagnosis["code"] == "HEALTHY" else [])
|
|
1653
|
+
)
|
|
1654
|
+
transaction_count = 0
|
|
1655
|
+
temp_count = 0
|
|
1656
|
+
retirement_grants: list[dict[str, str]] = []
|
|
1657
|
+
retirement_unattested: list[dict[str, str]] = []
|
|
1658
|
+
historical_transactions = []
|
|
1659
|
+
transaction_fields = {
|
|
1660
|
+
"schema_version",
|
|
1661
|
+
"transaction_id",
|
|
1662
|
+
"repository_id",
|
|
1663
|
+
"command",
|
|
1664
|
+
"phase",
|
|
1665
|
+
"target_path",
|
|
1666
|
+
"allocation_id",
|
|
1667
|
+
"branch",
|
|
1668
|
+
"expected_head",
|
|
1669
|
+
"expected_record_version",
|
|
1670
|
+
"created_at",
|
|
1671
|
+
"updated_at",
|
|
1672
|
+
}
|
|
1673
|
+
try:
|
|
1674
|
+
with store.lock(exclusive=False):
|
|
1675
|
+
if store.transactions_dir.exists():
|
|
1676
|
+
for transaction_path in sorted(store.transactions_dir.iterdir()):
|
|
1677
|
+
from .transaction_scope import is_atomic_temp, terminal_state
|
|
1678
|
+
|
|
1679
|
+
if is_atomic_temp(transaction_path.name):
|
|
1680
|
+
temp_count += 1
|
|
1681
|
+
continue
|
|
1682
|
+
if transaction_path.suffix != ".json" or not transaction_path.is_file():
|
|
1683
|
+
raise ControllerError("INVALID_TRANSACTION", "unknown transaction entry")
|
|
1684
|
+
state = terminal_state(store, transaction_path.stem)
|
|
1685
|
+
if state is not None:
|
|
1686
|
+
historical_transactions.append(
|
|
1687
|
+
{"transaction_id": transaction_path.stem, "state": state}
|
|
1688
|
+
)
|
|
1689
|
+
continue
|
|
1690
|
+
transaction = store._read_json(transaction_path)
|
|
1691
|
+
fields = set(transaction)
|
|
1692
|
+
release_fields = transaction_fields | {"release_plan_id"}
|
|
1693
|
+
teardown_fields = release_fields | {"teardown_receipt_id"}
|
|
1694
|
+
refresh_fields = transaction_fields | {"new_head", "intent_id"}
|
|
1695
|
+
cleanup_fields = transaction_fields | {"cleanup_plan"}
|
|
1696
|
+
cleanup_transaction = fields in (
|
|
1697
|
+
cleanup_fields,
|
|
1698
|
+
cleanup_fields | {"actions"},
|
|
1699
|
+
cleanup_fields | {"actions", "failure"},
|
|
1700
|
+
)
|
|
1701
|
+
teardown_transaction = fields == teardown_fields
|
|
1702
|
+
planned_release_transaction = fields in (release_fields, teardown_fields)
|
|
1703
|
+
refresh_transaction = fields == refresh_fields
|
|
1704
|
+
teardown_phases = {
|
|
1705
|
+
"TEARDOWN_PREPARED",
|
|
1706
|
+
"VENV_REMOVAL_STARTED",
|
|
1707
|
+
"VENV_REMOVED",
|
|
1708
|
+
}
|
|
1709
|
+
phase = transaction.get("phase")
|
|
1710
|
+
if (
|
|
1711
|
+
(
|
|
1712
|
+
fields != transaction_fields
|
|
1713
|
+
and fields != release_fields
|
|
1714
|
+
and fields != teardown_fields
|
|
1715
|
+
and fields != refresh_fields
|
|
1716
|
+
and not cleanup_transaction
|
|
1717
|
+
)
|
|
1718
|
+
or transaction.get("schema_version") not in {1, 2, 3, 4}
|
|
1719
|
+
or transaction.get("command")
|
|
1720
|
+
not in {"acquire", "release", "integration-refresh", "resource-clean"}
|
|
1721
|
+
or phase
|
|
1722
|
+
not in {
|
|
1723
|
+
"PREPARED",
|
|
1724
|
+
"TEARDOWN_PREPARED",
|
|
1725
|
+
"VENV_REMOVAL_STARTED",
|
|
1726
|
+
"VENV_REMOVED",
|
|
1727
|
+
"WORKTREE_ADDED",
|
|
1728
|
+
"METADATA_WRITTEN",
|
|
1729
|
+
"WORKTREE_REMOVED",
|
|
1730
|
+
"RELEASED_RECORDED",
|
|
1731
|
+
"REFRESH_PREPARED",
|
|
1732
|
+
"DETACHED_AT_NEW_HEAD",
|
|
1733
|
+
"BRANCH_UPDATED",
|
|
1734
|
+
}
|
|
1735
|
+
or (teardown_transaction and transaction.get("command") != "release")
|
|
1736
|
+
or (planned_release_transaction and transaction.get("command") != "release")
|
|
1737
|
+
or (
|
|
1738
|
+
transaction.get("schema_version") == 3
|
|
1739
|
+
and transaction.get("command") == "release"
|
|
1740
|
+
and not planned_release_transaction
|
|
1741
|
+
)
|
|
1742
|
+
or (
|
|
1743
|
+
refresh_transaction
|
|
1744
|
+
and transaction.get("command") != "integration-refresh"
|
|
1745
|
+
)
|
|
1746
|
+
or (phase in teardown_phases and not teardown_transaction)
|
|
1747
|
+
or (
|
|
1748
|
+
transaction.get("command") == "resource-clean"
|
|
1749
|
+
and (not cleanup_transaction or phase != "PREPARED")
|
|
1750
|
+
)
|
|
1751
|
+
or (cleanup_transaction and transaction.get("command") != "resource-clean")
|
|
1752
|
+
or (
|
|
1753
|
+
phase in {"REFRESH_PREPARED", "DETACHED_AT_NEW_HEAD", "BRANCH_UPDATED"}
|
|
1754
|
+
and not refresh_transaction
|
|
1755
|
+
)
|
|
1756
|
+
or (
|
|
1757
|
+
teardown_transaction
|
|
1758
|
+
and transaction.get("teardown_receipt_id")
|
|
1759
|
+
!= transaction.get("transaction_id")
|
|
1760
|
+
)
|
|
1761
|
+
):
|
|
1762
|
+
raise ControllerError(
|
|
1763
|
+
"INVALID_TRANSACTION",
|
|
1764
|
+
f"transaction journal schema is invalid: {transaction_path}",
|
|
1765
|
+
)
|
|
1766
|
+
if cleanup_transaction:
|
|
1767
|
+
cleanup_plan = transaction.get("cleanup_plan")
|
|
1768
|
+
if not isinstance(cleanup_plan, dict) or any(
|
|
1769
|
+
cleanup_plan.get(key) != transaction[key]
|
|
1770
|
+
for key in (
|
|
1771
|
+
"repository_id",
|
|
1772
|
+
"allocation_id",
|
|
1773
|
+
"target_path",
|
|
1774
|
+
"expected_head",
|
|
1775
|
+
"expected_record_version",
|
|
1776
|
+
)
|
|
1777
|
+
):
|
|
1778
|
+
raise ControllerError(
|
|
1779
|
+
"INVALID_TRANSACTION", "cleanup plan does not match journal"
|
|
1780
|
+
)
|
|
1781
|
+
if "actions" in transaction:
|
|
1782
|
+
actions = transaction["actions"]
|
|
1783
|
+
if (
|
|
1784
|
+
not isinstance(actions, list)
|
|
1785
|
+
or not all(
|
|
1786
|
+
isinstance(action, dict)
|
|
1787
|
+
and set(action) == {"path", "status"}
|
|
1788
|
+
and action["status"]
|
|
1789
|
+
in {"PENDING", "STARTED", "COMPLETED", "FAILED_OR_PARTIAL"}
|
|
1790
|
+
for action in actions
|
|
1791
|
+
)
|
|
1792
|
+
or [action["path"] for action in actions]
|
|
1793
|
+
!= cleanup_plan.get("cleanup_paths")
|
|
1794
|
+
):
|
|
1795
|
+
raise ControllerError(
|
|
1796
|
+
"INVALID_TRANSACTION", "cleanup action progress is invalid"
|
|
1797
|
+
)
|
|
1798
|
+
if "failure" in transaction:
|
|
1799
|
+
failure = transaction["failure"]
|
|
1800
|
+
if (
|
|
1801
|
+
not isinstance(failure, dict)
|
|
1802
|
+
or set(failure) != {"code", "errno"}
|
|
1803
|
+
or not isinstance(failure["code"], str)
|
|
1804
|
+
or (
|
|
1805
|
+
failure["errno"] is not None
|
|
1806
|
+
and not isinstance(failure["errno"], int)
|
|
1807
|
+
)
|
|
1808
|
+
):
|
|
1809
|
+
raise ControllerError(
|
|
1810
|
+
"INVALID_TRANSACTION", "cleanup failure evidence is invalid"
|
|
1811
|
+
)
|
|
1812
|
+
if planned_release_transaction:
|
|
1813
|
+
plan_id = transaction.get("release_plan_id")
|
|
1814
|
+
plan = (
|
|
1815
|
+
snapshot.release_plans.get(plan_id)
|
|
1816
|
+
if isinstance(plan_id, str)
|
|
1817
|
+
else None
|
|
1818
|
+
)
|
|
1819
|
+
if (
|
|
1820
|
+
plan is None
|
|
1821
|
+
or plan.repository_id != transaction["repository_id"]
|
|
1822
|
+
or plan.allocation_id != transaction["allocation_id"]
|
|
1823
|
+
or plan.target_path != transaction["target_path"]
|
|
1824
|
+
or plan.branch != transaction["branch"]
|
|
1825
|
+
or plan.expected_head != transaction["expected_head"]
|
|
1826
|
+
or plan.expected_record_version
|
|
1827
|
+
!= transaction["expected_record_version"]
|
|
1828
|
+
):
|
|
1829
|
+
raise ControllerError(
|
|
1830
|
+
"INVALID_TRANSACTION",
|
|
1831
|
+
f"release plan does not match journal: {transaction_path}",
|
|
1832
|
+
)
|
|
1833
|
+
if teardown_transaction:
|
|
1834
|
+
receipt = store.load_teardown_receipt(
|
|
1835
|
+
str(transaction["teardown_receipt_id"])
|
|
1836
|
+
)
|
|
1837
|
+
expected_results = {
|
|
1838
|
+
"TEARDOWN_PREPARED": {"prepared"},
|
|
1839
|
+
"VENV_REMOVAL_STARTED": {"removal_started"},
|
|
1840
|
+
"VENV_REMOVED": {"removed"},
|
|
1841
|
+
"PREPARED": {"removed"},
|
|
1842
|
+
"WORKTREE_REMOVED": {"removed"},
|
|
1843
|
+
"RELEASED_RECORDED": {"removed", "released"},
|
|
1844
|
+
}.get(phase)
|
|
1845
|
+
if (
|
|
1846
|
+
expected_results is None
|
|
1847
|
+
or receipt["result"] not in expected_results
|
|
1848
|
+
or receipt["repository_id"] != transaction["repository_id"]
|
|
1849
|
+
or receipt["allocation_id"] != transaction["allocation_id"]
|
|
1850
|
+
or receipt["worktree_path"] != transaction["target_path"]
|
|
1851
|
+
or receipt["worktree_head"] != transaction["expected_head"]
|
|
1852
|
+
):
|
|
1853
|
+
raise ControllerError(
|
|
1854
|
+
"INVALID_TRANSACTION",
|
|
1855
|
+
f"teardown receipt does not match journal: {transaction_path}",
|
|
1856
|
+
)
|
|
1857
|
+
transaction_count += 1
|
|
1858
|
+
outstanding, unattested = _retirement_execution_findings(store)
|
|
1859
|
+
retirement_grants.extend(outstanding)
|
|
1860
|
+
retirement_unattested.extend(unattested)
|
|
1861
|
+
except ControllerError as exc:
|
|
1862
|
+
error = _environment_first(exc, diagnosis)
|
|
1863
|
+
result["errors"].append({"code": error.code, "message": str(error)})
|
|
1864
|
+
if transaction_count:
|
|
1865
|
+
result["warnings"].append(
|
|
1866
|
+
_warning(
|
|
1867
|
+
"INTERRUPTED_TRANSACTION",
|
|
1868
|
+
"controller transaction journal requires review",
|
|
1869
|
+
count=str(transaction_count),
|
|
1870
|
+
)
|
|
1871
|
+
)
|
|
1872
|
+
if temp_count:
|
|
1873
|
+
result["warnings"].append(
|
|
1874
|
+
_warning(
|
|
1875
|
+
"ATOMIC_TEMP_RESIDUE",
|
|
1876
|
+
"interrupted atomic write temp files remain in transactions/ and are ignored",
|
|
1877
|
+
count=str(temp_count),
|
|
1878
|
+
)
|
|
1879
|
+
)
|
|
1880
|
+
for grant in retirement_grants:
|
|
1881
|
+
result["warnings"].append(
|
|
1882
|
+
_warning(
|
|
1883
|
+
"RETIREMENT_AUTHORIZATION_OUTSTANDING",
|
|
1884
|
+
"a retirement execution authorization is open for this repository",
|
|
1885
|
+
authorization_id=grant["authorization_id"],
|
|
1886
|
+
plan_id=grant["plan_id"],
|
|
1887
|
+
operation=grant["operation"],
|
|
1888
|
+
branch=grant["branch"],
|
|
1889
|
+
expires_at=grant["expires_at"],
|
|
1890
|
+
severity="degraded",
|
|
1891
|
+
)
|
|
1892
|
+
)
|
|
1893
|
+
for removal in retirement_unattested:
|
|
1894
|
+
result["warnings"].append(
|
|
1895
|
+
_warning(
|
|
1896
|
+
removal["code"],
|
|
1897
|
+
"a retirement ref is absent with no Controller-observed authorized transition",
|
|
1898
|
+
authorization_id=removal["authorization_id"],
|
|
1899
|
+
plan_id=removal["plan_id"],
|
|
1900
|
+
operation=removal["operation"],
|
|
1901
|
+
ref=removal.get("ref", ""),
|
|
1902
|
+
severity="blocking",
|
|
1903
|
+
)
|
|
1904
|
+
)
|
|
1905
|
+
failure_codes = {
|
|
1906
|
+
"ORPHAN_BINDING",
|
|
1907
|
+
"ORPHAN_RECORD",
|
|
1908
|
+
"MISSING_RECORD",
|
|
1909
|
+
"DUPLICATE_BINDING",
|
|
1910
|
+
"DUPLICATE_AUTHORITY",
|
|
1911
|
+
"DUPLICATE_TASK",
|
|
1912
|
+
"BUDGET_VIOLATION",
|
|
1913
|
+
"INTEGRATION_CAPACITY_VIOLATION",
|
|
1914
|
+
"FEATURE_CAPACITY_VIOLATION",
|
|
1915
|
+
"EMERGENCY_CAPACITY_VIOLATION",
|
|
1916
|
+
"RECOVERY_CAPACITY_VIOLATION",
|
|
1917
|
+
"MISSING_POLICY",
|
|
1918
|
+
"MISSING_REPO_METADATA",
|
|
1919
|
+
"REPOSITORY_LOCK_MISSING",
|
|
1920
|
+
"REPOSITORY_LOCK_INVALID",
|
|
1921
|
+
"INTERRUPTED_TRANSACTION",
|
|
1922
|
+
"MISSING_TASK_COMMIT",
|
|
1923
|
+
"TASK_HEAD_MISMATCH",
|
|
1924
|
+
"DELIVERED_CLOSEOUT_INVALID",
|
|
1925
|
+
"RETAINED_ARCHIVE_INVALID",
|
|
1926
|
+
"HANDOFF_HEAD_MISMATCH",
|
|
1927
|
+
"MISSING_FINALIZER_COMMIT",
|
|
1928
|
+
"FINALIZER_HEAD_MISMATCH",
|
|
1929
|
+
"MISSING_INTEGRATION_BASE",
|
|
1930
|
+
"INTEGRATION_BASE_DIVERGED",
|
|
1931
|
+
"MISSING_INTEGRATION_SOURCE",
|
|
1932
|
+
"MISSING_SOURCE_ALLOCATION",
|
|
1933
|
+
"SOURCE_EVIDENCE_MISMATCH",
|
|
1934
|
+
"MISSING_SOURCE_SCOPE",
|
|
1935
|
+
"INTEGRATION_SCOPE_MISMATCH",
|
|
1936
|
+
"COMMIT_SCOPE_VIOLATION",
|
|
1937
|
+
"PATH_SCOPE_VIOLATION",
|
|
1938
|
+
"RECOVERY_REQUIRED",
|
|
1939
|
+
"QUARANTINED",
|
|
1940
|
+
"PUBLICATION_LEASE_DRIFT",
|
|
1941
|
+
"PUBLICATION_REMOTE_DRIFT",
|
|
1942
|
+
"PUBLICATION_LEASE_EXPIRED",
|
|
1943
|
+
"INTEGRATION_INTENT_DRIFT",
|
|
1944
|
+
"INVALID_RETIREMENT_EXECUTION_RECORD",
|
|
1945
|
+
"RETIREMENT_REMOVAL_UNATTESTED",
|
|
1946
|
+
}
|
|
1947
|
+
failed = bool(result["errors"]) or any(
|
|
1948
|
+
warning.get("severity") == "blocking"
|
|
1949
|
+
or (warning.get("severity") is None and warning["code"] in failure_codes)
|
|
1950
|
+
for warning in result["warnings"]
|
|
1951
|
+
)
|
|
1952
|
+
degraded = any(warning.get("severity") == "degraded" for warning in result["warnings"])
|
|
1953
|
+
result["decision"] = {
|
|
1954
|
+
"code": "FAIL" if failed else "DEGRADED" if degraded else "PASS",
|
|
1955
|
+
"primary_diagnosis": _primary_diagnosis(
|
|
1956
|
+
diagnosis, result["warnings"] + result["errors"], failed=failed
|
|
1957
|
+
),
|
|
1958
|
+
"reason": (
|
|
1959
|
+
"diagnostic findings require review"
|
|
1960
|
+
if failed
|
|
1961
|
+
else "non-blocking lifecycle findings remain"
|
|
1962
|
+
if degraded
|
|
1963
|
+
else "no blocking drift detected"
|
|
1964
|
+
),
|
|
1965
|
+
"transactions": {
|
|
1966
|
+
"historical_terminal": historical_transactions,
|
|
1967
|
+
"unresolved_count": transaction_count,
|
|
1968
|
+
},
|
|
1969
|
+
"retirement_executions": {
|
|
1970
|
+
"outstanding": retirement_grants,
|
|
1971
|
+
"unattested_removals": retirement_unattested,
|
|
1972
|
+
},
|
|
1973
|
+
}
|
|
1974
|
+
return result
|