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,887 @@
|
|
|
1
|
+
"""Fail-closed executor admission; only post-exit cleanup is advisory.
|
|
2
|
+
|
|
3
|
+
Every caller uses this explicit session contract. No error-code denylist:
|
|
4
|
+
NOT_APPLICABLE requires positive evidence that the path is outside governance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .git_backend import inventory, resolve_repository
|
|
20
|
+
from .models import ControllerError, WriterHolder
|
|
21
|
+
from .policy import load_policy
|
|
22
|
+
from .store import MetadataStore
|
|
23
|
+
from .writer_lease import (
|
|
24
|
+
HOLDER_RESOLVER_VERSION,
|
|
25
|
+
acquire_writer_lease,
|
|
26
|
+
release_writer_lease,
|
|
27
|
+
resolve_holder,
|
|
28
|
+
same_holder,
|
|
29
|
+
writer_requirement,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
HOST_MANAGED_WRITER_SESSION_VERSION = 2
|
|
33
|
+
HOST_MANAGED_MODE = "host-managed-v2"
|
|
34
|
+
HOST_HANDOFF_CONTINUATION_VERSION = 2
|
|
35
|
+
HOST_ACCESS_CLASSES = ("read", "exec", "write")
|
|
36
|
+
# Lifecycles a holder's own handoff leaves on its still-leased allocation. They end
|
|
37
|
+
# feature writes on that checkout, not the holder's session.
|
|
38
|
+
CONTINUATION_LIFECYCLES = frozenset({"HANDOFF_READY", "PRESERVED"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def host_managed_requested() -> bool:
|
|
42
|
+
return "CONTROLLER_WRITER_MODE" in os.environ or "CONTROLLER_WRITER_CONTEXT" in os.environ
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _context_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
46
|
+
value: dict[str, Any] = {}
|
|
47
|
+
for key, item in pairs:
|
|
48
|
+
if key in value:
|
|
49
|
+
raise ValueError("duplicate context key")
|
|
50
|
+
value[key] = item
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def host_check(
|
|
55
|
+
path: Path, *, access: str | None = None, target: Path | None = None
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Validate a host declaration against current authority; never mutate a lease.
|
|
58
|
+
|
|
59
|
+
Without ``access`` this is the feature-write entitlement. A caller naming one
|
|
60
|
+
access class may also continue its own frozen handoff; see ``_continuation``.
|
|
61
|
+
"""
|
|
62
|
+
from .controller import guard_write
|
|
63
|
+
from .semantics import managed_record as _managed_record
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if access is not None and access not in HOST_ACCESS_CLASSES:
|
|
67
|
+
raise ValueError("unsupported host access class")
|
|
68
|
+
if (access == "write") != (target is not None):
|
|
69
|
+
raise ValueError("a write access check requires exactly one target path")
|
|
70
|
+
mode = os.environ.get("CONTROLLER_WRITER_MODE")
|
|
71
|
+
if mode not in {HOST_MANAGED_MODE, "host-managed-v1"}:
|
|
72
|
+
raise ValueError("unsupported or missing host-managed mode")
|
|
73
|
+
context = json.loads(
|
|
74
|
+
os.environ.get("CONTROLLER_WRITER_CONTEXT", ""), object_pairs_hook=_context_object
|
|
75
|
+
)
|
|
76
|
+
# A process survives activation with its original environment. Normalize
|
|
77
|
+
# only the complete published v1 shape; authority still comes from the
|
|
78
|
+
# current lease and its unchanged holder, never from this declaration.
|
|
79
|
+
if mode == "host-managed-v1" and isinstance(context, dict):
|
|
80
|
+
if "provider" not in context or "client_id" in context:
|
|
81
|
+
raise ValueError("v1 host context requires provider identity")
|
|
82
|
+
context["client_id"] = context.pop("provider")
|
|
83
|
+
fields = {
|
|
84
|
+
"holder",
|
|
85
|
+
"client_id",
|
|
86
|
+
"session_id",
|
|
87
|
+
"run_id",
|
|
88
|
+
"repository_path",
|
|
89
|
+
"worktree_path",
|
|
90
|
+
"allocation_id",
|
|
91
|
+
"lease_id",
|
|
92
|
+
}
|
|
93
|
+
if not isinstance(context, dict) or context.keys() != fields:
|
|
94
|
+
raise ValueError("host context requires the exact v2 fields, including explicit run_id")
|
|
95
|
+
for key in fields - {"holder", "run_id"}:
|
|
96
|
+
if not isinstance(context[key], str) or not context[key].strip():
|
|
97
|
+
raise ValueError(f"invalid host context field: {key}")
|
|
98
|
+
if context["run_id"] is not None and (
|
|
99
|
+
not isinstance(context["run_id"], str) or not context["run_id"].strip()
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("run_id must be a nonempty string or explicit null")
|
|
102
|
+
identity = context["holder"]
|
|
103
|
+
if (
|
|
104
|
+
not isinstance(identity, dict)
|
|
105
|
+
or identity.keys() != {"pid", "boot_id", "start_ticks"}
|
|
106
|
+
or any(
|
|
107
|
+
type(identity[key]) is not int or identity[key] <= 0
|
|
108
|
+
for key in ("pid", "start_ticks")
|
|
109
|
+
)
|
|
110
|
+
or not isinstance(identity["boot_id"], str)
|
|
111
|
+
or not identity["boot_id"].strip()
|
|
112
|
+
):
|
|
113
|
+
raise ValueError("complete host holder identity is required")
|
|
114
|
+
declared = WriterHolder(
|
|
115
|
+
**identity,
|
|
116
|
+
client_id=context["client_id"],
|
|
117
|
+
session_id=context["session_id"],
|
|
118
|
+
run_id=context["run_id"],
|
|
119
|
+
)
|
|
120
|
+
holder = resolve_holder(
|
|
121
|
+
str(declared.pid),
|
|
122
|
+
client_id=declared.client_id,
|
|
123
|
+
session_id=declared.session_id,
|
|
124
|
+
run_id=declared.run_id,
|
|
125
|
+
)
|
|
126
|
+
if not same_holder(declared, holder):
|
|
127
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "host holder differs from live identity")
|
|
128
|
+
from .environment import verify_holder_lineage
|
|
129
|
+
|
|
130
|
+
verify_holder_lineage(holder, os.getpid())
|
|
131
|
+
# The snapshot loaded under the lock below is the metadata check.
|
|
132
|
+
governed = governed_target(path, load_metadata=False)
|
|
133
|
+
if governed is None:
|
|
134
|
+
raise ControllerError("WRITER_LEASE_REQUIRED", "host-managed writes require governance")
|
|
135
|
+
repository, root = governed
|
|
136
|
+
if context["repository_path"] != str(repository) or context["worktree_path"] != str(root):
|
|
137
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "host repository/worktree differs")
|
|
138
|
+
state = inventory(root)
|
|
139
|
+
store = MetadataStore(state.common_dir)
|
|
140
|
+
if not store.lock_file.is_file() or store.lock_file.is_symlink():
|
|
141
|
+
raise ControllerError("INVALID_METADATA", "host check requires its repository lock")
|
|
142
|
+
# Hold the existing shared lock through the ordinary guard: lease ID and
|
|
143
|
+
# entitlement cannot come from different Controller mutation snapshots.
|
|
144
|
+
with store.lock(exclusive=False):
|
|
145
|
+
snapshot = store._load_unlocked()
|
|
146
|
+
item, record = _managed_record(state, snapshot, root)
|
|
147
|
+
if item.detached or item.locked or item.prunable:
|
|
148
|
+
raise ControllerError("INVALID_TARGET", "writer checkout requires review")
|
|
149
|
+
lease = snapshot.writer_leases.get(record.allocation_id)
|
|
150
|
+
if record.allocation_id != context["allocation_id"]:
|
|
151
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "host allocation differs")
|
|
152
|
+
if lease is None:
|
|
153
|
+
raise ControllerError("WRITER_LEASE_REQUIRED", "host lease is missing")
|
|
154
|
+
if lease.lease_id != context["lease_id"]:
|
|
155
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "host lease ID differs")
|
|
156
|
+
decision = guard_write(
|
|
157
|
+
repository,
|
|
158
|
+
target=root,
|
|
159
|
+
task_key=record.task_key,
|
|
160
|
+
authority_key=record.authority_key,
|
|
161
|
+
owner=record.owner,
|
|
162
|
+
operation="feature-write",
|
|
163
|
+
holder=holder,
|
|
164
|
+
observe=False,
|
|
165
|
+
state=state,
|
|
166
|
+
snapshot=snapshot,
|
|
167
|
+
)["decision"]
|
|
168
|
+
if decision["code"] != "WRITE_ALLOWED" and access is not None:
|
|
169
|
+
return _continuation(
|
|
170
|
+
decision,
|
|
171
|
+
access=access,
|
|
172
|
+
target=target,
|
|
173
|
+
repository=repository,
|
|
174
|
+
root=root,
|
|
175
|
+
state=state,
|
|
176
|
+
snapshot=snapshot,
|
|
177
|
+
item=item,
|
|
178
|
+
record=record,
|
|
179
|
+
lease=lease,
|
|
180
|
+
holder=holder,
|
|
181
|
+
)
|
|
182
|
+
return {
|
|
183
|
+
**decision,
|
|
184
|
+
"admission": "GRANTED" if decision["code"] == "WRITE_ALLOWED" else "REFUSED",
|
|
185
|
+
}
|
|
186
|
+
except (ControllerError, OSError, ValueError) as exc:
|
|
187
|
+
return {
|
|
188
|
+
"admission": "REFUSED",
|
|
189
|
+
"code": getattr(exc, "code", "INVALID_HOST_WRITER_CONTEXT"),
|
|
190
|
+
"reason": str(exc),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _continuation(
|
|
195
|
+
refusal: dict[str, Any],
|
|
196
|
+
*,
|
|
197
|
+
access: str,
|
|
198
|
+
target: Path | None,
|
|
199
|
+
repository: Path,
|
|
200
|
+
root: Path,
|
|
201
|
+
state: Any,
|
|
202
|
+
snapshot: Any,
|
|
203
|
+
item: Any,
|
|
204
|
+
record: Any,
|
|
205
|
+
lease: Any,
|
|
206
|
+
holder: WriterHolder,
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
"""Let the exact holder of its own frozen handoff continue through Controller.
|
|
209
|
+
|
|
210
|
+
The handoff fence is unchanged: the frozen checkout still refuses writes and new
|
|
211
|
+
bindings or writer sessions, which never pass an access class. The same holder
|
|
212
|
+
may read, run commands whose Controller verbs keep their own guards, and write
|
|
213
|
+
outside governance or into an integration candidate built from this allocation.
|
|
214
|
+
Every other state keeps the original refusal.
|
|
215
|
+
"""
|
|
216
|
+
from .controller import guard_write
|
|
217
|
+
from .semantics import evidence_head_issues
|
|
218
|
+
from .semantics import managed_record as _managed_record
|
|
219
|
+
from .writer_lease import check_writer_entitlement
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
refusal["code"] != "BLOCK_FEATURE_WRITE_LIFECYCLE"
|
|
223
|
+
or record.lifecycle not in CONTINUATION_LIFECYCLES
|
|
224
|
+
):
|
|
225
|
+
return {**refusal, "admission": "REFUSED"}
|
|
226
|
+
# Same snapshot as the guard: only the live holder of this exact lease, with
|
|
227
|
+
# no foreign runtime binding, keeps its session after its own handoff.
|
|
228
|
+
check_writer_entitlement(snapshot.repository_id, record, item.path, lease, holder)
|
|
229
|
+
drift = evidence_head_issues(item, record)
|
|
230
|
+
if drift:
|
|
231
|
+
raise ControllerError("RECOVERY_REQUIRED", "frozen handoff HEAD drift requires recovery")
|
|
232
|
+
granted = {
|
|
233
|
+
"admission": "CONTINUATION_GRANTED",
|
|
234
|
+
"access": access,
|
|
235
|
+
"allocation_id": record.allocation_id,
|
|
236
|
+
"lifecycle": record.lifecycle,
|
|
237
|
+
}
|
|
238
|
+
if access != "write":
|
|
239
|
+
return {**granted, "code": "HOST_HANDOFF_CONTINUATION",
|
|
240
|
+
"reason": f"{access} access continues after this holder's handoff"}
|
|
241
|
+
if target is None or not target.is_absolute():
|
|
242
|
+
raise ControllerError("INVALID_TARGET", "continuation write target must be absolute")
|
|
243
|
+
resolved = target.resolve(strict=False)
|
|
244
|
+
directory = next(parent for parent in (resolved, *resolved.parents) if parent.is_dir())
|
|
245
|
+
# Metadata of the target repository is loaded (and validated) below.
|
|
246
|
+
governed = governed_target(directory, load_metadata=False)
|
|
247
|
+
if governed is None:
|
|
248
|
+
return {**granted, "code": "HOST_HANDOFF_CONTINUATION",
|
|
249
|
+
"reason": "write target is outside Controller governance"}
|
|
250
|
+
target_repository, target_root = governed
|
|
251
|
+
if target_root == root:
|
|
252
|
+
return {**refusal, "admission": "REFUSED"}
|
|
253
|
+
same = (state, snapshot) if target_repository == repository else None
|
|
254
|
+
held = _held_checkout(target_repository, target_root, holder, known=same)
|
|
255
|
+
if held is not None:
|
|
256
|
+
return {**granted, "code": "HOST_HANDOFF_CONTINUATION",
|
|
257
|
+
"reason": "write target is another checkout this process holds a writer lease on",
|
|
258
|
+
"target_allocation_id": held}
|
|
259
|
+
if target_repository != repository:
|
|
260
|
+
raise ControllerError(
|
|
261
|
+
"BLOCK_HOST_CONTINUATION_TARGET", "frozen holder has no authority in that repository"
|
|
262
|
+
)
|
|
263
|
+
_target_item, candidate = _managed_record(state, snapshot, target_root)
|
|
264
|
+
if candidate.role != "integration" or record.allocation_id not in candidate.source_allocations:
|
|
265
|
+
raise ControllerError(
|
|
266
|
+
"BLOCK_HOST_CONTINUATION_TARGET",
|
|
267
|
+
"frozen holder may write only into an integration candidate of its own handoff",
|
|
268
|
+
)
|
|
269
|
+
decision = guard_write(
|
|
270
|
+
repository,
|
|
271
|
+
target=target_root,
|
|
272
|
+
task_key=candidate.task_key,
|
|
273
|
+
authority_key=candidate.authority_key,
|
|
274
|
+
owner=candidate.owner,
|
|
275
|
+
operation="integration-write",
|
|
276
|
+
observe=False,
|
|
277
|
+
state=state,
|
|
278
|
+
snapshot=snapshot,
|
|
279
|
+
)["decision"]
|
|
280
|
+
if decision["code"] != "WRITE_ALLOWED":
|
|
281
|
+
return {**decision, "admission": "REFUSED"}
|
|
282
|
+
return {**granted, "code": "HOST_HANDOFF_CONTINUATION",
|
|
283
|
+
"reason": "write target is an integration candidate of this handoff",
|
|
284
|
+
"candidate_allocation_id": candidate.allocation_id}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _held_checkout(
|
|
288
|
+
repository: Path, root: Path, holder: WriterHolder, *, known: tuple[Any, Any] | None = None
|
|
289
|
+
) -> str | None:
|
|
290
|
+
"""The allocation at ``root`` when this exact live process holds its writer lease.
|
|
291
|
+
|
|
292
|
+
One process may hold several leases under different client/session identities;
|
|
293
|
+
the process identity (PID, boot ID, start ticks) is what the lease bound. The
|
|
294
|
+
ordinary feature-write guard of that allocation still decides.
|
|
295
|
+
"""
|
|
296
|
+
from .controller import guard_write
|
|
297
|
+
from .semantics import managed_record as _managed_record
|
|
298
|
+
|
|
299
|
+
if known is not None:
|
|
300
|
+
state, snapshot = known
|
|
301
|
+
else:
|
|
302
|
+
state = inventory(root)
|
|
303
|
+
snapshot = MetadataStore(state.common_dir).load()
|
|
304
|
+
_item, record = _managed_record(state, snapshot, root)
|
|
305
|
+
lease = snapshot.writer_leases.get(record.allocation_id)
|
|
306
|
+
if lease is None or (lease.holder.pid, lease.holder.boot_id, lease.holder.start_ticks) != (
|
|
307
|
+
holder.pid, holder.boot_id, holder.start_ticks
|
|
308
|
+
):
|
|
309
|
+
return None
|
|
310
|
+
decision = guard_write(
|
|
311
|
+
repository,
|
|
312
|
+
target=root,
|
|
313
|
+
task_key=record.task_key,
|
|
314
|
+
authority_key=record.authority_key,
|
|
315
|
+
owner=record.owner,
|
|
316
|
+
operation="feature-write",
|
|
317
|
+
holder=lease.holder,
|
|
318
|
+
observe=False,
|
|
319
|
+
state=state,
|
|
320
|
+
snapshot=snapshot,
|
|
321
|
+
)["decision"]
|
|
322
|
+
return record.allocation_id if decision["code"] == "WRITE_ALLOWED" else None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _present(path: Path) -> bool:
|
|
326
|
+
try:
|
|
327
|
+
path.lstat()
|
|
328
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
329
|
+
return False
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _governance_marker(root: Path, common: Path | None = None) -> bool:
|
|
334
|
+
if _present(root / ".agents/worktree-policy.toml") or _present(
|
|
335
|
+
(common or root / ".git") / "worktree-controller"
|
|
336
|
+
):
|
|
337
|
+
return True
|
|
338
|
+
marker = root / ".git"
|
|
339
|
+
if common is None and marker.is_file():
|
|
340
|
+
# A broken linked HEAD can defeat Git discovery while its admin pointer
|
|
341
|
+
# still identifies governance. These hints can only cause refusal, never
|
|
342
|
+
# establish Git/worktree identity or grant admission.
|
|
343
|
+
with marker.open("rb") as stream:
|
|
344
|
+
pointer = os.fsdecode(stream.read(4097)).strip()
|
|
345
|
+
if len(pointer) <= 4096 and pointer.startswith("gitdir: "):
|
|
346
|
+
admin = root / pointer.removeprefix("gitdir: ")
|
|
347
|
+
link = admin / "commondir"
|
|
348
|
+
if _present(link):
|
|
349
|
+
with link.open("rb") as stream:
|
|
350
|
+
relative = os.fsdecode(stream.read(4097)).strip()
|
|
351
|
+
if relative and len(relative) <= 4096:
|
|
352
|
+
return _present(admin / relative / "worktree-controller")
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def governed_target(path: Path, *, load_metadata: bool = True) -> tuple[Path, Path] | None:
|
|
357
|
+
"""Separate Git discovery failures from evidence of Controller governance.
|
|
358
|
+
|
|
359
|
+
An unrelated broken ancestor marker is not a repository authority. Once a
|
|
360
|
+
policy, metadata or actual governed Git root is found, all failures propagate.
|
|
361
|
+
Nested repositories cannot hide an enclosing governed worktree.
|
|
362
|
+
``load_metadata=False`` is for callers that load (and so validate) the same
|
|
363
|
+
metadata themselves before deciding anything.
|
|
364
|
+
"""
|
|
365
|
+
resolved = path.expanduser().resolve(strict=True)
|
|
366
|
+
if not resolved.is_dir():
|
|
367
|
+
raise ControllerError("INVALID_TARGET", "writer path must be an existing directory")
|
|
368
|
+
selected = None
|
|
369
|
+
governed = None
|
|
370
|
+
broken_roots = []
|
|
371
|
+
for parent in (resolved, *resolved.parents):
|
|
372
|
+
explicit = _governance_marker(parent)
|
|
373
|
+
marker = _present(parent / ".git") or (
|
|
374
|
+
_present(parent / "HEAD") and _present(parent / "objects")
|
|
375
|
+
)
|
|
376
|
+
if not explicit and not marker:
|
|
377
|
+
continue
|
|
378
|
+
try:
|
|
379
|
+
root, common = resolve_repository(parent)
|
|
380
|
+
except ControllerError as exc:
|
|
381
|
+
# Only failed Git root discovery without governance evidence is
|
|
382
|
+
# inapplicable. Inventory/policy/metadata errors below never enter here.
|
|
383
|
+
if explicit or exc.code != "GIT_ERROR":
|
|
384
|
+
raise
|
|
385
|
+
broken_roots.append(parent)
|
|
386
|
+
continue
|
|
387
|
+
if root != parent:
|
|
388
|
+
if explicit:
|
|
389
|
+
raise ControllerError("GIT_DRIFT", "Git root does not match its discovery marker")
|
|
390
|
+
broken_roots.append(parent)
|
|
391
|
+
continue
|
|
392
|
+
repository = inventory(root)
|
|
393
|
+
if selected is None:
|
|
394
|
+
selected = root
|
|
395
|
+
if explicit or _governance_marker(repository.canonical_path, common):
|
|
396
|
+
if any(broken.is_relative_to(root) for broken in broken_roots):
|
|
397
|
+
raise ControllerError("GIT_ERROR", "malformed Git marker inside governed worktree")
|
|
398
|
+
policy = load_policy(repository.canonical_path)
|
|
399
|
+
if policy.path.is_symlink():
|
|
400
|
+
raise ControllerError("INVALID_POLICY", "policy marker is a symlink")
|
|
401
|
+
if load_metadata:
|
|
402
|
+
MetadataStore(common).load()
|
|
403
|
+
if selected != root:
|
|
404
|
+
raise ControllerError(
|
|
405
|
+
"UNREGISTERED_WORKTREE", "nested repository lies inside a governed path"
|
|
406
|
+
)
|
|
407
|
+
governed = repository.canonical_path, root
|
|
408
|
+
return governed
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def describe(path: Path, *, selector: str | None = None) -> dict[str, Any]:
|
|
412
|
+
"""Project the recorded writer identity for a path. Read-only, grants nothing.
|
|
413
|
+
|
|
414
|
+
A caller that survived a Controller activation may still speak an older request
|
|
415
|
+
shape. Reconstructing the strict request needs the identity the Controller
|
|
416
|
+
already recorded, not identity guessed from a pid, an owner or a directory, so
|
|
417
|
+
this returns exactly what is on record and nothing derived.
|
|
418
|
+
|
|
419
|
+
When the caller names the holder it believes it is, that selector is resolved by
|
|
420
|
+
the same version 1 resolver every other path uses and compared against the
|
|
421
|
+
recorded lease. ``holder_matches`` is the only agreement signal; it is never a
|
|
422
|
+
substitute for the Controller's own checks on the request that follows.
|
|
423
|
+
"""
|
|
424
|
+
from .semantics import managed_record as _managed_record
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
target = governed_target(path)
|
|
428
|
+
if target is None:
|
|
429
|
+
return {"admission": "NOT_APPLICABLE", "code": "OUTSIDE_CONTROLLER_GOVERNANCE"}
|
|
430
|
+
repository, root = target
|
|
431
|
+
state = inventory(root)
|
|
432
|
+
store = MetadataStore(state.common_dir)
|
|
433
|
+
if not store.lock_file.is_file() or store.lock_file.is_symlink():
|
|
434
|
+
raise ControllerError("INVALID_METADATA", "governed read requires its repository lock")
|
|
435
|
+
with store.lock(exclusive=False):
|
|
436
|
+
snapshot = store._load_unlocked()
|
|
437
|
+
item, record = _managed_record(state, snapshot, root)
|
|
438
|
+
lease = snapshot.writer_leases.get(record.allocation_id)
|
|
439
|
+
matches: bool | None = None
|
|
440
|
+
if selector is not None and lease is not None:
|
|
441
|
+
try:
|
|
442
|
+
matches = same_holder(
|
|
443
|
+
lease.holder,
|
|
444
|
+
resolve_holder(
|
|
445
|
+
selector,
|
|
446
|
+
client_id=lease.holder.client_id,
|
|
447
|
+
session_id=lease.holder.session_id,
|
|
448
|
+
run_id=lease.holder.run_id,
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
except ControllerError:
|
|
452
|
+
# An unresolvable or changed selector is a disagreement, never a match.
|
|
453
|
+
matches = False
|
|
454
|
+
return {
|
|
455
|
+
"admission": "NOT_APPLICABLE",
|
|
456
|
+
"code": "WRITER_SESSION_DESCRIBED",
|
|
457
|
+
"repository_path": str(repository),
|
|
458
|
+
"worktree_path": str(root),
|
|
459
|
+
"allocation_id": record.allocation_id,
|
|
460
|
+
"owner": record.owner,
|
|
461
|
+
"role": record.role,
|
|
462
|
+
"lifecycle": record.lifecycle,
|
|
463
|
+
"writer_requirement": writer_requirement(record.role),
|
|
464
|
+
"holder_matches": matches,
|
|
465
|
+
"lease": None
|
|
466
|
+
if lease is None
|
|
467
|
+
else {
|
|
468
|
+
"lease_id": lease.lease_id,
|
|
469
|
+
"owner": lease.owner,
|
|
470
|
+
"expires_at": lease.expires_at,
|
|
471
|
+
"holder": asdict(lease.holder),
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
except (ControllerError, OSError) as exc:
|
|
475
|
+
return {
|
|
476
|
+
"admission": "REFUSED",
|
|
477
|
+
"code": getattr(exc, "code", "PATH_UNAVAILABLE"),
|
|
478
|
+
"reason": str(exc),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def check(
|
|
483
|
+
path: Path,
|
|
484
|
+
*,
|
|
485
|
+
client_id: str,
|
|
486
|
+
selector: str,
|
|
487
|
+
session_id: str | None = None,
|
|
488
|
+
run_id: str | None = None,
|
|
489
|
+
access: str | None = None,
|
|
490
|
+
target: Path | None = None,
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Check an existing entitlement; never acquire or infer one from a PID.
|
|
493
|
+
|
|
494
|
+
``access``/``target`` follow ``host_check``: the exact lease holder keeps its
|
|
495
|
+
own frozen handoff's Controller flow, and nothing else changes.
|
|
496
|
+
"""
|
|
497
|
+
from .controller import guard_write
|
|
498
|
+
from .semantics import managed_record as _managed_record
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
if access is not None and access not in HOST_ACCESS_CLASSES:
|
|
502
|
+
raise ValueError("unsupported access class")
|
|
503
|
+
if (access == "write") != (target is not None):
|
|
504
|
+
raise ValueError("a write access check requires exactly one target path")
|
|
505
|
+
governed = governed_target(path, load_metadata=False)
|
|
506
|
+
if governed is None:
|
|
507
|
+
return {"admission": "NOT_APPLICABLE", "code": "OUTSIDE_CONTROLLER_GOVERNANCE"}
|
|
508
|
+
repository, root = governed
|
|
509
|
+
state = inventory(root)
|
|
510
|
+
store = MetadataStore(state.common_dir)
|
|
511
|
+
if not store.lock_file.is_file() or store.lock_file.is_symlink():
|
|
512
|
+
raise ControllerError("INVALID_METADATA", "governed check requires its repository lock")
|
|
513
|
+
# Keep the guard and its continuation on one locked snapshot, as in
|
|
514
|
+
# host_check. A concurrent lease/lifecycle change waits for this decision.
|
|
515
|
+
with store.lock(exclusive=False):
|
|
516
|
+
snapshot = store._load_unlocked()
|
|
517
|
+
item, record = _managed_record(state, snapshot, root)
|
|
518
|
+
if item.detached or item.locked or item.prunable:
|
|
519
|
+
raise ControllerError("INVALID_TARGET", "writer checkout requires review")
|
|
520
|
+
holder = resolve_holder(selector, client_id=client_id, session_id=session_id, run_id=run_id)
|
|
521
|
+
if writer_requirement(record.role) == "NOT_APPLICABLE":
|
|
522
|
+
return {
|
|
523
|
+
"admission": "NOT_APPLICABLE",
|
|
524
|
+
"code": "DEDICATED_ROLE_GATE",
|
|
525
|
+
"role": record.role,
|
|
526
|
+
}
|
|
527
|
+
decision = guard_write(
|
|
528
|
+
repository,
|
|
529
|
+
target=root,
|
|
530
|
+
task_key=record.task_key,
|
|
531
|
+
authority_key=record.authority_key,
|
|
532
|
+
owner=record.owner,
|
|
533
|
+
operation="feature-write",
|
|
534
|
+
holder=holder,
|
|
535
|
+
observe=False,
|
|
536
|
+
state=state,
|
|
537
|
+
snapshot=snapshot,
|
|
538
|
+
)["decision"]
|
|
539
|
+
if decision["code"] != "WRITE_ALLOWED" and access is not None:
|
|
540
|
+
return _continuation(
|
|
541
|
+
decision,
|
|
542
|
+
access=access,
|
|
543
|
+
target=target,
|
|
544
|
+
repository=repository,
|
|
545
|
+
root=root,
|
|
546
|
+
state=state,
|
|
547
|
+
snapshot=snapshot,
|
|
548
|
+
item=item,
|
|
549
|
+
record=record,
|
|
550
|
+
lease=snapshot.writer_leases.get(record.allocation_id),
|
|
551
|
+
holder=holder,
|
|
552
|
+
)
|
|
553
|
+
return {
|
|
554
|
+
**decision,
|
|
555
|
+
"admission": "GRANTED" if decision["code"] == "WRITE_ALLOWED" else "REFUSED",
|
|
556
|
+
}
|
|
557
|
+
except (ControllerError, OSError, ValueError) as exc:
|
|
558
|
+
return {
|
|
559
|
+
"admission": "REFUSED",
|
|
560
|
+
"code": getattr(exc, "code", "PATH_UNAVAILABLE"),
|
|
561
|
+
"reason": str(exc),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def admission(
|
|
566
|
+
path: Path,
|
|
567
|
+
*,
|
|
568
|
+
client_id: str,
|
|
569
|
+
selector: str,
|
|
570
|
+
session_id: str | None = None,
|
|
571
|
+
run_id: str | None = None,
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
try:
|
|
574
|
+
target = governed_target(path)
|
|
575
|
+
if target is None:
|
|
576
|
+
return {"admission": "NOT_APPLICABLE", "code": "OUTSIDE_CONTROLLER_GOVERNANCE"}
|
|
577
|
+
repository, root = target
|
|
578
|
+
holder = resolve_holder(selector, client_id=client_id, session_id=session_id, run_id=run_id)
|
|
579
|
+
return acquire_writer_lease(
|
|
580
|
+
repository,
|
|
581
|
+
target=root,
|
|
582
|
+
client_id=client_id,
|
|
583
|
+
holder_pid=holder.pid,
|
|
584
|
+
session_id=session_id,
|
|
585
|
+
run_id=run_id,
|
|
586
|
+
holder=holder,
|
|
587
|
+
)["decision"]
|
|
588
|
+
except (ControllerError, OSError) as exc:
|
|
589
|
+
return {
|
|
590
|
+
"admission": "REFUSED",
|
|
591
|
+
"code": getattr(exc, "code", "PATH_UNAVAILABLE"),
|
|
592
|
+
"reason": str(exc),
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def main(argv: list[str] | None = None) -> int:
|
|
597
|
+
parser = argparse.ArgumentParser(prog="worktree-controller writer-session")
|
|
598
|
+
commands = parser.add_subparsers(dest="action", required=True)
|
|
599
|
+
host_parser = commands.add_parser("host-check", help="read-only exact host entitlement check")
|
|
600
|
+
host_parser.add_argument("path", type=Path)
|
|
601
|
+
host_parser.add_argument("--access", choices=HOST_ACCESS_CLASSES)
|
|
602
|
+
host_parser.add_argument("--target", type=Path)
|
|
603
|
+
describe_parser = commands.add_parser(
|
|
604
|
+
"describe", help="read-only recorded writer identity for a path"
|
|
605
|
+
)
|
|
606
|
+
describe_parser.add_argument("path", type=Path)
|
|
607
|
+
describe_parser.add_argument("holder", nargs="?", default=None)
|
|
608
|
+
acquire_parser = commands.add_parser("acquire")
|
|
609
|
+
acquire_parser.add_argument("client_id")
|
|
610
|
+
acquire_parser.add_argument("path", type=Path)
|
|
611
|
+
acquire_parser.add_argument("holder", nargs="?", default=str(os.getppid()))
|
|
612
|
+
check_parser = commands.add_parser("check")
|
|
613
|
+
check_parser.add_argument("client_id")
|
|
614
|
+
check_parser.add_argument("path", type=Path)
|
|
615
|
+
check_parser.add_argument("holder", nargs="?", default=str(os.getppid()))
|
|
616
|
+
check_parser.add_argument("--access", choices=HOST_ACCESS_CLASSES)
|
|
617
|
+
check_parser.add_argument("--target", type=Path)
|
|
618
|
+
release_parser = commands.add_parser("release")
|
|
619
|
+
release_parser.add_argument("path", type=Path)
|
|
620
|
+
release_parser.add_argument("holder", nargs="?", default=str(os.getppid()))
|
|
621
|
+
release_parser.add_argument("--client-id", required=True)
|
|
622
|
+
for subparser in (acquire_parser, check_parser, release_parser):
|
|
623
|
+
subparser.add_argument("--session-id")
|
|
624
|
+
subparser.add_argument("--run-id")
|
|
625
|
+
parsed = parser.parse_args(argv)
|
|
626
|
+
if parsed.action == "describe":
|
|
627
|
+
# Deliberately before the host-managed branch: a projection is read-only and
|
|
628
|
+
# never an admission, so a host context must not turn it into one.
|
|
629
|
+
decision = describe(parsed.path, selector=parsed.holder)
|
|
630
|
+
print(
|
|
631
|
+
json.dumps(
|
|
632
|
+
{
|
|
633
|
+
"command": "writer-session",
|
|
634
|
+
"holder_resolver_version": HOLDER_RESOLVER_VERSION,
|
|
635
|
+
"decision": decision,
|
|
636
|
+
},
|
|
637
|
+
sort_keys=True,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
return 0 if decision.get("code") != "PATH_UNAVAILABLE" else 2
|
|
641
|
+
if parsed.action == "host-check" or host_managed_requested():
|
|
642
|
+
# Even a legacy acquire/release invocation cannot mutate a host-owned lease.
|
|
643
|
+
decision = host_check(
|
|
644
|
+
parsed.path,
|
|
645
|
+
access=getattr(parsed, "access", None),
|
|
646
|
+
target=getattr(parsed, "target", None),
|
|
647
|
+
)
|
|
648
|
+
result = 0 if decision.get("admission") in {"GRANTED", "CONTINUATION_GRANTED"} else 2
|
|
649
|
+
elif parsed.action in {"acquire", "check"}:
|
|
650
|
+
extra = (
|
|
651
|
+
{"access": parsed.access, "target": parsed.target} if parsed.action == "check" else {}
|
|
652
|
+
)
|
|
653
|
+
operation = admission if parsed.action == "acquire" else check
|
|
654
|
+
decision = operation(
|
|
655
|
+
parsed.path,
|
|
656
|
+
client_id=parsed.client_id,
|
|
657
|
+
selector=parsed.holder,
|
|
658
|
+
session_id=parsed.session_id,
|
|
659
|
+
run_id=parsed.run_id,
|
|
660
|
+
**extra,
|
|
661
|
+
)
|
|
662
|
+
result = (
|
|
663
|
+
0
|
|
664
|
+
if decision.get("admission") in {"GRANTED", "NOT_APPLICABLE", "CONTINUATION_GRANTED"}
|
|
665
|
+
else 2
|
|
666
|
+
)
|
|
667
|
+
else:
|
|
668
|
+
# This entry point is for post-exit cleanup; failures stay visible but cannot
|
|
669
|
+
# replace the original client_id exit status. Direct release API/CLI is strict.
|
|
670
|
+
try:
|
|
671
|
+
target = governed_target(parsed.path)
|
|
672
|
+
if target is None:
|
|
673
|
+
decision = {"code": "NO_TERMINAL_ACTION"}
|
|
674
|
+
else:
|
|
675
|
+
repository, root = target
|
|
676
|
+
holder = resolve_holder(
|
|
677
|
+
parsed.holder,
|
|
678
|
+
client_id=parsed.client_id,
|
|
679
|
+
session_id=parsed.session_id,
|
|
680
|
+
run_id=parsed.run_id,
|
|
681
|
+
)
|
|
682
|
+
decision = release_writer_lease(repository, target=root, holder=holder)["decision"]
|
|
683
|
+
except (ControllerError, OSError) as exc:
|
|
684
|
+
decision = {
|
|
685
|
+
"code": "NO_TERMINAL_ACTION",
|
|
686
|
+
"blocker": getattr(exc, "code", "PATH_UNAVAILABLE"),
|
|
687
|
+
"reason": str(exc),
|
|
688
|
+
}
|
|
689
|
+
result = 0
|
|
690
|
+
print(
|
|
691
|
+
json.dumps(
|
|
692
|
+
{
|
|
693
|
+
"command": "writer-session",
|
|
694
|
+
"holder_resolver_version": HOLDER_RESOLVER_VERSION,
|
|
695
|
+
"decision": decision,
|
|
696
|
+
},
|
|
697
|
+
sort_keys=True,
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
return result
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def run_main(argv: list[str] | None = None) -> int:
|
|
704
|
+
"""Run any executable under the same explicit writer contract.
|
|
705
|
+
|
|
706
|
+
The parent holds the lease until its child finishes. Nested tools receive the
|
|
707
|
+
exact host context, so they check that lease instead of acquiring another one.
|
|
708
|
+
"""
|
|
709
|
+
parser = argparse.ArgumentParser(prog="worktree-session", allow_abbrev=False)
|
|
710
|
+
parser.add_argument("--path", type=Path, required=True)
|
|
711
|
+
parser.add_argument("--client-id", required=True)
|
|
712
|
+
parser.add_argument("--session-id", required=True)
|
|
713
|
+
parser.add_argument("--run-id")
|
|
714
|
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
715
|
+
parsed = parser.parse_args(argv)
|
|
716
|
+
command = parsed.command
|
|
717
|
+
if command[:1] == ["--"]:
|
|
718
|
+
command = command[1:]
|
|
719
|
+
if not command:
|
|
720
|
+
parser.error("an executable after -- is required")
|
|
721
|
+
if not parsed.session_id.strip() or (parsed.run_id is not None and not parsed.run_id.strip()):
|
|
722
|
+
parser.error("session_id and an explicit run_id must be nonempty")
|
|
723
|
+
try:
|
|
724
|
+
resolve_holder(
|
|
725
|
+
str(os.getpid()),
|
|
726
|
+
client_id=parsed.client_id,
|
|
727
|
+
session_id=parsed.session_id,
|
|
728
|
+
run_id=parsed.run_id,
|
|
729
|
+
)
|
|
730
|
+
except ControllerError as exc:
|
|
731
|
+
parser.error(str(exc))
|
|
732
|
+
path = parsed.path.expanduser().resolve()
|
|
733
|
+
identity = {
|
|
734
|
+
"client_id": parsed.client_id,
|
|
735
|
+
"selector": str(os.getpid()),
|
|
736
|
+
"session_id": parsed.session_id,
|
|
737
|
+
"run_id": parsed.run_id,
|
|
738
|
+
}
|
|
739
|
+
hosted = host_managed_requested()
|
|
740
|
+
execution = None
|
|
741
|
+
projected = None
|
|
742
|
+
if not hosted:
|
|
743
|
+
projected = describe(path)
|
|
744
|
+
if projected.get("admission") == "REFUSED":
|
|
745
|
+
print(json.dumps(projected, sort_keys=True), file=sys.stderr)
|
|
746
|
+
return 2
|
|
747
|
+
execution = "managed" if projected.get("writer_requirement") == "REQUIRED" else "host"
|
|
748
|
+
|
|
749
|
+
def reserve_execution(*, cancel: bool = False) -> dict[str, Any]:
|
|
750
|
+
from .resource_admission import admit_execution, host_execution
|
|
751
|
+
|
|
752
|
+
if execution == "managed":
|
|
753
|
+
return admit_execution(Path(projected["repository_path"]), target=Path(projected["worktree_path"]),
|
|
754
|
+
owner=projected["owner"], client_id=parsed.client_id, session_id=parsed.session_id,
|
|
755
|
+
run_id=parsed.run_id, cancel=cancel)["decision"]
|
|
756
|
+
return host_execution("cancel" if cancel else "admit", target=path,
|
|
757
|
+
client_id=parsed.client_id, session_id=parsed.session_id, run_id=parsed.run_id)["decision"]
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
resources = reserve_execution() if execution else None
|
|
761
|
+
except (ControllerError, OSError) as exc:
|
|
762
|
+
print(f"REFUSED: {exc}", file=sys.stderr)
|
|
763
|
+
return 2
|
|
764
|
+
waiting_reason = None
|
|
765
|
+
while resources and resources.get("code") == "RESOURCE_WAIT":
|
|
766
|
+
# This existing session wrapper owns the command. Waiting owns no writer;
|
|
767
|
+
# the persisted operation can be inspected after disconnection/restart.
|
|
768
|
+
reason = (resources.get("code"), tuple(resources.get("blockers", [])))
|
|
769
|
+
# Ctrl-C anywhere in the wait (report, sleep or re-poll) cancels it; a request
|
|
770
|
+
# admitted meanwhile refuses cancellation and stays for reconciliation.
|
|
771
|
+
try:
|
|
772
|
+
if reason != waiting_reason:
|
|
773
|
+
print(json.dumps({key: resources.get(key) for key in ("code", "blockers", "request_ids")}, sort_keys=True), file=sys.stderr)
|
|
774
|
+
waiting_reason = reason
|
|
775
|
+
import time
|
|
776
|
+
|
|
777
|
+
time.sleep(5)
|
|
778
|
+
try:
|
|
779
|
+
resources = reserve_execution()
|
|
780
|
+
except (ControllerError, OSError) as exc:
|
|
781
|
+
print(f"Waiting request retained for reconciliation: {exc}", file=sys.stderr)
|
|
782
|
+
return 2
|
|
783
|
+
except KeyboardInterrupt:
|
|
784
|
+
try:
|
|
785
|
+
reserve_execution(cancel=True)
|
|
786
|
+
except (ControllerError, OSError, KeyError) as exc:
|
|
787
|
+
print(f"Waiting request retained for reconciliation: {type(exc).__name__}", file=sys.stderr)
|
|
788
|
+
return 130
|
|
789
|
+
admitted = host_check(path) if hosted else admission(path, **identity)
|
|
790
|
+
if admitted.get("admission") not in {"GRANTED", "NOT_APPLICABLE"}:
|
|
791
|
+
print(json.dumps(admitted, sort_keys=True), file=sys.stderr)
|
|
792
|
+
if execution:
|
|
793
|
+
try:
|
|
794
|
+
reserve_execution(cancel=True)
|
|
795
|
+
except (ControllerError, OSError) as exc:
|
|
796
|
+
print(f"Admission cleanup deferred: {exc}", file=sys.stderr)
|
|
797
|
+
return 2
|
|
798
|
+
own_lease = not hosted and admitted.get("admission") == "GRANTED"
|
|
799
|
+
projection = None
|
|
800
|
+
finished = False
|
|
801
|
+
runtime_bound = False
|
|
802
|
+
try:
|
|
803
|
+
checked = host_check(path) if hosted else check(path, **identity)
|
|
804
|
+
if checked.get("admission") not in {"GRANTED", "NOT_APPLICABLE"}:
|
|
805
|
+
print(json.dumps(checked, sort_keys=True), file=sys.stderr)
|
|
806
|
+
return 2
|
|
807
|
+
environment = os.environ.copy()
|
|
808
|
+
if own_lease:
|
|
809
|
+
projection = describe(path, selector=identity["selector"])
|
|
810
|
+
lease = projection.get("lease")
|
|
811
|
+
if (
|
|
812
|
+
projection.get("holder_matches") is not True
|
|
813
|
+
or not isinstance(lease, dict)
|
|
814
|
+
or lease.get("lease_id") != admitted.get("lease_id")
|
|
815
|
+
):
|
|
816
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "admitted lease changed")
|
|
817
|
+
recorded = lease["holder"]
|
|
818
|
+
context = {
|
|
819
|
+
"holder": {key: recorded[key] for key in ("pid", "boot_id", "start_ticks")},
|
|
820
|
+
**{key: recorded[key] for key in ("client_id", "session_id", "run_id")},
|
|
821
|
+
**{
|
|
822
|
+
key: projection[key]
|
|
823
|
+
for key in ("repository_path", "worktree_path", "allocation_id")
|
|
824
|
+
},
|
|
825
|
+
"lease_id": lease["lease_id"],
|
|
826
|
+
}
|
|
827
|
+
environment["CONTROLLER_WRITER_MODE"] = HOST_MANAGED_MODE
|
|
828
|
+
environment["CONTROLLER_WRITER_CONTEXT"] = json.dumps(context, sort_keys=True)
|
|
829
|
+
if execution == "managed":
|
|
830
|
+
from .lifecycle import update_external_binding
|
|
831
|
+
from .writer_lease import RUNTIME_BINDING_PREFIX
|
|
832
|
+
|
|
833
|
+
snapshot = MetadataStore(inventory(path).common_dir).load()
|
|
834
|
+
record = snapshot.records[projection["allocation_id"]]
|
|
835
|
+
binding = RUNTIME_BINDING_PREFIX + json.dumps({"holder": recorded,
|
|
836
|
+
"lease_id": admitted["lease_id"], "kind": "command"}, sort_keys=True, separators=(",", ":"))
|
|
837
|
+
update_external_binding(Path(projection["repository_path"]), target=Path(projection["worktree_path"]),
|
|
838
|
+
owner=record.owner, expected_record_version=record.record_version, binding=binding, action="add")
|
|
839
|
+
runtime_bound = True
|
|
840
|
+
elif execution == "host":
|
|
841
|
+
from .resource_admission import host_execution
|
|
842
|
+
|
|
843
|
+
host_execution("bind", request_id=resources["request_ids"][0], holder_pid=os.getpid())
|
|
844
|
+
runtime_bound = True
|
|
845
|
+
result = subprocess.run(command, cwd=path, env=environment, check=False).returncode
|
|
846
|
+
finished = True
|
|
847
|
+
return result if result >= 0 else 128 - result
|
|
848
|
+
except KeyboardInterrupt:
|
|
849
|
+
return 130
|
|
850
|
+
except (ControllerError, OSError) as exc:
|
|
851
|
+
print(f"REFUSED: {exc}", file=sys.stderr)
|
|
852
|
+
return 2
|
|
853
|
+
finally:
|
|
854
|
+
if not hosted and finished and projected and projected.get("repository_path"):
|
|
855
|
+
try:
|
|
856
|
+
from .session import main as session_end
|
|
857
|
+
|
|
858
|
+
# The starting checkout may already have been handed off and
|
|
859
|
+
# released. Select the real session across the repository.
|
|
860
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
861
|
+
session_end(["--path", projected["repository_path"],
|
|
862
|
+
"--trigger", "task-terminal", "--client-id", parsed.client_id,
|
|
863
|
+
"--session-id", parsed.session_id, "--holder", identity["selector"],
|
|
864
|
+
*(["--run-id", parsed.run_id] if parsed.run_id is not None else [])])
|
|
865
|
+
except (ControllerError, OSError) as exc:
|
|
866
|
+
print(f"Session cleanup deferred: {exc}", file=sys.stderr)
|
|
867
|
+
elif own_lease:
|
|
868
|
+
try:
|
|
869
|
+
target = governed_target(path)
|
|
870
|
+
if target is not None:
|
|
871
|
+
repository, root = target
|
|
872
|
+
holder = resolve_holder(
|
|
873
|
+
identity["selector"],
|
|
874
|
+
client_id=parsed.client_id,
|
|
875
|
+
session_id=parsed.session_id,
|
|
876
|
+
run_id=parsed.run_id,
|
|
877
|
+
)
|
|
878
|
+
release_writer_lease(
|
|
879
|
+
repository, target=root, holder=holder, lease_id=admitted["lease_id"]
|
|
880
|
+
)
|
|
881
|
+
except (ControllerError, OSError) as exc:
|
|
882
|
+
print(f"Session cleanup deferred: {exc}", file=sys.stderr)
|
|
883
|
+
if execution and not runtime_bound:
|
|
884
|
+
try:
|
|
885
|
+
reserve_execution(cancel=True)
|
|
886
|
+
except (ControllerError, OSError) as exc:
|
|
887
|
+
print(f"Execution cleanup deferred: {exc}", file=sys.stderr)
|