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.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. 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
+ )