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,505 @@
|
|
|
1
|
+
"""Provider-neutral session-end reconciliation entry point.
|
|
2
|
+
|
|
3
|
+
A directly launched executor session has no Controller bookkeeping of its own, so a
|
|
4
|
+
shell wrapper or an editor hook needs a single command it can call on exit. This
|
|
5
|
+
resolves the allocation that owns a working path, then routes it through the normal
|
|
6
|
+
``reconcile-session`` lifecycle. It never decides that a task finished: an unfinished
|
|
7
|
+
task is only ever retained.
|
|
8
|
+
|
|
9
|
+
It is deliberately advisory at the process boundary. A session exiting must not be
|
|
10
|
+
turned into a failure, so an uncertain or refused reconciliation is reported and the
|
|
11
|
+
command still exits successfully; nothing is mutated unless the Controller allows it.
|
|
12
|
+
|
|
13
|
+
A client that launched in one directory but leased linked checkouts elsewhere in the
|
|
14
|
+
same repository (an editor hook whose cwd is canonical) names its own session instead of
|
|
15
|
+
a path: ``--client-id``, ``--session-id`` and ``--holder ancestor:<program>``. Only
|
|
16
|
+
writer leases whose recorded holder is exactly that live ancestor process and session
|
|
17
|
+
are released, one by one, and each allocation then takes the same terminal route. A
|
|
18
|
+
hosted child (``CONTROLLER_WRITER_MODE``/``CONTROLLER_WRITER_CONTEXT``) never ends its
|
|
19
|
+
parent's allocation, and a repeated notification finds nothing left to do.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import uuid
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .controller import status
|
|
35
|
+
from .environment import unsafe_ignored_paths
|
|
36
|
+
from .git_backend import ignored_paths, inventory
|
|
37
|
+
from .lifecycle import reconcile_session
|
|
38
|
+
from .models import ControllerError, WriterHolder, WriterLease
|
|
39
|
+
from .owner_actions import owner_action
|
|
40
|
+
from .recovery_guidance import guide_argv
|
|
41
|
+
from .store import MetadataStore
|
|
42
|
+
from .writer_lease import holder_is_live, release_writer_lease, resolve_holder, same_holder
|
|
43
|
+
|
|
44
|
+
TRIGGERS = ("task-terminal", "session-archive", "manual")
|
|
45
|
+
COMPLETE_CODES = frozenset({"RETAINED", "RELEASE_INTENT_CREATED", "RELEASE_INTENT_EXISTS",
|
|
46
|
+
"CHECKPOINT_RETAINED", "HANDOFF_RETAINED"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _observed_allocations(store: MetadataStore, holder: WriterHolder, leases: list[WriterLease],
|
|
50
|
+
*, checking: bool) -> list[dict[str, Any]]:
|
|
51
|
+
"""Keep reporting an interrupted closeout after its lease has been released.
|
|
52
|
+
|
|
53
|
+
This is an observation of the original selector, never authority to mutate a
|
|
54
|
+
checkout without its lease. Only an existing Task may resolve the owner work.
|
|
55
|
+
"""
|
|
56
|
+
identity = asdict(holder)
|
|
57
|
+
key = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
|
58
|
+
path = store.session_closeouts_dir / f"{key}.json"
|
|
59
|
+
with store.lock(exclusive=not checking):
|
|
60
|
+
previous: list[dict[str, Any]] = []
|
|
61
|
+
if path.exists():
|
|
62
|
+
if path.stat().st_size > 1024 * 1024:
|
|
63
|
+
raise ControllerError("INVALID_METADATA", "session observation is too large")
|
|
64
|
+
value = store._read_json(path)
|
|
65
|
+
if (value.get("holder") != identity or value.get("contract") != "session-closeout/v1"
|
|
66
|
+
or not isinstance(value.get("allocations"), list)
|
|
67
|
+
or any(not isinstance(row, dict) or any(not isinstance(row.get(field), str)
|
|
68
|
+
for field in ("allocation_id", "owner", "task_key", "authority_key", "path"))
|
|
69
|
+
for row in value["allocations"])):
|
|
70
|
+
raise ControllerError("INVALID_METADATA", "session observation identity is invalid")
|
|
71
|
+
previous = value["allocations"]
|
|
72
|
+
if not checking and leases:
|
|
73
|
+
current = store._load_unlocked()
|
|
74
|
+
rows = {row["allocation_id"]: row for row in previous}
|
|
75
|
+
for lease in leases:
|
|
76
|
+
if current.writer_leases.get(lease.allocation_id) != lease:
|
|
77
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "writer changed before session observation")
|
|
78
|
+
record = current.records[lease.allocation_id]
|
|
79
|
+
rows[lease.allocation_id] = {"allocation_id": record.allocation_id, "path": lease.worktree_path,
|
|
80
|
+
**{field: getattr(record, field) for field in ("owner", "task_key", "authority_key")}}
|
|
81
|
+
store._atomic_json(path, {"contract": "session-closeout/v1", "holder": identity,
|
|
82
|
+
"allocations": list(rows.values())})
|
|
83
|
+
_prune_unreachable_closeouts(store, keep=path)
|
|
84
|
+
return previous
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _prune_unreachable_closeouts(store: MetadataStore, *, keep: Path) -> None:
|
|
88
|
+
"""Remove observations no selector can ever read again.
|
|
89
|
+
|
|
90
|
+
A closeout is keyed by its exact holder identity, which only that live
|
|
91
|
+
process can present. Once the holder is provably gone (another boot, or the
|
|
92
|
+
pid no longer has its recorded start time) the document is unreachable.
|
|
93
|
+
Anything unreadable or not provably dead is kept.
|
|
94
|
+
"""
|
|
95
|
+
directory = store.session_closeouts_dir
|
|
96
|
+
try:
|
|
97
|
+
candidates = sorted(directory.glob("*.json"))
|
|
98
|
+
except OSError:
|
|
99
|
+
return
|
|
100
|
+
for candidate in candidates:
|
|
101
|
+
if candidate == keep:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
if candidate.is_symlink() or candidate.stat().st_size > 1024 * 1024:
|
|
105
|
+
continue
|
|
106
|
+
value = store._read_json(candidate)
|
|
107
|
+
holder = WriterHolder(**value["holder"])
|
|
108
|
+
expected = hashlib.sha256(
|
|
109
|
+
json.dumps(asdict(holder), sort_keys=True).encode()
|
|
110
|
+
).hexdigest()
|
|
111
|
+
if (
|
|
112
|
+
value.get("contract") != "session-closeout/v1"
|
|
113
|
+
or candidate.stem != expected
|
|
114
|
+
or holder_is_live(holder)
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
candidate.unlink()
|
|
118
|
+
except (ControllerError, OSError, TypeError, KeyError, ValueError):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _previous_outcome(canonical: Path, observed: dict[str, Any]) -> dict[str, Any]:
|
|
123
|
+
row = {**observed, "terminal_complete": False, "task_completed": False}
|
|
124
|
+
try:
|
|
125
|
+
_, item = _resolve(Path(observed["path"]))
|
|
126
|
+
if any(item.get(key) != observed[key] for key in ("allocation_id", "owner", "task_key", "authority_key")):
|
|
127
|
+
raise ControllerError("OWNER_IDENTITY_CHANGED", "previous allocation identity changed")
|
|
128
|
+
checkpoint = item.get("wait_checkpoint")
|
|
129
|
+
lease_present = (item.get("lifecycle_observation") or {}).get("lease", {}).get("present")
|
|
130
|
+
usable = not (lease_present or item.get("metadata_missing") or item.get("detached")
|
|
131
|
+
or item.get("locked") or item.get("prunable") or item.get("external_bindings"))
|
|
132
|
+
if (usable and item.get("lifecycle") == "RETAINED" and isinstance(checkpoint, dict)
|
|
133
|
+
and checkpoint.get("head") == item.get("head") and not item.get("external_bindings")):
|
|
134
|
+
decision = {"code": "CHECKPOINT_RETAINED", "reason": "Task checkpoint retained; no completion asserted"}
|
|
135
|
+
elif (usable and item.get("lifecycle") == "HANDOFF_READY" and item.get("handoff_commit") == item.get("head")
|
|
136
|
+
and item.get("finalizer_commit") == item.get("head") and item.get("finalizer_record_id")
|
|
137
|
+
and not item.get("external_bindings")):
|
|
138
|
+
decision = {"code": "HANDOFF_RETAINED", "reason": "frozen Finalizer handoff retained"}
|
|
139
|
+
else:
|
|
140
|
+
decision = {"code": "OWNER_ACTION_REQUIRED", "reason": "previous closeout still needs its owner"}
|
|
141
|
+
row["decision"] = decision
|
|
142
|
+
row.update(_outcome(canonical, item, decision))
|
|
143
|
+
except (ControllerError, OSError) as error:
|
|
144
|
+
row.update(_closed_outcome(canonical, observed, error=error))
|
|
145
|
+
if not row["terminal_complete"]:
|
|
146
|
+
row["recovery_argv"] = guide_argv(str(canonical), observed["path"])
|
|
147
|
+
return row
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _closed_outcome(canonical: Path, observed: dict[str, Any],
|
|
151
|
+
*, error: Exception | None = None) -> dict[str, Any]:
|
|
152
|
+
"""Absence proves nothing; only complete matching release evidence settles a row."""
|
|
153
|
+
from .lifecycle_history import lifecycle_history
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
proof = lifecycle_history(canonical, allocation_id=observed["allocation_id"], limit=1)["decision"]
|
|
157
|
+
closed = proof.get("closed_releases", [])
|
|
158
|
+
if (not proof.get("live_allocations") and len(closed) == 1
|
|
159
|
+
and closed[0].get("proof_status") == "COMPLETE"
|
|
160
|
+
and closed[0].get("release_status") == "RELEASED"
|
|
161
|
+
and all(closed[0].get(key) == observed[key] for key in
|
|
162
|
+
("allocation_id", "owner", "task_key", "authority_key") if key in observed)):
|
|
163
|
+
return {"terminal_complete": True, "task_completed": False,
|
|
164
|
+
"decision": {"code": "CHECKOUT_RELEASED", "task_completed": False,
|
|
165
|
+
"release_receipt_id": closed[0]["release_receipt_id"]}}
|
|
166
|
+
except (ControllerError, OSError) as proof_error:
|
|
167
|
+
error = proof_error
|
|
168
|
+
return {"terminal_complete": False, "task_completed": False,
|
|
169
|
+
"decision": {"code": "ALLOCATION_NOT_SETTLED",
|
|
170
|
+
"blocker": getattr(error, "code", "MISSING_CLOSEOUT_EVIDENCE"),
|
|
171
|
+
"reason": str(error) if error else "expected allocation has no complete release proof"},
|
|
172
|
+
"inspect_argv": ["worktree-controller", "lifecycle-history", "--repo", str(canonical),
|
|
173
|
+
"--allocation-id", observed["allocation_id"], "--json"]}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cover_expected(result: dict[str, Any], path: Path, expected: list[str]) -> None:
|
|
177
|
+
"""Expected IDs add coverage obligations; they never select mutation targets."""
|
|
178
|
+
rows = [*result.get("allocations", []), *result.get("previous_allocations", [])]
|
|
179
|
+
missing = sorted(set(expected) - {row["allocation_id"] for row in rows})
|
|
180
|
+
additions = []
|
|
181
|
+
for allocation in missing:
|
|
182
|
+
observation = {"allocation_id": allocation}
|
|
183
|
+
additions.append({**observation, **_closed_outcome(path, observation)})
|
|
184
|
+
result["expected_allocations"] = additions
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _artifacts(item: dict[str, Any]) -> tuple[str, ...]:
|
|
188
|
+
registered = [resource["path"] for resource in item.get("disposable_resources", [])
|
|
189
|
+
if resource.get("state") == "registered"]
|
|
190
|
+
return tuple(path for path in unsafe_ignored_paths(ignored_paths(Path(item["path"])))
|
|
191
|
+
if not any(path.rstrip("/") == root or path.startswith(root + "/")
|
|
192
|
+
for root in registered))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _outcome(canonical: Path, item: dict[str, Any], decision: dict[str, Any],
|
|
196
|
+
*, checking: bool = False) -> dict[str, Any]:
|
|
197
|
+
"""Report lifecycle settlement separately from the advisory process exit."""
|
|
198
|
+
current = {**item, "record_version": decision.get("record_version", item.get("record_version"))}
|
|
199
|
+
complete = decision.get("code") in ({"SESSION_READY"} if checking else COMPLETE_CODES)
|
|
200
|
+
actions: tuple[str, ...] = ()
|
|
201
|
+
if item.get("dirty"):
|
|
202
|
+
actions = ("review_task_files", "finalize_or_checkpoint", "preserve_unknown_files")
|
|
203
|
+
elif not checking and item.get("lifecycle") == "ACTIVE" and decision.get("code") == "RETAINED":
|
|
204
|
+
actions = ("save_task_checkpoint",)
|
|
205
|
+
try:
|
|
206
|
+
unclassified = _artifacts(item)
|
|
207
|
+
except (ControllerError, OSError):
|
|
208
|
+
unclassified = ("<inspection-unavailable>",)
|
|
209
|
+
if unclassified:
|
|
210
|
+
actions += ("classify_local_artifacts",)
|
|
211
|
+
if not complete and not actions:
|
|
212
|
+
actions = ("resolve_terminal_blocker",)
|
|
213
|
+
result: dict[str, Any] = {"terminal_complete": complete and not actions,
|
|
214
|
+
"task_completed": False}
|
|
215
|
+
if actions:
|
|
216
|
+
result["owner_action"] = owner_action(str(canonical), current,
|
|
217
|
+
reason=str(decision.get("blocker") or decision.get("reason") or decision.get("code")),
|
|
218
|
+
required_actions=actions)
|
|
219
|
+
if not result["terminal_complete"]:
|
|
220
|
+
result["recovery_argv"] = guide_argv(str(canonical), item["path"])
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _check_terminal(item: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
refusal = _refuse(item)
|
|
226
|
+
if refusal:
|
|
227
|
+
return {"code": "NO_TERMINAL_ACTION", "reason": refusal}
|
|
228
|
+
if item.get("dirty"):
|
|
229
|
+
return {"code": "OWNER_ACTION_REQUIRED", "reason": "preserve task files before normal exit"}
|
|
230
|
+
if _artifacts(item):
|
|
231
|
+
return {"code": "OWNER_ACTION_REQUIRED", "reason": "classify local artifacts before normal exit"}
|
|
232
|
+
if item.get("lifecycle") != "HANDOFF_READY":
|
|
233
|
+
return {"code": "CHECKPOINT_REQUIRED", "reason":
|
|
234
|
+
"unfinished work needs a Task checkpoint and explicit park before normal exit"}
|
|
235
|
+
if (item.get("handoff_commit") != item.get("head")
|
|
236
|
+
or item.get("finalizer_commit") != item.get("head")
|
|
237
|
+
or not item.get("finalizer_record_id")):
|
|
238
|
+
return {"code": "OWNER_ACTION_REQUIRED", "blocker": "FROZEN_HANDOFF_DRIFT",
|
|
239
|
+
"reason": "HEAD must match the frozen handoff and Finalizer evidence before normal exit"}
|
|
240
|
+
return {"code": "SESSION_READY", "reason": "frozen handoff is ready for terminal reconciliation"}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _summary(result: dict[str, Any]) -> None:
|
|
244
|
+
rows = [*result.get("allocations", []), *result.get("previous_allocations", []),
|
|
245
|
+
*result.get("expected_allocations", [])]
|
|
246
|
+
complete = sum(row.get("terminal_complete") is True for row in rows)
|
|
247
|
+
result["summary"] = {
|
|
248
|
+
"contract": "session-closeout/v1", "selected": len(rows), "complete": complete,
|
|
249
|
+
"pending": len(rows) - complete,
|
|
250
|
+
"terminal_complete": (complete == len(rows) and result.get("selection_complete") is True),
|
|
251
|
+
"task_completed": False,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _resolve(path: Path) -> tuple[Path, dict[str, Any]]:
|
|
256
|
+
resolved = path.expanduser().resolve(strict=True)
|
|
257
|
+
repository = inventory(resolved)
|
|
258
|
+
payload = status(repository.canonical_path)
|
|
259
|
+
worktrees = payload.get("worktrees")
|
|
260
|
+
if not isinstance(worktrees, list):
|
|
261
|
+
raise ControllerError("UNKNOWN_TASK", "controller status is unavailable")
|
|
262
|
+
for item in worktrees:
|
|
263
|
+
candidate = item.get("path")
|
|
264
|
+
if not isinstance(candidate, str):
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
same = Path(candidate).resolve(strict=False) == resolved
|
|
268
|
+
except OSError:
|
|
269
|
+
continue
|
|
270
|
+
if same:
|
|
271
|
+
return repository.canonical_path, item
|
|
272
|
+
raise ControllerError("UNKNOWN_TASK", f"no governed allocation owns this path: {resolved}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _refuse(item: dict[str, Any]) -> str | None:
|
|
276
|
+
if item.get("canonical"):
|
|
277
|
+
return "canonical is never reconciled by a session hook"
|
|
278
|
+
if item.get("metadata_missing"):
|
|
279
|
+
return "allocation metadata is missing"
|
|
280
|
+
if item.get("detached"):
|
|
281
|
+
return "detached worktree"
|
|
282
|
+
if item.get("locked") or item.get("prunable"):
|
|
283
|
+
return "locked or prunable worktree requires review"
|
|
284
|
+
if item.get("external_bindings"):
|
|
285
|
+
return "registered external or runtime bindings"
|
|
286
|
+
if item.get("integration_intent_id"):
|
|
287
|
+
return "an integration intent still binds this allocation"
|
|
288
|
+
if item.get("role") not in {"feature", "audit"}:
|
|
289
|
+
return f"role {item.get('role')} has no session terminal action"
|
|
290
|
+
if item.get("lifecycle") not in {"ACTIVE", "HANDOFF_READY"}:
|
|
291
|
+
return f"lifecycle {item.get('lifecycle')} has no session terminal action"
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _terminal(
|
|
296
|
+
canonical: Path,
|
|
297
|
+
item: dict[str, Any],
|
|
298
|
+
*,
|
|
299
|
+
owner: str | None,
|
|
300
|
+
trigger: str,
|
|
301
|
+
expected_record_version: int | None = None,
|
|
302
|
+
expected_allocation_id: str | None = None,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Route one resolved allocation through reconcile-session, or say why not."""
|
|
305
|
+
|
|
306
|
+
refusal = _refuse(item)
|
|
307
|
+
if refusal is not None:
|
|
308
|
+
return {"code": "NO_TERMINAL_ACTION", "reason": refusal}
|
|
309
|
+
effective_owner = owner or item.get("owner")
|
|
310
|
+
if not isinstance(effective_owner, str) or not effective_owner:
|
|
311
|
+
return {"code": "NO_TERMINAL_ACTION", "reason": "owner is unknown"}
|
|
312
|
+
if owner and owner != item.get("owner"):
|
|
313
|
+
return {
|
|
314
|
+
"code": "NO_TERMINAL_ACTION",
|
|
315
|
+
"reason": "declared owner does not match the recorded owner",
|
|
316
|
+
}
|
|
317
|
+
try:
|
|
318
|
+
payload = reconcile_session(
|
|
319
|
+
canonical,
|
|
320
|
+
target=Path(str(item["path"])),
|
|
321
|
+
expected_record_version=(
|
|
322
|
+
int(item["record_version"])
|
|
323
|
+
if expected_record_version is None
|
|
324
|
+
else expected_record_version
|
|
325
|
+
),
|
|
326
|
+
owner=effective_owner,
|
|
327
|
+
trigger=trigger,
|
|
328
|
+
expected_allocation_id=expected_allocation_id or str(item["allocation_id"]),
|
|
329
|
+
)
|
|
330
|
+
except ControllerError as error:
|
|
331
|
+
return {
|
|
332
|
+
"code": "NO_TERMINAL_ACTION",
|
|
333
|
+
"reason": error.message if hasattr(error, "message") else str(error),
|
|
334
|
+
"blocker": error.code,
|
|
335
|
+
}
|
|
336
|
+
decision = payload.get("decision")
|
|
337
|
+
return decision if isinstance(decision, dict) else {"code": "NO_TERMINAL_ACTION"}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _end_session(
|
|
341
|
+
path: Path, *, client_id: str, session_id: str, holder_selector: str, trigger: str,
|
|
342
|
+
run_id: str | None = None, checking: bool = False,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""Release this session's own writer leases in the repository, then end each task."""
|
|
345
|
+
|
|
346
|
+
if not checking and ("CONTROLLER_WRITER_MODE" in os.environ or "CONTROLLER_WRITER_CONTEXT" in os.environ):
|
|
347
|
+
return {
|
|
348
|
+
"decision": {
|
|
349
|
+
"code": "NO_TERMINAL_ACTION",
|
|
350
|
+
"reason": "a hosted child never ends its parent's allocation",
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
holder = resolve_holder(holder_selector, client_id=client_id, session_id=session_id, run_id=run_id)
|
|
354
|
+
repository = inventory(path.expanduser().resolve(strict=True))
|
|
355
|
+
snapshot = MetadataStore(repository.common_dir).load()
|
|
356
|
+
leases = sorted(
|
|
357
|
+
(lease for lease in snapshot.writer_leases.values() if same_holder(lease.holder, holder)),
|
|
358
|
+
key=lambda lease: lease.worktree_path,
|
|
359
|
+
)
|
|
360
|
+
observed = _observed_allocations(MetadataStore(repository.common_dir), holder, leases, checking=checking)
|
|
361
|
+
related = [lease for lease in snapshot.writer_leases.values()
|
|
362
|
+
if lease.holder.pid == holder.pid and lease.holder.boot_id == holder.boot_id
|
|
363
|
+
and lease.holder.start_ticks == holder.start_ticks
|
|
364
|
+
and lease.holder.client_id == client_id and lease.holder.session_id == session_id]
|
|
365
|
+
if not leases and related:
|
|
366
|
+
return {"selection_complete": False, "allocations": [], "decision": {
|
|
367
|
+
"code": "SESSION_SELECTOR_MISMATCH",
|
|
368
|
+
"reason": "the recorded run differs; an old notification cannot end a replacement run"}}
|
|
369
|
+
ended: list[dict[str, Any]] = []
|
|
370
|
+
for lease in leases:
|
|
371
|
+
row: dict[str, Any] = {
|
|
372
|
+
"path": lease.worktree_path,
|
|
373
|
+
"allocation_id": lease.allocation_id,
|
|
374
|
+
"lease_id": lease.lease_id,
|
|
375
|
+
"run_id": lease.holder.run_id,
|
|
376
|
+
}
|
|
377
|
+
try:
|
|
378
|
+
record = snapshot.records.get(lease.allocation_id)
|
|
379
|
+
if record is None:
|
|
380
|
+
raise ControllerError("INVALID_METADATA", "leased allocation is missing")
|
|
381
|
+
row.update({key: getattr(record, key) for key in
|
|
382
|
+
("owner", "task_key", "authority_key", "record_version")})
|
|
383
|
+
if not checking:
|
|
384
|
+
released = release_writer_lease(
|
|
385
|
+
repository.canonical_path,
|
|
386
|
+
target=Path(lease.worktree_path),
|
|
387
|
+
lease_id=lease.lease_id,
|
|
388
|
+
holder=lease.holder,
|
|
389
|
+
)
|
|
390
|
+
row["lease"] = released["decision"]["code"]
|
|
391
|
+
canonical, item = _resolve(Path(lease.worktree_path))
|
|
392
|
+
if any(item.get(key) != getattr(record, key) for key in
|
|
393
|
+
("allocation_id", "owner", "task_key", "authority_key")):
|
|
394
|
+
raise ControllerError("IDENTITY_MISMATCH", "selected allocation identity changed")
|
|
395
|
+
if item.get("record_version") != record.record_version:
|
|
396
|
+
raise ControllerError("CAS_MISMATCH", "selected record changed during closeout")
|
|
397
|
+
if checking and MetadataStore(repository.common_dir).load().writer_leases.get(
|
|
398
|
+
lease.allocation_id) != lease:
|
|
399
|
+
raise ControllerError("WRITER_LEASE_MISMATCH", "selected writer changed during closeout check")
|
|
400
|
+
row["decision"] = _check_terminal(item) if checking else _terminal(
|
|
401
|
+
canonical,
|
|
402
|
+
item,
|
|
403
|
+
owner=None,
|
|
404
|
+
trigger=trigger,
|
|
405
|
+
expected_record_version=record.record_version,
|
|
406
|
+
expected_allocation_id=lease.allocation_id,
|
|
407
|
+
)
|
|
408
|
+
row.update(_outcome(canonical, item, row["decision"], checking=checking))
|
|
409
|
+
except (ControllerError, OSError) as error:
|
|
410
|
+
row["decision"] = {
|
|
411
|
+
"code": "NO_TERMINAL_ACTION",
|
|
412
|
+
"reason": str(error),
|
|
413
|
+
"blocker": getattr(error, "code", "OS_ERROR"),
|
|
414
|
+
}
|
|
415
|
+
row["terminal_complete"] = False
|
|
416
|
+
row["recovery_argv"] = guide_argv(str(repository.canonical_path), lease.worktree_path)
|
|
417
|
+
ended.append(row)
|
|
418
|
+
return {
|
|
419
|
+
"holder": {"client_id": client_id, "session_id": session_id, "run_id": run_id,
|
|
420
|
+
"pid": holder.pid},
|
|
421
|
+
"selection_complete": True,
|
|
422
|
+
"allocations": ended,
|
|
423
|
+
"previous_allocations": [_previous_outcome(repository.canonical_path, row) for row in observed
|
|
424
|
+
if row["allocation_id"] not in {lease.allocation_id for lease in leases}],
|
|
425
|
+
"decision": {
|
|
426
|
+
"code": ("SESSION_CHECKED" if checking else "SESSION_ENDED") if ended else "NO_TERMINAL_ACTION",
|
|
427
|
+
"reason": (
|
|
428
|
+
f"{len(ended)} allocation(s) examined; read each decision and summary.terminal_complete"
|
|
429
|
+
if ended
|
|
430
|
+
else "this session holds no writer lease in the repository"
|
|
431
|
+
),
|
|
432
|
+
},
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def main(argv: list[str] | None = None) -> int:
|
|
437
|
+
parser = argparse.ArgumentParser(
|
|
438
|
+
prog="worktree-session-end",
|
|
439
|
+
description="Reconcile the governed allocation that owns a path when a session ends",
|
|
440
|
+
)
|
|
441
|
+
parser.add_argument("--path", type=Path, default=Path.cwd())
|
|
442
|
+
parser.add_argument("--trigger", choices=TRIGGERS, default="task-terminal")
|
|
443
|
+
parser.add_argument("--owner")
|
|
444
|
+
parser.add_argument("--client-id", help="session selector: the writer lease client")
|
|
445
|
+
parser.add_argument("--session-id", help="session selector: the writer lease session")
|
|
446
|
+
parser.add_argument("--run-id", help="session selector: the exact recorded run, when present")
|
|
447
|
+
parser.add_argument("--holder", help="session selector: ancestor:<program> or pid")
|
|
448
|
+
parser.add_argument("--check", action="store_true", help="read-only preparation check; requires the full session selector")
|
|
449
|
+
parser.add_argument("--require-complete", action="store_true", help="exit 2 unless every selected checkout is settled")
|
|
450
|
+
parser.add_argument("--expected-allocation-id", type=lambda value: str(uuid.UUID(value)),
|
|
451
|
+
action="append", default=[], help="also require closeout proof for each known allocation")
|
|
452
|
+
arguments = parser.parse_args(argv)
|
|
453
|
+
|
|
454
|
+
result: dict[str, Any] = {"command": "session-end", "trigger": arguments.trigger}
|
|
455
|
+
selector = (arguments.client_id, arguments.session_id, arguments.holder)
|
|
456
|
+
if (any(selector) or arguments.run_id or arguments.check or arguments.require_complete
|
|
457
|
+
or arguments.expected_allocation_id):
|
|
458
|
+
if not all(selector) or arguments.owner:
|
|
459
|
+
result["decision"] = {
|
|
460
|
+
"code": "NO_TERMINAL_ACTION",
|
|
461
|
+
"reason": "the session selector needs --client-id, --session-id and --holder, "
|
|
462
|
+
"and never takes --owner",
|
|
463
|
+
}
|
|
464
|
+
else:
|
|
465
|
+
try:
|
|
466
|
+
result.update(
|
|
467
|
+
_end_session(
|
|
468
|
+
arguments.path,
|
|
469
|
+
client_id=arguments.client_id,
|
|
470
|
+
session_id=arguments.session_id,
|
|
471
|
+
holder_selector=arguments.holder,
|
|
472
|
+
trigger=arguments.trigger,
|
|
473
|
+
run_id=arguments.run_id,
|
|
474
|
+
checking=arguments.check,
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
except (ControllerError, OSError) as error:
|
|
478
|
+
result["decision"] = {
|
|
479
|
+
"code": "NO_TERMINAL_ACTION",
|
|
480
|
+
"reason": str(error),
|
|
481
|
+
"blocker": getattr(error, "code", "OS_ERROR"),
|
|
482
|
+
}
|
|
483
|
+
if arguments.expected_allocation_id:
|
|
484
|
+
_cover_expected(result, arguments.path, arguments.expected_allocation_id)
|
|
485
|
+
_summary(result)
|
|
486
|
+
print(json.dumps(result, sort_keys=True))
|
|
487
|
+
return 2 if (arguments.check or arguments.require_complete) and not result["summary"]["terminal_complete"] else 0
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
canonical, item = _resolve(arguments.path)
|
|
491
|
+
except (ControllerError, OSError) as error:
|
|
492
|
+
code = getattr(error, "code", "UNKNOWN_TASK")
|
|
493
|
+
result["decision"] = {"code": "NO_TERMINAL_ACTION", "reason": str(error), "blocker": code}
|
|
494
|
+
print(json.dumps(result, sort_keys=True))
|
|
495
|
+
return 0
|
|
496
|
+
|
|
497
|
+
result["decision"] = _terminal(
|
|
498
|
+
canonical, item, owner=arguments.owner, trigger=arguments.trigger
|
|
499
|
+
)
|
|
500
|
+
print(json.dumps(result, sort_keys=True))
|
|
501
|
+
return 0
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
if __name__ == "__main__": # pragma: no cover - console entry point
|
|
505
|
+
sys.exit(main())
|