ctrlrelay 0.10.0__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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,893 @@
1
+ """Git worktree management for isolated sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from ctrlrelay.core.github import GitHubCLI
13
+
14
+
15
+ # Open-PR probe (issue #52) retries a handful of times before failing
16
+ # closed. Transient gh/network errors are common enough that one-shot
17
+ # failure would be noisy; but infinite retry would block retries when
18
+ # gh is genuinely down. Three quick attempts strike a balance.
19
+ _PR_PROBE_RETRY_ATTEMPTS = 3
20
+ _PR_PROBE_RETRY_SLEEP_SECONDS = 1.0
21
+ # Per-call gh timeout for the open-PR probe. This probe holds the repo
22
+ # lock, so inheriting GitHubCLI's 60s default would let a hung gh
23
+ # process block other sessions on the same repo for up to ~3 minutes
24
+ # (3 attempts × 60s) before failing closed. 10s is plenty for a
25
+ # single pr-list call with --head filter against GitHub.
26
+ _PR_PROBE_TIMEOUT_SECONDS = 10
27
+
28
+ # Network-bound git transfers (bare clone, fetch) get their own, much
29
+ # larger budget than the 120s that suits local plumbing. A 1.8 GB repo
30
+ # cannot clone or fetch its first time inside two minutes on any normal
31
+ # link, and the failure mode was silent: `asyncio.wait_for` raises a
32
+ # bare `TimeoutError` whose `str()` is empty, so the sweep reported
33
+ # "Error processing <repo>" with no cause attached and wrote neither a
34
+ # session row nor a log line. 1800s matches claude's own
35
+ # `default_timeout_seconds` — a transfer still running past that is
36
+ # genuinely stuck, not merely large.
37
+ _GIT_TRANSFER_TIMEOUT_SECONDS = 1800
38
+
39
+
40
+ class WorktreeError(Exception):
41
+ """Raised when worktree operations fail."""
42
+
43
+
44
+ class StaleRecreatePartialFailureError(WorktreeError):
45
+ """Raised when `create_worktree_with_new_branch` had committed to the
46
+ stale-merged delete+recreate path (destroying the old local ref)
47
+ and then the subsequent `git worktree add -b` failed partway. The
48
+ exception signals to the caller that any leftover ref on disk was
49
+ introduced by THIS session, so run_dev_issue's FAILED-path cleanup
50
+ must delete it regardless of the pre-call branch_existed_before
51
+ snapshot — the old ref is gone and anything present now is ours.
52
+
53
+ Scoped to the exception path so concurrent sessions can't clobber
54
+ each other's ownership state (instance-level flags on the shared
55
+ WorktreeManager were racy under concurrent run_dev_issue calls)."""
56
+
57
+
58
+ @dataclass
59
+ class WorktreeManager:
60
+ """Manages git worktrees for session isolation."""
61
+
62
+ worktrees_dir: Path
63
+ bare_repos_dir: Path
64
+ timeout: int = 120
65
+
66
+ def __post_init__(self) -> None:
67
+ self.worktrees_dir = Path(self.worktrees_dir)
68
+ self.bare_repos_dir = Path(self.bare_repos_dir)
69
+ self.worktrees_dir.mkdir(parents=True, exist_ok=True)
70
+ self.bare_repos_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ async def _run_git(
73
+ self,
74
+ *args: str,
75
+ cwd: Path | None = None,
76
+ timeout: int | None = None,
77
+ ) -> str:
78
+ """Run git command and return stdout. `timeout` overrides self.timeout
79
+ for one call — useful for cheap probes that shouldn't inherit the full
80
+ 120s default when network is flaky."""
81
+ cmd = ["git", *args]
82
+ effective_timeout = timeout if timeout is not None else self.timeout
83
+ proc = await asyncio.create_subprocess_exec(
84
+ *cmd,
85
+ cwd=cwd,
86
+ stdout=asyncio.subprocess.PIPE,
87
+ stderr=asyncio.subprocess.PIPE,
88
+ )
89
+ try:
90
+ stdout, stderr = await asyncio.wait_for(
91
+ proc.communicate(),
92
+ timeout=effective_timeout,
93
+ )
94
+ except asyncio.TimeoutError:
95
+ try:
96
+ proc.kill()
97
+ await proc.wait()
98
+ except Exception:
99
+ pass
100
+ # Re-raise the SAME type (callers filter on TimeoutError) but
101
+ # with a message attached. A bare `asyncio.TimeoutError()`
102
+ # stringifies to "", so every layer above rendered it as
103
+ # empty: the secops sweep reported "Error processing <repo>"
104
+ # with no cause, and the operator got a Telegram alert that
105
+ # named a repo and nothing else.
106
+ raise asyncio.TimeoutError(
107
+ f"git {' '.join(args)} timed out after "
108
+ f"{effective_timeout}s"
109
+ + (f" (cwd={cwd})" if cwd is not None else "")
110
+ ) from None
111
+ except asyncio.CancelledError:
112
+ # Scheduler/shutdown cancel: kill the child BEFORE re-raising
113
+ # so the git subprocess isn't left mutating the bare repo /
114
+ # worktree after the daemon exits. A restarted daemon would
115
+ # otherwise race with a stray `git worktree add` on the
116
+ # same repo. Shield the reaping so a second cancel between
117
+ # kill() and wait() doesn't leak the zombie.
118
+ if proc.returncode is None:
119
+ try:
120
+ proc.kill()
121
+ await asyncio.shield(proc.wait())
122
+ except (asyncio.CancelledError, Exception):
123
+ pass
124
+ raise
125
+
126
+ if proc.returncode != 0:
127
+ raise WorktreeError(f"git failed: {stderr.decode().strip()}")
128
+
129
+ return stdout.decode()
130
+
131
+ def _get_bare_repo_path(self, repo: str) -> Path:
132
+ """Get path to bare repo clone."""
133
+ repo_name = repo.replace("/", "-")
134
+ return self.bare_repos_dir / f"{repo_name}.git"
135
+
136
+ def _get_worktree_path(self, repo: str, session_id: str) -> Path:
137
+ """Get path for a worktree."""
138
+ repo_name = repo.replace("/", "-")
139
+ return self.worktrees_dir / f"{repo_name}-{session_id}"
140
+
141
+ async def get_default_branch(self, repo: str) -> str:
142
+ """Get the default branch for a repo from bare clone."""
143
+ bare_path = self._get_bare_repo_path(repo)
144
+ output = await self._run_git("symbolic-ref", "HEAD", cwd=bare_path)
145
+ return output.strip().replace("refs/heads/", "")
146
+
147
+ async def create_worktree(
148
+ self,
149
+ repo: str,
150
+ session_id: str,
151
+ branch: str | None = None,
152
+ ) -> Path:
153
+ """Create a worktree for a session."""
154
+ bare_path = self._get_bare_repo_path(repo)
155
+ worktree_path = self._get_worktree_path(repo, session_id)
156
+
157
+ if worktree_path.exists():
158
+ raise WorktreeError(f"Worktree already exists: {worktree_path}")
159
+
160
+ if branch is None:
161
+ branch = await self.get_default_branch(repo)
162
+
163
+ await self._run_git(
164
+ "worktree", "add",
165
+ str(worktree_path),
166
+ branch,
167
+ cwd=bare_path,
168
+ )
169
+
170
+ return worktree_path
171
+
172
+ async def create_worktree_with_new_branch(
173
+ self,
174
+ repo: str,
175
+ session_id: str,
176
+ new_branch: str,
177
+ base_branch: str | None = None,
178
+ github: GitHubCLI | None = None,
179
+ ) -> tuple[Path, bool]:
180
+ """Create a worktree for ``new_branch``.
181
+
182
+ If ``new_branch`` already exists in the bare repo — e.g. because a
183
+ previous session for the same issue ran out of fix attempts and left
184
+ its PR branch pushed to origin — reuse that branch so the retry can
185
+ iterate on the prior commits instead of hitting
186
+ ``fatal: a branch named 'fix/issue-N' already exists`` from
187
+ ``git worktree add -b``. Without this, any issue that exhausts the
188
+ verify-fix loop once gets permanently wedged until someone manually
189
+ deletes the branch.
190
+
191
+ When we have to fall back to a brand-new branch, it's cut from
192
+ ``base_branch`` (default: the repo's default branch).
193
+
194
+ Returns ``(worktree_path, created_fresh)`` where ``created_fresh``
195
+ is True when THIS call either created a brand-new branch from
196
+ ``base_branch`` or detected a stale fully-merged local branch,
197
+ deleted it, and recreated it. Callers use this flag to decide
198
+ whether cleanup on session failure should delete the branch
199
+ (issue #51): a pre-snapshotted ``branch_exists_locally`` check
200
+ goes stale the moment we delete+recreate, so the ownership
201
+ signal has to come from the function that performed the
202
+ mutation.
203
+
204
+ When ``github`` is provided and the branch already exists locally,
205
+ probe GitHub for an open PR with ``new_branch`` as head. If one
206
+ exists, refuse to reuse the branch — the reviewer's already-
207
+ reviewed work must not be hijacked by a fresh session, and
208
+ ``gh pr create`` would later fail with "A pull request already
209
+ exists" (issue #52). Raises :class:`WorktreeError` with the PR
210
+ number and a concrete operator action. The probe runs BEFORE
211
+ any ref mutations so a concurrent PR is surfaced cleanly.
212
+ """
213
+ # Per-call flag: tracks whether we committed to the stale-merged
214
+ # delete+recreate path. Must stay local to this coroutine call —
215
+ # placing it on self would race under concurrent sessions sharing
216
+ # the WorktreeManager instance (one session's start-of-call reset
217
+ # would clobber another's in-progress signal).
218
+ entered_stale_recreate = False
219
+
220
+ bare_path = self._get_bare_repo_path(repo)
221
+ worktree_path = self._get_worktree_path(repo, session_id)
222
+
223
+ if worktree_path.exists():
224
+ raise WorktreeError(f"Worktree already exists: {worktree_path}")
225
+
226
+ if await self.branch_exists_locally(repo, new_branch):
227
+ # CRITICAL safety: never mutate or delete a branch that's still
228
+ # checked out by another linked worktree (e.g. a BLOCKED session
229
+ # that kept its worktree alive on purpose so it can be resumed
230
+ # when the operator replies). `git update-ref` / `update-ref -d`
231
+ # don't refuse in that case; they'd leave the live worktree's
232
+ # HEAD pointing at a stale or missing ref, corrupting the
233
+ # resumable state. Surface a clean error instead.
234
+ if await self._branch_is_checked_out_elsewhere(bare_path, new_branch):
235
+ raise WorktreeError(
236
+ f"Branch {new_branch!r} is already checked out in another "
237
+ f"worktree of {repo!r}. Resolve with `git worktree list` + "
238
+ "`git worktree remove` in the bare repo before retrying."
239
+ )
240
+
241
+ # Issue #52: probe for an open same-repo PR BEFORE any ref
242
+ # mutation. Must run for BOTH on-remote and local-only
243
+ # branches — GitHub can keep a PR open after its head
244
+ # branch is deleted, and our next push would recreate
245
+ # origin/<branch>, re-attaching this session's commits to
246
+ # the reviewer-owned PR (codex round-7). Fail-closed on
247
+ # probe error is the correct trade-off: a flaky gh can
248
+ # delay recovery, but a missed probe silently hijacks a
249
+ # PR. See _refuse_if_branch_backs_open_pr.
250
+ if github is not None:
251
+ await self._refuse_if_branch_backs_open_pr(
252
+ github, repo, new_branch,
253
+ )
254
+
255
+ # Remote presence has to be KNOWN to take either sync-to-origin
256
+ # or stale-merged branches. branch_exists_on_remote is
257
+ # fail-closed (returns True on timeout/auth error) which is
258
+ # good for "should I delete this branch" decisions but wrong
259
+ # here — a transient probe failure would skip the stale-merged
260
+ # recreate path and resurrect already-merged commits. Use the
261
+ # strict variant that raises on probe failure; on error we
262
+ # reuse the local ref unchanged, without mutations.
263
+ try:
264
+ on_remote = await self._branch_exists_on_remote_strict(
265
+ bare_path, new_branch,
266
+ )
267
+ except Exception:
268
+ await self._worktree_add_with_stale_cleanup(
269
+ bare_path, worktree_path, new_branch,
270
+ )
271
+ return worktree_path, False
272
+
273
+ if on_remote:
274
+ # Remote exists → sync local to remote head (preserving
275
+ # unpushed ahead-of-origin commits) and reuse.
276
+ await self._sync_reused_branch_to_origin(bare_path, new_branch)
277
+ await self._worktree_add_with_stale_cleanup(
278
+ bare_path, worktree_path, new_branch,
279
+ )
280
+ return worktree_path, False
281
+
282
+ # Local-only (confirmed). Distinguish between:
283
+ # (a) stale-merged: the prior PR was merged (any strategy) and
284
+ # the remote branch auto-deleted. The local ref's commits
285
+ # are all patch-equivalent to commits in the default
286
+ # branch. Reusing it would resurrect already-merged
287
+ # changes into the next PR — delete + create fresh.
288
+ # (b) recoverable unpushed work: the prior session made
289
+ # commits locally and died before push. Local has
290
+ # content not represented in the default branch — reuse
291
+ # so the operator's work isn't silently dropped.
292
+ if await self._branch_is_fully_merged(repo, new_branch):
293
+ # Commit to the delete+recreate path. From here on,
294
+ # any ref left on disk — whether the old one we just
295
+ # deleted, a partial one `worktree add -b` wrote
296
+ # before crashing, or the successfully recreated one
297
+ # — belongs to THIS session. Flip the local flag
298
+ # BEFORE the delete so even an exception in
299
+ # update-ref still surfaces StaleRecreatePartialFailureError
300
+ # to the caller.
301
+ entered_stale_recreate = True
302
+ try:
303
+ await self._run_git(
304
+ "update-ref", "-d", f"refs/heads/{new_branch}",
305
+ cwd=bare_path, timeout=10,
306
+ )
307
+ except Exception:
308
+ pass
309
+ # Fall through to the fresh-branch creation below. The
310
+ # delete+recreate path is the exact scenario issue #51
311
+ # covers: the branch that now exists was created by THIS
312
+ # session, so cleanup on failure must treat it as ours.
313
+ else:
314
+ await self._worktree_add_with_stale_cleanup(
315
+ bare_path, worktree_path, new_branch,
316
+ )
317
+ return worktree_path, False
318
+
319
+ if base_branch is None:
320
+ base_branch = await self.get_default_branch(repo)
321
+
322
+ try:
323
+ await self._run_git(
324
+ "worktree", "add",
325
+ "-b", new_branch,
326
+ str(worktree_path),
327
+ base_branch,
328
+ cwd=bare_path,
329
+ )
330
+ except Exception as e:
331
+ # If we destroyed the old stale-merged ref on the way here,
332
+ # re-raise as StaleRecreatePartialFailureError so the caller
333
+ # knows any leftover ref belongs to this session. Without
334
+ # this signal, a pre-call branch_existed_before=True
335
+ # snapshot would misclassify the leftover as "not ours"
336
+ # and leak it into the next retry (issue #51).
337
+ if entered_stale_recreate:
338
+ raise StaleRecreatePartialFailureError(str(e)) from e
339
+ raise
340
+ return worktree_path, True
341
+
342
+ async def _refuse_if_branch_backs_open_pr(
343
+ self, github: GitHubCLI, repo: str, branch: str,
344
+ ) -> None:
345
+ """Issue #52: raise WorktreeError if ``branch`` is the head of an
346
+ open PR on ``repo``. Probe is fail-closed (on repeated probe
347
+ error, we refuse reuse): the dev workflow pushes to the shared
348
+ branch BEFORE calling ``gh pr create``, so relying on the
349
+ later create to catch a PR-backed branch is unsafe — a
350
+ transient ``gh`` error would let the push hijack the existing
351
+ PR before any failure surfaces.
352
+
353
+ ``gh pr list --head`` filters by branch name only, so PRs from
354
+ unrelated repos (external forks, same-owner forks like
355
+ ``acme/repo-fork`` targeting ``acme/repo``) with the same
356
+ branch name would appear in the result and wrongly block
357
+ our reuse. Filter client-side on the FULL head repo identity
358
+ (owner + name) so only PRs whose head actually lives in the
359
+ repo we're about to push to can veto reuse.
360
+ """
361
+ last_exc: Exception | None = None
362
+ prs: list | None = None
363
+ for attempt in range(_PR_PROBE_RETRY_ATTEMPTS):
364
+ try:
365
+ prs = await github.list_prs(
366
+ repo, state="open", head=branch,
367
+ timeout=_PR_PROBE_TIMEOUT_SECONDS,
368
+ )
369
+ last_exc = None
370
+ break
371
+ except Exception as e:
372
+ last_exc = e
373
+ if attempt < _PR_PROBE_RETRY_ATTEMPTS - 1:
374
+ await asyncio.sleep(_PR_PROBE_RETRY_SLEEP_SECONDS)
375
+ if prs is None:
376
+ raise WorktreeError(
377
+ f"Could not determine whether branch {branch!r} on "
378
+ f"{repo!r} backs an open PR (gh probe failed after "
379
+ f"{_PR_PROBE_RETRY_ATTEMPTS} attempts: "
380
+ f"{type(last_exc).__name__ if last_exc else 'unknown'}). "
381
+ "Refusing reuse to avoid hijacking an open PR. Retry "
382
+ "once gh is reachable, or delete the branch manually "
383
+ "if you're certain no PR is open."
384
+ ) from last_exc
385
+ target_owner, _, target_name = repo.partition("/")
386
+ # GitHub owner/repo names are case-insensitive: config may say
387
+ # "Owner/Repo" while the API returns "owner/repo". Normalize
388
+ # both sides before comparing so a case mismatch doesn't let
389
+ # a colliding PR slip past the veto.
390
+ target_owner_lc = target_owner.lower()
391
+ target_name_lc = target_name.lower()
392
+
393
+ def _same_repo(pr: dict) -> bool:
394
+ owner = (pr.get("headRepositoryOwner") or {}).get("login") or ""
395
+ name = (pr.get("headRepository") or {}).get("name") or ""
396
+ return (
397
+ owner.lower() == target_owner_lc
398
+ and name.lower() == target_name_lc
399
+ )
400
+
401
+ same_repo_prs = [p for p in prs if _same_repo(p)]
402
+ if not same_repo_prs:
403
+ return
404
+ pr_number = same_repo_prs[0].get("number")
405
+ raise WorktreeError(
406
+ f"Branch {branch!r} already has open PR #{pr_number} on "
407
+ f"{repo!r} — close or merge it before retrying this issue."
408
+ )
409
+
410
+ async def _worktree_add_with_stale_cleanup(
411
+ self, bare_path: Path, worktree_path: Path, branch: str,
412
+ ) -> None:
413
+ """``git worktree add <path> <branch>`` with targeted recovery
414
+ if the add fails because a stale (prunable) admin entry still
415
+ claims the branch.
416
+
417
+ Try the add. If it succeeds, done. If it fails with "already
418
+ checked out" AND we've already established there's no LIVE
419
+ checkout of this branch (the caller checked
420
+ _branch_is_checked_out_elsewhere), we scope a `git worktree
421
+ prune` to this one stale entry and retry.
422
+
423
+ We deliberately do NOT prune up front: an unconditional prune
424
+ in a bare repo whose worktrees_dir is on a removable / network
425
+ path would destroy admin state for unrelated worktrees whose
426
+ paths are temporarily unavailable. Scoping to this specific
427
+ branch via a post-failure recovery keeps the blast radius
428
+ minimal.
429
+ """
430
+ try:
431
+ await self._run_git(
432
+ "worktree", "add", str(worktree_path), branch,
433
+ cwd=bare_path,
434
+ )
435
+ return
436
+ except WorktreeError as e:
437
+ if "already checked out" not in str(e):
438
+ raise
439
+ # Find the stale admin entry for this branch and clear it
440
+ # WITHOUT running repo-wide `git worktree prune` — that would
441
+ # also discard admin state for unrelated prunable worktrees
442
+ # whose paths are temporarily unavailable (removable media,
443
+ # network mounts), making them unrecoverable when the path
444
+ # comes back. Instead, delete just the one admin directory.
445
+ stale_path = await self._find_stale_worktree_path(bare_path, branch)
446
+ if stale_path is None:
447
+ raise
448
+ # Only touch stale entries we OWN — under our managed
449
+ # worktrees_dir — AND only when our worktrees_dir is actually
450
+ # accessible on disk right now. A prunable stanza can also
451
+ # represent a worktree on a temporarily-unavailable path
452
+ # (network mount, removable drive); if the mount is down,
453
+ # every managed worktree under it appears prunable. Deleting
454
+ # their admin state would orphan real checkouts when the
455
+ # mount comes back.
456
+ #
457
+ # Safety gate: require worktrees_dir itself to exist and be
458
+ # a directory. If the whole mount is offline, bail.
459
+ if not self.worktrees_dir.is_dir():
460
+ raise e
461
+ try:
462
+ stale_resolved = Path(stale_path).resolve()
463
+ wt_root = self.worktrees_dir.resolve()
464
+ stale_resolved.relative_to(wt_root)
465
+ except (OSError, ValueError):
466
+ raise e # not under our dir — unsafe to recover
467
+ # Locate the admin dir via its canonical `gitdir` pointer,
468
+ # NOT by assuming the path basename matches. Git sanitizes
469
+ # names (`foo bar` → `foo-bar`) and disambiguates duplicates
470
+ # (`wt`, `wt1`, …), so Path(stale_path).name can point at the
471
+ # wrong admin dir or a live unrelated worktree's metadata.
472
+ # Each admin dir has a `gitdir` file that points at
473
+ # `<worktree-path>/.git` — that pointer is the source of truth.
474
+ admin_dir = self._resolve_admin_dir(bare_path, stale_path)
475
+ if admin_dir is not None and admin_dir.exists():
476
+ try:
477
+ shutil.rmtree(admin_dir)
478
+ except OSError:
479
+ pass
480
+ # Retry the add; if it fails again, let the error surface.
481
+ await self._run_git(
482
+ "worktree", "add", str(worktree_path), branch,
483
+ cwd=bare_path,
484
+ )
485
+
486
+ def _resolve_admin_dir(
487
+ self, bare_path: Path, worktree_dir: str,
488
+ ) -> Path | None:
489
+ """Find the bare repo's admin dir whose ``gitdir`` pointer file
490
+ resolves to ``<worktree_dir>/.git``. Returns None if no
491
+ matching entry exists.
492
+
493
+ Git may name admin dirs differently from the worktree path
494
+ basename (sanitization, collision-suffix), so the only robust
495
+ way to pair them is to inspect each admin's gitdir pointer.
496
+ """
497
+ worktrees_root = bare_path / "worktrees"
498
+ if not worktrees_root.is_dir():
499
+ return None
500
+ target = str(Path(worktree_dir) / ".git")
501
+ try:
502
+ entries = list(worktrees_root.iterdir())
503
+ except OSError:
504
+ return None
505
+ for admin_dir in entries:
506
+ gitdir_file = admin_dir / "gitdir"
507
+ if not gitdir_file.exists():
508
+ continue
509
+ try:
510
+ pointer = gitdir_file.read_text().strip()
511
+ except OSError:
512
+ continue
513
+ if pointer == target:
514
+ return admin_dir
515
+ return None
516
+
517
+ async def _find_stale_worktree_path(
518
+ self, bare_path: Path, branch: str,
519
+ ) -> str | None:
520
+ """If ``refs/heads/<branch>`` is claimed by a worktree entry marked
521
+ ``prunable``, return the path of that entry. Otherwise None.
522
+ Used to confirm that a "already checked out" failure is safe to
523
+ recover from via prune (i.e. the stale entry IS for our branch
524
+ and not a real live checkout).
525
+ """
526
+ try:
527
+ output = await self._run_git(
528
+ "worktree", "list", "--porcelain",
529
+ cwd=bare_path, timeout=10,
530
+ )
531
+ except Exception:
532
+ return None
533
+ target = f"refs/heads/{branch}"
534
+ current_path: str | None = None
535
+ current_branch: str | None = None
536
+ current_prunable = False
537
+ for raw in output.splitlines() + [""]:
538
+ line = raw.strip()
539
+ if not line:
540
+ if (
541
+ current_branch == target
542
+ and current_prunable
543
+ and current_path is not None
544
+ ):
545
+ return current_path
546
+ current_path = None
547
+ current_branch = None
548
+ current_prunable = False
549
+ continue
550
+ if line.startswith("worktree "):
551
+ current_path = line[9:].strip()
552
+ elif line.startswith("branch "):
553
+ current_branch = line[7:].strip()
554
+ elif line.startswith("prunable"):
555
+ current_prunable = True
556
+ return None
557
+
558
+ async def _sync_reused_branch_to_origin(
559
+ self, bare_path: Path, branch: str,
560
+ ) -> None:
561
+ """Fast-forward the local ``branch`` to origin's head using a
562
+ scratch ref so it never touches ``refs/heads/<branch>`` except
563
+ via a safe `update-ref` that's gated by a merge-base ancestor
564
+ check.
565
+
566
+ Bare clones from `git clone --bare` have fetch refspec
567
+ `+refs/heads/*:refs/heads/*`, so there are NO
568
+ `refs/remotes/origin/*` tracking refs AND an unscoped
569
+ `git fetch origin <branch>` would overwrite the live local ref
570
+ directly — destroying any unpushed local commits before we get
571
+ to compare. Routing through a scratch ref in
572
+ `refs/ctrlrelay/sync/*` avoids both problems.
573
+
574
+ Rules:
575
+ - If local is ancestor of the fetched remote tip → fast-forward.
576
+ - If local is ahead or diverged → leave local alone (ahead
577
+ contains unpushed work; forcing would destroy it).
578
+
579
+ All steps are best-effort: any failure (timeout, network,
580
+ permissions, bad ref) falls back to reusing the local ref
581
+ unchanged, so a flaky origin never aborts the retry.
582
+ """
583
+ scratch_ref = f"refs/ctrlrelay/sync/{branch}"
584
+ fetched = False
585
+ try:
586
+ await self._run_git(
587
+ "fetch", "origin",
588
+ f"+refs/heads/{branch}:{scratch_ref}",
589
+ cwd=bare_path, timeout=30,
590
+ )
591
+ fetched = True
592
+ except Exception:
593
+ return
594
+ try:
595
+ # Distinguish three cases cleanly:
596
+ # a) local is ancestor of remote → local behind → fast-forward
597
+ # b) remote is ancestor of local → local ahead (unpushed work) → preserve
598
+ # c) neither → diverged → raise; caller surfaces as session failure
599
+ # so we don't silently reuse a branch that will fail on push.
600
+ local_behind = False
601
+ local_ahead = False
602
+ try:
603
+ await self._run_git(
604
+ "merge-base", "--is-ancestor", branch, scratch_ref,
605
+ cwd=bare_path, timeout=10,
606
+ )
607
+ local_behind = True
608
+ except WorktreeError:
609
+ pass
610
+ except Exception:
611
+ # Timeout or other — play safe and preserve local.
612
+ return
613
+ if not local_behind:
614
+ try:
615
+ await self._run_git(
616
+ "merge-base", "--is-ancestor", scratch_ref, branch,
617
+ cwd=bare_path, timeout=10,
618
+ )
619
+ local_ahead = True
620
+ except WorktreeError:
621
+ pass
622
+ except Exception:
623
+ return
624
+
625
+ if local_behind:
626
+ try:
627
+ await self._run_git(
628
+ "update-ref", f"refs/heads/{branch}", scratch_ref,
629
+ cwd=bare_path, timeout=10,
630
+ )
631
+ except Exception:
632
+ pass
633
+ elif local_ahead:
634
+ # Preserve local — ahead means unpushed operator work.
635
+ pass
636
+ else:
637
+ # Diverged: local has commits origin doesn't, and origin has
638
+ # commits local doesn't. Reusing either side silently loses
639
+ # work or produces non-ff pushes. Surface the conflict so
640
+ # the session fails cleanly.
641
+ raise WorktreeError(
642
+ f"Local branch {branch!r} has diverged from "
643
+ f"origin/{branch}. Rebase or reset manually in the bare "
644
+ "repo before retrying."
645
+ )
646
+ finally:
647
+ if fetched:
648
+ try:
649
+ await self._run_git(
650
+ "update-ref", "-d", scratch_ref,
651
+ cwd=bare_path, timeout=10,
652
+ )
653
+ except Exception:
654
+ pass
655
+
656
+ async def _branch_exists_on_remote_strict(
657
+ self, bare_path: Path, branch: str,
658
+ ) -> bool:
659
+ """Strict variant of branch_exists_on_remote: True if origin
660
+ has the branch, False if it does not, RAISES on probe failure.
661
+
662
+ The public branch_exists_on_remote is fail-closed (returns True
663
+ on error) so callers making "safe to delete" decisions err
664
+ on preservation. That semantic is wrong for the reuse path,
665
+ where a transient error must NOT be interpreted as "remote
666
+ exists" — that would skip the stale-merged recreate branch and
667
+ resurrect already-merged commits into a new PR.
668
+ """
669
+ output = await self._run_git(
670
+ "ls-remote", "--heads", "origin", branch,
671
+ cwd=bare_path, timeout=10,
672
+ )
673
+ return bool(output.strip())
674
+
675
+ async def _branch_is_checked_out_elsewhere(
676
+ self, bare_path: Path, branch: str,
677
+ ) -> bool:
678
+ """Return True if ``refs/heads/<branch>`` is currently checked out
679
+ by any LIVE linked worktree of this bare repo.
680
+
681
+ `git worktree list --porcelain` emits a blank-line-separated
682
+ stanza per worktree. A stanza with a ``prunable <reason>`` line
683
+ is a stale metadata entry — the worktree directory is gone, we
684
+ just haven't run ``git worktree prune`` yet. Such stanzas don't
685
+ represent a real checkout and MUST be ignored here, otherwise a
686
+ crash between ``shutil.rmtree()`` and ``worktree prune`` wedges
687
+ the branch for all future retries.
688
+
689
+ Conservative (fails closed): if the probe itself errors, returns
690
+ True — better to refuse mutation than to risk corrupting a live
691
+ worktree.
692
+ """
693
+ try:
694
+ output = await self._run_git(
695
+ "worktree", "list", "--porcelain",
696
+ cwd=bare_path, timeout=10,
697
+ )
698
+ except Exception:
699
+ return True
700
+
701
+ target = f"refs/heads/{branch}"
702
+ current_branch: str | None = None
703
+ current_prunable = False
704
+ for raw in output.splitlines() + [""]: # trailing "" to flush last stanza
705
+ line = raw.strip()
706
+ if not line:
707
+ # End of stanza — check if this one matches and is live.
708
+ if (
709
+ current_branch == target
710
+ and not current_prunable
711
+ ):
712
+ return True
713
+ current_branch = None
714
+ current_prunable = False
715
+ continue
716
+ if line.startswith("branch "):
717
+ current_branch = line[7:].strip()
718
+ elif line.startswith("prunable"):
719
+ current_prunable = True
720
+ return False
721
+
722
+ async def _branch_is_fully_merged(self, repo: str, branch: str) -> bool:
723
+ """Return True if every commit reachable from ``branch`` is
724
+ patch-equivalent to something already in the default branch.
725
+
726
+ Uses ``git cherry <default> <branch>`` which compares commits
727
+ by patch-id (content), not by SHA — so this catches squash and
728
+ rebase merges too, not just regular merge commits.
729
+
730
+ Conservative: on any error (unknown default branch, cherry
731
+ failure) returns False so we preserve the local ref.
732
+ """
733
+ bare_path = self._get_bare_repo_path(repo)
734
+ try:
735
+ default = await self.get_default_branch(repo)
736
+ except Exception:
737
+ return False
738
+ try:
739
+ out = await self._run_git(
740
+ "cherry", default, branch,
741
+ cwd=bare_path, timeout=15,
742
+ )
743
+ except Exception:
744
+ return False
745
+ lines = [line for line in out.splitlines() if line.strip()]
746
+ if not lines:
747
+ # No commits in branch not in default — fully merged / stale.
748
+ return True
749
+ # Each line starts with "+" (unique) or "-" (patch-equivalent in upstream).
750
+ return all(line.startswith("-") for line in lines)
751
+
752
+ async def push_branch(self, worktree_path: Path, branch: str) -> None:
753
+ """Push a branch to origin."""
754
+ await self._run_git("push", "-u", "origin", branch, cwd=worktree_path)
755
+
756
+ async def ensure_bare_repo(self, repo: str) -> Path:
757
+ """Ensure bare repo exists, cloning if needed.
758
+
759
+ Uses an explicit refspec on fetch instead of relying on
760
+ ``remote.origin.fetch`` config. Some bare clones in the wild
761
+ have the refspec missing or empty; ``git fetch --all`` is a
762
+ silent no-op in that case, so repeated calls never update
763
+ ``refs/heads/*``. Worktrees created from that stale bare
764
+ then check out commits days or weeks behind origin —
765
+ exactly what we saw today when a task-pipeline run reported
766
+ test counts from a commit that was two weeks old.
767
+
768
+ Refspec: ``refs/heads/*:refs/heads/*`` (no leading ``+``).
769
+ Fast-forward-only on purpose — codex caught this on the
770
+ first pass: a force update would clobber the dev pipeline's
771
+ ``create_worktree_with_new_branch`` reuse path, which keeps
772
+ local-ahead-of-origin commits when a prior session pushed
773
+ partial work. Non-force semantics give us the right
774
+ behavior in all three cases:
775
+
776
+ - Local branch BEHIND origin (default-branch case, the bug
777
+ we're fixing): fast-forward applies, local catches up.
778
+ - Local branch AHEAD of origin (unpushed dev work): fetch
779
+ refuses to rewind, local stays as-is, dev's reuse logic
780
+ gets to inspect divergence and decide.
781
+ - Local and origin equal: no-op.
782
+
783
+ ``--prune`` drops refs that no longer exist on origin so
784
+ deleted branches don't linger forever.
785
+ """
786
+ bare_path = self._get_bare_repo_path(repo)
787
+
788
+ if bare_path.exists():
789
+ await self._run_git(
790
+ "fetch", "--prune", "origin",
791
+ "refs/heads/*:refs/heads/*",
792
+ cwd=bare_path,
793
+ timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
794
+ )
795
+ else:
796
+ await self._run_git(
797
+ "clone", "--bare",
798
+ f"https://github.com/{repo}.git",
799
+ str(bare_path),
800
+ timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
801
+ )
802
+
803
+ return bare_path
804
+
805
+ async def remove_worktree(self, repo: str, session_id: str) -> None:
806
+ """Remove a worktree and clean up."""
807
+ bare_path = self._get_bare_repo_path(repo)
808
+ worktree_path = self._get_worktree_path(repo, session_id)
809
+
810
+ if worktree_path.exists():
811
+ shutil.rmtree(worktree_path)
812
+
813
+ if bare_path.exists():
814
+ await self._run_git("worktree", "prune", cwd=bare_path)
815
+
816
+ async def delete_branch(self, repo: str, branch: str) -> None:
817
+ """Delete a local branch in the bare repo. Best-effort; no-op if absent."""
818
+ bare_path = self._get_bare_repo_path(repo)
819
+ if not bare_path.exists():
820
+ return
821
+ try:
822
+ await self._run_git("branch", "-D", branch, cwd=bare_path)
823
+ except WorktreeError:
824
+ pass
825
+
826
+ async def branch_exists_locally(self, repo: str, branch: str) -> bool:
827
+ """Check if `branch` exists as a local ref in the bare repo."""
828
+ bare_path = self._get_bare_repo_path(repo)
829
+ if not bare_path.exists():
830
+ return False
831
+ try:
832
+ await self._run_git(
833
+ "show-ref", "--verify", "--quiet", f"refs/heads/{branch}",
834
+ cwd=bare_path,
835
+ )
836
+ return True
837
+ except Exception:
838
+ return False
839
+
840
+ async def branch_exists_on_remote(self, repo: str, branch: str) -> bool:
841
+ """Return True if `branch` exists on origin. Fail-closed: on any error
842
+ (WorktreeError, asyncio.TimeoutError from _run_git's wait_for, etc.)
843
+ returns True so callers err on the side of NOT deleting."""
844
+ bare_path = self._get_bare_repo_path(repo)
845
+ if not bare_path.exists():
846
+ return True
847
+ try:
848
+ output = await self._run_git(
849
+ "ls-remote", "--heads", "origin", branch,
850
+ cwd=bare_path,
851
+ timeout=10,
852
+ )
853
+ return bool(output.strip())
854
+ except Exception:
855
+ return True
856
+
857
+ def _get_gitdir(self, worktree_path: Path) -> Path:
858
+ """Get the real gitdir for a worktree.
859
+
860
+ In linked worktrees, .git is a file pointing to the actual gitdir.
861
+ """
862
+ dot_git = worktree_path / ".git"
863
+ if dot_git.is_file():
864
+ content = dot_git.read_text().strip()
865
+ if content.startswith("gitdir: "):
866
+ return Path(content[8:])
867
+ return dot_git
868
+
869
+ def symlink_context(
870
+ self,
871
+ worktree_path: Path,
872
+ context_path: Path,
873
+ ) -> None:
874
+ """Symlink CLAUDE.md into worktree."""
875
+ target = worktree_path / "CLAUDE.md"
876
+
877
+ if target.exists() or target.is_symlink():
878
+ target.unlink()
879
+
880
+ target.symlink_to(context_path.resolve())
881
+
882
+ gitdir = self._get_gitdir(worktree_path)
883
+ exclude_file = gitdir / "info" / "exclude"
884
+ if exclude_file.exists():
885
+ content = exclude_file.read_text()
886
+ if "CLAUDE.md" not in content:
887
+ exclude_file.write_text(content.rstrip() + "\nCLAUDE.md\n")
888
+
889
+ def remove_context_symlink(self, worktree_path: Path) -> None:
890
+ """Remove CLAUDE.md symlink before git operations."""
891
+ target = worktree_path / "CLAUDE.md"
892
+ if target.is_symlink():
893
+ target.unlink()