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,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)