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,150 @@
|
|
|
1
|
+
"""Fast-forward an ACTIVE, idle canonical checkout to its published main."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .canonical_transition import _check_target_policy, _published_policy
|
|
9
|
+
from .controller import envelope
|
|
10
|
+
from .git_backend import fetch_remote_branch, git_write, inventory, is_ancestor
|
|
11
|
+
from .models import ControllerError
|
|
12
|
+
from .policy import load_policy
|
|
13
|
+
from .semantics import record_for as _record_for
|
|
14
|
+
from .store import MetadataStore
|
|
15
|
+
from .transaction_scope import conflicts
|
|
16
|
+
from .writer_lease import ProcessPathScan, own_ancestry
|
|
17
|
+
|
|
18
|
+
CANONICAL_REFRESH_VERSION = 1
|
|
19
|
+
_PROC_ROOT = Path("/proc")
|
|
20
|
+
_PROCESS_LIMIT = 5
|
|
21
|
+
_PATH_LIMIT = 3
|
|
22
|
+
_PATH_LENGTH = 512
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _process_metadata(pid: int) -> dict[str, Any]:
|
|
26
|
+
"""Best-effort kernel identity, separate from the scan that already blocked refresh."""
|
|
27
|
+
unknown = {"name": "unknown", "parent_pid": None, "metadata_status": "unknown"}
|
|
28
|
+
try:
|
|
29
|
+
with (_PROC_ROOT / str(pid) / "stat").open(encoding="utf-8", errors="replace") as stream:
|
|
30
|
+
raw = stream.read(4097)
|
|
31
|
+
prefix, separator, suffix = raw.rpartition(")")
|
|
32
|
+
name = prefix.partition("(")[2]
|
|
33
|
+
if len(raw) > 4096 or not separator or not name:
|
|
34
|
+
return unknown
|
|
35
|
+
parent = int(suffix.split()[1])
|
|
36
|
+
if parent < 0:
|
|
37
|
+
return unknown
|
|
38
|
+
return {"name": name[:64], "parent_pid": parent, "metadata_status": "observed"}
|
|
39
|
+
except (OSError, ValueError, IndexError):
|
|
40
|
+
return unknown
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CanonicalProcessOccupancyError(ControllerError):
|
|
44
|
+
"""The ordinary refusal with bounded advisory metadata and a guarded retry hint."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, target: Path, used: list[tuple[int, list[str]]]) -> None:
|
|
47
|
+
shown = sorted(used)[:_PROCESS_LIMIT]
|
|
48
|
+
super().__init__(
|
|
49
|
+
"CANONICAL_IN_USE",
|
|
50
|
+
"live processes use the canonical checkout: "
|
|
51
|
+
+ ", ".join(str(pid) for pid, _paths in shown),
|
|
52
|
+
)
|
|
53
|
+
processes = []
|
|
54
|
+
for pid, paths in shown:
|
|
55
|
+
metadata = _process_metadata(pid)
|
|
56
|
+
parent = metadata["parent_pid"]
|
|
57
|
+
parent_name = _process_metadata(parent)["name"] if parent is not None else "unknown"
|
|
58
|
+
unique_paths = list(dict.fromkeys(paths))
|
|
59
|
+
processes.append({
|
|
60
|
+
"pid": pid,
|
|
61
|
+
**metadata,
|
|
62
|
+
"parent_name": parent_name,
|
|
63
|
+
"matched_paths": [path[:_PATH_LENGTH] for path in unique_paths[:_PATH_LIMIT]],
|
|
64
|
+
"paths_truncated": len(unique_paths) > _PATH_LIMIT
|
|
65
|
+
or any(len(path) > _PATH_LENGTH for path in unique_paths[:_PATH_LIMIT]),
|
|
66
|
+
})
|
|
67
|
+
self.diagnostics = {
|
|
68
|
+
"process_count": len(used),
|
|
69
|
+
"processes": processes,
|
|
70
|
+
"processes_omitted": len(used) - len(shown),
|
|
71
|
+
"recovery_steps": [
|
|
72
|
+
"Verify process ownership and the exact owning task/session; this metadata is advisory.",
|
|
73
|
+
"For tools owned by a shared daemon, use its native session inventory to check active work. "
|
|
74
|
+
"End only the exact completed task-owned session through its normal lifecycle; "
|
|
75
|
+
"closing the current CLI may not release those tools.",
|
|
76
|
+
"After references clear, run retry_argv. If CANONICAL_REFRESH_READY is returned, "
|
|
77
|
+
"repeat without --dry-run. Unknown metadata keeps the original refusal.",
|
|
78
|
+
],
|
|
79
|
+
"retry_argv": ["worktree-controller", "canonical-refresh", "--repo", str(target),
|
|
80
|
+
"--dry-run", "--json"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def refresh_canonical(repository_path: Path, *, dry_run: bool = False) -> dict[str, Any]:
|
|
85
|
+
"""Move canonical main forward to the fetched publication branch, or say why not.
|
|
86
|
+
|
|
87
|
+
Integrations publish from candidates, so the canonical checkout falls behind
|
|
88
|
+
published main. This only ever fast-forwards a clean checkout that is ACTIVE,
|
|
89
|
+
on the publication branch, holds no writer lease or binding and is not used by
|
|
90
|
+
any live process; a frozen canonical keeps the exact ``canonical-transition``
|
|
91
|
+
flow. Controller metadata is not changed.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
repository_initial = inventory(repository_path)
|
|
95
|
+
store = MetadataStore(repository_initial.common_dir)
|
|
96
|
+
with store.lock(exclusive=True):
|
|
97
|
+
repository = inventory(repository_path)
|
|
98
|
+
snapshot = store._load_unlocked()
|
|
99
|
+
policy = load_policy(repository.canonical_path)
|
|
100
|
+
if not policy.present or not policy.canonical_protected or policy.policy_schema_version < 2:
|
|
101
|
+
raise ControllerError("INVALID_POLICY", "canonical refresh requires a governed publication branch")
|
|
102
|
+
item = next((worktree for worktree in repository.worktrees if worktree.canonical), None)
|
|
103
|
+
record = _record_for(item, snapshot) if item is not None else None
|
|
104
|
+
if item is None or record is None:
|
|
105
|
+
raise ControllerError("UNMANAGED_CANONICAL", "canonical checkout has no Controller record")
|
|
106
|
+
if record.lifecycle != "ACTIVE":
|
|
107
|
+
raise ControllerError(
|
|
108
|
+
"CANONICAL_NOT_ACTIVE",
|
|
109
|
+
f"canonical lifecycle {record.lifecycle}; a frozen canonical uses canonical-transition",
|
|
110
|
+
)
|
|
111
|
+
if item.detached or item.branch != policy.publication_branch:
|
|
112
|
+
raise ControllerError("CANONICAL_BRANCH", "canonical is not on the publication branch")
|
|
113
|
+
if not item.clean or item.locked or item.prunable:
|
|
114
|
+
raise ControllerError("CANONICAL_DIRTY", "canonical checkout is not clean")
|
|
115
|
+
if record.allocation_id in snapshot.writer_leases or record.external_bindings:
|
|
116
|
+
raise ControllerError("CANONICAL_IN_USE", "canonical holds a writer lease or binding")
|
|
117
|
+
if conflicts(store, allocation=record.allocation_id, target=item.path, assume_serialized=True):
|
|
118
|
+
raise ControllerError("INTERRUPTED_TRANSACTION", "canonical refresh conflicts with a transaction")
|
|
119
|
+
ancestry = own_ancestry()
|
|
120
|
+
used = [entry for entry in ProcessPathScan().referenced_paths_in_use(item.path)
|
|
121
|
+
if entry[0] not in ancestry]
|
|
122
|
+
if used:
|
|
123
|
+
raise CanonicalProcessOccupancyError(item.path, used)
|
|
124
|
+
before = item.head
|
|
125
|
+
target_oid = fetch_remote_branch(item.path, policy.publication_remote, policy.publication_branch)
|
|
126
|
+
decision: dict[str, Any] = {
|
|
127
|
+
"path": str(item.path),
|
|
128
|
+
"branch": item.branch,
|
|
129
|
+
"before": before,
|
|
130
|
+
"target": target_oid,
|
|
131
|
+
"dry_run": dry_run,
|
|
132
|
+
}
|
|
133
|
+
if before == target_oid:
|
|
134
|
+
decision.update(code="CANONICAL_CURRENT", reason="canonical already equals published main")
|
|
135
|
+
elif not is_ancestor(item.path, before, target_oid):
|
|
136
|
+
raise ControllerError("NON_FAST_FORWARD", "canonical main is not an ancestor of published main")
|
|
137
|
+
elif dry_run:
|
|
138
|
+
decision.update(code="CANONICAL_REFRESH_READY", reason="canonical can fast-forward")
|
|
139
|
+
else:
|
|
140
|
+
# A published main that this Controller could not govern is refused first.
|
|
141
|
+
_check_target_policy(_published_policy(repository, target_oid, policy),
|
|
142
|
+
policy.publication_branch, policy.publication_remote)
|
|
143
|
+
git_write(item.path, ("merge", "--ff-only", "--no-edit", "--no-overwrite-ignore", target_oid))
|
|
144
|
+
after = next(worktree for worktree in inventory(item.path).worktrees if worktree.canonical)
|
|
145
|
+
if after.head != target_oid or not after.clean or after.branch != policy.publication_branch:
|
|
146
|
+
raise ControllerError("REFRESH_INCOMPLETE", "canonical did not reach exact published main")
|
|
147
|
+
decision.update(code="CANONICAL_REFRESHED", reason="canonical fast-forwarded to published main")
|
|
148
|
+
payload = envelope("canonical-refresh")
|
|
149
|
+
payload["decision"] = decision
|
|
150
|
+
return payload
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Move a frozen canonical checkout onto an exact published main commit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .controller import status
|
|
11
|
+
from .git_backend import (
|
|
12
|
+
_git,
|
|
13
|
+
fetch_remote_branch,
|
|
14
|
+
git_write,
|
|
15
|
+
head_paths,
|
|
16
|
+
inventory,
|
|
17
|
+
is_ancestor,
|
|
18
|
+
resolve_commit,
|
|
19
|
+
update_branch_ref,
|
|
20
|
+
validate_branch_name,
|
|
21
|
+
)
|
|
22
|
+
from .lifecycle import _updated_record
|
|
23
|
+
from .models import (
|
|
24
|
+
ControllerError,
|
|
25
|
+
MetadataRecord,
|
|
26
|
+
MetadataSnapshot,
|
|
27
|
+
RepositoryInventory,
|
|
28
|
+
WorktreeGitState,
|
|
29
|
+
WorktreePolicy,
|
|
30
|
+
)
|
|
31
|
+
from .policy import default_policy, load_policy, parse_policy
|
|
32
|
+
from .semantics import managed_record as _managed_record
|
|
33
|
+
from .store import MetadataStore
|
|
34
|
+
from .transaction_scope import conflicts
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_frozen(
|
|
38
|
+
repository: RepositoryInventory,
|
|
39
|
+
item: WorktreeGitState,
|
|
40
|
+
record: MetadataRecord,
|
|
41
|
+
snapshot: MetadataSnapshot,
|
|
42
|
+
*,
|
|
43
|
+
expected_branch: str,
|
|
44
|
+
expected_head: str,
|
|
45
|
+
owner: str,
|
|
46
|
+
target_branch: str,
|
|
47
|
+
target_oid: str,
|
|
48
|
+
expected_main_head: str,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not item.canonical or item.path != repository.canonical_path:
|
|
51
|
+
raise ControllerError("BLOCK_CANONICAL", "transition requires the canonical checkout")
|
|
52
|
+
if record.owner != owner:
|
|
53
|
+
raise ControllerError("OWNER_MISMATCH", "frozen canonical owner differs")
|
|
54
|
+
if record.lifecycle != "HANDOFF_READY" or record.role not in {"canonical", "control-plane"}:
|
|
55
|
+
raise ControllerError("INVALID_LIFECYCLE", "canonical transition requires a frozen handoff")
|
|
56
|
+
if not record.finalizer_record_id or any(
|
|
57
|
+
value != expected_head
|
|
58
|
+
for value in (record.handoff_commit, record.task_commit, record.finalizer_commit)
|
|
59
|
+
):
|
|
60
|
+
raise ControllerError("HANDOFF_IDENTITY_MISMATCH", "frozen handoff evidence differs")
|
|
61
|
+
if item.detached or item.locked or item.prunable or not item.clean:
|
|
62
|
+
raise ControllerError("DIRTY_WORKTREE", "canonical checkout is not clean and attached")
|
|
63
|
+
refresh_main = expected_branch == target_branch
|
|
64
|
+
starting_head = expected_main_head if refresh_main else expected_head
|
|
65
|
+
if (item.branch, item.head) not in {
|
|
66
|
+
(expected_branch, starting_head),
|
|
67
|
+
(target_branch, target_oid),
|
|
68
|
+
}:
|
|
69
|
+
raise ControllerError("HEAD_MISMATCH", "canonical branch and HEAD disagree")
|
|
70
|
+
retained_head = resolve_commit(item.path, f"refs/heads/{expected_branch}")
|
|
71
|
+
if retained_head not in ({expected_main_head, target_oid} if refresh_main else {expected_head}):
|
|
72
|
+
raise ControllerError("HEAD_MISMATCH", "frozen branch no longer retains its handoff")
|
|
73
|
+
if record.external_bindings or any(
|
|
74
|
+
resource["state"] != "cleaned" for resource in record.disposable_resources
|
|
75
|
+
):
|
|
76
|
+
raise ControllerError("ACTIVE_BINDING", "canonical transition has a resource binding")
|
|
77
|
+
if record.source_allocations or record.quarantine_expected_commit or record.recovery_ref:
|
|
78
|
+
raise ControllerError(
|
|
79
|
+
"ACTIVE_LINEAGE", "canonical transition cannot discard recovery lineage"
|
|
80
|
+
)
|
|
81
|
+
if snapshot.publication_lease is not None or record.allocation_id in snapshot.writer_leases:
|
|
82
|
+
raise ControllerError("ACTIVE_LEASE", "canonical transition conflicts with an active lease")
|
|
83
|
+
if any(
|
|
84
|
+
record.allocation_id in intent.source_allocations
|
|
85
|
+
and intent.state not in {"RELEASED", "ABANDONED"}
|
|
86
|
+
for intent in snapshot.integration_intents.values()
|
|
87
|
+
) or any(
|
|
88
|
+
intent.allocation_id == record.allocation_id for intent in snapshot.release_intents.values()
|
|
89
|
+
):
|
|
90
|
+
raise ControllerError(
|
|
91
|
+
"ACTIVE_INTENT", "canonical transition conflicts with an active intent"
|
|
92
|
+
)
|
|
93
|
+
if any(
|
|
94
|
+
other.path != item.path and other.branch == target_branch for other in repository.worktrees
|
|
95
|
+
):
|
|
96
|
+
raise ControllerError("BRANCH_OCCUPIED", "target main branch is checked out elsewhere")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _published_policy(
|
|
100
|
+
repository: RepositoryInventory, target_oid: str, current: WorktreePolicy
|
|
101
|
+
) -> WorktreePolicy:
|
|
102
|
+
relative = ".agents/worktree-policy.toml"
|
|
103
|
+
entries = _git(
|
|
104
|
+
repository.canonical_path, "ls-tree", "-z", target_oid, "--", relative
|
|
105
|
+
).stdout.split(b"\0")
|
|
106
|
+
entries = [entry for entry in entries if entry]
|
|
107
|
+
if not entries:
|
|
108
|
+
parent = _git(repository.canonical_path, "ls-tree", target_oid, "--", ".agents").stdout
|
|
109
|
+
if parent and not parent.startswith(b"040000 tree "):
|
|
110
|
+
raise ControllerError("INVALID_POLICY", "published policy parent is not a directory")
|
|
111
|
+
# An untracked local policy survives a fast-forward; a tracked deletion does not.
|
|
112
|
+
return (
|
|
113
|
+
default_policy(repository.canonical_path)
|
|
114
|
+
if head_paths(repository.canonical_path, relative)
|
|
115
|
+
else current
|
|
116
|
+
)
|
|
117
|
+
header, separator, name = entries[0].partition(b"\t")
|
|
118
|
+
fields = header.split()
|
|
119
|
+
if (
|
|
120
|
+
len(entries) != 1
|
|
121
|
+
or not separator
|
|
122
|
+
or name != relative.encode()
|
|
123
|
+
or len(fields) != 3
|
|
124
|
+
or fields[0] not in {b"100644", b"100755"}
|
|
125
|
+
or fields[1] != b"blob"
|
|
126
|
+
):
|
|
127
|
+
raise ControllerError("INVALID_POLICY", "published policy is not a regular file")
|
|
128
|
+
raw = _git(repository.canonical_path, "cat-file", "blob", fields[2].decode()).stdout
|
|
129
|
+
try:
|
|
130
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
132
|
+
raise ControllerError("INVALID_POLICY", f"cannot read published policy: {exc}") from exc
|
|
133
|
+
return parse_policy(repository.canonical_path, data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_target_policy(policy: WorktreePolicy, branch: str, remote: str) -> None:
|
|
137
|
+
if (
|
|
138
|
+
not policy.present
|
|
139
|
+
or not policy.canonical_protected
|
|
140
|
+
or policy.publication_branch != branch
|
|
141
|
+
or policy.publication_remote != remote
|
|
142
|
+
or "control-plane" not in policy.canonical_allowed_roles
|
|
143
|
+
):
|
|
144
|
+
raise ControllerError("INVALID_TARGET_POLICY", "published main has incompatible policy")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def transition_canonical(
|
|
148
|
+
repository_path: Path,
|
|
149
|
+
*,
|
|
150
|
+
target: Path,
|
|
151
|
+
expected_record_version: int,
|
|
152
|
+
owner: str,
|
|
153
|
+
expected_branch: str,
|
|
154
|
+
expected_head: str,
|
|
155
|
+
expected_main_head: str,
|
|
156
|
+
expected_remote_head: str,
|
|
157
|
+
new_owner: str,
|
|
158
|
+
new_task_key: str,
|
|
159
|
+
new_authority_key: str,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""Fast-forward local main and rebind canonical authority without releasing its path.
|
|
162
|
+
|
|
163
|
+
A retry can finish a checkout that reached the exact target before metadata was
|
|
164
|
+
written. A frozen publication branch is refreshed in place and retains its
|
|
165
|
+
handoff evidence; other frozen branches retain both their ref and evidence.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
if not all(
|
|
169
|
+
value.strip() and "\x00" not in value
|
|
170
|
+
for value in (owner, new_owner, new_task_key, new_authority_key)
|
|
171
|
+
):
|
|
172
|
+
raise ControllerError("INVALID_IDENTITY", "transition identities must be nonempty")
|
|
173
|
+
repository_initial = inventory(repository_path)
|
|
174
|
+
store = MetadataStore(repository_initial.common_dir)
|
|
175
|
+
with store.lock(exclusive=True):
|
|
176
|
+
repository = inventory(repository_path)
|
|
177
|
+
snapshot = store._load_unlocked()
|
|
178
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
179
|
+
if record.record_version != expected_record_version:
|
|
180
|
+
raise ControllerError("CAS_MISMATCH", "canonical record version changed")
|
|
181
|
+
if any(
|
|
182
|
+
other.allocation_id != record.allocation_id
|
|
183
|
+
and other.lifecycle not in {"RELEASED", "ARCHIVED"}
|
|
184
|
+
and (other.task_key == new_task_key or other.authority_key == new_authority_key)
|
|
185
|
+
for other in snapshot.records.values()
|
|
186
|
+
):
|
|
187
|
+
raise ControllerError(
|
|
188
|
+
"AUTHORITY_CONFLICT", "new control-plane identity is already active"
|
|
189
|
+
)
|
|
190
|
+
policy = load_policy(repository.canonical_path)
|
|
191
|
+
if (
|
|
192
|
+
not policy.present
|
|
193
|
+
or not policy.canonical_protected
|
|
194
|
+
or policy.publication_branch not in {"main", "master"}
|
|
195
|
+
):
|
|
196
|
+
raise ControllerError(
|
|
197
|
+
"INVALID_POLICY", "canonical transition requires governed main or master"
|
|
198
|
+
)
|
|
199
|
+
remote = policy.publication_remote
|
|
200
|
+
main_branch = policy.publication_branch
|
|
201
|
+
validate_branch_name(item.path, expected_branch)
|
|
202
|
+
refresh_main = expected_branch == main_branch
|
|
203
|
+
if len(expected_remote_head) != 40 or any(
|
|
204
|
+
character not in "0123456789abcdef" for character in expected_remote_head
|
|
205
|
+
):
|
|
206
|
+
raise ControllerError(
|
|
207
|
+
"INVALID_COMMIT", "expected remote HEAD must be a full commit OID"
|
|
208
|
+
)
|
|
209
|
+
source_oid = resolve_commit(item.path, expected_head)
|
|
210
|
+
original_main = resolve_commit(item.path, expected_main_head)
|
|
211
|
+
if (source_oid, original_main) != (expected_head, expected_main_head):
|
|
212
|
+
raise ControllerError("INVALID_COMMIT", "transition requires full commit OIDs")
|
|
213
|
+
_check_frozen(
|
|
214
|
+
repository,
|
|
215
|
+
item,
|
|
216
|
+
record,
|
|
217
|
+
snapshot,
|
|
218
|
+
expected_branch=expected_branch,
|
|
219
|
+
expected_head=expected_head,
|
|
220
|
+
owner=owner,
|
|
221
|
+
target_branch=main_branch,
|
|
222
|
+
target_oid=expected_remote_head,
|
|
223
|
+
expected_main_head=original_main,
|
|
224
|
+
)
|
|
225
|
+
if conflicts(
|
|
226
|
+
store, allocation=record.allocation_id, target=item.path, assume_serialized=True
|
|
227
|
+
):
|
|
228
|
+
raise ControllerError(
|
|
229
|
+
"INTERRUPTED_TRANSACTION", "canonical transition conflicts with a transaction"
|
|
230
|
+
)
|
|
231
|
+
target_oid = fetch_remote_branch(item.path, remote, main_branch)
|
|
232
|
+
if target_oid != expected_remote_head:
|
|
233
|
+
raise ControllerError(
|
|
234
|
+
"REMOTE_DRIFT", "remote main differs from the exact expected commit"
|
|
235
|
+
)
|
|
236
|
+
if (
|
|
237
|
+
not is_ancestor(item.path, source_oid, target_oid)
|
|
238
|
+
or not is_ancestor(item.path, original_main, target_oid)
|
|
239
|
+
or (refresh_main and not is_ancestor(item.path, source_oid, original_main))
|
|
240
|
+
):
|
|
241
|
+
raise ControllerError(
|
|
242
|
+
"NON_FAST_FORWARD", "remote main does not contain frozen and local main commits"
|
|
243
|
+
)
|
|
244
|
+
local_main = resolve_commit(item.path, f"refs/heads/{main_branch}")
|
|
245
|
+
if local_main not in {original_main, target_oid}:
|
|
246
|
+
raise ControllerError("MAIN_DRIFT", "local main changed outside transition bounds")
|
|
247
|
+
if refresh_main:
|
|
248
|
+
# Validate the immutable target before moving a checked-out publication ref.
|
|
249
|
+
_check_target_policy(_published_policy(repository, target_oid, policy), main_branch, remote)
|
|
250
|
+
if local_main != target_oid:
|
|
251
|
+
git_write(
|
|
252
|
+
item.path, ("merge", "--ff-only", "--no-edit", "--no-overwrite-ignore", target_oid)
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
if local_main == original_main and original_main != target_oid:
|
|
256
|
+
update_branch_ref(item.path, main_branch, expected=original_main, new=target_oid)
|
|
257
|
+
if item.branch == expected_branch:
|
|
258
|
+
git_write(item.path, ("checkout", "--no-overwrite-ignore", main_branch))
|
|
259
|
+
try:
|
|
260
|
+
after = inventory(item.path)
|
|
261
|
+
current = next(worktree for worktree in after.worktrees if worktree.canonical)
|
|
262
|
+
if current.branch != main_branch or current.head != target_oid or not current.clean:
|
|
263
|
+
raise ControllerError(
|
|
264
|
+
"TRANSITION_INCOMPLETE", "canonical checkout did not reach exact main"
|
|
265
|
+
)
|
|
266
|
+
target_policy = load_policy(after.canonical_path)
|
|
267
|
+
_check_target_policy(target_policy, main_branch, remote)
|
|
268
|
+
if fetch_remote_branch(item.path, remote, main_branch) != target_oid:
|
|
269
|
+
raise ControllerError(
|
|
270
|
+
"REMOTE_DRIFT", "remote main moved during canonical transition"
|
|
271
|
+
)
|
|
272
|
+
except ControllerError:
|
|
273
|
+
# An in-place advance stays frozen for exact recovery; never rewind main.
|
|
274
|
+
if not refresh_main and item.branch == expected_branch:
|
|
275
|
+
git_write(item.path, ("checkout", "--no-overwrite-ignore", expected_branch))
|
|
276
|
+
raise
|
|
277
|
+
updated = _updated_record(
|
|
278
|
+
record,
|
|
279
|
+
"CANONICAL_MAIN_REFRESHED" if refresh_main else "CANONICAL_TRANSITIONED",
|
|
280
|
+
lifecycle="ACTIVE",
|
|
281
|
+
owner=new_owner,
|
|
282
|
+
task_key=new_task_key,
|
|
283
|
+
authority_key=new_authority_key,
|
|
284
|
+
module="repository",
|
|
285
|
+
role="control-plane",
|
|
286
|
+
base_commit=target_oid,
|
|
287
|
+
task_commit=target_oid,
|
|
288
|
+
handoff_commit=None,
|
|
289
|
+
finalizer_commit=None,
|
|
290
|
+
finalizer_record_id=None,
|
|
291
|
+
scope_paths=(),
|
|
292
|
+
source_allocations=(),
|
|
293
|
+
)
|
|
294
|
+
event = {
|
|
295
|
+
**updated.history[-1],
|
|
296
|
+
"old_branch": expected_branch,
|
|
297
|
+
"old_head": source_oid,
|
|
298
|
+
"old_owner": owner,
|
|
299
|
+
"old_task_key": record.task_key,
|
|
300
|
+
"old_authority_key": record.authority_key,
|
|
301
|
+
"old_finalizer_record_id": record.finalizer_record_id,
|
|
302
|
+
"old_scope_paths": list(record.scope_paths),
|
|
303
|
+
"main_before": original_main,
|
|
304
|
+
"main_after": target_oid,
|
|
305
|
+
"remote": remote,
|
|
306
|
+
}
|
|
307
|
+
updated = replace(updated, history=(*updated.history[:-1], event))
|
|
308
|
+
store.replace_record(updated)
|
|
309
|
+
payload = status(repository_path)
|
|
310
|
+
payload["command"] = "canonical-transition"
|
|
311
|
+
payload["decision"] = {
|
|
312
|
+
"code": "CANONICAL_MAIN_REFRESHED" if refresh_main else "CANONICAL_TRANSITIONED",
|
|
313
|
+
"allocation_id": updated.allocation_id,
|
|
314
|
+
"record_version": updated.record_version,
|
|
315
|
+
"old_branch": expected_branch,
|
|
316
|
+
"old_head": source_oid,
|
|
317
|
+
"main_head": target_oid,
|
|
318
|
+
"reason": (
|
|
319
|
+
"exact remote main verified; frozen handoff history retained and canonical authority moved"
|
|
320
|
+
if refresh_main else
|
|
321
|
+
"exact remote main verified; frozen branch retained and canonical authority moved"
|
|
322
|
+
),
|
|
323
|
+
}
|
|
324
|
+
return payload
|