froid-loop 0.11.1__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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/workspace.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Where code+git work happens, decoupled from where run state lives.
|
|
2
|
+
|
|
3
|
+
A Workspace pairs the directory sessions run in (and git operates on) with the
|
|
4
|
+
artifact paths rebased onto it. Run state (run_dir, journal, state.json) always
|
|
5
|
+
lives in the main repo and is passed separately — it never moves.
|
|
6
|
+
|
|
7
|
+
- isolation = none → Workspace.default(paths): root = paths.repo_root, behavior
|
|
8
|
+
identical to operating directly on the project.
|
|
9
|
+
- isolation = worktree → per unit: a git worktree mounted under the run dir
|
|
10
|
+
(.froid-loop/runs/<run_id>/worktrees/, which `froid-loop init` gitignores, so it
|
|
11
|
+
stays invisible to the main checkout's `git status`), with paths rebased onto
|
|
12
|
+
it. open_unit_workspace / close_unit_workspace manage the branch + worktree
|
|
13
|
+
lifecycle; the engine merges the unit branch back into the target branch from
|
|
14
|
+
the main repo between units.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import verify
|
|
25
|
+
from .froidconfig import ProjectPaths
|
|
26
|
+
from .platform_util import safe_ref_segment, safe_segment
|
|
27
|
+
|
|
28
|
+
# Per-unit worktrees live under the run dir (.froid-loop/runs/<run_id>/worktrees/),
|
|
29
|
+
# which `froid-loop init` already gitignores — so unit checkouts never show up as
|
|
30
|
+
# untracked files in the main checkout. Crucially they must NOT live under .git/:
|
|
31
|
+
# a cwd inside .git/ is treated as git-internal by the coding CLIs (Claude Code),
|
|
32
|
+
# which then refuse to load the project's froid-loop-* skills — breaking every
|
|
33
|
+
# worktree session (`Unknown command: /froid-build-auto`).
|
|
34
|
+
WORKTREE_DIRNAME = "worktrees"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unit_worktrees_dir(run_dir: Path) -> Path:
|
|
38
|
+
"""The parent dir holding this run's per-unit worktrees."""
|
|
39
|
+
return run_dir / WORKTREE_DIRNAME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rmtree_confined(wt: Path, run_dir: Path) -> bool:
|
|
43
|
+
"""rmtree `wt` only when it resolves to a strict descendant of this run's
|
|
44
|
+
worktrees dir; returns whether deletion was attempted. The teardown fallbacks
|
|
45
|
+
reach for rmtree with paths that can arrive from persisted task state
|
|
46
|
+
(`task.worktree_path` via discard_worktree and `_reopen_unit`), and rmtree —
|
|
47
|
+
unlike `git worktree remove` — performs no validation of its own, so a
|
|
48
|
+
corrupt or hand-edited state entry must not be able to point it at the repo
|
|
49
|
+
root or anywhere else outside the run's scaffolding (same doctrine as
|
|
50
|
+
runs.reconcile_orphan_worktrees / resolve_run_dir)."""
|
|
51
|
+
try:
|
|
52
|
+
root = unit_worktrees_dir(run_dir).resolve()
|
|
53
|
+
target = wt.resolve()
|
|
54
|
+
target.relative_to(root)
|
|
55
|
+
except (ValueError, OSError):
|
|
56
|
+
return False
|
|
57
|
+
if target == root:
|
|
58
|
+
return False
|
|
59
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Workspace:
|
|
65
|
+
root: Path # where sessions run (cwd) and git operates
|
|
66
|
+
paths: ProjectPaths # artifact paths rebased onto `root`
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def default(cls, paths: ProjectPaths) -> Workspace:
|
|
70
|
+
"""The zero-config workspace: work happens in the repo root in place."""
|
|
71
|
+
return cls(root=paths.repo_root, paths=paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class UnitWorkspace:
|
|
76
|
+
"""A per-unit worktree workspace plus the bookkeeping needed to merge it
|
|
77
|
+
back and tear it down from the main repo."""
|
|
78
|
+
|
|
79
|
+
workspace: Workspace # rebased onto the worktree dir
|
|
80
|
+
repo_root: Path # the main repo (where merges + worktree removal happen)
|
|
81
|
+
branch: str # the unit branch checked out in the worktree
|
|
82
|
+
path: Path # the worktree dir
|
|
83
|
+
baseline: str # commit the worktree was cut from (for failed-diff capture)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def unit_branch_name(run_id: str, unit_key: str, branch_per: str) -> str:
|
|
87
|
+
"""branch_per=run shares one branch across the whole run; branch_per=story
|
|
88
|
+
gives each unit its own branch.
|
|
89
|
+
|
|
90
|
+
Both segments are ref-sanitized: `--run-id` is user-suppliable and a unit key is
|
|
91
|
+
a sprint-board / ledger id, so either can carry ref-illegal sequences (`:`, `..`,
|
|
92
|
+
`@{`, a trailing `.lock`) that git rejects at branch-creation time. Clean ids —
|
|
93
|
+
every auto-generated run id and every conventional story key — pass through
|
|
94
|
+
byte-identical. This is the single source of the name: `open_unit_workspace` is
|
|
95
|
+
the sole caller and stores the result on `task.branch`, which every consumer
|
|
96
|
+
(`_merge_local`, `discard_worktree`, `close_unit_workspace`) reuses.
|
|
97
|
+
"""
|
|
98
|
+
if branch_per == "run":
|
|
99
|
+
return f"froid-loop/{safe_ref_segment(run_id)}"
|
|
100
|
+
return f"froid-loop/{safe_ref_segment(run_id)}/{safe_ref_segment(unit_key)}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def open_unit_workspace(
|
|
104
|
+
repo_root: Path,
|
|
105
|
+
paths: ProjectPaths,
|
|
106
|
+
run_id: str,
|
|
107
|
+
unit_key: str,
|
|
108
|
+
base: str,
|
|
109
|
+
branch_per: str,
|
|
110
|
+
run_dir: Path,
|
|
111
|
+
) -> UnitWorkspace:
|
|
112
|
+
"""Mount a fresh worktree for `unit_key` and return its rebased workspace.
|
|
113
|
+
|
|
114
|
+
The worktree is mounted under the run dir (see unit_worktrees_dir), not under
|
|
115
|
+
.git/. The unit branch is cut from `base` (the target branch's HEAD). When the
|
|
116
|
+
branch already exists (branch_per=run re-mounting the shared run branch
|
|
117
|
+
across serial units) it is re-checked-out from its own HEAD instead, so it
|
|
118
|
+
keeps the commits earlier units already landed on it.
|
|
119
|
+
"""
|
|
120
|
+
branch = unit_branch_name(run_id, unit_key, branch_per)
|
|
121
|
+
unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key)
|
|
122
|
+
try:
|
|
123
|
+
wt = unresolved_wt.resolve()
|
|
124
|
+
except (OSError, RuntimeError) as e:
|
|
125
|
+
raise verify.GitError(
|
|
126
|
+
f"cannot resolve worktree mount path for {unit_key} ({unresolved_wt}): {e}"
|
|
127
|
+
) from e
|
|
128
|
+
wt.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
# Reclaim whatever still occupies this unit's mount point before adding.
|
|
130
|
+
# `wt` and `branch` are both DETERMINISTIC in (run_id, unit_key, run_dir), so a
|
|
131
|
+
# re-mount targets the exact path a previous mount used — and `worktree_add`
|
|
132
|
+
# refuses a target that exists or a branch checked out elsewhere, which makes a
|
|
133
|
+
# leftover registration a hard `GitError` rather than a recoverable state.
|
|
134
|
+
# `engine._finish_inflight` reaches that shape by design: when live policy leaves
|
|
135
|
+
# isolation it releases the mount's state and clears the task's claim but
|
|
136
|
+
# deliberately LEAVES the directory standing (the journal names it), so a later
|
|
137
|
+
# flip back to `worktree` re-derives this same path and used to be unrecoverable
|
|
138
|
+
# through the normal run flow. Reclaiming here rather than deleting at the flip
|
|
139
|
+
# keeps that preservation intact for the in-place run and spends the orphan only
|
|
140
|
+
# when a mount actually needs its path.
|
|
141
|
+
#
|
|
142
|
+
# The BRANCH is deliberately not passed: `discard_worktree` would force-delete it,
|
|
143
|
+
# and under `branch_per=run` this name is the SHARED run branch carrying commits
|
|
144
|
+
# earlier units already landed. Dropping only the worktree frees the checkout that
|
|
145
|
+
# blocks `worktree_add` while leaving those commits reachable, so the
|
|
146
|
+
# `branch_exists` fork below still re-mounts the branch from its own HEAD.
|
|
147
|
+
discard_worktree(repo_root, str(wt), "", run_dir=run_dir)
|
|
148
|
+
if verify.branch_exists(repo_root, branch):
|
|
149
|
+
verify.worktree_add(repo_root, wt, branch, create=False)
|
|
150
|
+
else:
|
|
151
|
+
verify.worktree_add(repo_root, wt, branch, base=base, create=True)
|
|
152
|
+
baseline = verify.rev_parse_head(wt)
|
|
153
|
+
return UnitWorkspace(
|
|
154
|
+
workspace=Workspace(root=wt, paths=paths.rebased(wt)),
|
|
155
|
+
repo_root=repo_root,
|
|
156
|
+
branch=branch,
|
|
157
|
+
path=wt,
|
|
158
|
+
baseline=baseline,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def close_unit_workspace(
|
|
163
|
+
unit: UnitWorkspace,
|
|
164
|
+
*,
|
|
165
|
+
success: bool,
|
|
166
|
+
keep_failed: bool,
|
|
167
|
+
run_dir: Path,
|
|
168
|
+
unit_key: str,
|
|
169
|
+
delete_branch: bool = True,
|
|
170
|
+
detach_kept: bool = False,
|
|
171
|
+
diff_max_file_bytes: int | None = None,
|
|
172
|
+
on_teardown_degraded: Callable[[str], None] | None = None,
|
|
173
|
+
) -> Path | None:
|
|
174
|
+
"""Tear down (or preserve) a unit's worktree.
|
|
175
|
+
|
|
176
|
+
On failure the unit's full diff against its baseline is written to
|
|
177
|
+
`run_dir/failed/<unit_key>/changes.patch` for forensics; when keep_failed is
|
|
178
|
+
set the worktree + branch are left mounted for inspection and nothing else
|
|
179
|
+
happens. On success (or failure without keep_failed) the worktree is removed
|
|
180
|
+
and, if delete_branch, the branch deleted. Returns the patch path it wrote,
|
|
181
|
+
or None.
|
|
182
|
+
|
|
183
|
+
Invariant: the teardown tail is post-merge/post-capture housekeeping — the
|
|
184
|
+
unit's content is already safe (merged on success, patch-captured on failure)
|
|
185
|
+
before it runs, so no git teardown failure escapes it. Every `GitError` from
|
|
186
|
+
the worktree removal or branch deletion degrades to a call of
|
|
187
|
+
on_teardown_degraded (given the failure message) instead of crashing the run;
|
|
188
|
+
a clean or force-retried removal is silent (see the teardown tail below). A
|
|
189
|
+
failed diff *capture* breaks the tail's premise instead — the worktree would
|
|
190
|
+
then hold the only copy of the unit's changes — so it is reported the same
|
|
191
|
+
way but preserves the worktree + branch rather than tearing them down. The
|
|
192
|
+
callback itself is the caller's (the engine's `journal.append`, whose OSError
|
|
193
|
+
is engine-wide journal semantics, deliberately unguarded here).
|
|
194
|
+
|
|
195
|
+
detach_kept (branch_per=run only): when keep_failed preserves the worktree,
|
|
196
|
+
detach its HEAD so the shared run branch it holds is freed for the next unit
|
|
197
|
+
to mount — otherwise every later unit's `git worktree add` collides on the
|
|
198
|
+
already-checked-out branch. Best effort; see the keep_failed branch below.
|
|
199
|
+
|
|
200
|
+
diff_max_file_bytes caps the per-untracked-file size in that forensic patch
|
|
201
|
+
(None = no cap); see verify.capture_diff.
|
|
202
|
+
"""
|
|
203
|
+
patch: Path | None = None
|
|
204
|
+
if not success:
|
|
205
|
+
capture_err: verify.GitError | None = None
|
|
206
|
+
try:
|
|
207
|
+
diff = (
|
|
208
|
+
verify.capture_diff(unit.path, unit.baseline, max_file_bytes=diff_max_file_bytes)
|
|
209
|
+
if unit.baseline
|
|
210
|
+
else ""
|
|
211
|
+
)
|
|
212
|
+
except verify.GitError as e:
|
|
213
|
+
capture_err = e
|
|
214
|
+
diff = ""
|
|
215
|
+
if diff:
|
|
216
|
+
patch = run_dir / "failed" / safe_segment(unit_key) / "changes.patch"
|
|
217
|
+
patch.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
patch.write_text(diff, encoding="utf-8")
|
|
219
|
+
if capture_err is not None and on_teardown_degraded is not None:
|
|
220
|
+
# the forensic patch is the only copy of a dropped unit's changes; a
|
|
221
|
+
# failed capture means the teardown below would destroy them, so the
|
|
222
|
+
# unit is preserved as if keep_failed (the `or` on the branch below).
|
|
223
|
+
on_teardown_degraded(
|
|
224
|
+
f"diff capture failed for {unit.path}: {capture_err}; "
|
|
225
|
+
"worktree and branch preserved (uncaptured changes)"
|
|
226
|
+
)
|
|
227
|
+
if keep_failed or capture_err is not None:
|
|
228
|
+
if detach_kept:
|
|
229
|
+
# branch_per=run shares one branch across the run; a kept worktree
|
|
230
|
+
# left checked out on it blocks every later unit's `git worktree
|
|
231
|
+
# add` (git refuses a branch checked out elsewhere). Detach HEAD to
|
|
232
|
+
# free the shared branch name while preserving the working tree,
|
|
233
|
+
# uncommitted changes, and the branch ref (still at the kept commit)
|
|
234
|
+
# for inspection. Best effort: on failure the later unit still
|
|
235
|
+
# surfaces the collision via the worktree-open-failed defer path.
|
|
236
|
+
try:
|
|
237
|
+
verify.checkout_detach(unit.path)
|
|
238
|
+
except verify.GitError:
|
|
239
|
+
pass
|
|
240
|
+
return patch # leave the worktree mounted (branch detached if shared)
|
|
241
|
+
|
|
242
|
+
# success, or a failure we are not keeping: remove the worktree. A failed
|
|
243
|
+
# tree is dirty, so force; a successful unit was committed + merged, so its
|
|
244
|
+
# tree is clean, but force is harmless and tolerant of stray artifacts.
|
|
245
|
+
try:
|
|
246
|
+
verify.worktree_remove(unit.repo_root, unit.path, force=not success)
|
|
247
|
+
except verify.GitError as first_err:
|
|
248
|
+
try:
|
|
249
|
+
# Ordinary dirty-tree case: the plain remove refused stray untracked
|
|
250
|
+
# artifacts, which --force clears. Not a degradation — stay silent.
|
|
251
|
+
verify.worktree_remove(unit.repo_root, unit.path, force=True)
|
|
252
|
+
except verify.GitError as retry_err:
|
|
253
|
+
# gh-139 fingerprint the retry CAN'T fix: a process the just-ended
|
|
254
|
+
# session left running (e.g. pytest recreating `.pytest_cache`) makes
|
|
255
|
+
# the plain remove fail with ENOTEMPTY (first_err), and by then git has
|
|
256
|
+
# already deleted its admin entry `.git/worktrees/<id>` — so the --force
|
|
257
|
+
# retry fails with "is not a working tree" (retry_err). Force can't
|
|
258
|
+
# restore a dropped admin entry, so fall back to git's own reclaim path
|
|
259
|
+
# (plain rmtree + prune) and degrade to a warning: the content already
|
|
260
|
+
# landed, so this is housekeeping, not a run failure. Both git errors
|
|
261
|
+
# go into the message — each half of the fingerprint is diagnostic.
|
|
262
|
+
# The rmtree is confined to the run's worktrees dir: on resume the
|
|
263
|
+
# path comes from persisted state (_reopen_unit), which git validates
|
|
264
|
+
# but rmtree would not.
|
|
265
|
+
removed = _rmtree_confined(unit.path, run_dir)
|
|
266
|
+
verify.worktree_prune(unit.repo_root)
|
|
267
|
+
msg = (
|
|
268
|
+
f"worktree remove failed for {unit.path} ({first_err}); "
|
|
269
|
+
f"force retry failed ({retry_err}); fell back to rmtree+prune"
|
|
270
|
+
)
|
|
271
|
+
if not removed:
|
|
272
|
+
msg += f" (rmtree refused: {unit.path} resolves outside this run's worktrees dir)"
|
|
273
|
+
elif unit.path.exists():
|
|
274
|
+
# rmtree lost the race too (the writer recreated files under it):
|
|
275
|
+
# the dir survives under the gitignored run dir and is reclaimed
|
|
276
|
+
# later by trim_run_dir / clean — note it, don't block on it.
|
|
277
|
+
msg += f" (dir still present: {unit.path})"
|
|
278
|
+
if on_teardown_degraded is not None:
|
|
279
|
+
on_teardown_degraded(msg)
|
|
280
|
+
try:
|
|
281
|
+
if delete_branch and verify.branch_exists(unit.repo_root, unit.branch):
|
|
282
|
+
# the unit's content is already on the target branch (success) or saved
|
|
283
|
+
# to a patch (failure), so a force delete loses nothing — and squash
|
|
284
|
+
# merges leave the branch looking "unmerged" to `git branch -d`.
|
|
285
|
+
# prune above frees a branch whose worktree dir was rmtree'd, so this
|
|
286
|
+
# still runs after a degraded removal.
|
|
287
|
+
verify.delete_branch(unit.repo_root, unit.branch, force=True)
|
|
288
|
+
except verify.GitError as e:
|
|
289
|
+
# second crash door in this teardown tail: branch deletion is likewise
|
|
290
|
+
# post-merge housekeeping, so degrade it rather than raise past here.
|
|
291
|
+
if on_teardown_degraded is not None:
|
|
292
|
+
on_teardown_degraded(f"branch delete failed for {unit.branch}: {e}")
|
|
293
|
+
return patch
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def discard_worktree(repo_root: Path, worktree_path: str, branch: str, *, run_dir: Path) -> None:
|
|
297
|
+
"""Best-effort force teardown of a worktree + branch by path/name, for
|
|
298
|
+
resume-restart of a crashed/interrupted unit. Tolerant of partial state.
|
|
299
|
+
`worktree_path` arrives from persisted task state, so the rmtree fallback is
|
|
300
|
+
confined to `run_dir`'s worktrees dir (see _rmtree_confined)."""
|
|
301
|
+
if worktree_path:
|
|
302
|
+
wt = Path(worktree_path)
|
|
303
|
+
try:
|
|
304
|
+
if wt.exists():
|
|
305
|
+
verify.worktree_remove(repo_root, wt, force=True)
|
|
306
|
+
except verify.GitError:
|
|
307
|
+
# same gh-139 hazard as close_unit_workspace: a stray process (or a
|
|
308
|
+
# dropped admin entry) can keep `git worktree remove` from clearing
|
|
309
|
+
# the dir. A leftover dir breaks the resume re-mount at this same path
|
|
310
|
+
# (`git worktree add` refuses a non-empty target), so drop to rmtree.
|
|
311
|
+
_rmtree_confined(wt, run_dir)
|
|
312
|
+
# prune git's admin entry regardless (harmless when nothing is stale) so a
|
|
313
|
+
# half-removed worktree can't block that re-mount.
|
|
314
|
+
verify.worktree_prune(repo_root)
|
|
315
|
+
if branch:
|
|
316
|
+
try:
|
|
317
|
+
if verify.branch_exists(repo_root, branch):
|
|
318
|
+
verify.delete_branch(repo_root, branch, force=True)
|
|
319
|
+
except verify.GitError:
|
|
320
|
+
pass
|