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,662 @@
|
|
|
1
|
+
"""Writer leases: temporary write entitlement, separate from durable task identity.
|
|
2
|
+
|
|
3
|
+
An allocation records *who owns a task* and preserves its work. A writer lease
|
|
4
|
+
records *which live process may currently write through it*. Keeping the two apart
|
|
5
|
+
is what lets capacity be reclaimed without ever asserting that a task finished: a
|
|
6
|
+
lease can be proven dead while the allocation, its owner and its uncommitted work
|
|
7
|
+
stay exactly as they were.
|
|
8
|
+
|
|
9
|
+
Holder identity deliberately never rests on a pid alone. Linux reuses pids, so a
|
|
10
|
+
lease naming a recycled pid would read as live and permanently pin a writer slot.
|
|
11
|
+
The holder is the triple (boot id, pid, kernel start ticks), which no later process
|
|
12
|
+
can satisfy, optionally strengthened by a client_id session id.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import asdict, replace
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .models import (
|
|
28
|
+
CANONICAL_ROLES,
|
|
29
|
+
FEATURE_AUTHORITY_ROLES,
|
|
30
|
+
RECOVERY_ROLES,
|
|
31
|
+
ControllerError,
|
|
32
|
+
MetadataRecord,
|
|
33
|
+
WriterHolder,
|
|
34
|
+
WriterLease,
|
|
35
|
+
)
|
|
36
|
+
from .store import WRITER_LEASE_SCHEMA_VERSION
|
|
37
|
+
|
|
38
|
+
CLIENT_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
|
39
|
+
HOLDER_RESOLVER_VERSION = 1
|
|
40
|
+
_BOOT_ID_FILE = Path("/proc/sys/kernel/random/boot_id")
|
|
41
|
+
RUNTIME_BINDING_PREFIX = "runtime:process-v1:"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def runtime_binding(value: str) -> tuple[WriterHolder, str | None] | None:
|
|
45
|
+
"""Decode a typed occupancy within the existing external-binding registry."""
|
|
46
|
+
if not value.startswith(RUNTIME_BINDING_PREFIX):
|
|
47
|
+
return None
|
|
48
|
+
def unique(pairs):
|
|
49
|
+
result = {}
|
|
50
|
+
for key, item in pairs:
|
|
51
|
+
if key in result:
|
|
52
|
+
raise ValueError("duplicate runtime identity field")
|
|
53
|
+
result[key] = item
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(value[len(RUNTIME_BINDING_PREFIX):], object_pairs_hook=unique)
|
|
58
|
+
if set(data) != {"holder", "lease_id", "kind"} or data["kind"] not in {"agent", "test", "preview", "background", "command"}:
|
|
59
|
+
raise ValueError("invalid runtime binding")
|
|
60
|
+
identity = data["holder"]
|
|
61
|
+
if set(identity) != {"pid", "boot_id", "start_ticks", "client_id", "session_id", "run_id"}:
|
|
62
|
+
raise ValueError("incomplete runtime holder")
|
|
63
|
+
if any(type(identity[key]) is not int or identity[key] < 1 for key in ("pid", "start_ticks")):
|
|
64
|
+
raise ValueError("invalid runtime holder")
|
|
65
|
+
if not isinstance(identity["client_id"], str) or not CLIENT_ID_PATTERN.fullmatch(identity["client_id"]):
|
|
66
|
+
raise ValueError("invalid runtime client")
|
|
67
|
+
for key in ("session_id", "run_id"):
|
|
68
|
+
if identity[key] is not None and (not isinstance(identity[key], str) or not identity[key] or "\x00" in identity[key]):
|
|
69
|
+
raise ValueError("invalid runtime identity")
|
|
70
|
+
uuid.UUID(identity["boot_id"])
|
|
71
|
+
if data["lease_id"] is not None:
|
|
72
|
+
uuid.UUID(data["lease_id"])
|
|
73
|
+
return WriterHolder(**identity), data["lease_id"]
|
|
74
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
75
|
+
raise ControllerError("INVALID_RUNTIME_BINDING", "runtime occupancy identity is malformed") from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def assert_runtime_clear(target: Path, holders: tuple[WriterHolder, ...]) -> None:
|
|
79
|
+
"""Clear this execution's occupancy; allocation release still checks all references."""
|
|
80
|
+
from .environment import _current_process_identity, runtime_authority_evidence
|
|
81
|
+
|
|
82
|
+
if any(holder_is_live(holder) or process_group_is_live(holder) for holder in holders):
|
|
83
|
+
raise ControllerError("RUNTIME_OCCUPIED", "runtime holder is still alive")
|
|
84
|
+
evidence = runtime_authority_evidence(target, holders=holders)
|
|
85
|
+
observed = []
|
|
86
|
+
for reference in evidence["observed_references"]:
|
|
87
|
+
process = _current_process_identity(Path("/proc") / str(reference["pid"]))
|
|
88
|
+
# A still-open human terminal can predate this entire execution. Kernel
|
|
89
|
+
# creation order proves it is not a descendant of any declared holder.
|
|
90
|
+
# Newer, reused or unobservable identities remain blocking. The allocation
|
|
91
|
+
# cleanup path retains this positive reference without this exclusion.
|
|
92
|
+
if (not holders or process is None or any(holder.boot_id != boot_id()
|
|
93
|
+
or process.start_ticks >= holder.start_ticks for holder in holders)):
|
|
94
|
+
observed.append(reference)
|
|
95
|
+
if observed or any(evidence[key] for key in ("managed_references", "managed_visibility_uncertainties", "scan_uncertainties")):
|
|
96
|
+
raise ControllerError("RUNTIME_OCCUPIED", "runtime remains until managed and observed use is clear")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def foreign_runtime_bindings(record: MetadataRecord, holder: WriterHolder, lease_id: str | None) -> tuple[str, ...]:
|
|
100
|
+
"""One owner's runtime occupancy does not fence its own write entitlement."""
|
|
101
|
+
foreign = []
|
|
102
|
+
for value in record.external_bindings:
|
|
103
|
+
declared = runtime_binding(value)
|
|
104
|
+
# A descendant already admitted by the formal transfer operation may keep
|
|
105
|
+
# running after the root writer exits. It shares occupancy, not ownership.
|
|
106
|
+
if (declared is None or lease_id != declared[1]
|
|
107
|
+
or (holder.client_id, holder.session_id, holder.run_id)
|
|
108
|
+
!= (declared[0].client_id, declared[0].session_id, declared[0].run_id)):
|
|
109
|
+
foreign.append(value)
|
|
110
|
+
return tuple(foreign)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def writer_requirement(role: str) -> str:
|
|
114
|
+
"""Classify declared roles, never Controller errors or future unknown roles.
|
|
115
|
+
|
|
116
|
+
NOT_APPLICABLE grants no write authority: integration, recovery, publication
|
|
117
|
+
and control-plane operations retain their dedicated write/lifecycle gates.
|
|
118
|
+
"""
|
|
119
|
+
if role in FEATURE_AUTHORITY_ROLES:
|
|
120
|
+
return "REQUIRED"
|
|
121
|
+
if role in CANONICAL_ROLES | RECOVERY_ROLES:
|
|
122
|
+
return "NOT_APPLICABLE"
|
|
123
|
+
raise ControllerError("INVALID_ROLE", f"no writer applicability contract for role {role}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def now() -> str:
|
|
127
|
+
return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def boot_id() -> str:
|
|
131
|
+
"""Identify this boot, so a lease can never survive a reboot into a reused pid."""
|
|
132
|
+
try:
|
|
133
|
+
value = _BOOT_ID_FILE.read_text(encoding="utf-8").strip()
|
|
134
|
+
except OSError:
|
|
135
|
+
value = ""
|
|
136
|
+
if not value or value == "unknown-boot":
|
|
137
|
+
raise ControllerError("INVALID_HOLDER", "boot identity is unavailable")
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def start_ticks(pid: int) -> int | None:
|
|
142
|
+
"""Kernel start time for ``pid``, or None when the process is gone.
|
|
143
|
+
|
|
144
|
+
Field 22 of ``/proc/<pid>/stat`` counts clock ticks since boot. The comm field
|
|
145
|
+
can contain spaces and parentheses, so the tail is parsed after the final ')'.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
149
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
150
|
+
return None
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise ControllerError("INVALID_HOLDER", f"process {pid} cannot be inspected") from exc
|
|
153
|
+
cut = raw.rfind(")")
|
|
154
|
+
if cut == -1:
|
|
155
|
+
raise ControllerError("INVALID_HOLDER", "invalid process stat")
|
|
156
|
+
fields = raw[cut + 2 :].split()
|
|
157
|
+
if len(fields) < 20:
|
|
158
|
+
raise ControllerError("INVALID_HOLDER", "incomplete process stat")
|
|
159
|
+
try:
|
|
160
|
+
return int(fields[19])
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
raise ControllerError("INVALID_HOLDER", "invalid process start ticks") from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_holder(
|
|
166
|
+
selector: str, *, client_id: str, session_id: str | None = None, run_id: str | None = None
|
|
167
|
+
) -> WriterHolder:
|
|
168
|
+
"""Version 1 resolver shared by CLI, hooks and wrappers; never match shell arguments."""
|
|
169
|
+
if selector.startswith("ancestor:"):
|
|
170
|
+
name = selector.removeprefix("ancestor:")
|
|
171
|
+
if not name or Path(name).name != name:
|
|
172
|
+
raise ControllerError("INVALID_HOLDER", "an ancestor program name is required")
|
|
173
|
+
pid = os.getppid()
|
|
174
|
+
for _ in range(32):
|
|
175
|
+
if pid <= 1:
|
|
176
|
+
break
|
|
177
|
+
try:
|
|
178
|
+
before = start_ticks(pid)
|
|
179
|
+
argv0 = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0", 1)[0]
|
|
180
|
+
executable = Path(os.readlink(f"/proc/{pid}/exe"))
|
|
181
|
+
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
182
|
+
parent = int(raw[raw.rfind(")") + 2 :].split()[1])
|
|
183
|
+
except (OSError, ValueError, IndexError) as exc:
|
|
184
|
+
raise ControllerError("INVALID_HOLDER", "ancestor identity unavailable") from exc
|
|
185
|
+
if before is None or start_ticks(pid) != before:
|
|
186
|
+
raise ControllerError("INVALID_HOLDER", "ancestor process changed")
|
|
187
|
+
if Path(os.fsdecode(argv0)).name == name or executable.name == name:
|
|
188
|
+
holder = describe_holder(
|
|
189
|
+
pid, client_id=client_id, session_id=session_id, run_id=run_id
|
|
190
|
+
)
|
|
191
|
+
if holder.start_ticks != before:
|
|
192
|
+
raise ControllerError("INVALID_HOLDER", "ancestor process changed")
|
|
193
|
+
return holder
|
|
194
|
+
pid = parent
|
|
195
|
+
raise ControllerError("INVALID_HOLDER", f"no ancestor process runs {name!r}")
|
|
196
|
+
if not selector.isdecimal():
|
|
197
|
+
raise ControllerError("INVALID_HOLDER", "holder must be a pid or ancestor:<program>")
|
|
198
|
+
return describe_holder(int(selector), client_id=client_id, session_id=session_id, run_id=run_id)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def same_holder(left: WriterHolder, right: WriterHolder) -> bool:
|
|
202
|
+
"""Optional session/run identity cannot be omitted to inherit another entitlement."""
|
|
203
|
+
return left == right
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def describe_holder(
|
|
207
|
+
pid: int,
|
|
208
|
+
*,
|
|
209
|
+
client_id: str,
|
|
210
|
+
session_id: str | None = None,
|
|
211
|
+
run_id: str | None = None,
|
|
212
|
+
) -> WriterHolder:
|
|
213
|
+
if not isinstance(client_id, str) or not CLIENT_ID_PATTERN.fullmatch(client_id) or pid <= 0:
|
|
214
|
+
raise ControllerError(
|
|
215
|
+
"INVALID_HOLDER", "writer client_id must be a bounded opaque identifier"
|
|
216
|
+
)
|
|
217
|
+
ticks = start_ticks(pid)
|
|
218
|
+
if ticks is None:
|
|
219
|
+
raise ControllerError("INVALID_HOLDER", f"process {pid} is not running")
|
|
220
|
+
return WriterHolder(
|
|
221
|
+
client_id=client_id,
|
|
222
|
+
pid=pid,
|
|
223
|
+
boot_id=boot_id(),
|
|
224
|
+
start_ticks=ticks,
|
|
225
|
+
session_id=session_id,
|
|
226
|
+
run_id=run_id,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def holder_is_live(holder: WriterHolder) -> bool:
|
|
231
|
+
"""True only when the exact original process is still running.
|
|
232
|
+
|
|
233
|
+
A different boot, a vanished pid, or a pid whose start time no longer matches
|
|
234
|
+
all mean the original holder is gone - never that its task finished.
|
|
235
|
+
"""
|
|
236
|
+
if not holder.boot_id or holder.boot_id == "unknown-boot":
|
|
237
|
+
raise ControllerError("INVALID_HOLDER", "recorded boot identity is unknown")
|
|
238
|
+
if holder.boot_id != boot_id():
|
|
239
|
+
return False
|
|
240
|
+
return start_ticks(holder.pid) == holder.start_ticks
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def process_group_is_live(holder: WriterHolder) -> bool:
|
|
244
|
+
"""True when the holder's process group still has a member running.
|
|
245
|
+
|
|
246
|
+
A wrapper can die while the real work continues in a surviving child, so the
|
|
247
|
+
group is checked before a lease is treated as abandoned.
|
|
248
|
+
"""
|
|
249
|
+
if holder.boot_id != boot_id():
|
|
250
|
+
return False
|
|
251
|
+
ticks = start_ticks(holder.pid)
|
|
252
|
+
if ticks is not None and ticks != holder.start_ticks:
|
|
253
|
+
# A reused pid's new group is not evidence about the original holder.
|
|
254
|
+
return False
|
|
255
|
+
try:
|
|
256
|
+
pgid = os.getpgid(holder.pid)
|
|
257
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
258
|
+
pgid = holder.pid
|
|
259
|
+
for entry in os.listdir("/proc"):
|
|
260
|
+
if not entry.isdigit():
|
|
261
|
+
continue
|
|
262
|
+
candidate = int(entry)
|
|
263
|
+
try:
|
|
264
|
+
if os.getpgid(candidate) == pgid:
|
|
265
|
+
return True
|
|
266
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
267
|
+
continue
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def own_ancestry() -> set[int]:
|
|
272
|
+
"""This process and its ancestors: the caller asking for an action is not a user of the checkout."""
|
|
273
|
+
chain, pid = set(), os.getpid()
|
|
274
|
+
while pid > 1 and pid not in chain:
|
|
275
|
+
chain.add(pid)
|
|
276
|
+
try:
|
|
277
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
278
|
+
except OSError:
|
|
279
|
+
break
|
|
280
|
+
pid = int(stat.rsplit(")", 1)[1].split()[1])
|
|
281
|
+
return chain
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def paths_in_use(target: Path) -> list[tuple[int, str]]:
|
|
285
|
+
"""Live processes whose cwd, open files, or mappings sit inside ``target``.
|
|
286
|
+
|
|
287
|
+
This is the evidence that covers a session holding no lease at all, so a
|
|
288
|
+
legacy or externally started writer is never mistaken for an absent one.
|
|
289
|
+
"""
|
|
290
|
+
return ProcessPathScan().in_use(target)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ProcessPathScan:
|
|
294
|
+
"""One ``/proc`` reading shared by every target checked in a single pass.
|
|
295
|
+
|
|
296
|
+
Reading each process's cwd, descriptors and mappings is the expensive part, so a
|
|
297
|
+
pass that checks many checkouts reads them once, on first use, instead of once
|
|
298
|
+
per checkout. Each answer is the same as a fresh ``paths_in_use`` over that
|
|
299
|
+
reading; the reading is as old as the pass, like any other pass-time evidence.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(self) -> None:
|
|
303
|
+
self._references: list[tuple[int, list[str]]] | None = None
|
|
304
|
+
|
|
305
|
+
def in_use(self, target: Path) -> list[tuple[int, str]]:
|
|
306
|
+
root = str(target)
|
|
307
|
+
prefix = root + os.sep
|
|
308
|
+
return [
|
|
309
|
+
(pid, _command(pid))
|
|
310
|
+
for pid, candidates in self._read_references()
|
|
311
|
+
if any(candidate == root or candidate.startswith(prefix) for candidate in candidates)
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
def _read_references(self) -> list[tuple[int, list[str]]]:
|
|
315
|
+
if self._references is None:
|
|
316
|
+
self._references = [
|
|
317
|
+
(pid, _referenced_paths(pid))
|
|
318
|
+
for pid in (int(entry) for entry in os.listdir("/proc") if entry.isdigit())
|
|
319
|
+
if pid != os.getpid()
|
|
320
|
+
]
|
|
321
|
+
return self._references
|
|
322
|
+
|
|
323
|
+
def referenced_paths_in_use(self, target: Path) -> list[tuple[int, list[str]]]:
|
|
324
|
+
"""Reuse the same scan without reading process command-line arguments."""
|
|
325
|
+
root = str(target)
|
|
326
|
+
prefix = root + os.sep
|
|
327
|
+
result = []
|
|
328
|
+
for pid, candidates in self._read_references():
|
|
329
|
+
matches = [path for path in candidates if path == root or path.startswith(prefix)]
|
|
330
|
+
if matches:
|
|
331
|
+
result.append((pid, matches))
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _referenced_paths(pid: int) -> list[str]:
|
|
336
|
+
seen: list[str] = []
|
|
337
|
+
with contextlib.suppress(OSError):
|
|
338
|
+
seen.append(os.path.realpath(f"/proc/{pid}/cwd"))
|
|
339
|
+
try:
|
|
340
|
+
for handle in os.listdir(f"/proc/{pid}/fd"):
|
|
341
|
+
try:
|
|
342
|
+
seen.append(os.path.realpath(f"/proc/{pid}/fd/{handle}"))
|
|
343
|
+
except OSError:
|
|
344
|
+
continue
|
|
345
|
+
except OSError:
|
|
346
|
+
pass
|
|
347
|
+
try:
|
|
348
|
+
with open(f"/proc/{pid}/maps", encoding="utf-8") as stream:
|
|
349
|
+
for line in stream:
|
|
350
|
+
parts = line.rstrip("\n").split(" ", 5)
|
|
351
|
+
if len(parts) == 6 and parts[5].startswith("/"):
|
|
352
|
+
seen.append(parts[5].strip())
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
return seen
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _command(pid: int) -> str:
|
|
359
|
+
try:
|
|
360
|
+
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
361
|
+
except OSError:
|
|
362
|
+
return "<unknown>"
|
|
363
|
+
return raw.replace(b"\0", b" ").decode("utf-8", "replace").strip()[:120]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def new_lease(
|
|
367
|
+
*,
|
|
368
|
+
repository_id: str,
|
|
369
|
+
allocation_id: str,
|
|
370
|
+
worktree_path: str,
|
|
371
|
+
owner: str,
|
|
372
|
+
holder: WriterHolder,
|
|
373
|
+
ttl_seconds: int | None,
|
|
374
|
+
) -> WriterLease:
|
|
375
|
+
stamp = now()
|
|
376
|
+
expires_at = None
|
|
377
|
+
if ttl_seconds is not None:
|
|
378
|
+
if ttl_seconds <= 0:
|
|
379
|
+
raise ControllerError("INVALID_HOLDER", "lease ttl must be positive")
|
|
380
|
+
moment = datetime.fromisoformat(stamp)
|
|
381
|
+
expires_at = moment.fromtimestamp(
|
|
382
|
+
moment.timestamp() + ttl_seconds, tz=moment.tzinfo
|
|
383
|
+
).isoformat(timespec="seconds")
|
|
384
|
+
return WriterLease(
|
|
385
|
+
schema_version=WRITER_LEASE_SCHEMA_VERSION,
|
|
386
|
+
lease_id=str(uuid.uuid4()),
|
|
387
|
+
repository_id=repository_id,
|
|
388
|
+
allocation_id=allocation_id,
|
|
389
|
+
worktree_path=worktree_path,
|
|
390
|
+
owner=owner,
|
|
391
|
+
holder=holder,
|
|
392
|
+
acquired_at=stamp,
|
|
393
|
+
renewed_at=stamp,
|
|
394
|
+
expires_at=expires_at,
|
|
395
|
+
record_version=1,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def lease_is_expired(lease: WriterLease, *, at: str | None = None) -> bool:
|
|
400
|
+
"""Expiry means the writer claim needs verification - never that work is done."""
|
|
401
|
+
if lease.expires_at is None:
|
|
402
|
+
return False
|
|
403
|
+
moment = datetime.fromisoformat(at or now())
|
|
404
|
+
return datetime.fromisoformat(lease.expires_at) < moment
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def acquire_writer_lease(
|
|
408
|
+
repository_path: Path,
|
|
409
|
+
*,
|
|
410
|
+
target: Path,
|
|
411
|
+
owner: str | None = None,
|
|
412
|
+
client_id: str,
|
|
413
|
+
holder_pid: int,
|
|
414
|
+
session_id: str | None = None,
|
|
415
|
+
run_id: str | None = None,
|
|
416
|
+
ttl_seconds: int | None = None,
|
|
417
|
+
holder: WriterHolder | None = None,
|
|
418
|
+
expected_record_version: int | None = None,
|
|
419
|
+
observe: bool = True,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
"""Claim write entitlement for one live process, without touching ownership."""
|
|
422
|
+
|
|
423
|
+
from .controller import envelope
|
|
424
|
+
from .controller import status as _status
|
|
425
|
+
from .git_backend import inventory as _inventory
|
|
426
|
+
from .policy import load_policy
|
|
427
|
+
from .semantics import assert_record_version as _cas
|
|
428
|
+
from .semantics import managed_record as _managed_record
|
|
429
|
+
from .store import MetadataStore as _Store
|
|
430
|
+
|
|
431
|
+
repository_initial = _inventory(repository_path)
|
|
432
|
+
try:
|
|
433
|
+
selected = _inventory(target)
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
raise ControllerError("INVALID_TARGET", "writer path is unavailable") from exc
|
|
436
|
+
if selected.common_dir != repository_initial.common_dir:
|
|
437
|
+
raise ControllerError("UNREGISTERED_WORKTREE", "writer path belongs to another repository")
|
|
438
|
+
target = selected.requested_path
|
|
439
|
+
store = _Store(repository_initial.common_dir)
|
|
440
|
+
if not store.lock_file.is_file() or store.lock_file.is_symlink():
|
|
441
|
+
raise ControllerError(
|
|
442
|
+
"INVALID_METADATA", "governed writer acquisition requires its repository lock"
|
|
443
|
+
)
|
|
444
|
+
with store.lock(exclusive=True):
|
|
445
|
+
policy = load_policy(repository_initial.canonical_path)
|
|
446
|
+
if policy.path.is_symlink():
|
|
447
|
+
raise ControllerError("INVALID_POLICY", "policy marker is a symlink")
|
|
448
|
+
snapshot = store._load_unlocked()
|
|
449
|
+
if snapshot.repository_id is None:
|
|
450
|
+
raise ControllerError("INVALID_METADATA", "governed repository identity is missing")
|
|
451
|
+
item, record = _managed_record(_inventory(repository_path), snapshot, target)
|
|
452
|
+
if expected_record_version is not None:
|
|
453
|
+
_cas(record, expected_record_version)
|
|
454
|
+
if item.detached or item.locked or item.prunable:
|
|
455
|
+
raise ControllerError("INVALID_TARGET", "writer checkout requires review")
|
|
456
|
+
# A wrapper at a process boundary cannot know the owner string; being able to
|
|
457
|
+
# run in the governed checkout is the entitlement. A caller that does declare
|
|
458
|
+
# an owner is still held to it, so a declared mismatch fails closed.
|
|
459
|
+
if owner is not None and record.owner != owner:
|
|
460
|
+
raise ControllerError(
|
|
461
|
+
"OWNER_MISMATCH", "writer lease owner does not match the allocation owner"
|
|
462
|
+
)
|
|
463
|
+
observed = describe_holder(
|
|
464
|
+
holder_pid, client_id=client_id, session_id=session_id, run_id=run_id
|
|
465
|
+
)
|
|
466
|
+
if holder is not None and not same_holder(holder, observed):
|
|
467
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "holder changed during acquisition")
|
|
468
|
+
holder = observed
|
|
469
|
+
if ttl_seconds is not None and ttl_seconds <= 0:
|
|
470
|
+
raise ControllerError("INVALID_HOLDER", "lease ttl must be positive")
|
|
471
|
+
if writer_requirement(record.role) == "NOT_APPLICABLE":
|
|
472
|
+
payload = envelope("writer-lease-acquire")
|
|
473
|
+
payload["decision"] = {
|
|
474
|
+
"admission": "NOT_APPLICABLE",
|
|
475
|
+
"code": "DEDICATED_ROLE_GATE",
|
|
476
|
+
"role": record.role,
|
|
477
|
+
"allocation_id": record.allocation_id,
|
|
478
|
+
"reason": "no ordinary writer entitlement; dedicated operation gates still apply",
|
|
479
|
+
}
|
|
480
|
+
return payload
|
|
481
|
+
existing = snapshot.writer_leases.get(record.allocation_id)
|
|
482
|
+
if foreign_runtime_bindings(record, holder, existing.lease_id if existing else None):
|
|
483
|
+
raise ControllerError("EXTERNAL_BINDING", "runtime bindings prevent a new writer")
|
|
484
|
+
if record.lifecycle != "ACTIVE":
|
|
485
|
+
raise ControllerError(
|
|
486
|
+
"INVALID_LIFECYCLE",
|
|
487
|
+
f"lifecycle {record.lifecycle} cannot take a writer lease; resume it first",
|
|
488
|
+
)
|
|
489
|
+
if existing is not None and not same_holder(existing.holder, holder) and (
|
|
490
|
+
holder_is_live(existing.holder) or process_group_is_live(existing.holder) or paths_in_use(item.path)
|
|
491
|
+
):
|
|
492
|
+
raise ControllerError("WRITER_LEASE_HELD", "another live process already holds this allocation")
|
|
493
|
+
if existing is not None:
|
|
494
|
+
same = same_holder(existing.holder, holder)
|
|
495
|
+
if (existing.repository_id, existing.worktree_path, existing.owner) != (
|
|
496
|
+
snapshot.repository_id,
|
|
497
|
+
str(item.path),
|
|
498
|
+
record.owner,
|
|
499
|
+
):
|
|
500
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "lease allocation identity drifted")
|
|
501
|
+
if same:
|
|
502
|
+
if lease_is_expired(existing) and ttl_seconds is None:
|
|
503
|
+
raise ControllerError(
|
|
504
|
+
"WRITER_LEASE_EXPIRED", "expired lease requires explicit renewal ttl"
|
|
505
|
+
)
|
|
506
|
+
renewed = replace(
|
|
507
|
+
existing, renewed_at=now(), record_version=existing.record_version + 1
|
|
508
|
+
)
|
|
509
|
+
if ttl_seconds is not None:
|
|
510
|
+
renewed = replace(
|
|
511
|
+
renewed,
|
|
512
|
+
expires_at=_shift(renewed.renewed_at, ttl_seconds),
|
|
513
|
+
)
|
|
514
|
+
store.write_writer_lease(renewed)
|
|
515
|
+
lease = renewed
|
|
516
|
+
code = "WRITER_LEASE_RENEWED"
|
|
517
|
+
elif (
|
|
518
|
+
holder_is_live(existing.holder)
|
|
519
|
+
or process_group_is_live(existing.holder)
|
|
520
|
+
or paths_in_use(item.path)
|
|
521
|
+
):
|
|
522
|
+
raise ControllerError(
|
|
523
|
+
"WRITER_LEASE_HELD",
|
|
524
|
+
"another live process already holds the writer lease for this allocation",
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
lease = new_lease(
|
|
528
|
+
repository_id=snapshot.repository_id or "unknown",
|
|
529
|
+
allocation_id=record.allocation_id,
|
|
530
|
+
worktree_path=str(item.path),
|
|
531
|
+
owner=record.owner,
|
|
532
|
+
holder=holder,
|
|
533
|
+
ttl_seconds=ttl_seconds,
|
|
534
|
+
)
|
|
535
|
+
store.write_writer_lease(lease)
|
|
536
|
+
code = "WRITER_LEASE_TAKEN_OVER"
|
|
537
|
+
else:
|
|
538
|
+
lease = new_lease(
|
|
539
|
+
repository_id=snapshot.repository_id or "unknown",
|
|
540
|
+
allocation_id=record.allocation_id,
|
|
541
|
+
worktree_path=str(item.path),
|
|
542
|
+
owner=record.owner,
|
|
543
|
+
holder=holder,
|
|
544
|
+
ttl_seconds=ttl_seconds,
|
|
545
|
+
)
|
|
546
|
+
store.write_writer_lease(lease)
|
|
547
|
+
code = "WRITER_LEASE_ACQUIRED"
|
|
548
|
+
payload = _status(repository_path) if observe else envelope("writer-lease-acquire")
|
|
549
|
+
payload["command"] = "writer-lease-acquire"
|
|
550
|
+
payload["decision"] = {
|
|
551
|
+
"code": code,
|
|
552
|
+
"admission": "GRANTED",
|
|
553
|
+
"reason": "writer entitlement bound to a verifiable live process",
|
|
554
|
+
"lease_id": lease.lease_id,
|
|
555
|
+
"allocation_id": lease.allocation_id,
|
|
556
|
+
"owner": lease.owner,
|
|
557
|
+
"holder": asdict(lease.holder),
|
|
558
|
+
"holder_resolver_version": HOLDER_RESOLVER_VERSION,
|
|
559
|
+
"expires_at": lease.expires_at,
|
|
560
|
+
}
|
|
561
|
+
return payload
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _shift(stamp: str, seconds: int) -> str:
|
|
565
|
+
moment = datetime.fromisoformat(stamp)
|
|
566
|
+
return moment.fromtimestamp(moment.timestamp() + seconds, tz=moment.tzinfo).isoformat(
|
|
567
|
+
timespec="seconds"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def release_writer_lease(
|
|
572
|
+
repository_path: Path,
|
|
573
|
+
*,
|
|
574
|
+
target: Path,
|
|
575
|
+
holder_pid: int | None = None,
|
|
576
|
+
lease_id: str | None = None,
|
|
577
|
+
holder: WriterHolder | None = None,
|
|
578
|
+
) -> dict[str, Any]:
|
|
579
|
+
"""Give up write entitlement. This says nothing about whether the task finished."""
|
|
580
|
+
|
|
581
|
+
from .controller import status as _status
|
|
582
|
+
from .git_backend import inventory as _inventory
|
|
583
|
+
from .semantics import managed_record as _managed_record
|
|
584
|
+
from .store import MetadataStore as _Store
|
|
585
|
+
|
|
586
|
+
if holder is None:
|
|
587
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "complete holder identity is required")
|
|
588
|
+
if holder_pid is not None and holder_pid != holder.pid:
|
|
589
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "conflicting holder pid")
|
|
590
|
+
repository_initial = _inventory(repository_path)
|
|
591
|
+
store = _Store(repository_initial.common_dir)
|
|
592
|
+
removed = False
|
|
593
|
+
exited = False
|
|
594
|
+
reason = "no writer lease was held"
|
|
595
|
+
with store.lock(exclusive=True):
|
|
596
|
+
snapshot = store._load_unlocked()
|
|
597
|
+
item, record = _managed_record(_inventory(repository_path), snapshot, target)
|
|
598
|
+
lease = snapshot.writer_leases.get(record.allocation_id)
|
|
599
|
+
if lease is not None:
|
|
600
|
+
if lease_id is not None and lease.lease_id != lease_id:
|
|
601
|
+
raise ControllerError(
|
|
602
|
+
"WRITER_LEASE_MISMATCH", "lease id does not match the recorded lease"
|
|
603
|
+
)
|
|
604
|
+
if not same_holder(lease.holder, holder):
|
|
605
|
+
raise ControllerError(
|
|
606
|
+
"WRITER_LEASE_MISMATCH", "exact live holder identity does not match the lease"
|
|
607
|
+
)
|
|
608
|
+
exited = not holder_is_live(holder)
|
|
609
|
+
if exited and (
|
|
610
|
+
lease_id is None
|
|
611
|
+
or process_group_is_live(holder)
|
|
612
|
+
or any(pid not in own_ancestry() for pid, _ in paths_in_use(item.path))
|
|
613
|
+
):
|
|
614
|
+
# A host releases after its runtime reaped the session; the exact recorded
|
|
615
|
+
# identity and lease ID prove it is that holder's own lease. Surviving
|
|
616
|
+
# children, or any other process still inside the checkout, keep it.
|
|
617
|
+
raise ControllerError(
|
|
618
|
+
"WRITER_LEASE_MISMATCH",
|
|
619
|
+
"an exited holder's lease needs its exact lease ID and no surviving children",
|
|
620
|
+
)
|
|
621
|
+
removed = store.remove_writer_lease(record.allocation_id)
|
|
622
|
+
reason = (
|
|
623
|
+
"exited holder's writer entitlement released; allocation and work untouched"
|
|
624
|
+
if exited
|
|
625
|
+
else "writer entitlement released; allocation and work untouched"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
payload = _status(repository_path)
|
|
629
|
+
payload["command"] = "writer-lease-release"
|
|
630
|
+
payload["decision"] = {
|
|
631
|
+
"code": "WRITER_LEASE_RELEASED" if removed else "NO_WRITER_LEASE",
|
|
632
|
+
"reason": reason,
|
|
633
|
+
"allocation_id": record.allocation_id,
|
|
634
|
+
"holder_exited": exited,
|
|
635
|
+
}
|
|
636
|
+
return payload
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def check_writer_entitlement(
|
|
640
|
+
repository_id: str | None,
|
|
641
|
+
record: MetadataRecord,
|
|
642
|
+
target: Path,
|
|
643
|
+
lease: WriterLease | None,
|
|
644
|
+
holder: WriterHolder | None,
|
|
645
|
+
) -> None:
|
|
646
|
+
"""Check the current entitlement from the same snapshot as write authority."""
|
|
647
|
+
if lease is None or holder is None:
|
|
648
|
+
raise ControllerError("WRITER_LEASE_REQUIRED", "an exact current writer lease is required")
|
|
649
|
+
if foreign_runtime_bindings(record, holder, lease.lease_id):
|
|
650
|
+
raise ControllerError("EXTERNAL_BINDING", "runtime bindings fence writer entitlement")
|
|
651
|
+
if (
|
|
652
|
+
lease.repository_id != repository_id
|
|
653
|
+
or lease.allocation_id != record.allocation_id
|
|
654
|
+
or lease.worktree_path != str(target)
|
|
655
|
+
or lease.owner != record.owner
|
|
656
|
+
or not same_holder(lease.holder, holder)
|
|
657
|
+
or not holder_is_live(holder)
|
|
658
|
+
or lease_is_expired(lease)
|
|
659
|
+
):
|
|
660
|
+
raise ControllerError(
|
|
661
|
+
"WRITER_LEASE_MISMATCH", "writer entitlement is stale or belongs to another holder"
|
|
662
|
+
)
|