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,171 @@
|
|
|
1
|
+
"""Narrow recovery of an archived Codex thread on a still-live shared daemon.
|
|
2
|
+
|
|
3
|
+
The normal dead-process gate stays unchanged. This opt-in path observes the native
|
|
4
|
+
server, never archives a thread, stops a process, or accepts caller-authored status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import stat
|
|
14
|
+
import struct
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .environment import runtime_authority_evidence
|
|
21
|
+
from .models import ControllerError, WriterHolder
|
|
22
|
+
from .writer_lease import holder_is_live
|
|
23
|
+
|
|
24
|
+
CONTRACT = "archived-codex-binding-recovery/v1"
|
|
25
|
+
_MAX_MESSAGE = 2_000_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _blocked(reason: str) -> ControllerError:
|
|
29
|
+
return ControllerError("NATIVE_SESSION_RECOVERY_REFUSED", reason)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _shared_daemon(holder: WriterHolder) -> None:
|
|
33
|
+
if holder.client_id not in {"codex", "codex-cli"} or not holder.session_id:
|
|
34
|
+
raise _blocked("an exact Codex session identity is required")
|
|
35
|
+
if not holder_is_live(holder):
|
|
36
|
+
raise _blocked("use ordinary binding removal for an exited process")
|
|
37
|
+
proc = Path("/proc") / str(holder.pid)
|
|
38
|
+
arguments = (proc / "cmdline").read_bytes().split(b"\0")
|
|
39
|
+
if (
|
|
40
|
+
proc.stat().st_uid != os.getuid()
|
|
41
|
+
or b"app-server" not in arguments
|
|
42
|
+
or b"--managed-daemon" not in arguments
|
|
43
|
+
):
|
|
44
|
+
raise _blocked("the holder is not the same user's managed shared Codex daemon")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _native_observation(holder: WriterHolder) -> dict[str, Any]:
|
|
48
|
+
# Lazy import keeps ordinary Controller observations usable without a transport.
|
|
49
|
+
from websockets.sync.client import unix_connect
|
|
50
|
+
|
|
51
|
+
path = Path.home() / ".codex/app-server-control/app-server-control.sock"
|
|
52
|
+
if path.lstat().st_uid != os.getuid():
|
|
53
|
+
raise _blocked("native control socket link authority is invalid")
|
|
54
|
+
# Codex's stable entry is a symlink to its private Unix socket. Pin the
|
|
55
|
+
# resolved endpoint, then verify the kernel peer against the recorded holder.
|
|
56
|
+
path = path.resolve(strict=True)
|
|
57
|
+
info = path.lstat()
|
|
58
|
+
if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid():
|
|
59
|
+
raise _blocked("native control socket authority is invalid")
|
|
60
|
+
with unix_connect(
|
|
61
|
+
str(path), uri="ws://localhost/", open_timeout=5, close_timeout=1, max_size=_MAX_MESSAGE
|
|
62
|
+
) as client:
|
|
63
|
+
pid, uid, _gid = struct.unpack(
|
|
64
|
+
"3i", client.socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
|
65
|
+
)
|
|
66
|
+
if pid != holder.pid or uid != os.getuid():
|
|
67
|
+
raise _blocked("native socket peer differs from the recorded holder")
|
|
68
|
+
|
|
69
|
+
def call(number: int, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
client.send(json.dumps({"id": number, "method": method, "params": params}))
|
|
71
|
+
deadline = time.monotonic() + 5
|
|
72
|
+
for _ in range(100):
|
|
73
|
+
remaining = deadline - time.monotonic()
|
|
74
|
+
if remaining <= 0:
|
|
75
|
+
raise _blocked("native observation timed out")
|
|
76
|
+
response = json.loads(client.recv(timeout=remaining))
|
|
77
|
+
if not isinstance(response, dict):
|
|
78
|
+
raise _blocked("native response is not an object")
|
|
79
|
+
if response.get("id") == number:
|
|
80
|
+
result = response.get("result")
|
|
81
|
+
if not isinstance(result, dict) or "error" in response:
|
|
82
|
+
raise _blocked("native observation was refused")
|
|
83
|
+
return result
|
|
84
|
+
raise _blocked("native observation exceeded its notification bound")
|
|
85
|
+
|
|
86
|
+
call(
|
|
87
|
+
1,
|
|
88
|
+
"initialize",
|
|
89
|
+
{"clientInfo": {"name": "worktree_controller_recovery", "version": "1"}},
|
|
90
|
+
)
|
|
91
|
+
client.send(json.dumps({"method": "initialized", "params": {}}))
|
|
92
|
+
thread = call(2, "thread/read", {"threadId": holder.session_id, "includeTurns": False}).get(
|
|
93
|
+
"thread"
|
|
94
|
+
)
|
|
95
|
+
loaded = call(3, "thread/loaded/list", {}).get("data")
|
|
96
|
+
if not isinstance(thread, dict) or not isinstance(loaded, list):
|
|
97
|
+
raise _blocked("native thread observation is incomplete")
|
|
98
|
+
return {"thread": thread, "loaded": loaded, "peer_pid": pid}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _archived_thread(holder: WriterHolder, observation: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
thread, loaded = observation["thread"], observation["loaded"]
|
|
103
|
+
if (
|
|
104
|
+
thread.get("id") != holder.session_id
|
|
105
|
+
or thread.get("status") != {"type": "notLoaded"}
|
|
106
|
+
or holder.session_id in loaded
|
|
107
|
+
):
|
|
108
|
+
raise _blocked("the exact native thread must be unloaded")
|
|
109
|
+
archive = Path.home() / ".codex/archived_sessions"
|
|
110
|
+
raw_path = thread.get("path")
|
|
111
|
+
if not isinstance(raw_path, str):
|
|
112
|
+
raise _blocked("native archived rollout identity is missing")
|
|
113
|
+
path = Path(raw_path)
|
|
114
|
+
if not path.is_absolute() or not path.is_relative_to(archive):
|
|
115
|
+
raise _blocked("the exact native thread has not been archived")
|
|
116
|
+
if path.resolve(strict=True) != path or path.lstat().st_uid != os.getuid():
|
|
117
|
+
raise _blocked("native archived rollout authority is invalid")
|
|
118
|
+
if not stat.S_ISREG(path.lstat().st_mode):
|
|
119
|
+
raise _blocked("native archived rollout is not a regular file")
|
|
120
|
+
with path.open("rb") as stream:
|
|
121
|
+
first = stream.readline(262_145)
|
|
122
|
+
if len(first) > 262_144:
|
|
123
|
+
raise _blocked("native session metadata exceeds its bound")
|
|
124
|
+
record = json.loads(first)
|
|
125
|
+
if (
|
|
126
|
+
not isinstance(record, dict)
|
|
127
|
+
or record.get("type") != "session_meta"
|
|
128
|
+
or not isinstance(record.get("payload"), dict)
|
|
129
|
+
or record["payload"].get("id") != holder.session_id
|
|
130
|
+
):
|
|
131
|
+
raise _blocked("native archived session metadata identity differs")
|
|
132
|
+
return {
|
|
133
|
+
"thread_id": holder.session_id,
|
|
134
|
+
"status": "notLoaded",
|
|
135
|
+
"archived_rollout": str(path),
|
|
136
|
+
"session_metadata_sha256": hashlib.sha256(first).hexdigest(),
|
|
137
|
+
"other_loaded_thread_count": len(loaded),
|
|
138
|
+
"peer_pid": observation["peer_pid"],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def observe_archived_session(target: Path, holder: WriterHolder) -> dict[str, Any]:
|
|
143
|
+
"""Prove a native terminal boundary and no remaining references to this checkout."""
|
|
144
|
+
try:
|
|
145
|
+
_shared_daemon(holder)
|
|
146
|
+
native = _archived_thread(holder, _native_observation(holder))
|
|
147
|
+
references = runtime_authority_evidence(target)
|
|
148
|
+
if any(
|
|
149
|
+
references[key]
|
|
150
|
+
for key in (
|
|
151
|
+
"managed_references",
|
|
152
|
+
"observed_references",
|
|
153
|
+
"managed_visibility_uncertainties",
|
|
154
|
+
"scan_uncertainties",
|
|
155
|
+
)
|
|
156
|
+
):
|
|
157
|
+
raise _blocked("checkout use or uncertain runtime references remain")
|
|
158
|
+
_shared_daemon(holder)
|
|
159
|
+
return {
|
|
160
|
+
"contract": CONTRACT,
|
|
161
|
+
"holder": asdict(holder),
|
|
162
|
+
"native": native,
|
|
163
|
+
"runtime_authority": references,
|
|
164
|
+
"processes_stopped": 0,
|
|
165
|
+
"threads_archived": 0,
|
|
166
|
+
}
|
|
167
|
+
except ControllerError:
|
|
168
|
+
raise
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
# No native response, rollout contents, connection details or credentials in errors.
|
|
171
|
+
raise _blocked(f"native recovery observation failed ({type(exc).__name__})") from None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Structured requests for the recorded task owner; never execution authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
CONTRACT = "checkout-owner-action/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def owner_action(repository_path: str, item: dict[str, Any], *, reason: str,
|
|
13
|
+
required_actions: tuple[str, ...]) -> dict[str, Any]:
|
|
14
|
+
"""Bind a continuation request to the exact allocation, owner, HEAD and CAS.
|
|
15
|
+
|
|
16
|
+
A Task consumer must match its existing binding and re-observe these facts
|
|
17
|
+
before restoring its owner. The request itself grants no writer or cleanup.
|
|
18
|
+
"""
|
|
19
|
+
request = {
|
|
20
|
+
"contract": CONTRACT,
|
|
21
|
+
"repository_path": repository_path,
|
|
22
|
+
**{key: item.get(key) for key in
|
|
23
|
+
("allocation_id", "task_key", "authority_key", "owner", "path", "branch", "head")},
|
|
24
|
+
"expected_record_version": item.get("record_version"),
|
|
25
|
+
"reason": reason,
|
|
26
|
+
"required_actions": list(required_actions),
|
|
27
|
+
"automatic_discard": False,
|
|
28
|
+
"execution_authority": False,
|
|
29
|
+
}
|
|
30
|
+
request["action_id"] = hashlib.sha256(
|
|
31
|
+
json.dumps(request, sort_keys=True, separators=(",", ":")).encode()
|
|
32
|
+
).hexdigest()
|
|
33
|
+
request["inspect_argv"] = ["worktree-controller", "status", "--repo", repository_path, "--json"]
|
|
34
|
+
return request
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Load and validate repository worktree policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .models import CANONICAL_ROLES, ControllerError, ConvergencePolicy, WorktreePolicy
|
|
10
|
+
|
|
11
|
+
EXPECTED_TOP_LEVEL = {
|
|
12
|
+
"schema_version",
|
|
13
|
+
"limits",
|
|
14
|
+
"canonical",
|
|
15
|
+
"slots",
|
|
16
|
+
"review",
|
|
17
|
+
"authority",
|
|
18
|
+
}
|
|
19
|
+
EXPECTED_TOP_LEVEL_V2 = EXPECTED_TOP_LEVEL | {"publication"}
|
|
20
|
+
EXPECTED_TOP_LEVEL_V3 = EXPECTED_TOP_LEVEL_V2 | {"release"}
|
|
21
|
+
EXPECTED_TOP_LEVEL_V4 = EXPECTED_TOP_LEVEL_V3
|
|
22
|
+
EXPECTED_TOP_LEVEL_V5 = EXPECTED_TOP_LEVEL_V4
|
|
23
|
+
EXPECTED_TOP_LEVEL_V6 = EXPECTED_TOP_LEVEL_V5 | {"reconciler"}
|
|
24
|
+
EXPECTED_TOP_LEVEL_V7 = EXPECTED_TOP_LEVEL_V6 | {"admission"}
|
|
25
|
+
EXPECTED_TOP_LEVEL_V8 = EXPECTED_TOP_LEVEL_V7 | {"convergence"}
|
|
26
|
+
CONVERGENCE_FIELDS = {
|
|
27
|
+
"enabled",
|
|
28
|
+
"grace_minutes",
|
|
29
|
+
"idle_hours_max",
|
|
30
|
+
"idle_hours_min",
|
|
31
|
+
"standing_reserve",
|
|
32
|
+
}
|
|
33
|
+
# Optional [convergence] switches; an absent switch keeps the conservative default.
|
|
34
|
+
CONVERGENCE_OPTIONAL_FIELDS = {"release_unintegrated_handoffs"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _table(data: dict[str, Any], name: str) -> dict[str, Any]:
|
|
38
|
+
value = data.get(name)
|
|
39
|
+
if not isinstance(value, dict):
|
|
40
|
+
raise ControllerError("INVALID_POLICY", f"policy table [{name}] is required")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _positive_int(table: dict[str, Any], name: str) -> int:
|
|
45
|
+
value = table.get(name)
|
|
46
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
47
|
+
raise ControllerError("INVALID_POLICY", f"policy {name} must be a positive integer")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _nonempty_string(table: dict[str, Any], name: str) -> str:
|
|
52
|
+
value = table.get(name)
|
|
53
|
+
if not isinstance(value, str) or not value.strip() or "\x00" in value:
|
|
54
|
+
raise ControllerError("INVALID_POLICY", f"policy {name} must be a non-empty string")
|
|
55
|
+
return value.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _boolean(table: dict[str, Any], name: str) -> bool:
|
|
59
|
+
value = table.get(name)
|
|
60
|
+
if not isinstance(value, bool):
|
|
61
|
+
raise ControllerError("INVALID_POLICY", f"policy {name} must be boolean")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_policy(canonical_path: Path) -> WorktreePolicy:
|
|
66
|
+
return WorktreePolicy(
|
|
67
|
+
path=canonical_path / ".agents" / "worktree-policy.toml",
|
|
68
|
+
present=False,
|
|
69
|
+
policy_schema_version=1,
|
|
70
|
+
normal_limit=5,
|
|
71
|
+
hard_limit=6,
|
|
72
|
+
canonical_protected=True,
|
|
73
|
+
canonical_allowed_roles=CANONICAL_ROLES,
|
|
74
|
+
integration_candidate_limit=1,
|
|
75
|
+
integration_prepare_limit=1,
|
|
76
|
+
feature_audit_slots=3,
|
|
77
|
+
emergency_slots=1,
|
|
78
|
+
recovery_reserve=0,
|
|
79
|
+
retained_slots=0,
|
|
80
|
+
reconciler_enabled=False,
|
|
81
|
+
reconciler_actor="lifecycle-reconciler",
|
|
82
|
+
reconciler_legacy_adoption=False,
|
|
83
|
+
main_publish_limit=1,
|
|
84
|
+
publication_remote="origin",
|
|
85
|
+
publication_branch="main",
|
|
86
|
+
automatic_feature_audit_release=False,
|
|
87
|
+
automatic_integration_continuation=False,
|
|
88
|
+
preserved_auto_release=False,
|
|
89
|
+
release_intent_ttl_seconds=3600,
|
|
90
|
+
stale_warning_days=7,
|
|
91
|
+
require_adoption=True,
|
|
92
|
+
require_task_key=True,
|
|
93
|
+
require_authority_key=True,
|
|
94
|
+
unique_active_feature_authority=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_policy(canonical_path: Path) -> WorktreePolicy:
|
|
99
|
+
path = canonical_path / ".agents" / "worktree-policy.toml"
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return default_policy(canonical_path)
|
|
102
|
+
if not path.is_file() or path.is_symlink():
|
|
103
|
+
raise ControllerError("INVALID_POLICY", f"policy is not a regular file: {path}")
|
|
104
|
+
try:
|
|
105
|
+
with path.open("rb") as handle:
|
|
106
|
+
data = tomllib.load(handle)
|
|
107
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
108
|
+
raise ControllerError("INVALID_POLICY", f"cannot read policy: {exc}") from exc
|
|
109
|
+
return parse_policy(canonical_path, data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_policy(canonical_path: Path, data: dict[str, Any]) -> WorktreePolicy:
|
|
113
|
+
"""Validate policy data read from the checkout or a pinned publication tree."""
|
|
114
|
+
path = canonical_path / ".agents" / "worktree-policy.toml"
|
|
115
|
+
schema_version = data.get("schema_version")
|
|
116
|
+
if schema_version == 1:
|
|
117
|
+
if set(data) != EXPECTED_TOP_LEVEL:
|
|
118
|
+
raise ControllerError("INVALID_POLICY", "policy schema v1 fields are invalid")
|
|
119
|
+
elif schema_version == 2:
|
|
120
|
+
if set(data) != EXPECTED_TOP_LEVEL_V2:
|
|
121
|
+
raise ControllerError("INVALID_POLICY", "policy schema v2 fields are invalid")
|
|
122
|
+
elif schema_version == 3:
|
|
123
|
+
if set(data) != EXPECTED_TOP_LEVEL_V3:
|
|
124
|
+
raise ControllerError("INVALID_POLICY", "policy schema v3 fields are invalid")
|
|
125
|
+
elif schema_version == 4:
|
|
126
|
+
if set(data) != EXPECTED_TOP_LEVEL_V4:
|
|
127
|
+
raise ControllerError("INVALID_POLICY", "policy schema v4 fields are invalid")
|
|
128
|
+
elif schema_version == 5:
|
|
129
|
+
if set(data) != EXPECTED_TOP_LEVEL_V5:
|
|
130
|
+
raise ControllerError("INVALID_POLICY", "policy schema v5 fields are invalid")
|
|
131
|
+
elif schema_version == 6:
|
|
132
|
+
if set(data) != EXPECTED_TOP_LEVEL_V6:
|
|
133
|
+
raise ControllerError("INVALID_POLICY", "policy schema v6 fields are invalid")
|
|
134
|
+
elif schema_version == 7:
|
|
135
|
+
if set(data) != EXPECTED_TOP_LEVEL_V7:
|
|
136
|
+
raise ControllerError("INVALID_POLICY", "policy schema v7 fields are invalid")
|
|
137
|
+
elif schema_version == 8:
|
|
138
|
+
if set(data) != EXPECTED_TOP_LEVEL_V8:
|
|
139
|
+
raise ControllerError("INVALID_POLICY", "policy schema v8 fields are invalid")
|
|
140
|
+
else:
|
|
141
|
+
raise ControllerError("INVALID_POLICY", "unsupported policy schema version")
|
|
142
|
+
|
|
143
|
+
limits = _table(data, "limits")
|
|
144
|
+
canonical = _table(data, "canonical")
|
|
145
|
+
slots = _table(data, "slots")
|
|
146
|
+
review = _table(data, "review")
|
|
147
|
+
authority = _table(data, "authority")
|
|
148
|
+
if set(limits) != {"normal_limit", "hard_limit"}:
|
|
149
|
+
raise ControllerError("INVALID_POLICY", "policy [limits] fields are invalid")
|
|
150
|
+
if set(canonical) != {"protected", "allowed_roles"}:
|
|
151
|
+
raise ControllerError("INVALID_POLICY", "policy [canonical] fields are invalid")
|
|
152
|
+
expected_slots = (
|
|
153
|
+
{"integration", "feature_or_audit", "emergency"}
|
|
154
|
+
if schema_version == 1
|
|
155
|
+
else {
|
|
156
|
+
("integration_prepare_limit" if schema_version >= 4 else "integration_candidate_limit"),
|
|
157
|
+
"feature_or_audit",
|
|
158
|
+
"emergency",
|
|
159
|
+
"recovery_reserve",
|
|
160
|
+
}
|
|
161
|
+
| ({"retained"} if schema_version >= 5 else set())
|
|
162
|
+
)
|
|
163
|
+
if set(slots) != expected_slots:
|
|
164
|
+
raise ControllerError("INVALID_POLICY", "policy [slots] fields are invalid")
|
|
165
|
+
if set(review) != {"stale_warning_days"}:
|
|
166
|
+
raise ControllerError("INVALID_POLICY", "policy [review] fields are invalid")
|
|
167
|
+
if set(authority) != {
|
|
168
|
+
"require_adoption",
|
|
169
|
+
"require_task_key",
|
|
170
|
+
"require_authority_key",
|
|
171
|
+
"unique_active_feature_authority",
|
|
172
|
+
}:
|
|
173
|
+
raise ControllerError("INVALID_POLICY", "policy [authority] fields are invalid")
|
|
174
|
+
|
|
175
|
+
normal_limit = _positive_int(limits, "normal_limit")
|
|
176
|
+
hard_limit = _positive_int(limits, "hard_limit")
|
|
177
|
+
if normal_limit >= hard_limit:
|
|
178
|
+
raise ControllerError("INVALID_POLICY", "normal_limit must be below hard_limit")
|
|
179
|
+
allowed_roles = canonical.get("allowed_roles")
|
|
180
|
+
if (
|
|
181
|
+
not isinstance(allowed_roles, list)
|
|
182
|
+
or not allowed_roles
|
|
183
|
+
or not all(isinstance(item, str) and item for item in allowed_roles)
|
|
184
|
+
):
|
|
185
|
+
raise ControllerError("INVALID_POLICY", "canonical allowed_roles must be a string list")
|
|
186
|
+
stale = _positive_int(review, "stale_warning_days")
|
|
187
|
+
integration = _positive_int(
|
|
188
|
+
slots,
|
|
189
|
+
(
|
|
190
|
+
"integration"
|
|
191
|
+
if schema_version == 1
|
|
192
|
+
else "integration_prepare_limit"
|
|
193
|
+
if schema_version >= 4
|
|
194
|
+
else "integration_candidate_limit"
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
feature_audit = _positive_int(slots, "feature_or_audit")
|
|
198
|
+
emergency = _positive_int(slots, "emergency")
|
|
199
|
+
recovery = 0 if schema_version == 1 else _positive_int(slots, "recovery_reserve")
|
|
200
|
+
retained = _positive_int(slots, "retained") if schema_version >= 5 else 0
|
|
201
|
+
|
|
202
|
+
# Automatic reconciliation is delegated, never assumed: a repository that has
|
|
203
|
+
# not opted in keeps the pre-lease behaviour exactly, so installing a newer
|
|
204
|
+
# Controller before migrating a policy changes nothing.
|
|
205
|
+
reconciler_enabled = False
|
|
206
|
+
reconciler_actor = "lifecycle-reconciler"
|
|
207
|
+
reconciler_legacy_adoption = False
|
|
208
|
+
if schema_version >= 6:
|
|
209
|
+
reconciler = _table(data, "reconciler")
|
|
210
|
+
if set(reconciler) != {"enabled", "actor", "allow_legacy_adoption"}:
|
|
211
|
+
raise ControllerError("INVALID_POLICY", "policy [reconciler] fields are invalid")
|
|
212
|
+
reconciler_enabled = _boolean(reconciler, "enabled")
|
|
213
|
+
reconciler_actor = _nonempty_string(reconciler, "actor")
|
|
214
|
+
reconciler_legacy_adoption = _boolean(reconciler, "allow_legacy_adoption")
|
|
215
|
+
# Counts are independent observations under resource admission. Requiring
|
|
216
|
+
# their historical slot sums to balance would still turn them into a veto.
|
|
217
|
+
|
|
218
|
+
if schema_version == 1:
|
|
219
|
+
main_publish_limit = 1
|
|
220
|
+
publication_remote = "origin"
|
|
221
|
+
publication_branch = "main"
|
|
222
|
+
else:
|
|
223
|
+
publication = _table(data, "publication")
|
|
224
|
+
if set(publication) != {"main_publish_limit", "remote", "branch"}:
|
|
225
|
+
raise ControllerError("INVALID_POLICY", "policy [publication] fields are invalid")
|
|
226
|
+
main_publish_limit = _positive_int(publication, "main_publish_limit")
|
|
227
|
+
if main_publish_limit != 1:
|
|
228
|
+
raise ControllerError(
|
|
229
|
+
"INVALID_POLICY", "this controller version requires main_publish_limit = 1"
|
|
230
|
+
)
|
|
231
|
+
publication_remote = _nonempty_string(publication, "remote")
|
|
232
|
+
publication_branch = _nonempty_string(publication, "branch")
|
|
233
|
+
|
|
234
|
+
if schema_version < 3:
|
|
235
|
+
automatic_feature_audit_release = False
|
|
236
|
+
automatic_integration_continuation = False
|
|
237
|
+
preserved_auto_release = False
|
|
238
|
+
release_intent_ttl_seconds = 3600
|
|
239
|
+
else:
|
|
240
|
+
release_policy = _table(data, "release")
|
|
241
|
+
expected_release = {"automatic_feature_audit", "intent_ttl_seconds"}
|
|
242
|
+
if schema_version >= 4:
|
|
243
|
+
expected_release |= {
|
|
244
|
+
"automatic_integration_continuation",
|
|
245
|
+
"preserved_auto_release",
|
|
246
|
+
}
|
|
247
|
+
if set(release_policy) != expected_release:
|
|
248
|
+
raise ControllerError("INVALID_POLICY", "policy [release] fields are invalid")
|
|
249
|
+
automatic_feature_audit_release = _boolean(release_policy, "automatic_feature_audit")
|
|
250
|
+
automatic_integration_continuation = (
|
|
251
|
+
_boolean(release_policy, "automatic_integration_continuation")
|
|
252
|
+
if schema_version >= 4
|
|
253
|
+
else False
|
|
254
|
+
)
|
|
255
|
+
preserved_auto_release = (
|
|
256
|
+
_boolean(release_policy, "preserved_auto_release") if schema_version >= 4 else False
|
|
257
|
+
)
|
|
258
|
+
if preserved_auto_release:
|
|
259
|
+
raise ControllerError(
|
|
260
|
+
"INVALID_POLICY", "this controller version requires preserved_auto_release = false"
|
|
261
|
+
)
|
|
262
|
+
release_intent_ttl_seconds = _positive_int(release_policy, "intent_ttl_seconds")
|
|
263
|
+
|
|
264
|
+
growth_bytes, growth_inodes = 2 * 1024**3, 100_000
|
|
265
|
+
if schema_version >= 7:
|
|
266
|
+
admission = _table(data, "admission")
|
|
267
|
+
if set(admission) != {"strategy", "checkout_growth_bytes", "checkout_growth_inodes"} or admission.get("strategy") != "resource-budget-v1":
|
|
268
|
+
raise ControllerError("INVALID_POLICY", "unsupported resource admission policy")
|
|
269
|
+
growth_bytes = _positive_int(admission, "checkout_growth_bytes")
|
|
270
|
+
growth_inodes = _positive_int(admission, "checkout_growth_inodes")
|
|
271
|
+
|
|
272
|
+
convergence = None
|
|
273
|
+
if schema_version >= 8:
|
|
274
|
+
table = _table(data, "convergence")
|
|
275
|
+
if not CONVERGENCE_FIELDS <= set(table) <= CONVERGENCE_FIELDS | CONVERGENCE_OPTIONAL_FIELDS:
|
|
276
|
+
raise ControllerError("INVALID_POLICY", "policy [convergence] fields are invalid")
|
|
277
|
+
idle_max = _positive_int(table, "idle_hours_max")
|
|
278
|
+
idle_min = _positive_int(table, "idle_hours_min")
|
|
279
|
+
if idle_min > idle_max:
|
|
280
|
+
raise ControllerError(
|
|
281
|
+
"INVALID_POLICY", "policy idle_hours_min must not exceed idle_hours_max"
|
|
282
|
+
)
|
|
283
|
+
if not reconciler_enabled:
|
|
284
|
+
raise ControllerError(
|
|
285
|
+
"INVALID_POLICY", "retention convergence requires the delegated reconciler"
|
|
286
|
+
)
|
|
287
|
+
convergence = ConvergencePolicy(
|
|
288
|
+
enabled=_boolean(table, "enabled"),
|
|
289
|
+
grace_minutes=_positive_int(table, "grace_minutes"),
|
|
290
|
+
idle_hours_max=idle_max,
|
|
291
|
+
idle_hours_min=idle_min,
|
|
292
|
+
standing_reserve=_positive_int(table, "standing_reserve"),
|
|
293
|
+
release_unintegrated_handoffs=(
|
|
294
|
+
_boolean(table, "release_unintegrated_handoffs")
|
|
295
|
+
if "release_unintegrated_handoffs" in table
|
|
296
|
+
else False
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return WorktreePolicy(
|
|
301
|
+
path=path,
|
|
302
|
+
present=True,
|
|
303
|
+
policy_schema_version=schema_version,
|
|
304
|
+
normal_limit=normal_limit,
|
|
305
|
+
hard_limit=hard_limit,
|
|
306
|
+
canonical_protected=_boolean(canonical, "protected"),
|
|
307
|
+
canonical_allowed_roles=frozenset(allowed_roles),
|
|
308
|
+
integration_candidate_limit=integration,
|
|
309
|
+
integration_prepare_limit=integration,
|
|
310
|
+
feature_audit_slots=feature_audit,
|
|
311
|
+
emergency_slots=emergency,
|
|
312
|
+
recovery_reserve=recovery,
|
|
313
|
+
retained_slots=retained,
|
|
314
|
+
reconciler_enabled=reconciler_enabled,
|
|
315
|
+
reconciler_actor=reconciler_actor,
|
|
316
|
+
reconciler_legacy_adoption=reconciler_legacy_adoption,
|
|
317
|
+
main_publish_limit=main_publish_limit,
|
|
318
|
+
publication_remote=publication_remote,
|
|
319
|
+
publication_branch=publication_branch,
|
|
320
|
+
automatic_feature_audit_release=automatic_feature_audit_release,
|
|
321
|
+
automatic_integration_continuation=automatic_integration_continuation,
|
|
322
|
+
preserved_auto_release=preserved_auto_release,
|
|
323
|
+
release_intent_ttl_seconds=release_intent_ttl_seconds,
|
|
324
|
+
stale_warning_days=stale,
|
|
325
|
+
require_adoption=_boolean(authority, "require_adoption"),
|
|
326
|
+
require_task_key=_boolean(authority, "require_task_key"),
|
|
327
|
+
require_authority_key=_boolean(authority, "require_authority_key"),
|
|
328
|
+
unique_active_feature_authority=_boolean(authority, "unique_active_feature_authority"),
|
|
329
|
+
resource_admission=True,
|
|
330
|
+
checkout_growth_bytes=growth_bytes,
|
|
331
|
+
checkout_growth_inodes=growth_inodes,
|
|
332
|
+
convergence=convergence,
|
|
333
|
+
)
|