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
|
@@ -0,0 +1,2301 @@
|
|
|
1
|
+
"""Per-unit worktree isolation + integration flow.
|
|
2
|
+
|
|
3
|
+
Extracted from :class:`froid_loop.engine.Engine` (issue #244, findings F-3/F-9a):
|
|
4
|
+
``Engine`` was a god-class whose worktree/integration cluster is an independent
|
|
5
|
+
state machine. It lives here as a collaborator built from narrow dependencies
|
|
6
|
+
(repo paths, the policy, run state, journal, plugin registry, loaded adapters)
|
|
7
|
+
plus a handful of engine callbacks (emit a plugin hook, save state, run the
|
|
8
|
+
per-unit ready gate, carry isolated ledger writes, escalate-pause, and get/set
|
|
9
|
+
the engine's active workspace).
|
|
10
|
+
The collaborator never receives the whole ``Engine`` — it cannot reach engine
|
|
11
|
+
internals beyond those callables.
|
|
12
|
+
|
|
13
|
+
``Engine`` keeps same-name private methods that delegate here, so its tests and
|
|
14
|
+
the ``SweepEngine``/``StoriesEngine`` subclasses see an unchanged surface.
|
|
15
|
+
|
|
16
|
+
``provision_worktree`` (previously in ``install.py``) is rehomed here too so the
|
|
17
|
+
runtime control loop no longer imports the installer; ``install.py`` re-exports
|
|
18
|
+
it lazily for its own tests.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import copy
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import Collection, Sequence
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Callable, NoReturn
|
|
29
|
+
|
|
30
|
+
from . import gates, verify
|
|
31
|
+
from .install import (
|
|
32
|
+
_REVIEW_LAYER_SKILLS,
|
|
33
|
+
BASE_SKILLS,
|
|
34
|
+
FROID_DIR,
|
|
35
|
+
FROID_SCRIPTS_SEED_REL,
|
|
36
|
+
FROID_SEED_EXCLUDES,
|
|
37
|
+
CENTRAL_CONFIG_REL,
|
|
38
|
+
DEV_PRIMITIVE_MARKERS,
|
|
39
|
+
DEV_PRIMITIVE_ROLES,
|
|
40
|
+
HOOK_SCRIPT_REL,
|
|
41
|
+
MERGED_REVIEW_SKILL,
|
|
42
|
+
MODULE_SKILLS,
|
|
43
|
+
RENDER_DIR_REL,
|
|
44
|
+
RENDERER_SCRIPT_MARKER,
|
|
45
|
+
RENDERER_SCRIPT_UNIT_REL,
|
|
46
|
+
RENDERER_SEED_SENTINELS,
|
|
47
|
+
_absent_renderer_sources,
|
|
48
|
+
_copy_traversable,
|
|
49
|
+
_is_dir,
|
|
50
|
+
_is_file,
|
|
51
|
+
_occupied,
|
|
52
|
+
_renderer_unit_required,
|
|
53
|
+
_walk_traversable_files,
|
|
54
|
+
_worktree_local_exclude,
|
|
55
|
+
dev_primitive_or_default,
|
|
56
|
+
merge_hooks,
|
|
57
|
+
missing_stories_support,
|
|
58
|
+
renderer_stub_resolved,
|
|
59
|
+
resolve_review_layers,
|
|
60
|
+
strip_relay_hooks,
|
|
61
|
+
)
|
|
62
|
+
from .model import Phase
|
|
63
|
+
from .platform_util import atomic_write_text
|
|
64
|
+
from .process_host import get_process_host
|
|
65
|
+
from .workspace import (
|
|
66
|
+
UnitWorkspace,
|
|
67
|
+
Workspace,
|
|
68
|
+
close_unit_workspace,
|
|
69
|
+
discard_worktree,
|
|
70
|
+
unit_worktrees_dir,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from importlib.resources.abc import Traversable
|
|
75
|
+
|
|
76
|
+
from .adapters.base import CodingCLIAdapter
|
|
77
|
+
from .adapters.profile import CLIProfile
|
|
78
|
+
from .froidconfig import ProjectPaths
|
|
79
|
+
from .journal import Journal
|
|
80
|
+
from .model import RunState, StoryTask
|
|
81
|
+
from .plugins import PluginRegistry
|
|
82
|
+
from .policy import Policy
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# CLI profile name -> the agent id the Unity-MCP CLI's `setup-mcp` expects (see
|
|
86
|
+
# `unity-mcp-cli setup-mcp --list`). All but claude differ only by claude's
|
|
87
|
+
# "-code" suffix; codex/gemini/cursor and any custom profile pass through as-is.
|
|
88
|
+
_SETUP_MCP_AGENT_IDS = {"claude": "claude-code"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _setup_mcp_agent_id(profile_name: str) -> str:
|
|
92
|
+
"""Map a CLI profile name to its Unity-MCP `setup-mcp` agent id."""
|
|
93
|
+
return _SETUP_MCP_AGENT_IDS.get(profile_name, profile_name)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _worktree_skill_copy_candidates(repo_root: Path, tree: str) -> tuple[str, ...]:
|
|
97
|
+
"""Every upstream skill worth best-effort copying into ``tree``."""
|
|
98
|
+
resolved = resolve_review_layers(repo_root, tree)
|
|
99
|
+
return tuple(dict.fromkeys((*BASE_SKILLS, *(resolved.skills() if resolved else ()))))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _required_worktree_skills(repo_root: Path, tree: str) -> tuple[str, ...]:
|
|
103
|
+
"""Upstream skills whose absence deterministically stalls this tree's run.
|
|
104
|
+
|
|
105
|
+
Match :func:`install.missing_base_skills`: gate the selected dev primitive and
|
|
106
|
+
resolved required review skills only. If the review shape is unknown, prefer a
|
|
107
|
+
present merged reviewer; otherwise require the standalone fallback reviewers.
|
|
108
|
+
Catalog-only and advisory skills remain copy candidates but never arm the fatal
|
|
109
|
+
pre-dispatch completeness gate.
|
|
110
|
+
"""
|
|
111
|
+
resolved = resolve_review_layers(repo_root, tree)
|
|
112
|
+
if resolved is not None:
|
|
113
|
+
review_skills = tuple(resolved.required)
|
|
114
|
+
elif (repo_root / tree / MERGED_REVIEW_SKILL / "SKILL.md").is_file():
|
|
115
|
+
review_skills = (MERGED_REVIEW_SKILL,)
|
|
116
|
+
else:
|
|
117
|
+
review_skills = tuple(sorted(_REVIEW_LAYER_SKILLS))
|
|
118
|
+
return tuple(dict.fromkeys((dev_primitive_or_default(repo_root, tree), *review_skills)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _escape_exclude_pattern(pattern: str) -> str:
|
|
122
|
+
"""Render one shield pattern so it names the literal path it spells (#476).
|
|
123
|
+
|
|
124
|
+
Patterns are built as ``f"/{rel}"`` from real on-disk rels, but git reads them
|
|
125
|
+
as gitignore(5) patterns, so a rel carrying pattern syntax names something
|
|
126
|
+
else entirely — and it goes wrong in both directions at once. Measured on git
|
|
127
|
+
2.55.0, identical at 2.20.4 (the shield's floor):
|
|
128
|
+
|
|
129
|
+
* ``/cfg[env]`` leaves ``cfg[env]/conf.json`` STAGEABLE. The path we seeded is
|
|
130
|
+
never shielded, which is the one job the shield has (#476).
|
|
131
|
+
* The same line hides ``cfge/`` and ``cfgn/``, because ``[env]`` is a class
|
|
132
|
+
over ``e``/``n``/``v``. A broken pattern silently hides an UNRELATED file
|
|
133
|
+
that the unit meant to commit (#401, consolidated into #476).
|
|
134
|
+
|
|
135
|
+
An unescaped trailing space does both in one line: git drops it, so ``/kept``
|
|
136
|
+
plus a space shields ``kept/`` and leaves ``kept /`` visible.
|
|
137
|
+
|
|
138
|
+
gitignore(5)'s own escape rule is the fix — a backslash before a wildmatch
|
|
139
|
+
special (``*``, ``?``, ``[``, and the backslash itself) or before a trailing
|
|
140
|
+
space makes git match that character literally. ``]`` is deliberately left
|
|
141
|
+
alone: it is not special without an opening ``[``, which this escapes. ``!``
|
|
142
|
+
and ``#`` matter only at line start, and every pattern starts with ``/``.
|
|
143
|
+
|
|
144
|
+
One trailing ``/`` is split off and re-appended unescaped: it is the MUSTBEDIR
|
|
145
|
+
marker (``RENDER_DIR_REL``), not part of the name.
|
|
146
|
+
|
|
147
|
+
Ordinary rels come back byte-identical, which is what makes this inert for
|
|
148
|
+
every path the shield has ever written.
|
|
149
|
+
"""
|
|
150
|
+
body, suffix = (pattern[:-1], "/") if pattern.endswith("/") else (pattern, "")
|
|
151
|
+
for special in ("\\", "*", "?", "["):
|
|
152
|
+
body = body.replace(special, "\\" + special)
|
|
153
|
+
stripped = body.rstrip(" ")
|
|
154
|
+
return stripped + "\\ " * (len(body) - len(stripped)) + suffix
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _fits_one_exclude_line(pattern: str) -> bool:
|
|
158
|
+
"""True when ``pattern`` survives a round trip through the exclude file.
|
|
159
|
+
|
|
160
|
+
The exclude is a line-oriented format with NO escape for its own line
|
|
161
|
+
boundary, so two characters in a real filename cannot be written as a pattern
|
|
162
|
+
at all — unlike the wildmatch specials, which :func:`_escape_exclude_pattern`
|
|
163
|
+
can quote. `_worktree_local_exclude` writes each pattern `\n`-terminated
|
|
164
|
+
(`install.py`), and git reads lines back the way #472 measured them at 2.55.0:
|
|
165
|
+
`\n` boundaries with exactly ONE trailing `\r` trimmed.
|
|
166
|
+
|
|
167
|
+
* An embedded ``\n`` SPLITS the pattern into two. Neither half names the file,
|
|
168
|
+
so it is not shielded — and the orphaned second half is a live, unanchored
|
|
169
|
+
pattern that can hide an UNRELATED file, which is #401's harm direction
|
|
170
|
+
arriving through the one character #476's escaping cannot quote.
|
|
171
|
+
* A TRAILING ``\r`` is eaten as the line terminator's other half, so the
|
|
172
|
+
pattern names the path WITHOUT it — #476's harm direction, same cause.
|
|
173
|
+
|
|
174
|
+
An embedded ``\r`` is content to git (`/hidden\rjunk` ignores nothing but a
|
|
175
|
+
file spelled that way), so it needs no exclusion here.
|
|
176
|
+
"""
|
|
177
|
+
return "\n" not in pattern and not pattern.endswith("\r")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reconcile_tracked_patterns(
|
|
181
|
+
worktree: Path, patterns: set[str], written: Collection[str]
|
|
182
|
+
) -> tuple[set[str], str | None]:
|
|
183
|
+
"""Reshape the shield patterns that name a TRACKED path; keep the rest as written.
|
|
184
|
+
|
|
185
|
+
Git consults ignore rules only for UNTRACKED paths, so what a pattern is worth
|
|
186
|
+
depends on what it names — and the two tracked shapes need opposite answers.
|
|
187
|
+
|
|
188
|
+
A pattern naming a tracked REGULAR FILE shields nothing and costs something
|
|
189
|
+
(#392, reported from production by an external user whose repo-hygiene gate then
|
|
190
|
+
blocked the story's commit). Measured on git 2.55.0 with the shield's own
|
|
191
|
+
private-exclude + worktree-scoped `core.excludesFile` shape, a tracked
|
|
192
|
+
`.codex/hooks.json`, and its pattern present:
|
|
193
|
+
|
|
194
|
+
* `git add -A` STAGES a modification to it anyway — ignore rules are consulted only
|
|
195
|
+
for untracked paths, so the pattern never did the job it is here for;
|
|
196
|
+
* `git ls-files -ci --exclude-standard` inside the worktree REPORTS it, which is the
|
|
197
|
+
tracked-and-ignored state hygiene gates reject.
|
|
198
|
+
|
|
199
|
+
So the pattern is pure cost and it is DROPPED. This is the second half of #384
|
|
200
|
+
— its reporter proposed it as their option 3 ("skip any pattern whose path already
|
|
201
|
+
contains tracked files … costs nothing and removes the surprising case entirely");
|
|
202
|
+
PR #385 landed option 1 (scope + lifetime) and this half was dropped rather than
|
|
203
|
+
rejected, which is how a second reporter hit it.
|
|
204
|
+
|
|
205
|
+
A pattern naming a tracked DIRECTORY is that reporter's shape one step out (#484),
|
|
206
|
+
and its answer is SUBSTITUTION. The measurement that used to justify keeping it
|
|
207
|
+
still stands and is kept here: a dir pattern really does hide new children, and no
|
|
208
|
+
pattern SHAPE both hides them and keeps the report clean — `dir/*`, `dir/**` and
|
|
209
|
+
`dir` plus a trailing negation all measured identical to `dir`, because gitignore
|
|
210
|
+
cannot re-include anything under an excluded parent, while the one shape that did
|
|
211
|
+
clear the report (`dir/*` with per-child negations) leaked a new file into the
|
|
212
|
+
commit. What reverses is the VERDICT, on the #392 physics: over a tracked tree the
|
|
213
|
+
pattern's protection is already mostly inert — every modification to a tracked
|
|
214
|
+
child stages regardless — so it was buying new-child coverage alone at the price of
|
|
215
|
+
a false tracked-and-ignored report for the whole tree.
|
|
216
|
+
|
|
217
|
+
The fix is therefore a different set of PATHS rather than a different shape: drop
|
|
218
|
+
the dir pattern and substitute one pattern per file THIS provisioning run actually
|
|
219
|
+
wrote below it (``written``). Those files are untracked by construction —
|
|
220
|
+
copy-when-absent cannot land on top of a tracked child — which is why they are
|
|
221
|
+
exactly the case an ignore rule works for, and why none of them is re-probed here:
|
|
222
|
+
the spawn count stays one per pattern the caller built. A tracked directory that
|
|
223
|
+
received nothing at all drops to no pattern, which is the same answer for the same
|
|
224
|
+
reason: there is nothing of ours below it to shield.
|
|
225
|
+
|
|
226
|
+
ACCEPTED RESIDUAL (maintainer decision on #484, 2026-08-08, which is this
|
|
227
|
+
function's design authority): a file the SESSION creates under a tracked tool
|
|
228
|
+
directory can be staged, where the dir pattern would have hidden it. That is the
|
|
229
|
+
trade, deliberately taken — it matches the project's own decision to TRACK that
|
|
230
|
+
tree, and everything the orchestrator put there keeps a pattern of its own. It is
|
|
231
|
+
not a defect to fix by widening back to a dir pattern.
|
|
232
|
+
|
|
233
|
+
APPEND-ONLY RESIDUE: `_worktree_local_exclude` never removes a line, by design —
|
|
234
|
+
operator lines ride in its rewrite prefix and its last-match-wins reasoning depends
|
|
235
|
+
on nothing being deleted, with no marker separating our stale line from theirs. So
|
|
236
|
+
a `/dir` line an OLDER provisioning of the SAME worktree wrote survives a
|
|
237
|
+
re-provision that would no longer write it. Accepted rather than fixed: the residue
|
|
238
|
+
can only be too wide, never too narrow, and the private exclude dies with the
|
|
239
|
+
worktree.
|
|
240
|
+
|
|
241
|
+
Patterns ending in `/` are directory-shaped by construction (`RENDER_DIR_REL`) and
|
|
242
|
+
are never probed.
|
|
243
|
+
|
|
244
|
+
A substituted rel that cannot be spelled as ONE exclude line
|
|
245
|
+
(:func:`_fits_one_exclude_line`) sends the WHOLE directory back to its dir
|
|
246
|
+
pattern, journaled. That is the same trade as the degrade below and for the same
|
|
247
|
+
reason: no per-file pattern exists for such a name, so substituting would leave
|
|
248
|
+
the file stageable and — for a newline — write an orphan half-pattern that hides
|
|
249
|
+
unrelated files.
|
|
250
|
+
|
|
251
|
+
Degrades by KEEPING every pattern it could not resolve, in its ORIGINAL shape —
|
|
252
|
+
the dir shape included, never the substitution. A shield staying too wide is
|
|
253
|
+
cosmetic, while dropping or narrowing a pattern on a guess can leak the
|
|
254
|
+
orchestrator's own seeded files into a story commit. Returns the surviving patterns
|
|
255
|
+
and a reason for the caller to journal, or None when nothing went wrong.
|
|
256
|
+
|
|
257
|
+
NOT-A-REPO IS SILENT, and it is the ordinary case rather than an edge one: many
|
|
258
|
+
callers provision plain non-repo directories, where there is no index, no shield and
|
|
259
|
+
no `git add -A` to be wrong about. The same `rev-parse --absolute-git-dir` gate
|
|
260
|
+
`_worktree_local_exclude` skips on is asked FIRST, so this cannot emit a degrade per
|
|
261
|
+
call and train operators past the one that matters. Only a repo that answers that
|
|
262
|
+
probe and then fails the per-path one is reported."""
|
|
263
|
+
try:
|
|
264
|
+
if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
|
|
265
|
+
return patterns, None
|
|
266
|
+
except (verify.GitError, OSError):
|
|
267
|
+
return patterns, None
|
|
268
|
+
kept: set[str] = set()
|
|
269
|
+
unprobed: list[str] = []
|
|
270
|
+
unwritable: list[str] = []
|
|
271
|
+
for pattern in patterns:
|
|
272
|
+
rel = pattern.lstrip("/")
|
|
273
|
+
if not rel or pattern.endswith("/"):
|
|
274
|
+
kept.add(pattern)
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
kind = verify.path_tracked_kind(worktree, rel)
|
|
278
|
+
except (verify.GitError, OSError) as e:
|
|
279
|
+
kept.add(pattern)
|
|
280
|
+
unprobed.append(f"{rel} ({e})")
|
|
281
|
+
continue
|
|
282
|
+
if kind == "untracked":
|
|
283
|
+
kept.add(pattern)
|
|
284
|
+
elif kind == "dir":
|
|
285
|
+
subs = {f"/{w}" for w in written if w.startswith(rel + "/")}
|
|
286
|
+
unrepresentable = sorted(s for s in subs if not _fits_one_exclude_line(s))
|
|
287
|
+
if unrepresentable:
|
|
288
|
+
# Substituting here would write a pattern the exclude cannot carry,
|
|
289
|
+
# leaving that file stageable AND (for a `\n`) loosing an unanchored
|
|
290
|
+
# orphan pattern on unrelated files. The dir pattern is the only shape
|
|
291
|
+
# that still covers it, so this degrades the way every other
|
|
292
|
+
# unanswerable case does: KEEP the original, journal the reason. Too
|
|
293
|
+
# wide is cosmetic — this tree keeps its tracked-and-ignored report —
|
|
294
|
+
# while dropping or half-substituting leaks provisioned work into the
|
|
295
|
+
# unit's commit.
|
|
296
|
+
kept.add(pattern)
|
|
297
|
+
unwritable.extend(unrepresentable)
|
|
298
|
+
else:
|
|
299
|
+
kept |= subs
|
|
300
|
+
# "file": dropped. The pattern is measurably inert over a tracked file and
|
|
301
|
+
# only feeds it to repo-hygiene gates as tracked-and-ignored (#392).
|
|
302
|
+
reasons: list[str] = []
|
|
303
|
+
if unprobed:
|
|
304
|
+
reasons.append(
|
|
305
|
+
"worktree git-add shield could not check whether these paths are tracked, "
|
|
306
|
+
"so their patterns were kept as built; a tracked path among them will read "
|
|
307
|
+
f"as ignored to repo-hygiene checks (#392, #484): {'; '.join(sorted(unprobed))}"
|
|
308
|
+
)
|
|
309
|
+
if unwritable:
|
|
310
|
+
reasons.append(
|
|
311
|
+
"worktree git-add shield kept a tracked directory's whole-dir pattern "
|
|
312
|
+
"because a file provisioning wrote below it cannot be spelled as one "
|
|
313
|
+
"exclude line (a newline, or a trailing carriage return); that directory "
|
|
314
|
+
"will read as ignored to repo-hygiene checks (#484): "
|
|
315
|
+
f"{'; '.join(sorted(unwritable))}"
|
|
316
|
+
)
|
|
317
|
+
return kept, (" ".join(reasons) if reasons else None)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _pin_tracked_config_rewrite(worktree: Path, rel: str) -> str | None:
|
|
321
|
+
"""Keep a rewritten TRACKED hook config out of the unit's story commits.
|
|
322
|
+
|
|
323
|
+
The worktree-local exclude cannot: git consults ignore rules only for
|
|
324
|
+
untracked paths (#392). When a project tracks its hook config, the relay
|
|
325
|
+
rewrite — a machine-specific absolute command — would ride every
|
|
326
|
+
`git add -A` (the skill's own commits and finalize_commit alike) into the
|
|
327
|
+
story commit and merge back to the target branch, handing every other
|
|
328
|
+
checkout a relay path that does not exist there. The worktree's own index
|
|
329
|
+
carries a skip-worktree bit that `add -A`, `status` and checkout all honor
|
|
330
|
+
(it is the sparse-checkout mechanism) and that dies with the worktree, so
|
|
331
|
+
the rewrite stays session-local.
|
|
332
|
+
|
|
333
|
+
While the pin holds, the config is orchestrator-owned: a story's own edit to
|
|
334
|
+
the pinned file stays session-local and is discarded with the worktree. That
|
|
335
|
+
is deliberate — before this pin the tracked case stalled outright (#352), so
|
|
336
|
+
there is no prior working behavior to preserve, and any file-level hiding
|
|
337
|
+
that keeps OUR rewrite out of `add -A` hides a story's edit with it.
|
|
338
|
+
|
|
339
|
+
NOT-A-REPO IS SILENT for the same reason the shield's tracked-probe is:
|
|
340
|
+
provisioning a plain directory is ordinary, and there is no index and no
|
|
341
|
+
`git add -A` to be wrong about. An untracked config needs nothing — the
|
|
342
|
+
exclude shield owns it. A tracked-probe that cannot answer is returned for
|
|
343
|
+
the caller to journal (observation degrades; the rewrite stands, since a
|
|
344
|
+
stalled session is the worse outcome, #352). But a failed update-index on a
|
|
345
|
+
KNOWN-tracked config raises: the pin is a repair write, and continuing
|
|
346
|
+
without it knowingly leaves `git add -A` free to commit the machine-specific
|
|
347
|
+
command and merge it back.
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
if verify.git_bytes(worktree, "rev-parse", "--absolute-git-dir").returncode != 0:
|
|
351
|
+
return None
|
|
352
|
+
except (verify.GitError, OSError):
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
if not verify.path_tracked_file(worktree, rel):
|
|
356
|
+
return None
|
|
357
|
+
except (verify.GitError, OSError) as e:
|
|
358
|
+
return (
|
|
359
|
+
f"could not check whether the rewritten hook config {rel} is tracked "
|
|
360
|
+
f"({e}); if the project tracks it, the worktree's machine-specific relay "
|
|
361
|
+
"command may be committed and merged back (#352)"
|
|
362
|
+
)
|
|
363
|
+
pinned = verify.git_bytes(worktree, "update-index", "--skip-worktree", "--", rel)
|
|
364
|
+
if pinned.returncode != 0:
|
|
365
|
+
raise verify.GitError(
|
|
366
|
+
f"git update-index --skip-worktree {rel} failed in the worktree; the hook "
|
|
367
|
+
"config is tracked, so without the pin its machine-specific relay rewrite "
|
|
368
|
+
"would reach story commits and merge back (#352)"
|
|
369
|
+
)
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _seed_froid_tree(worktree: Path, repo_root: Path) -> tuple[list[str], list[str]]:
|
|
374
|
+
"""Merge the repo's project-local FROID surface into an isolated worktree.
|
|
375
|
+
|
|
376
|
+
Renderer-backed skills receive the worktree as their project root and do not
|
|
377
|
+
walk upward for ``_froid``. Copy every usable file except generated render output,
|
|
378
|
+
per-file and without clobbering checkout content. The shared Traversable walk is
|
|
379
|
+
intentional: unlike ``rglob``, it descends a symlinked child directory, allowing
|
|
380
|
+
the result-side completeness predicates to see every file the copier considered.
|
|
381
|
+
|
|
382
|
+
Returns ``(shield_rels, written)``, two readings of the same copy:
|
|
383
|
+
|
|
384
|
+
* ``shield_rels`` — what the caller turns into git-add shield patterns: ``[]``
|
|
385
|
+
when nothing landed, ``[FROID_DIR]`` when the root was absent before seeding
|
|
386
|
+
(one pattern covers a tree that is wholly ours), otherwise each file that
|
|
387
|
+
actually landed. A fresh worktree checkout materializes every tracked path, so
|
|
388
|
+
"root absent AND tracked" cannot arise and this collapse is already correct for
|
|
389
|
+
the tracked-directory rule (#484): the collapsed shape only ever names an
|
|
390
|
+
untracked root.
|
|
391
|
+
* ``written`` — always the per-file landed rels, never the collapse. This is the
|
|
392
|
+
substitution ledger the shield reads when a tool directory turns out to be
|
|
393
|
+
TRACKED (#484): the dir pattern is dropped and these files get patterns of
|
|
394
|
+
their own, so the answer has to stay per-file even when ``shield_rels``
|
|
395
|
+
collapsed.
|
|
396
|
+
"""
|
|
397
|
+
src_root = repo_root / FROID_DIR
|
|
398
|
+
if not _is_dir(src_root):
|
|
399
|
+
return [], []
|
|
400
|
+
dst_root = worktree / FROID_DIR
|
|
401
|
+
had_froid = _is_dir(dst_root)
|
|
402
|
+
try:
|
|
403
|
+
tops = sorted(src_root.iterdir(), key=lambda entry: entry.name)
|
|
404
|
+
except OSError:
|
|
405
|
+
return [], []
|
|
406
|
+
|
|
407
|
+
seeded: list[str] = []
|
|
408
|
+
for top in tops:
|
|
409
|
+
if top.name in FROID_SEED_EXCLUDES:
|
|
410
|
+
continue
|
|
411
|
+
for rel, src in _walk_traversable_files(top, top.name):
|
|
412
|
+
if not _is_file(src):
|
|
413
|
+
continue
|
|
414
|
+
dst = dst_root.joinpath(*rel.split("/"))
|
|
415
|
+
if _copy_traversable(
|
|
416
|
+
src,
|
|
417
|
+
dst,
|
|
418
|
+
skip_existing=True,
|
|
419
|
+
worktree=worktree,
|
|
420
|
+
repo_root=repo_root,
|
|
421
|
+
):
|
|
422
|
+
seeded.append(f"{FROID_DIR}/{rel}")
|
|
423
|
+
if not seeded:
|
|
424
|
+
return [], []
|
|
425
|
+
return ([FROID_DIR] if not had_froid else seeded), seeded
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _record_seeded(
|
|
429
|
+
seeded_from: dict[Path, Path],
|
|
430
|
+
landed: Sequence[Path],
|
|
431
|
+
src: Path,
|
|
432
|
+
dst: Path,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""Record where each path a seed entry just wrote was copied FROM.
|
|
435
|
+
|
|
436
|
+
`_copy_traversable` reproduces the source tree's shape under ``dst``, so a landed
|
|
437
|
+
path's rel against ``dst`` is also its source's rel against ``src``; the root of a
|
|
438
|
+
file entry gives ``Path(".")``, which pathlib drops, mapping ``dst`` to ``src``.
|
|
439
|
+
|
|
440
|
+
``src`` is the RESOLVED source, so a seed entry reached through a symlink names
|
|
441
|
+
the file that really holds the bytes rather than the link the operator listed.
|
|
442
|
+
First writer wins: nothing overwrites an earlier entry, because a path is only
|
|
443
|
+
ever written once — copy-when-absent makes the second entry naming it a skip, and
|
|
444
|
+
a skip lands nothing to record (#592).
|
|
445
|
+
"""
|
|
446
|
+
for path in landed:
|
|
447
|
+
seeded_from.setdefault(path, src / path.relative_to(dst))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _written_rels(worktree: Path, landed: Sequence[Path]) -> list[str]:
|
|
451
|
+
"""Worktree-relative rels of the FILES a copy call just wrote.
|
|
452
|
+
|
|
453
|
+
Feeds the shield's tracked-directory substitution (#484): over a tracked tool
|
|
454
|
+
dir the whole-dir pattern is replaced by one pattern per file provisioning
|
|
455
|
+
actually landed there, so this is the ledger of what there is to shield.
|
|
456
|
+
|
|
457
|
+
The :func:`_is_file` filter is load-bearing rather than defensive.
|
|
458
|
+
``_copy_traversable``'s ``copied_paths`` records every destination that landed —
|
|
459
|
+
each file written AND each directory the call created (``install.py:1427-1436``).
|
|
460
|
+
A directory rel reaching the substitution would render as another whole-dir
|
|
461
|
+
pattern, reintroducing the exact shape #484 removes, one level down.
|
|
462
|
+
|
|
463
|
+
``as_posix`` so a substituted pattern anchors on Windows too; ``os.sep`` would
|
|
464
|
+
not, and the exclude is read by git, not by the platform.
|
|
465
|
+
"""
|
|
466
|
+
return [p.relative_to(worktree).as_posix() for p in landed if _is_file(p)]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _froid_scripts_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
|
|
470
|
+
"""Whether a required repo renderer unit member missed the worktree.
|
|
471
|
+
|
|
472
|
+
Match the renderer preflight's content-keyed required-file predicate. Arbitrary
|
|
473
|
+
sibling scripts are still merge-seeded, but their absence cannot prove the
|
|
474
|
+
renderer will HALT and therefore must not arm the CRITICAL escalation gate.
|
|
475
|
+
"""
|
|
476
|
+
return any(
|
|
477
|
+
_renderer_unit_required(repo_root, rel)
|
|
478
|
+
and _is_file(repo_root / rel)
|
|
479
|
+
and not _is_file(worktree / rel)
|
|
480
|
+
for rel in RENDERER_SCRIPT_UNIT_REL
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _central_config_seed_incomplete(worktree: Path, repo_root: Path) -> bool:
|
|
485
|
+
"""Whether the repo's required renderer config failed to reach the worktree."""
|
|
486
|
+
return _is_file(repo_root / CENTRAL_CONFIG_REL) and not _is_file(worktree / CENTRAL_CONFIG_REL)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def base_skills_seed_incomplete(worktree: Path, repo_root: Path, trees: Sequence[str]) -> list[str]:
|
|
490
|
+
"""Required upstream skill rels present in the repo but absent in the worktree.
|
|
491
|
+
|
|
492
|
+
``BASE_SKILLS`` is a copy-if-present catalog, not a requirement set. Gate only
|
|
493
|
+
the selected primitive and required review skills; advisory/conditional and
|
|
494
|
+
inactive catalog entries are still provisioned best-effort, but their absence
|
|
495
|
+
cannot prove the dev/review session will stall. Unknown review shapes use the
|
|
496
|
+
same merged-or-standalone fallback as the run-start preflight.
|
|
497
|
+
|
|
498
|
+
Within those active skills, mirror the deterministic run-start contract rather
|
|
499
|
+
than treating every descendant as fatal: reviewers require ``SKILL.md``; the dev
|
|
500
|
+
primitive additionally requires its defined markers and any renderer snapshot
|
|
501
|
+
sources its worktree copy resolves. Other descendants still copy best-effort,
|
|
502
|
+
but their absence does not prove the outer session will write no artifact.
|
|
503
|
+
|
|
504
|
+
A missing ``SKILL.md`` reports the coarse skill rel. A repo directory without
|
|
505
|
+
``SKILL.md`` remains the run-start preflight's concern and cannot produce a false
|
|
506
|
+
CRITICAL escalation here. Stories mode adds its content-keyed dispatch probe at
|
|
507
|
+
the caller, where the run mode is available.
|
|
508
|
+
"""
|
|
509
|
+
missing: list[str] = []
|
|
510
|
+
for tree in dict.fromkeys(trees):
|
|
511
|
+
primitive = dev_primitive_or_default(repo_root, tree)
|
|
512
|
+
for skill in _required_worktree_skills(repo_root, tree):
|
|
513
|
+
repo_skill = repo_root / tree / skill
|
|
514
|
+
worktree_skill = worktree / tree / skill
|
|
515
|
+
if not _is_file(repo_skill / "SKILL.md"):
|
|
516
|
+
# Distinguish an unreadable directory from a skill the repo simply
|
|
517
|
+
# does not carry. The walk yields only the former as a directory leaf.
|
|
518
|
+
if any(_is_dir(src) for _, src in _walk_traversable_files(repo_skill)):
|
|
519
|
+
missing.append(f"{tree}/{skill}")
|
|
520
|
+
continue
|
|
521
|
+
if not _is_file(worktree_skill / "SKILL.md"):
|
|
522
|
+
missing.append(f"{tree}/{skill}")
|
|
523
|
+
continue
|
|
524
|
+
if skill != primitive:
|
|
525
|
+
continue
|
|
526
|
+
missing.extend(
|
|
527
|
+
f"{tree}/{skill}/{rel}"
|
|
528
|
+
for rel in DEV_PRIMITIVE_MARKERS
|
|
529
|
+
if _is_file(repo_skill / rel) and not _is_file(worktree_skill / rel)
|
|
530
|
+
)
|
|
531
|
+
missing.extend(
|
|
532
|
+
f"{tree}/{skill}/{rel}"
|
|
533
|
+
for rel in _absent_renderer_sources(worktree_skill)
|
|
534
|
+
if _is_file(repo_skill.joinpath(*rel.split("/")))
|
|
535
|
+
)
|
|
536
|
+
return missing
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def worktree_seed_undelivered(
|
|
540
|
+
worktree: Path,
|
|
541
|
+
repo_root: Path,
|
|
542
|
+
seed_files: Sequence[str] = (),
|
|
543
|
+
seed_globs: Sequence[str] = (),
|
|
544
|
+
config_paths: Sequence[str] = (),
|
|
545
|
+
) -> list[str]:
|
|
546
|
+
"""Seed rels the repo carries that never reached the worktree.
|
|
547
|
+
|
|
548
|
+
Source containment is deliberately *not* an eligibility requirement: a source
|
|
549
|
+
symlink resolving outside the repo is the canonical entry the seed loop refuses
|
|
550
|
+
and this result check must report. Delivery does require every usable source
|
|
551
|
+
entry to remain within the repo, matching the copier. Destination containment is
|
|
552
|
+
likewise required so a path outside the worktree cannot masquerade as delivery.
|
|
553
|
+
|
|
554
|
+
Hook configs need a different result question because provisioning writes the
|
|
555
|
+
hook registration itself after seeding. For those rels, existence proves nothing;
|
|
556
|
+
source escape, a symlinked destination, or destination escape proves the seed was
|
|
557
|
+
refused. This report is informational and is never an escalation gate.
|
|
558
|
+
"""
|
|
559
|
+
unresolved_repo_root = repo_root
|
|
560
|
+
try:
|
|
561
|
+
worktree = worktree.resolve()
|
|
562
|
+
repo_root = repo_root.resolve()
|
|
563
|
+
except (OSError, RuntimeError):
|
|
564
|
+
# Observation only: root uncertainty cannot prove delivery, but it must
|
|
565
|
+
# not turn an informational journal probe into a run-wide failure.
|
|
566
|
+
rels = [str(rel) for rel in seed_files]
|
|
567
|
+
for pattern in seed_globs:
|
|
568
|
+
try:
|
|
569
|
+
matches = sorted(unresolved_repo_root.glob(pattern))
|
|
570
|
+
except (OSError, RuntimeError):
|
|
571
|
+
continue
|
|
572
|
+
rels.extend(match.relative_to(unresolved_repo_root).as_posix() for match in matches)
|
|
573
|
+
return list(dict.fromkeys(rels))
|
|
574
|
+
rels = [str(rel) for rel in seed_files]
|
|
575
|
+
for pattern in seed_globs:
|
|
576
|
+
rels.extend(
|
|
577
|
+
match.relative_to(repo_root).as_posix() for match in sorted(repo_root.glob(pattern))
|
|
578
|
+
)
|
|
579
|
+
hook_configs = {Path(rel) for rel in config_paths}
|
|
580
|
+
|
|
581
|
+
def contained(path: Path, root: Path) -> bool:
|
|
582
|
+
try:
|
|
583
|
+
return path.resolve().is_relative_to(root)
|
|
584
|
+
except (OSError, RuntimeError):
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
def delivered(src: Path, dst: Path) -> bool:
|
|
588
|
+
"""Whether every usable source descendant has a matching destination."""
|
|
589
|
+
if not contained(src, repo_root) or not contained(dst, worktree):
|
|
590
|
+
return False
|
|
591
|
+
if _is_file(src):
|
|
592
|
+
return _is_file(dst)
|
|
593
|
+
if not _is_dir(src) or not _is_dir(dst):
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
complete = True
|
|
597
|
+
|
|
598
|
+
def visit_dir(rel: str, source) -> bool:
|
|
599
|
+
nonlocal complete
|
|
600
|
+
if not isinstance(source, Path) or not contained(source, repo_root):
|
|
601
|
+
complete = False
|
|
602
|
+
return False
|
|
603
|
+
target = dst.joinpath(*rel.split("/")) if rel else dst
|
|
604
|
+
if not contained(target, worktree) or not _is_dir(target):
|
|
605
|
+
complete = False
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
for child_rel, child in _walk_traversable_files(src, _visit_dir=visit_dir):
|
|
609
|
+
target = dst.joinpath(*child_rel.split("/")) if child_rel else dst
|
|
610
|
+
if _is_file(child):
|
|
611
|
+
if (
|
|
612
|
+
not isinstance(child, Path)
|
|
613
|
+
or not contained(child, repo_root)
|
|
614
|
+
or not contained(target, worktree)
|
|
615
|
+
or not _is_file(target)
|
|
616
|
+
):
|
|
617
|
+
complete = False
|
|
618
|
+
elif _is_dir(child):
|
|
619
|
+
# Directories are yielded only when enumeration was refused, so
|
|
620
|
+
# their unknown descendants cannot be claimed as delivered.
|
|
621
|
+
complete = False
|
|
622
|
+
return complete
|
|
623
|
+
|
|
624
|
+
undelivered: list[str] = []
|
|
625
|
+
for rel in dict.fromkeys(rels):
|
|
626
|
+
src = repo_root / rel
|
|
627
|
+
if not (_is_file(src) or _is_dir(src)):
|
|
628
|
+
continue
|
|
629
|
+
dst = worktree / rel
|
|
630
|
+
if Path(rel) in hook_configs:
|
|
631
|
+
try:
|
|
632
|
+
destination_is_link = dst.is_symlink()
|
|
633
|
+
except OSError:
|
|
634
|
+
destination_is_link = True
|
|
635
|
+
if not contained(src, repo_root) or not contained(dst, worktree) or destination_is_link:
|
|
636
|
+
undelivered.append(rel)
|
|
637
|
+
continue
|
|
638
|
+
if delivered(src, dst):
|
|
639
|
+
continue
|
|
640
|
+
undelivered.append(rel)
|
|
641
|
+
return undelivered
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def module_skills_seed_undelivered(
|
|
645
|
+
worktree: Path,
|
|
646
|
+
trees: Sequence[str],
|
|
647
|
+
skills_root: Traversable | None = None,
|
|
648
|
+
) -> list[str]:
|
|
649
|
+
"""Wheel-bundled ``MODULE_SKILLS`` whose content never reached the worktree.
|
|
650
|
+
|
|
651
|
+
Re-probes DISK, never the copier's bookkeeping: a user-authored
|
|
652
|
+
``scm.worktree_seed`` entry that happens to spell a skill rel can therefore
|
|
653
|
+
neither forge nor mask a report. The source is the wheel's own skills tree, which
|
|
654
|
+
may be a zip Traversable rather than a real directory, so enumeration goes through
|
|
655
|
+
the shared :func:`install._walk_traversable_files` walk instead of ``rglob``.
|
|
656
|
+
|
|
657
|
+
Presence is the whole contract; content is NEVER compared. Seeding is per-FILE
|
|
658
|
+
no-clobber, so a checkout carrying its own divergent fork of a bundled skill keeps
|
|
659
|
+
those bytes while its absent siblings are filled in — comparing content would
|
|
660
|
+
report that healthy shape as undelivered. A skill the wheel itself lacks is
|
|
661
|
+
skipped: a broken wheel is the module-skills sync test's concern, not a run's.
|
|
662
|
+
|
|
663
|
+
Informational, and NEVER an escalation gate. No ``MODULE_SKILLS`` entry has a
|
|
664
|
+
worktree-resident consumer — sweep triage dispatches ``/froid-loop-sweep`` at the
|
|
665
|
+
MAIN checkout, ``froid-loop-resolve`` runs at the main checkout too, and
|
|
666
|
+
``froid-loop-setup`` has no session consumer at all — so an absence here cannot
|
|
667
|
+
prove a stall, and a CRITICAL gate would refuse healthy runs. Extension point: if
|
|
668
|
+
a consumer ever becomes worktree-resident (e.g. sweep triage moving into unit
|
|
669
|
+
worktrees), arm the gate by routing this predicate's result into
|
|
670
|
+
:meth:`WorktreeFlow.escalate_unit` for that consumer's required subset.
|
|
671
|
+
|
|
672
|
+
Returns coarse ``"{tree}/{skill}"`` posix rels in iteration order.
|
|
673
|
+
"""
|
|
674
|
+
if skills_root is None:
|
|
675
|
+
skills_root = resources.files("froid_loop.data").joinpath("skills")
|
|
676
|
+
try:
|
|
677
|
+
worktree = worktree.resolve()
|
|
678
|
+
except (OSError, RuntimeError):
|
|
679
|
+
# This is a journal-only observation. Root uncertainty means every
|
|
680
|
+
# bundled skill the wheel actually carries is coarsely undelivered.
|
|
681
|
+
return [
|
|
682
|
+
f"{tree}/{skill}"
|
|
683
|
+
for tree in dict.fromkeys(trees)
|
|
684
|
+
for skill in MODULE_SKILLS
|
|
685
|
+
if _is_file(skills_root.joinpath(skill)) or _is_dir(skills_root.joinpath(skill))
|
|
686
|
+
]
|
|
687
|
+
|
|
688
|
+
def contained(target: Path) -> bool:
|
|
689
|
+
try:
|
|
690
|
+
return target.resolve().is_relative_to(worktree)
|
|
691
|
+
except (OSError, RuntimeError):
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
def delivered(src: Traversable, dst: Path) -> bool:
|
|
695
|
+
"""Whether every usable wheel entry has a matching worktree destination."""
|
|
696
|
+
for rel, entry in _walk_traversable_files(src):
|
|
697
|
+
target = dst.joinpath(*rel.split("/")) if rel else dst
|
|
698
|
+
if _is_file(entry):
|
|
699
|
+
if contained(target) and _is_file(target):
|
|
700
|
+
continue
|
|
701
|
+
return False
|
|
702
|
+
if _is_dir(entry):
|
|
703
|
+
# Directories are yielded only when enumeration was refused, so
|
|
704
|
+
# their unknown descendants cannot be claimed as delivered.
|
|
705
|
+
return False
|
|
706
|
+
# Neither a readable file nor a directory: the copier has no path for
|
|
707
|
+
# such an entry either, so its absence is not a delivery failure.
|
|
708
|
+
return True
|
|
709
|
+
|
|
710
|
+
undelivered: list[str] = []
|
|
711
|
+
for tree in dict.fromkeys(trees):
|
|
712
|
+
for skill in MODULE_SKILLS:
|
|
713
|
+
src = skills_root.joinpath(skill)
|
|
714
|
+
if not (_is_file(src) or _is_dir(src)):
|
|
715
|
+
continue
|
|
716
|
+
if not delivered(src, worktree / tree / skill):
|
|
717
|
+
undelivered.append(f"{tree}/{skill}")
|
|
718
|
+
return undelivered
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def provision_worktree(
|
|
722
|
+
worktree: Path,
|
|
723
|
+
profiles: Sequence[CLIProfile],
|
|
724
|
+
repo_root: Path,
|
|
725
|
+
seed_files: Sequence[str] = (),
|
|
726
|
+
seed_globs: Sequence[str] = (),
|
|
727
|
+
*,
|
|
728
|
+
on_degraded: Callable[[str], None] | None = None,
|
|
729
|
+
) -> list[str]:
|
|
730
|
+
"""Make a freshly-created git worktree a self-sufficient froid-loop project.
|
|
731
|
+
|
|
732
|
+
A worktree checks out tracked files only, but the skill trees (.claude/skills,
|
|
733
|
+
.agents/skills), the hook config, and the project's gitignored MCP/CLI configs
|
|
734
|
+
are absent from the checkout. Without them the bundled froid-loop-* skills are missing,
|
|
735
|
+
the Stop-signal hook never fires, and isolated sessions can't reach their MCP
|
|
736
|
+
server. Lay the bundled skills + signal hook into the worktree for the active
|
|
737
|
+
CLI profiles, and copy the `seed_files` configs in from the main repo. The
|
|
738
|
+
upstream skills the orchestrator drives (BASE_SKILLS: froid-build-auto + the review
|
|
739
|
+
hunters, plus whatever review layers this project's own config names) are not
|
|
740
|
+
bundled in the wheel, so they are copied from the MAIN REPO's installed tree
|
|
741
|
+
instead — together with `_froid/custom/`, the customization those layers resolve
|
|
742
|
+
through, so the isolated run resolves the same layer set the preflight
|
|
743
|
+
validated. Quiet (no stdout) — unlike `install_into` this runs inside the
|
|
744
|
+
engine loop under a TUI. No-op when there's nothing to do.
|
|
745
|
+
|
|
746
|
+
seed_globs are project-relative glob patterns (e.g. ".claude/skills/*") expanded
|
|
747
|
+
against the main repo; every match is copied into the worktree under the same
|
|
748
|
+
relative path, copy-when-absent like seed_files. A game-engine plugin uses these
|
|
749
|
+
to pull its MCP-generated skill tree (gitignored, so absent from the checkout)
|
|
750
|
+
into a per_worktree Editor's checkout.
|
|
751
|
+
|
|
752
|
+
A `seed_files` entry naming a DIRECTORY whose destination already exists is
|
|
753
|
+
seeded child by child: the children the checkout lacks are copied in, the ones
|
|
754
|
+
it carries are left untouched. A worktree checks out tracked files, so such a
|
|
755
|
+
dir always exists and the entry would otherwise be a total no-op (issue #230).
|
|
756
|
+
|
|
757
|
+
Kept safe against the unit's eventual `git add -A` commit:
|
|
758
|
+
- skills + seed files are copied only when ABSENT — at FILE granularity, so a
|
|
759
|
+
project that commits its own skill tree (e.g. .agents/) or config keeps it
|
|
760
|
+
untouched (no diff merged back);
|
|
761
|
+
- the hook points at the MAIN repo's already-installed relay via an absolute
|
|
762
|
+
path (the relay locates its events directory from $FROID_LOOP_EVENTS_DIR,
|
|
763
|
+
falling back to $FROID_LOOP_RUN_DIR/events — never from its own location),
|
|
764
|
+
so nothing is written into the worktree's .froid-loop/. Since #494 the
|
|
765
|
+
primary channel is out of the project tree entirely, so a worktree cannot
|
|
766
|
+
carry a run's control plane at all — but the fallback still resolves under
|
|
767
|
+
the MAIN run dir, so the guarantee holds for an older installed relay too;
|
|
768
|
+
- everything we wrote is excluded from git, in a file private to THIS worktree
|
|
769
|
+
(`.git/worktrees/<id>/info/exclude`, activated per-worktree) that dies with it
|
|
770
|
+
when the worktree is removed. It is never the repository-wide
|
|
771
|
+
`.git/info/exclude`: that file is shared with the operator's own checkout and
|
|
772
|
+
permanent, so shielding through it hid every new file under a tool dir from
|
|
773
|
+
their `git add -A` forever (#384). That write is best-effort: when git can't
|
|
774
|
+
be queried at all it is skipped silently, but any fault after that — including
|
|
775
|
+
a refusal to scope the shield, and a shield that was written but which git does
|
|
776
|
+
not resolve to — is reported to `on_degraded` (once, with the reason) rather
|
|
777
|
+
than swallowed, and the shield is skipped rather than widened back to the
|
|
778
|
+
shared file. An unshielded worktree lets `git add -A` stage the tool files, so
|
|
779
|
+
it must not fail invisibly.
|
|
780
|
+
Skill trees, the per-CLI hook config, and the seeded configs all live in dirs
|
|
781
|
+
projects gitignore — but the exclude shields them even when a project doesn't.
|
|
782
|
+
|
|
783
|
+
seed_files are copied BEFORE the hook step so a seeded settings file that is
|
|
784
|
+
also a hook config_path (.claude/settings.json, .gemini/settings.json) keeps its
|
|
785
|
+
real content rather than being created empty. Its relay entry is replaced, not
|
|
786
|
+
kept: the seeded copy carries the main repo's $CLAUDE_PROJECT_DIR-relative relay
|
|
787
|
+
command, which resolves to the worktree, so the hook step strips it and registers
|
|
788
|
+
its own absolute command in its place (#352). A config that is already there but
|
|
789
|
+
cannot be parsed refuses provisioning outright — `verify.GitError`, which the
|
|
790
|
+
caller escalates as CRITICAL and pauses the run — rather than being replaced by
|
|
791
|
+
a hooks-only file: an unparseable config is evidence of an earlier fault, and the
|
|
792
|
+
operator's bytes are left intact for inspection. The refusal sends the repair to
|
|
793
|
+
whichever source supplied those bytes — read from the per-path record of what
|
|
794
|
+
seeding actually wrote, never inferred from the seed entry that covers the path
|
|
795
|
+
(#592).
|
|
796
|
+
|
|
797
|
+
The repo's `_froid/` surface is also merge-seeded, excluding generated render
|
|
798
|
+
output. Renderer and upstream-skill completeness failures share the return
|
|
799
|
+
channel with ordinary no-op seeds so they are journaled, but the caller re-probes
|
|
800
|
+
the skill result and content-gates the renderer sentinels before escalating.
|
|
801
|
+
|
|
802
|
+
Returns the `seed_files` entries that copied NOTHING because everything they
|
|
803
|
+
name was already present, plus reserved completeness reports. A directory entry
|
|
804
|
+
that seeded even one child is not a no-op and is not reported.
|
|
805
|
+
"""
|
|
806
|
+
if not profiles and not seed_files and not seed_globs and not _is_dir(repo_root / FROID_DIR):
|
|
807
|
+
return []
|
|
808
|
+
unresolved_worktree = worktree
|
|
809
|
+
unresolved_repo_root = repo_root
|
|
810
|
+
try:
|
|
811
|
+
worktree = worktree.resolve()
|
|
812
|
+
repo_root = repo_root.resolve()
|
|
813
|
+
except (OSError, RuntimeError) as e:
|
|
814
|
+
raise verify.GitError(
|
|
815
|
+
"cannot resolve worktree provisioning roots safely "
|
|
816
|
+
f"(worktree={unresolved_worktree}, repo_root={unresolved_repo_root}): {e}"
|
|
817
|
+
) from e
|
|
818
|
+
relay = repo_root / HOOK_SCRIPT_REL
|
|
819
|
+
skills_root = resources.files("froid_loop.data").joinpath("skills")
|
|
820
|
+
|
|
821
|
+
# project gitignored MCP/CLI configs: copy from the main repo when absent.
|
|
822
|
+
# Resolve-and-contain guards against an `..`/absolute entry escaping either tree.
|
|
823
|
+
seeded: list[str] = []
|
|
824
|
+
# Every FILE provisioning actually wrote, worktree-relative. `seeded` answers per
|
|
825
|
+
# ENTRY and collapses a directory to its root; this stays per-file, because over a
|
|
826
|
+
# TRACKED tool directory the shield drops the dir pattern and substitutes one
|
|
827
|
+
# pattern per file we landed under it (#484). A path is here only if this run
|
|
828
|
+
# wrote it, which is what makes the substituted patterns untracked by construction
|
|
829
|
+
# — copy-when-absent cannot land on top of a tracked child.
|
|
830
|
+
written: set[str] = set()
|
|
831
|
+
# Which source supplied each path seeding actually WROTE, keyed by where it
|
|
832
|
+
# landed. `seeded` answers per ENTRY and cannot be read per FILE: a directory
|
|
833
|
+
# entry records only its own rel below, and copy-when-absent skips occupied
|
|
834
|
+
# children one at a time, so a dir rel here means "at least one child landed",
|
|
835
|
+
# never "this child did". The hook step reads this map to name the real source of
|
|
836
|
+
# an unparseable config, where guessing sends the operator to repair the wrong
|
|
837
|
+
# file (#592). `_copy_traversable` mirrors the source layout under `dst`, so each
|
|
838
|
+
# landed path's rel is its source's rel too.
|
|
839
|
+
seeded_from: dict[Path, Path] = {}
|
|
840
|
+
# Entries that named a real source but copied nothing, because every path they
|
|
841
|
+
# name already exists. Reported to the caller (this function is quiet by
|
|
842
|
+
# contract — it runs under a TUI) because the no-op is otherwise silent: an
|
|
843
|
+
# entry that reads as applied configuration is not. Per-CHILD skips inside a
|
|
844
|
+
# directory entry are deliberately not reported — the checkout is expected to
|
|
845
|
+
# carry its tracked children, so that is routine rather than a
|
|
846
|
+
# misconfiguration, exactly like the glob-expanded matches below.
|
|
847
|
+
skipped: list[str] = []
|
|
848
|
+
for rel in seed_files:
|
|
849
|
+
raw = worktree / rel
|
|
850
|
+
try:
|
|
851
|
+
src = (repo_root / rel).resolve()
|
|
852
|
+
dst = raw.resolve()
|
|
853
|
+
except (OSError, RuntimeError):
|
|
854
|
+
continue
|
|
855
|
+
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
|
|
856
|
+
continue
|
|
857
|
+
if not (_is_file(src) or _is_dir(src)):
|
|
858
|
+
continue
|
|
859
|
+
if _occupied(dst):
|
|
860
|
+
# A live symlink remains the ordinary existing-destination no-op. This
|
|
861
|
+
# arm must precede the raw-vs-resolved refusal below so it stays named in
|
|
862
|
+
# `skipped` rather than turning into a silent drop.
|
|
863
|
+
if dst != raw:
|
|
864
|
+
skipped.append(str(rel))
|
|
865
|
+
continue
|
|
866
|
+
# File entries keep the classic copy-when-absent skip. A destination
|
|
867
|
+
# that is not a directory while the source is (the checkout carries a
|
|
868
|
+
# FILE where the seed names a dir) is a type mismatch: recursing would
|
|
869
|
+
# try to mkdir over the file, so the entry is skipped whole instead.
|
|
870
|
+
if not _is_dir(src) or not _is_dir(raw):
|
|
871
|
+
skipped.append(str(rel))
|
|
872
|
+
continue
|
|
873
|
+
# A DIRECTORY whose destination exists is the case #230 reported: the
|
|
874
|
+
# checkout carries some tracked child, so the whole entry used to be a
|
|
875
|
+
# no-op — including the gitignored children that are absent and would
|
|
876
|
+
# clobber nothing. Recurse instead, copying only what is missing.
|
|
877
|
+
landed: list[Path] = []
|
|
878
|
+
if not _copy_traversable(
|
|
879
|
+
src,
|
|
880
|
+
raw,
|
|
881
|
+
skip_existing=True,
|
|
882
|
+
worktree=worktree,
|
|
883
|
+
repo_root=repo_root,
|
|
884
|
+
copied_paths=landed,
|
|
885
|
+
):
|
|
886
|
+
# every child was already present: still a total no-op, still
|
|
887
|
+
# reported. Only a PARTIAL seed stops being reported.
|
|
888
|
+
skipped.append(str(rel))
|
|
889
|
+
continue
|
|
890
|
+
# Partially seeded, so the entry must still reach `patterns` below:
|
|
891
|
+
# the children we just wrote have to stay out of the unit's
|
|
892
|
+
# `git add -A`. Excluding the whole dir is safe — an exclude does not
|
|
893
|
+
# untrack the tracked children that were already there.
|
|
894
|
+
seeded.append(rel)
|
|
895
|
+
_record_seeded(seeded_from, landed, src, raw)
|
|
896
|
+
written.update(_written_rels(worktree, landed))
|
|
897
|
+
continue
|
|
898
|
+
# `resolve()` is non-strict, so a dangling leaf or parent link answers for
|
|
899
|
+
# its target. Never mkdir/copy through it. Existing live links were handled
|
|
900
|
+
# by the skip arm above, preserving copy-when-absent reporting.
|
|
901
|
+
if dst != raw:
|
|
902
|
+
continue
|
|
903
|
+
landed = []
|
|
904
|
+
if _copy_traversable(
|
|
905
|
+
src,
|
|
906
|
+
raw,
|
|
907
|
+
skip_existing=True,
|
|
908
|
+
worktree=worktree,
|
|
909
|
+
repo_root=repo_root,
|
|
910
|
+
copied_paths=landed,
|
|
911
|
+
):
|
|
912
|
+
seeded.append(rel)
|
|
913
|
+
_record_seeded(seeded_from, landed, src, raw)
|
|
914
|
+
written.update(_written_rels(worktree, landed))
|
|
915
|
+
|
|
916
|
+
# glob-seeded trees (e.g. an engine plugin's MCP skill dirs): expand each
|
|
917
|
+
# pattern against the main repo and copy matches in, same contain guard +
|
|
918
|
+
# copy-when-absent semantics. rel is taken from the unresolved match so the
|
|
919
|
+
# worktree path mirrors the repo layout; resolve only guards containment.
|
|
920
|
+
for pattern in seed_globs:
|
|
921
|
+
for match in sorted(repo_root.glob(pattern)):
|
|
922
|
+
rel = match.relative_to(repo_root)
|
|
923
|
+
raw = worktree / rel
|
|
924
|
+
try:
|
|
925
|
+
src = match.resolve()
|
|
926
|
+
dst = raw.resolve()
|
|
927
|
+
except (OSError, RuntimeError):
|
|
928
|
+
continue
|
|
929
|
+
if not src.is_relative_to(repo_root) or not dst.is_relative_to(worktree):
|
|
930
|
+
continue
|
|
931
|
+
if not (_is_file(src) or _is_dir(src)) or _occupied(dst):
|
|
932
|
+
continue
|
|
933
|
+
if dst != raw:
|
|
934
|
+
continue
|
|
935
|
+
landed = []
|
|
936
|
+
if not _copy_traversable(
|
|
937
|
+
src,
|
|
938
|
+
raw,
|
|
939
|
+
skip_existing=True,
|
|
940
|
+
worktree=worktree,
|
|
941
|
+
repo_root=repo_root,
|
|
942
|
+
copied_paths=landed,
|
|
943
|
+
):
|
|
944
|
+
continue
|
|
945
|
+
# as_posix so the exclude pattern anchors on Windows too (os.sep would not)
|
|
946
|
+
seeded.append(rel.as_posix())
|
|
947
|
+
_record_seeded(seeded_from, landed, src, raw)
|
|
948
|
+
written.update(_written_rels(worktree, landed))
|
|
949
|
+
|
|
950
|
+
# Renderer-backed skills are handed the worktree as their project root. Merge
|
|
951
|
+
# the repo's project-local FROID surface after explicit seeds (operator intent wins
|
|
952
|
+
# on collisions) and reserve the two renderer sentinels for result-side checks.
|
|
953
|
+
seeded_froid, froid_written = _seed_froid_tree(worktree, repo_root)
|
|
954
|
+
written.update(froid_written)
|
|
955
|
+
skipped = [rel for rel in skipped if rel not in RENDERER_SEED_SENTINELS]
|
|
956
|
+
if _froid_scripts_seed_incomplete(worktree, repo_root):
|
|
957
|
+
skipped.append(FROID_SCRIPTS_SEED_REL)
|
|
958
|
+
if _central_config_seed_incomplete(worktree, repo_root):
|
|
959
|
+
skipped.append(CENTRAL_CONFIG_REL)
|
|
960
|
+
|
|
961
|
+
# bundled skills into each CLI's skill tree (deduped: codex+gemini share one);
|
|
962
|
+
# never clobber a skill the checkout already carries (tracked or pre-existing).
|
|
963
|
+
for tree in dict.fromkeys(p.skill_tree for p in profiles):
|
|
964
|
+
tree_dir = worktree / tree
|
|
965
|
+
for skill in MODULE_SKILLS:
|
|
966
|
+
dst = tree_dir / skill
|
|
967
|
+
# `copied_paths` is read only by the shield: when this tree turns out to
|
|
968
|
+
# be TRACKED, these files are what gets a pattern in place of the dropped
|
|
969
|
+
# dir pattern (#484). Completeness is still re-probed on disk below —
|
|
970
|
+
# copy bookkeeping never arms a gate.
|
|
971
|
+
landed = []
|
|
972
|
+
_copy_traversable(
|
|
973
|
+
skills_root.joinpath(skill),
|
|
974
|
+
dst,
|
|
975
|
+
skip_existing=True,
|
|
976
|
+
worktree=worktree,
|
|
977
|
+
copied_paths=landed,
|
|
978
|
+
)
|
|
979
|
+
written.update(_written_rels(worktree, landed))
|
|
980
|
+
# Wheel MODULE_SKILLS get the same result-side completeness re-probe as every
|
|
981
|
+
# other seeded surface (`module_skills_seed_undelivered`, journaled by the
|
|
982
|
+
# caller as `worktree-module-skills-dropped`) — but journal-only, never the
|
|
983
|
+
# CRITICAL gate below: no MODULE_SKILLS entry has a worktree-resident consumer
|
|
984
|
+
# (sweep triage and froid-loop-resolve dispatch at the MAIN checkout;
|
|
985
|
+
# froid-loop-setup has no session consumer), so a partial wheel copy cannot
|
|
986
|
+
# prove a stall the way a missing upstream dev/review skill does. A future
|
|
987
|
+
# worktree-resident consumer arms the gate via escalate_unit over that
|
|
988
|
+
# predicate's result.
|
|
989
|
+
# The orchestrator-driven upstream skills are not in the wheel; copy them
|
|
990
|
+
# from the MAIN REPO's installed tree (same tree path) so an isolated
|
|
991
|
+
# worktree can still resolve the dev primitive and the review layers. Skip
|
|
992
|
+
# silently when the main repo lacks them — the run-start preflight reports
|
|
993
|
+
# it.
|
|
994
|
+
#
|
|
995
|
+
# BASE_SKILLS names BOTH primitive eras, which is what carries the skill
|
|
996
|
+
# across the rename: the resolution below returns REVIEW skills only, so a
|
|
997
|
+
# primitive the catalog did not name would be silently left behind (the
|
|
998
|
+
# is_dir guard swallows the miss) and every isolated session would stall on
|
|
999
|
+
# an Unknown command.
|
|
1000
|
+
#
|
|
1001
|
+
# BASE_SKILLS is only the floor. The review layers this project actually
|
|
1002
|
+
# invokes are read from the installed primitive — resolved on disk, so a
|
|
1003
|
+
# renamed project resolves its own layers rather than degrading to the
|
|
1004
|
+
# static catalog — exactly as the preflight reads them, so a reviewer named
|
|
1005
|
+
# by a project override (a custom or renamed skill) is provisioned too.
|
|
1006
|
+
# Validating a skill here and then not copying it is how preflight passes in
|
|
1007
|
+
# the main checkout while the isolated review fails on a skill that was
|
|
1008
|
+
# never there.
|
|
1009
|
+
for skill in _worktree_skill_copy_candidates(repo_root, tree):
|
|
1010
|
+
dst = tree_dir / skill
|
|
1011
|
+
try:
|
|
1012
|
+
src = (repo_root / tree / skill).resolve()
|
|
1013
|
+
except (OSError, RuntimeError):
|
|
1014
|
+
continue
|
|
1015
|
+
if not src.is_relative_to(repo_root) or not _is_dir(src):
|
|
1016
|
+
continue
|
|
1017
|
+
landed = []
|
|
1018
|
+
_copy_traversable(
|
|
1019
|
+
src,
|
|
1020
|
+
dst,
|
|
1021
|
+
skip_existing=True,
|
|
1022
|
+
worktree=worktree,
|
|
1023
|
+
repo_root=repo_root,
|
|
1024
|
+
copied_paths=landed,
|
|
1025
|
+
)
|
|
1026
|
+
written.update(_written_rels(worktree, landed))
|
|
1027
|
+
|
|
1028
|
+
# Re-ask the result rather than trusting copy bookkeeping. A user-authored seed
|
|
1029
|
+
# can happen to spell a skill rel, so only this disk predicate may arm the gate.
|
|
1030
|
+
skipped.extend(
|
|
1031
|
+
base_skills_seed_incomplete(
|
|
1032
|
+
worktree, repo_root, [profile.skill_tree for profile in profiles]
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
# per-CLI signal-hook registration, baked to the main repo's relay (absolute).
|
|
1037
|
+
# Hookless profiles (HTTP/SSE transport) have no config to merge.
|
|
1038
|
+
stripped_paths: set[Path] = set()
|
|
1039
|
+
for profile in profiles:
|
|
1040
|
+
if profile.hookless:
|
|
1041
|
+
continue
|
|
1042
|
+
raw_config_path = worktree / profile.hooks.config_path
|
|
1043
|
+
# Refuse before mkdir, read, or write: hook commands are worktree-specific
|
|
1044
|
+
# and must never mutate a shared dotfile through a live or dangling link.
|
|
1045
|
+
# Inspect every component: a non-strict resolve either leaves a symlink cycle
|
|
1046
|
+
# unresolved — and so textually equal to the raw path — or raises RuntimeError,
|
|
1047
|
+
# which the except below takes; which of the two happens is interpreter-version
|
|
1048
|
+
# dependent. The resolved comparison additionally refuses ``..`` and absolute
|
|
1049
|
+
# profiles.
|
|
1050
|
+
refused = not raw_config_path.is_relative_to(worktree)
|
|
1051
|
+
cursor = raw_config_path
|
|
1052
|
+
try:
|
|
1053
|
+
while not refused and cursor != worktree:
|
|
1054
|
+
if cursor.is_symlink():
|
|
1055
|
+
refused = True
|
|
1056
|
+
break
|
|
1057
|
+
cursor = cursor.parent
|
|
1058
|
+
config_path = raw_config_path.resolve()
|
|
1059
|
+
except (OSError, RuntimeError):
|
|
1060
|
+
continue
|
|
1061
|
+
if refused or config_path != raw_config_path or not config_path.is_relative_to(worktree):
|
|
1062
|
+
continue
|
|
1063
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1064
|
+
config: dict = {}
|
|
1065
|
+
if _is_file(config_path):
|
|
1066
|
+
try:
|
|
1067
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
1068
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
1069
|
+
# Refuse, exactly as `_register_hooks` does at init
|
|
1070
|
+
# (install.py:1244-1248): same file, same merge, one policy (#592).
|
|
1071
|
+
# JSON has no partial read, so a config that will not parse is
|
|
1072
|
+
# evidence of an earlier fault rather than a blank slate — and
|
|
1073
|
+
# swallowing it to `{}` is not a degrade but a destructive write:
|
|
1074
|
+
# `baseline_config` below deep-copies that `{}`, so the change gate
|
|
1075
|
+
# always fires and publishes a hooks-only file over the operator's
|
|
1076
|
+
# allowlist, env and MCP entries, erasing the very evidence.
|
|
1077
|
+
# UnicodeDecodeError rides along because `read_text` raises it on
|
|
1078
|
+
# invalid UTF-8 — the same "operator file unreadable as content"
|
|
1079
|
+
# family, and uncaught it crashes the engine instead of escalating.
|
|
1080
|
+
# Raising mid-loop, after earlier profiles were provisioned, is
|
|
1081
|
+
# safe: the escalation pauses the run, and provisioning re-runs on
|
|
1082
|
+
# resume without duplicating its work (pinned by
|
|
1083
|
+
# test_shield_reprovision_does_not_duplicate_patterns).
|
|
1084
|
+
#
|
|
1085
|
+
# Send the repair to whatever SUPPLIES these bytes, which is never
|
|
1086
|
+
# this file: the worktree is disposable, an escalated story re-enters
|
|
1087
|
+
# the run only through a re-arm, and that discards the worktree
|
|
1088
|
+
# (`engine._finish_inflight` -> `discard_worktree`) and provisions a
|
|
1089
|
+
# fresh one. `seeded_from` answers which source that is POSITIVELY —
|
|
1090
|
+
# keyed on THIS path, and populated only where a copy actually landed.
|
|
1091
|
+
# Both halves are load-bearing. Existence of a main-checkout
|
|
1092
|
+
# counterpart proves nothing: seeding is copy-when-absent, so a config
|
|
1093
|
+
# the project TRACKS is skipped as an occupied destination and arrives
|
|
1094
|
+
# with the branch checkout instead (the call site says so at the
|
|
1095
|
+
# `config_path` seed), and repairing the counterpart would not take —
|
|
1096
|
+
# the fresh worktree checks the committed version out again and the
|
|
1097
|
+
# refusal recurs, so that lane is told to commit. Nor can provenance
|
|
1098
|
+
# come from the seed ENTRY that covers this path: a directory entry is
|
|
1099
|
+
# recorded once, for the parent, while its children are skipped
|
|
1100
|
+
# individually, so a config landing under a seeded `.claude/` and one
|
|
1101
|
+
# merely sitting there beside a seeded sibling are indistinguishable
|
|
1102
|
+
# at that granularity — and misreading the second as seeded advises
|
|
1103
|
+
# committing a gitignored file that may carry credentials.
|
|
1104
|
+
# ESCALATED is terminal with no transition out (`statemachine.py`),
|
|
1105
|
+
# and `resolve` takes a required `run_id` (`cli.py:4232`), so the
|
|
1106
|
+
# remedy names the re-arm in a form that actually runs.
|
|
1107
|
+
seed_rel = profile.hooks.config_path
|
|
1108
|
+
seeded_source = seeded_from.get(config_path)
|
|
1109
|
+
if seeded_source is not None:
|
|
1110
|
+
src_note = (
|
|
1111
|
+
f"this copy was seeded from {seeded_source}, which a "
|
|
1112
|
+
"re-arm seeds in again, so"
|
|
1113
|
+
)
|
|
1114
|
+
remedy = f"repair or remove {seeded_source} in the main checkout"
|
|
1115
|
+
else:
|
|
1116
|
+
src_note = (
|
|
1117
|
+
"nothing seeded this copy — it arrived with the branch "
|
|
1118
|
+
"checkout, which a re-arm checks out again, so"
|
|
1119
|
+
)
|
|
1120
|
+
remedy = f"commit a repaired {seed_rel} on the target branch"
|
|
1121
|
+
raise verify.GitError(
|
|
1122
|
+
f"hook config {config_path} cannot be parsed ({e}); an "
|
|
1123
|
+
"unparseable config is evidence of an earlier fault, not a blank "
|
|
1124
|
+
"slate — provisioning refuses rather than replace the operator's "
|
|
1125
|
+
"allowlist, env, and MCP settings with a hooks-only file. "
|
|
1126
|
+
f"{src_note} {remedy}, then re-arm this escalation with "
|
|
1127
|
+
"`froid-loop resolve <run-id> --no-interactive`: ESCALATED is "
|
|
1128
|
+
"terminal, so repairing the file alone does not put the story "
|
|
1129
|
+
"back in the run (#592)"
|
|
1130
|
+
) from e
|
|
1131
|
+
host = get_process_host()
|
|
1132
|
+
interp = host.hook_interpreter()
|
|
1133
|
+
registrations = {
|
|
1134
|
+
native: f"{interp} {host.shell_quote(str(relay))} {canonical}"
|
|
1135
|
+
for native, canonical in profile.hooks.events.items()
|
|
1136
|
+
}
|
|
1137
|
+
# A seeded config_path (.claude/settings.json is both a seeded file and the
|
|
1138
|
+
# hook config) arrives carrying the MAIN repo's relay command, which for the
|
|
1139
|
+
# claude dialect is $CLAUDE_PROJECT_DIR-relative and resolves to a path that
|
|
1140
|
+
# does not exist inside the worktree. merge_hooks will not replace an
|
|
1141
|
+
# already-registered relay, so strip it first and let this registration —
|
|
1142
|
+
# baked to the main repo's relay, absolute — be authoritative. Strip only on
|
|
1143
|
+
# FIRST encounter per config file: profiles can share a config_path
|
|
1144
|
+
# (user-overlay aliases of one CLI), and a later profile's pass must not
|
|
1145
|
+
# tear out the relay events an earlier one just registered — merge_hooks
|
|
1146
|
+
# unions its events in, the first registration winning a shared event.
|
|
1147
|
+
baseline_config = copy.deepcopy(config)
|
|
1148
|
+
if config_path not in stripped_paths:
|
|
1149
|
+
strip_relay_hooks(config, profile.hooks.dialect)
|
|
1150
|
+
stripped_paths.add(config_path)
|
|
1151
|
+
config, _ = merge_hooks(config, registrations, profile.hooks.dialect)
|
|
1152
|
+
# Write — and pin — only when the strip+merge actually changed the parsed
|
|
1153
|
+
# config. Non-claude dialects bake the absolute main-repo relay at init
|
|
1154
|
+
# (_hook_command), so a tracked codex/gemini config often arrives already
|
|
1155
|
+
# carrying exactly the command registered here: strip-then-merge nets to
|
|
1156
|
+
# zero, and a pin would claim orchestrator ownership of a file this run
|
|
1157
|
+
# never modified, hiding a story's own edit to it for no benefit.
|
|
1158
|
+
if config != baseline_config:
|
|
1159
|
+
# atomic_write_text, never write_text (#379). `_register_hooks` states
|
|
1160
|
+
# the rule at length; the stakes are higher here. This config is the
|
|
1161
|
+
# SEEDED copy of the operator's own — allowlist, env, MCP entries and
|
|
1162
|
+
# their hooks all round-trip through the parse above — and a truncating
|
|
1163
|
+
# `"w"` publishes a prefix of it on a short write. Nothing downstream
|
|
1164
|
+
# re-reads it to complain, either: the session starts against a settings
|
|
1165
|
+
# file whose JSON no longer parses, so the CLI falls back to its defaults
|
|
1166
|
+
# and the Stop hook never registers. That is #363's stall with no
|
|
1167
|
+
# diagnostic. follow_symlinks stays at the default, matching the
|
|
1168
|
+
# `write_text` it replaces — and the symlink question is already settled
|
|
1169
|
+
# above this line, by the component-wise refusal walk that skips the
|
|
1170
|
+
# profile entirely when any component of the path is a link — a walk
|
|
1171
|
+
# stricter than the confined writers' (it refuses a link anywhere on the
|
|
1172
|
+
# path, in the worktree as well as out), so a confined write would
|
|
1173
|
+
# relax this site rather than harden it. The #597 flag is the whole
|
|
1174
|
+
# change here: `os.replace` needs write permission on the parent
|
|
1175
|
+
# DIRECTORY, never on the entry it replaces, so a seeded config the
|
|
1176
|
+
# operator had marked read-only was rewritten anyway and came back
|
|
1177
|
+
# reading `0444`. This is the operator's own settings file, round-tripped
|
|
1178
|
+
# through the parse above, so a read-only one earns the `PermissionError`
|
|
1179
|
+
# the `write_text` this replaced raised.
|
|
1180
|
+
atomic_write_text(
|
|
1181
|
+
config_path,
|
|
1182
|
+
json.dumps(config, indent=2) + "\n",
|
|
1183
|
+
require_writable_target=True,
|
|
1184
|
+
)
|
|
1185
|
+
pin_degrade = _pin_tracked_config_rewrite(worktree, profile.hooks.config_path)
|
|
1186
|
+
if pin_degrade is not None and on_degraded is not None:
|
|
1187
|
+
on_degraded(pin_degrade)
|
|
1188
|
+
|
|
1189
|
+
# Shield exactly the paths we wrote (skill trees + hook configs + seeded
|
|
1190
|
+
# configs) from the unit's `git add -A`, in case a project doesn't gitignore
|
|
1191
|
+
# its tool dirs. Scoped to this worktree and expiring with it — these are
|
|
1192
|
+
# paths projects legitimately TRACK, so a repo-wide exclude here went on
|
|
1193
|
+
# hiding their new files from the operator's own checkout (#384).
|
|
1194
|
+
#
|
|
1195
|
+
# Built here at ENTRY granularity, which is only provisional: the reconcile step
|
|
1196
|
+
# below re-asks git what each one names, drops a pattern over a tracked file as
|
|
1197
|
+
# inert (#392), and over a tracked DIRECTORY swaps the entry for one pattern per
|
|
1198
|
+
# file `written` recorded under it (#484). `written` is therefore not an
|
|
1199
|
+
# alternative source of patterns but the substitution ledger that step reads.
|
|
1200
|
+
patterns = {f"/{p.skill_tree}" for p in profiles}
|
|
1201
|
+
# hookless profiles have no config_path, so there is nothing to shield: their
|
|
1202
|
+
# empty string would render as a bare "/", which git strips to a zero-length
|
|
1203
|
+
# pattern (the trailing slash becomes MUSTBEDIR) that matches nothing — inert,
|
|
1204
|
+
# unlike "/*" or "*", which do blanket. A junk line in a generated file, then,
|
|
1205
|
+
# not a worktree-wide exclusion.
|
|
1206
|
+
patterns |= {f"/{p.hooks.config_path}" for p in profiles if not p.hookless}
|
|
1207
|
+
patterns |= {f"/{rel}" for rel in seeded}
|
|
1208
|
+
patterns |= {f"/{rel}" for rel in seeded_froid}
|
|
1209
|
+
if f"/{FROID_DIR}" not in patterns:
|
|
1210
|
+
# The renderer may create or rewrite this generated directory during the
|
|
1211
|
+
# session, after provisioning has finished. Give it a dedicated transient
|
|
1212
|
+
# shield unless the blanket root shield already subsumes it: `/_froid` prunes
|
|
1213
|
+
# the directory before git descends, so `/_froid/render/` would provably never
|
|
1214
|
+
# be consulted. Avoiding that inert sibling keeps the worktree-local exclude
|
|
1215
|
+
# precise; the file and its lines disappear with this worktree.
|
|
1216
|
+
patterns.add(f"/{RENDER_DIR_REL}/")
|
|
1217
|
+
patterns, tracked_degrade = _reconcile_tracked_patterns(worktree, patterns, written)
|
|
1218
|
+
if tracked_degrade is not None and on_degraded is not None:
|
|
1219
|
+
on_degraded(tracked_degrade)
|
|
1220
|
+
# Escaping is the LAST transform: `_reconcile_tracked_patterns` strips the leading
|
|
1221
|
+
# "/" and probes git with the LITERAL rel, and the per-file patterns it substitutes
|
|
1222
|
+
# come back RAW too, so it has to keep seeing and emitting unescaped patterns —
|
|
1223
|
+
# escaping must stay downstream of the tracked-pattern transform.
|
|
1224
|
+
reason = _worktree_local_exclude(worktree, sorted(_escape_exclude_pattern(p) for p in patterns))
|
|
1225
|
+
if reason is not None and on_degraded is not None:
|
|
1226
|
+
on_degraded(reason)
|
|
1227
|
+
return skipped
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
class WorktreeFlow:
|
|
1231
|
+
"""Provision, drive, integrate and reclaim per-unit git worktrees.
|
|
1232
|
+
|
|
1233
|
+
Built once per engine from narrow deps + engine callbacks (see module
|
|
1234
|
+
docstring). Behavior is identical to the cluster it was carved out of; the
|
|
1235
|
+
only structural changes are that engine-owned effects go through injected
|
|
1236
|
+
callables: ``emit`` fires a plugin hook (late-bound so a monkeypatched
|
|
1237
|
+
``Engine._emit`` still wins), ``save`` persists run state, ``gate_unit`` runs
|
|
1238
|
+
the per-unit ready gate, ``carry_isolated_ledger_writes`` applies Engine-owned
|
|
1239
|
+
ledger bookkeeping after a successful merge, ``workspace_get``/``workspace_set``
|
|
1240
|
+
read and swap the engine's active workspace, and ``escalation_pause`` raises the
|
|
1241
|
+
engine's ``RunPaused`` (injected so this module need not import ``engine`` — that
|
|
1242
|
+
would reintroduce a runtime<->engine import cycle)."""
|
|
1243
|
+
|
|
1244
|
+
def __init__(
|
|
1245
|
+
self,
|
|
1246
|
+
*,
|
|
1247
|
+
paths: ProjectPaths,
|
|
1248
|
+
policy: Policy,
|
|
1249
|
+
state: RunState,
|
|
1250
|
+
journal: Journal,
|
|
1251
|
+
run_dir: Path,
|
|
1252
|
+
registry: PluginRegistry,
|
|
1253
|
+
adapters_get: Callable[[], dict[str, CodingCLIAdapter]],
|
|
1254
|
+
open_unit_workspace: Callable[..., UnitWorkspace],
|
|
1255
|
+
emit: Callable[..., object],
|
|
1256
|
+
save: Callable[[], None],
|
|
1257
|
+
gate_unit: Callable[[StoryTask], bool],
|
|
1258
|
+
carry_isolated_ledger_writes: Callable[[StoryTask], None],
|
|
1259
|
+
escalation_pause: Callable[..., NoReturn],
|
|
1260
|
+
workspace_get: Callable[[], Workspace],
|
|
1261
|
+
workspace_set: Callable[[Workspace], None],
|
|
1262
|
+
) -> None:
|
|
1263
|
+
self.paths = paths
|
|
1264
|
+
self.policy = policy
|
|
1265
|
+
self.state = state
|
|
1266
|
+
self.journal = journal
|
|
1267
|
+
self.run_dir = run_dir
|
|
1268
|
+
self._registry = registry
|
|
1269
|
+
# Read live (a getter, not a captured dict) so a test that rebinds
|
|
1270
|
+
# `engine.adapters` after construction is still seen here.
|
|
1271
|
+
self._adapters_get = adapters_get
|
|
1272
|
+
# Injected late-bound so a test patching the `engine.open_unit_workspace`
|
|
1273
|
+
# module global still wins (worktree_flow's own binding wouldn't).
|
|
1274
|
+
self._open_unit_workspace = open_unit_workspace
|
|
1275
|
+
self._emit = emit
|
|
1276
|
+
self._save = save
|
|
1277
|
+
self._gate_unit = gate_unit
|
|
1278
|
+
self._carry_isolated_ledger_writes = carry_isolated_ledger_writes
|
|
1279
|
+
self._pause = escalation_pause
|
|
1280
|
+
self._workspace_get = workspace_get
|
|
1281
|
+
self._workspace_set = workspace_set
|
|
1282
|
+
|
|
1283
|
+
@property
|
|
1284
|
+
def isolated(self) -> bool:
|
|
1285
|
+
return self.policy.scm.isolation == "worktree"
|
|
1286
|
+
|
|
1287
|
+
def ensure_target_branch(self) -> None:
|
|
1288
|
+
"""Resolve (once, at run start) the branch every unit merges back into.
|
|
1289
|
+
|
|
1290
|
+
No-op unless isolation=worktree. Default target is the branch checked out
|
|
1291
|
+
now; a configured target is created if missing and checked out in the
|
|
1292
|
+
main repo (merges land on whatever the main repo has checked out, and a
|
|
1293
|
+
unit worktree must never check out the target itself). Pinned in state so
|
|
1294
|
+
resume keeps targeting the same branch."""
|
|
1295
|
+
if not self.isolated or self.state.target_branch:
|
|
1296
|
+
return
|
|
1297
|
+
if self.policy.scm.failed_diff_unlimited:
|
|
1298
|
+
# the safety cap is off; make sure the operator knows a failed unit
|
|
1299
|
+
# could write a very large forensic patch.
|
|
1300
|
+
self.journal.append(
|
|
1301
|
+
"scm-failed-diff-unlimited",
|
|
1302
|
+
note="failed-unit diff capture is uncapped (scm.failed_diff_unlimited); "
|
|
1303
|
+
"changes.patch may be very large",
|
|
1304
|
+
)
|
|
1305
|
+
repo = self.paths.repo_root
|
|
1306
|
+
configured = self.policy.scm.target_branch.strip()
|
|
1307
|
+
if configured:
|
|
1308
|
+
if not verify.branch_exists(repo, configured):
|
|
1309
|
+
try:
|
|
1310
|
+
verify.create_branch(repo, configured, "HEAD")
|
|
1311
|
+
except verify.GitError as e:
|
|
1312
|
+
# e.g. an unborn repo (no commit to base a branch on).
|
|
1313
|
+
self._pause(f"cannot create target branch {configured!r}: {e}", cause=e)
|
|
1314
|
+
self.journal.append("target-branch-created", branch=configured)
|
|
1315
|
+
if verify.current_branch(repo) != configured:
|
|
1316
|
+
verify.checkout_branch(repo, configured)
|
|
1317
|
+
self.journal.append("target-branch-checkout", branch=configured)
|
|
1318
|
+
self.state.target_branch = configured
|
|
1319
|
+
else:
|
|
1320
|
+
current = verify.current_branch(repo)
|
|
1321
|
+
if current == "HEAD":
|
|
1322
|
+
# detached HEAD has no branch to merge into; merges would land on
|
|
1323
|
+
# an unreferenced commit. Require a real branch (or a configured
|
|
1324
|
+
# target) before isolating work into worktrees.
|
|
1325
|
+
self._pause(
|
|
1326
|
+
"isolation=worktree on a detached HEAD: check out a branch or "
|
|
1327
|
+
"set scm.target_branch before running"
|
|
1328
|
+
)
|
|
1329
|
+
self.state.target_branch = current
|
|
1330
|
+
self.journal.append("target-branch", branch=self.state.target_branch)
|
|
1331
|
+
self._save()
|
|
1332
|
+
|
|
1333
|
+
def worktree_profiles(self) -> list[CLIProfile]:
|
|
1334
|
+
"""The distinct CLI profiles of the dev + review adapters, for provisioning
|
|
1335
|
+
their skills/hooks into a worktree. Adapters without a `profile` (e.g. test
|
|
1336
|
+
fakes) contribute nothing, so provisioning is a no-op for them.
|
|
1337
|
+
|
|
1338
|
+
The role set is :data:`install.DEV_PRIMITIVE_ROLES` — the same constant
|
|
1339
|
+
`cli._skill_trees` gates on — rather than a local pair, so the provisioned
|
|
1340
|
+
set and the gated set cannot drift apart. A tree gated but not provisioned
|
|
1341
|
+
ships a session into the `Unknown command` stall the preflight exists to
|
|
1342
|
+
catch; a tree provisioned but not gated refuses runs over a skill no session
|
|
1343
|
+
reads."""
|
|
1344
|
+
seen: dict[str, CLIProfile] = {}
|
|
1345
|
+
adapters = self._adapters_get()
|
|
1346
|
+
for adapter in (adapters[role] for role in DEV_PRIMITIVE_ROLES):
|
|
1347
|
+
profile = getattr(adapter, "profile", None)
|
|
1348
|
+
if profile is not None and profile.name not in seen:
|
|
1349
|
+
seen[profile.name] = profile
|
|
1350
|
+
return list(seen.values())
|
|
1351
|
+
|
|
1352
|
+
def engine_agent_ids(self) -> list[str]:
|
|
1353
|
+
"""The Unity-MCP `setup-mcp` agent ids for every CLI that runs in a
|
|
1354
|
+
worktree (dev + review). A worktree can host more than one agent — e.g.
|
|
1355
|
+
dev=claude, review=codex — and each reads its own MCP config file, so the
|
|
1356
|
+
per_worktree setup must point every one of them at the worktree's Editor,
|
|
1357
|
+
not just the dev agent. Deduped, order-preserving; empty for test fakes."""
|
|
1358
|
+
ids: list[str] = []
|
|
1359
|
+
for profile in self.worktree_profiles():
|
|
1360
|
+
agent = _setup_mcp_agent_id(profile.name)
|
|
1361
|
+
if agent not in ids:
|
|
1362
|
+
ids.append(agent)
|
|
1363
|
+
return ids
|
|
1364
|
+
|
|
1365
|
+
def _ledger_seed(self, worktree: Path) -> tuple[str, ...]:
|
|
1366
|
+
"""The deferred-work ledger, when a worktree checkout cannot deliver it.
|
|
1367
|
+
|
|
1368
|
+
`git worktree add` checks out TRACKED files only, so a project that
|
|
1369
|
+
gitignores its ledger — the default shape — gets a unit worktree with
|
|
1370
|
+
none. The orchestrator is the ledger's single writer under the generic
|
|
1371
|
+
dev path and writes through ``self.workspace.paths``, i.e. that missing
|
|
1372
|
+
copy: ``mark_done`` returns False on an absent file,
|
|
1373
|
+
``verify_review_bundle`` reads the same absent file and never sees the
|
|
1374
|
+
ids `done`, so the bundle defers on a fixable retry and `open_ids`
|
|
1375
|
+
re-bundles the same work for ever (#426). No DONE-leg carry can rescue
|
|
1376
|
+
that — the unit never reaches DONE. Seeding moves the failure onto a leg
|
|
1377
|
+
that has one, where ``SweepEngine._carry_isolated_ledger_writes`` applies
|
|
1378
|
+
the close to the main checkout.
|
|
1379
|
+
|
|
1380
|
+
The copy is not what delivers the close: every seeded rel is shielded
|
|
1381
|
+
from the unit's ``git add -A``, so the worktree's flip never rides the
|
|
1382
|
+
merge. The seeded ledger exists so the GATE can read the orchestrator's
|
|
1383
|
+
own write; the carry stays the delivery path, hence
|
|
1384
|
+
``sweep-bundle-close-carry-uncommitted`` on a run that lands.
|
|
1385
|
+
|
|
1386
|
+
Excluded: a ledger already in the checkout (without the exclusion the copy
|
|
1387
|
+
would be a no-op the seed loop reports as ``worktree-seed-skipped`` on every
|
|
1388
|
+
tracked-ledger project); one absent from the main checkout (dropped
|
|
1389
|
+
silently, so the entry would be invisible rather than merely inert — the
|
|
1390
|
+
common case, since the first harvest is what CREATES the file); and one
|
|
1391
|
+
resolving outside the project tree, which for an out-of-tree artifacts DIR
|
|
1392
|
+
``ProjectPaths.rebased`` leaves unmoved, so the worktree already reads it.
|
|
1393
|
+
Presence is asked of the WORKTREE, not of git: that is the predicate the
|
|
1394
|
+
seed loop itself decides on, and unlike ``verify.path_tracked`` it costs no
|
|
1395
|
+
subprocess and cannot raise.
|
|
1396
|
+
|
|
1397
|
+
A ledger that is itself a symlink keeps the dir in-tree, so ``rebased``
|
|
1398
|
+
moves it and the worktree path does not exist: the exclusion is still right
|
|
1399
|
+
for an out-of-repo target (``provision_worktree`` refuses that source
|
|
1400
|
+
whatever rel it is handed), but an in-repo target is seeded to the WRONG
|
|
1401
|
+
path and still hits #426 (#462). Resolving also names the target of a
|
|
1402
|
+
TRACKED ledger symlink whose target is untracked — the only path the seed
|
|
1403
|
+
loop will write, since it refuses to copy through a link. ``relative_to``
|
|
1404
|
+
decides PLACEMENT; containment is re-checked against both roots at the copy
|
|
1405
|
+
site.
|
|
1406
|
+
|
|
1407
|
+
Deduped against ``scm.worktree_seed`` by the caller.
|
|
1408
|
+
"""
|
|
1409
|
+
ledger = self.paths.deferred_work
|
|
1410
|
+
repo = self.paths.repo_root
|
|
1411
|
+
try:
|
|
1412
|
+
rel = ledger.resolve().relative_to(repo.resolve()).as_posix()
|
|
1413
|
+
except (OSError, RuntimeError, ValueError):
|
|
1414
|
+
return ()
|
|
1415
|
+
if not _is_file(ledger) or _is_file(worktree / rel):
|
|
1416
|
+
return ()
|
|
1417
|
+
return (rel,)
|
|
1418
|
+
|
|
1419
|
+
def _board_seed(self, worktree: Path) -> tuple[str, ...]:
|
|
1420
|
+
"""The sprint board, when a worktree checkout cannot deliver it (#350).
|
|
1421
|
+
|
|
1422
|
+
The structural sibling of :meth:`_ledger_seed`, for the other
|
|
1423
|
+
orchestrator-owned artifact in the FROID artifacts dir, and the same shape
|
|
1424
|
+
for the same reason: `git worktree add` checks out TRACKED files only, so a
|
|
1425
|
+
project that gitignores its board gets a unit worktree with none, while the
|
|
1426
|
+
orchestrator writes the board through ``self.workspace.paths`` — i.e. that
|
|
1427
|
+
missing copy.
|
|
1428
|
+
|
|
1429
|
+
What the ledger's absence costs is a livelock; the board's is a CRASH.
|
|
1430
|
+
``Engine._post_dev_state_sync``'s ``advance`` returns None on a file that is
|
|
1431
|
+
not there (a silent no-op), and ``verify_dev`` then reads the SAME absent
|
|
1432
|
+
file through ``story_status``, where ``sprintstatus.load`` raises
|
|
1433
|
+
``SprintStatusError`` — a class cli, operatoractions and the TUI all catch
|
|
1434
|
+
and neither engine.py nor verify.py does, so the story dies and takes the
|
|
1435
|
+
run with it. Seeding removes that structurally: the gate reads the
|
|
1436
|
+
orchestrator's own write instead of a hole.
|
|
1437
|
+
|
|
1438
|
+
Per the maintainer decision on #350 the worktree copy is CANONICAL for the
|
|
1439
|
+
duration of the story. As with the ledger, the copy is not what delivers the
|
|
1440
|
+
advance back: every seeded rel is shielded from the unit's ``git add -A``,
|
|
1441
|
+
so the worktree's flip never rides the merge, and a post-merge carry is the
|
|
1442
|
+
delivery path.
|
|
1443
|
+
|
|
1444
|
+
Excluded, arm for arm as the ledger's: a board already in the checkout
|
|
1445
|
+
(tracked, hence delivered — seeding it would copy nothing and journal
|
|
1446
|
+
``worktree-seed-skipped`` on every isolated unit of every project that
|
|
1447
|
+
tracks its board, which is the common shape for this file); one absent from
|
|
1448
|
+
the main checkout, which the seed loop drops SILENTLY — neither
|
|
1449
|
+
``worktree-seed-skipped`` nor ``worktree-seed-dropped`` — so naming it would
|
|
1450
|
+
be invisible rather than merely inert (a story run cannot reach here without
|
|
1451
|
+
a board, since ``_pick_next`` reads it first, but a sweep or stories run
|
|
1452
|
+
needs no board at all); and one resolving outside the project tree, which
|
|
1453
|
+
for an out-of-tree artifacts dir ``ProjectPaths.rebased`` leaves unmoved, so
|
|
1454
|
+
the worktree already reads this very file. Presence is asked of the
|
|
1455
|
+
WORKTREE, not of git, for the ledger's reason: it is the predicate the seed
|
|
1456
|
+
loop itself decides on, it costs no subprocess and it cannot raise.
|
|
1457
|
+
|
|
1458
|
+
A board that is itself a symlink inherits ``_ledger_seed``'s caveat verbatim
|
|
1459
|
+
(#462); it is derived there and not repeated here.
|
|
1460
|
+
|
|
1461
|
+
INHERITED LIMITATION — parity, not a regression, and NOT fixed here: a
|
|
1462
|
+
non-fixable rollback does not restore a seeded board. Rollback resets
|
|
1463
|
+
TRACKED paths and removes untracked NON-IGNORED ones
|
|
1464
|
+
(``verify.untracked_files``); an ignored board is in neither set, so nothing
|
|
1465
|
+
puts back the pre-attempt status. An attempt that advanced the worktree
|
|
1466
|
+
board to ``done`` therefore PINS it there — ``advance`` never regresses — and
|
|
1467
|
+
a later attempt that parks instead cannot satisfy ``verify_dev``'s
|
|
1468
|
+
``awaiting-operator`` expectation. That is exactly what an in-place
|
|
1469
|
+
gitignored board already does under ``isolation = "none"``; only a TRACKED
|
|
1470
|
+
board escapes, via the rollback's ``reset --hard``. Seeding neither
|
|
1471
|
+
introduces the trap nor widens it.
|
|
1472
|
+
|
|
1473
|
+
Deduped against ``scm.worktree_seed`` by the caller.
|
|
1474
|
+
"""
|
|
1475
|
+
board = self.paths.sprint_status
|
|
1476
|
+
repo = self.paths.repo_root
|
|
1477
|
+
try:
|
|
1478
|
+
rel = board.resolve().relative_to(repo.resolve()).as_posix()
|
|
1479
|
+
except (OSError, RuntimeError, ValueError):
|
|
1480
|
+
return ()
|
|
1481
|
+
if not _is_file(board) or _is_file(worktree / rel):
|
|
1482
|
+
return ()
|
|
1483
|
+
return (rel,)
|
|
1484
|
+
|
|
1485
|
+
def run_isolated(self, task: StoryTask, drive: Callable[[StoryTask], None]) -> None:
|
|
1486
|
+
"""Run one unit's `drive` body in a fresh per-unit worktree, then merge
|
|
1487
|
+
it back into the target branch. `drive` either returns (DONE/DEFERRED →
|
|
1488
|
+
integrate) or raises RunPaused (spec-approval gate / escalation → leave
|
|
1489
|
+
the worktree mounted for resume/inspection, integration skipped)."""
|
|
1490
|
+
try:
|
|
1491
|
+
unit = self._open_unit_workspace(
|
|
1492
|
+
self.paths.repo_root,
|
|
1493
|
+
self.paths,
|
|
1494
|
+
self.state.run_id,
|
|
1495
|
+
task.story_key,
|
|
1496
|
+
self.state.target_branch,
|
|
1497
|
+
self.policy.scm.branch_per,
|
|
1498
|
+
self.run_dir,
|
|
1499
|
+
)
|
|
1500
|
+
except verify.GitSpawnError as e:
|
|
1501
|
+
# a spawn fault is machine-wide, not this unit's: deferring would
|
|
1502
|
+
# march the whole queue into DEFERRED one notification at a time
|
|
1503
|
+
# and end the run "finished" over a broken environment (#194/#343).
|
|
1504
|
+
self._pause(
|
|
1505
|
+
f"cannot spawn git while opening a worktree for {task.story_key}: {e}",
|
|
1506
|
+
task.story_key,
|
|
1507
|
+
cause=e,
|
|
1508
|
+
)
|
|
1509
|
+
except verify.GitError as e:
|
|
1510
|
+
# could not mount a worktree (e.g. branch_per=run with a kept-failed
|
|
1511
|
+
# unit still holding the shared branch). Defer this unit rather than
|
|
1512
|
+
# crash the whole run; the operator can free the branch and re-run.
|
|
1513
|
+
task.defer_reason = f"could not open worktree: {e}"
|
|
1514
|
+
task.phase = Phase.DEFERRED # deliberate: no legal move from PENDING
|
|
1515
|
+
self.journal.append("worktree-open-failed", story_key=task.story_key, error=str(e))
|
|
1516
|
+
gates.notify(
|
|
1517
|
+
self.policy, self.run_dir, f"worktree open failed: {task.story_key}", str(e)
|
|
1518
|
+
)
|
|
1519
|
+
self._save()
|
|
1520
|
+
return
|
|
1521
|
+
task.worktree_path = str(unit.path)
|
|
1522
|
+
self.journal.append(
|
|
1523
|
+
"worktree-opened", story_key=task.story_key, branch=unit.branch, path=str(unit.path)
|
|
1524
|
+
)
|
|
1525
|
+
task.branch = unit.branch
|
|
1526
|
+
# A worktree checks out tracked files only, but the froid-loop-* skill
|
|
1527
|
+
# trees + signal-hook config are typically gitignored, so they are absent
|
|
1528
|
+
# from the fresh checkout. Re-lay them into the worktree so the bundled
|
|
1529
|
+
# froid-loop-* skills are present and the Stop-signal hook fires. Also seed the loaded
|
|
1530
|
+
# adapters' gitignored MCP/CLI configs so isolated sessions can reach their
|
|
1531
|
+
# MCP server (seed_adapter_defaults) plus any extra project-listed paths.
|
|
1532
|
+
profiles = self.worktree_profiles()
|
|
1533
|
+
scm = self.policy.scm
|
|
1534
|
+
seeds: list[str] = []
|
|
1535
|
+
if scm.seed_adapter_defaults:
|
|
1536
|
+
for profile in profiles:
|
|
1537
|
+
seeds.extend(profile.seed_files)
|
|
1538
|
+
# The hook config is seeded from the SAME list that shields it. It was
|
|
1539
|
+
# only ever in the shield set (`provision_worktree`'s `patterns`), never
|
|
1540
|
+
# here, so whether it got seeded depended on a profile happening to name
|
|
1541
|
+
# it twice: claude's `seed_files` carries `.claude/settings.json`, which
|
|
1542
|
+
# is also its `config_path`, and codex's does not carry
|
|
1543
|
+
# `.codex/hooks.json` (#471).
|
|
1544
|
+
#
|
|
1545
|
+
# What seeding fixes, stated only as far as it is measured: the hook
|
|
1546
|
+
# step in `provision_worktree` creates and writes that config whether
|
|
1547
|
+
# or not it was seeded (`merge_hooks` on an absent file returns
|
|
1548
|
+
# changed=True), so froid-loop's OWN Stop hook registers either way.
|
|
1549
|
+
# What an unseeded worktree loses is the PROJECT's hook configuration —
|
|
1550
|
+
# the session runs against a file holding the relay registrations alone.
|
|
1551
|
+
# #471's reported stall is consistent with the CLI declining hooks from
|
|
1552
|
+
# a config it has not trusted (codex.toml's `first_run_note`), but that
|
|
1553
|
+
# mechanism is UNCONFIRMED and nothing here rests on it; the issue's own
|
|
1554
|
+
# stated mechanism — the file being absent — is false at this line.
|
|
1555
|
+
#
|
|
1556
|
+
# Deriving it from `config_path` rather than restating it per profile is
|
|
1557
|
+
# what keeps a future profile from regressing the same way. Hookless
|
|
1558
|
+
# profiles have no config to seed. The seed loop skips an occupied
|
|
1559
|
+
# destination, so a project that TRACKS this path keeps its checked-out
|
|
1560
|
+
# copy untouched.
|
|
1561
|
+
if not profile.hookless and profile.hooks.config_path:
|
|
1562
|
+
seeds.append(profile.hooks.config_path)
|
|
1563
|
+
seeds.extend(scm.worktree_seed)
|
|
1564
|
+
# the two orchestrator-owned artifacts a tracked-only checkout can leave
|
|
1565
|
+
# behind — each decides its own exclusions; see the methods.
|
|
1566
|
+
seeds.extend(self._ledger_seed(unit.path))
|
|
1567
|
+
seeds.extend(self._board_seed(unit.path))
|
|
1568
|
+
# plugins (e.g. the Unity engine) may prime an isolated checkout with
|
|
1569
|
+
# gitignored paths they need — e.g. an MCP-generated skill tree + client
|
|
1570
|
+
# config so the worktree's Editor MCP is reachable. Aggregate every loaded
|
|
1571
|
+
# plugin's declared seeds.
|
|
1572
|
+
seeds.extend(self._registry.seed_files())
|
|
1573
|
+
seed_files = list(dict.fromkeys(seeds)) # dedupe, preserve order
|
|
1574
|
+
seed_globs = self._registry.seed_globs()
|
|
1575
|
+
try:
|
|
1576
|
+
skipped_seeds = provision_worktree(
|
|
1577
|
+
unit.path,
|
|
1578
|
+
profiles,
|
|
1579
|
+
self.paths.repo_root,
|
|
1580
|
+
seed_files=seed_files,
|
|
1581
|
+
seed_globs=seed_globs,
|
|
1582
|
+
on_degraded=lambda msg: self._exclude_degraded(task.story_key, msg),
|
|
1583
|
+
)
|
|
1584
|
+
except verify.GitError as e:
|
|
1585
|
+
# Every provisioning refusal carries its own cause — an unresolvable
|
|
1586
|
+
# root, a config pin that could not be recorded, an unparseable seeded
|
|
1587
|
+
# hook config (#592) — so the wrapper names the unit and defers the
|
|
1588
|
+
# "why" to the inner message rather than asserting one of the three.
|
|
1589
|
+
reason = f"cannot safely provision the worktree for {task.story_key}: {e}"
|
|
1590
|
+
self.escalate_unit(task, reason) # always raises RunPaused
|
|
1591
|
+
if skipped_seeds:
|
|
1592
|
+
# A seed entry whose destination already exists is a no-op. Harmless for
|
|
1593
|
+
# a file the checkout legitimately carries, but a directory entry is
|
|
1594
|
+
# skipped WHOLE the moment any child is tracked — so a `worktree_seed`
|
|
1595
|
+
# that looks applied can be copying nothing. Journal it; provision is
|
|
1596
|
+
# quiet by contract (it runs under the TUI).
|
|
1597
|
+
self.journal.append(
|
|
1598
|
+
"worktree-seed-skipped", story_key=task.story_key, entries=skipped_seeds
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
# A dropped arbitrary seed is observable but not fatal. Unlike the two gates
|
|
1602
|
+
# below, froid-loop cannot know that a user/plugin config is required by the
|
|
1603
|
+
# session, and its usual trigger is a healthy shared/dotfile-managed config.
|
|
1604
|
+
# Hook config destinations cannot prove delivery because provisioning writes
|
|
1605
|
+
# the Stop registration itself after the seed step.
|
|
1606
|
+
undelivered_seeds = worktree_seed_undelivered(
|
|
1607
|
+
unit.path,
|
|
1608
|
+
self.paths.repo_root,
|
|
1609
|
+
seed_files=seed_files,
|
|
1610
|
+
seed_globs=seed_globs,
|
|
1611
|
+
config_paths=[p.hooks.config_path for p in profiles if not p.hookless],
|
|
1612
|
+
)
|
|
1613
|
+
if undelivered_seeds:
|
|
1614
|
+
self.journal.append(
|
|
1615
|
+
"worktree-seed-dropped", story_key=task.story_key, entries=undelivered_seeds
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
trees = [p.skill_tree for p in profiles]
|
|
1619
|
+
# The wheel's own bundled skills, journal-only like worktree-seed-dropped but
|
|
1620
|
+
# under their own kind so a user seed that spells a skill rel can neither forge
|
|
1621
|
+
# nor mask an entry. No MODULE_SKILLS entry has a worktree-resident consumer,
|
|
1622
|
+
# so an absence here cannot prove a stall and must never escalate.
|
|
1623
|
+
undelivered_module_skills = module_skills_seed_undelivered(unit.path, trees)
|
|
1624
|
+
if undelivered_module_skills:
|
|
1625
|
+
self.journal.append(
|
|
1626
|
+
"worktree-module-skills-dropped",
|
|
1627
|
+
story_key=task.story_key,
|
|
1628
|
+
entries=undelivered_module_skills,
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
# Missing upstream skill content is a determinate stall for both inline and
|
|
1632
|
+
# renderer-era primitives. Re-probe disk rather than trusting skipped_seeds,
|
|
1633
|
+
# which a user-authored seed rel could otherwise forge. Check this first so a
|
|
1634
|
+
# wholly absent primitive is not misdiagnosed as a renderer-surface problem.
|
|
1635
|
+
absent_skills = base_skills_seed_incomplete(unit.path, self.paths.repo_root, trees)
|
|
1636
|
+
if absent_skills:
|
|
1637
|
+
reason = (
|
|
1638
|
+
"the worktree is missing required upstream skill contract files the repo has "
|
|
1639
|
+
f"({', '.join(absent_skills)}) — the session would stall having "
|
|
1640
|
+
"written nothing: on `Unknown command` when the whole skill is "
|
|
1641
|
+
"absent, or at a required primitive marker or renderer source. "
|
|
1642
|
+
"The usual cause is a required skill directory or file symlinked "
|
|
1643
|
+
"to a shared Froid install outside the repo, which worktree seeding "
|
|
1644
|
+
"cannot follow"
|
|
1645
|
+
)
|
|
1646
|
+
self.escalate_unit(task, reason) # always raises RunPaused
|
|
1647
|
+
|
|
1648
|
+
# Stories mode has a stricter, content-keyed primitive contract than sprint
|
|
1649
|
+
# mode. Re-run that exact preflight against the mounted worktree: a step-01
|
|
1650
|
+
# through-link can pass in the main checkout yet be refused during copying,
|
|
1651
|
+
# while a tracked stale worktree copy proves that existence alone is not
|
|
1652
|
+
# enough. Either shape would HALT a folder+id dispatch before writing a spec.
|
|
1653
|
+
stories_support = (
|
|
1654
|
+
missing_stories_support(unit.path, trees) if self.state.source == "stories" else []
|
|
1655
|
+
)
|
|
1656
|
+
if stories_support:
|
|
1657
|
+
short_dispatch = []
|
|
1658
|
+
for finding in stories_support:
|
|
1659
|
+
detail = finding.detail or {}
|
|
1660
|
+
rel = f"{detail['tree']}/{detail['skill']}/{detail['file']}"
|
|
1661
|
+
marker = detail.get("marker")
|
|
1662
|
+
short_dispatch.append(f"{rel} (missing {marker!r})" if marker else rel)
|
|
1663
|
+
reason = (
|
|
1664
|
+
"the worktree's dev primitive does not satisfy stories-mode dispatch "
|
|
1665
|
+
f"support ({', '.join(short_dispatch)}) — the folder+id session would "
|
|
1666
|
+
"HALT without writing a spec. The usual cause is a required router "
|
|
1667
|
+
"file symlinked to a shared Froid install outside the repo, which "
|
|
1668
|
+
"worktree seeding cannot follow"
|
|
1669
|
+
)
|
|
1670
|
+
self.escalate_unit(task, reason) # always raises RunPaused
|
|
1671
|
+
|
|
1672
|
+
# Provisioning owns these exact sentinel strings, but only a content-confirmed
|
|
1673
|
+
# renderer stub consumes the surface. The conjunct is load-bearing: inline
|
|
1674
|
+
# pre-#2601 SKILL.md projects may carry the same repo paths and must proceed.
|
|
1675
|
+
short_surface = [rel for rel in RENDERER_SEED_SENTINELS if rel in skipped_seeds]
|
|
1676
|
+
if short_surface and renderer_stub_resolved(self.paths.project, trees):
|
|
1677
|
+
reason = (
|
|
1678
|
+
f"the dev primitive renders via {RENDERER_SCRIPT_MARKER} but the "
|
|
1679
|
+
"worktree's renderer surface came up short of the repo's "
|
|
1680
|
+
f"({', '.join(short_surface)}) — the session would HALT without "
|
|
1681
|
+
"writing a spec. The usual cause is a symlinked _froid/ pointing "
|
|
1682
|
+
"outside the repo, which worktree seeding cannot follow"
|
|
1683
|
+
)
|
|
1684
|
+
self.escalate_unit(task, reason) # always raises RunPaused
|
|
1685
|
+
|
|
1686
|
+
self._save()
|
|
1687
|
+
prev = self._workspace_get()
|
|
1688
|
+
self._workspace_set(unit.workspace)
|
|
1689
|
+
try:
|
|
1690
|
+
# A plugin (e.g. the Unity engine) may launch the unit's managed Editor
|
|
1691
|
+
# at pre_worktree_setup + wait for its MCP at pre_ready_gate before
|
|
1692
|
+
# driving. A veto (defer) at either stage leaves the task DEFERRED and
|
|
1693
|
+
# skips drive(); both fall through to _integrate_unit, which tears the
|
|
1694
|
+
# (empty) worktree down via the DEFERRED path.
|
|
1695
|
+
if self._gate_unit(task):
|
|
1696
|
+
self._emit("post_worktree_setup", task)
|
|
1697
|
+
drive(task)
|
|
1698
|
+
finally:
|
|
1699
|
+
# always run teardown — on success, on a deferral, and on a RunPaused
|
|
1700
|
+
# (spec gate / escalation) propagating through — before the workspace is
|
|
1701
|
+
# restored, so a managed Editor never outlives its worktree. Teardown
|
|
1702
|
+
# stages are observe-only (a veto here cannot un-tear-down).
|
|
1703
|
+
self._emit("pre_worktree_teardown", task)
|
|
1704
|
+
self._emit("post_worktree_teardown", task)
|
|
1705
|
+
self._workspace_set(prev)
|
|
1706
|
+
# reached only on a normal return (DONE or DEFERRED); a RunPaused from the
|
|
1707
|
+
# spec gate or an escalation propagates past here, leaving the worktree up.
|
|
1708
|
+
self.integrate_unit(task, unit)
|
|
1709
|
+
|
|
1710
|
+
def _exclude_degraded(self, story_key: str, msg: str) -> None:
|
|
1711
|
+
"""The git-add shield was owed for this unit's worktree and did not happen.
|
|
1712
|
+
|
|
1713
|
+
Journaled AND notified, the way `worktree-open-failed` is above. The notify
|
|
1714
|
+
is the half that was missing: the shield's whole degrade policy is to SKIP
|
|
1715
|
+
rather than widen — activating over patterns it could not copy would shadow
|
|
1716
|
+
the operator's own excludes — and skipping is only defensible if the operator
|
|
1717
|
+
finds out. A run that ends "finished" with a journal line nobody reads is how
|
|
1718
|
+
the provisioned tool files reach a story's merge unnoticed.
|
|
1719
|
+
|
|
1720
|
+
`gates.notify` is best-effort and never raises, and is inert unless
|
|
1721
|
+
`notify.file`/`notify.desktop` is configured, so this cannot break a run —
|
|
1722
|
+
which matters, because it is called from inside provisioning.
|
|
1723
|
+
"""
|
|
1724
|
+
self.journal.append("worktree-exclude-degraded", story_key=story_key, error=msg)
|
|
1725
|
+
gates.notify(self.policy, self.run_dir, f"worktree exclude degraded: {story_key}", msg)
|
|
1726
|
+
|
|
1727
|
+
def failed_diff_max_bytes(self) -> int | None:
|
|
1728
|
+
"""Per-untracked-file size cap for a failed unit's forensic patch, in
|
|
1729
|
+
bytes — or None when the operator lifted the cap (scm.failed_diff_unlimited)."""
|
|
1730
|
+
scm = self.policy.scm
|
|
1731
|
+
if scm.failed_diff_unlimited:
|
|
1732
|
+
return None
|
|
1733
|
+
return scm.failed_diff_max_mb * 1_048_576
|
|
1734
|
+
|
|
1735
|
+
def integrate_unit(self, task: StoryTask, unit: UnitWorkspace) -> None:
|
|
1736
|
+
self._emit("pre_integrate", task)
|
|
1737
|
+
scm = self.policy.scm
|
|
1738
|
+
# AWAITING_OPERATOR merges beside DONE: a parked story CARRIES A COMMIT
|
|
1739
|
+
# (that is what separates it from DEFERRED/ESCALATED), and stranding that
|
|
1740
|
+
# commit on a torn-down unit branch would lose finished work over an
|
|
1741
|
+
# obligation that lives outside the repo entirely. The human's remaining
|
|
1742
|
+
# actions are recorded in the registry, not in this worktree.
|
|
1743
|
+
if task.phase in (Phase.DONE, Phase.AWAITING_OPERATOR):
|
|
1744
|
+
# Merge the unit branch into the target branch locally. We open PRs
|
|
1745
|
+
# ourselves by hand once the branch has landed; the orchestrator only
|
|
1746
|
+
# commits the worktree onto the selected target.
|
|
1747
|
+
self.merge_local(task, unit)
|
|
1748
|
+
# Engine-authored ledger writes normally ride the unit commit, but a
|
|
1749
|
+
# gitignored ledger is omitted by finalize_commit's `git add -A` and
|
|
1750
|
+
# would otherwise disappear with successful teardown. Carry only after
|
|
1751
|
+
# merge: a tracked ledger reaches the target through the merge first,
|
|
1752
|
+
# where Engine's all-status provenance scan can deduplicate it.
|
|
1753
|
+
self._carry_isolated_ledger_writes(task)
|
|
1754
|
+
# Phase is already terminal and persisted before integration, so make
|
|
1755
|
+
# the carry completion durable here. Engine replays an unlatched carry
|
|
1756
|
+
# after a crash in the merge-to-carry window.
|
|
1757
|
+
task.isolated_ledger_carried = True
|
|
1758
|
+
self._save()
|
|
1759
|
+
else: # DEFERRED — capture the diff, keep or drop per keep_failed
|
|
1760
|
+
patch = close_unit_workspace(
|
|
1761
|
+
unit,
|
|
1762
|
+
success=False,
|
|
1763
|
+
keep_failed=scm.keep_failed,
|
|
1764
|
+
run_dir=self.run_dir,
|
|
1765
|
+
unit_key=task.story_key,
|
|
1766
|
+
delete_branch=scm.delete_branch,
|
|
1767
|
+
detach_kept=scm.branch_per == "run",
|
|
1768
|
+
diff_max_file_bytes=self.failed_diff_max_bytes(),
|
|
1769
|
+
on_teardown_degraded=lambda msg: self.journal.append(
|
|
1770
|
+
"worktree-teardown-degraded", story_key=task.story_key, error=msg
|
|
1771
|
+
),
|
|
1772
|
+
)
|
|
1773
|
+
self.journal.append(
|
|
1774
|
+
"unit-closed",
|
|
1775
|
+
story_key=task.story_key,
|
|
1776
|
+
branch=unit.branch,
|
|
1777
|
+
kept=scm.keep_failed,
|
|
1778
|
+
patch=str(patch) if patch else None,
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
def _carried_artifact_rels(self, repo: Path) -> tuple[str, ...]:
|
|
1782
|
+
"""The repo-relative posix paths the RUN commits for itself after the merge —
|
|
1783
|
+
``clean_incoming_collisions``' ``protected`` operand (#618).
|
|
1784
|
+
|
|
1785
|
+
The sprint board and the deferred-work ledger, because those are the two
|
|
1786
|
+
files the four post-merge carries name: ``_carry_harvested_deferrals``,
|
|
1787
|
+
``_carry_review_budget_followups`` and ``_carry_story_deferred_closes`` pass
|
|
1788
|
+
``paths.deferred_work`` and ``_carry_board_advance`` passes
|
|
1789
|
+
``paths.sprint_status``, all four to ``verify.commit_paths`` against this same
|
|
1790
|
+
``repo``. That call stages by PATHSPEC — `git add -- :(literal)<path>` — so
|
|
1791
|
+
whatever the working tree holds at that path is committed no matter who wrote
|
|
1792
|
+
it, and a merge that walked past an operator's edit there hands the run its
|
|
1793
|
+
own bytes to commit under a `chore(...)` message. The blast radius is strictly
|
|
1794
|
+
same-path (`git commit -- <pathspec>` is implicitly `--only`), which is why
|
|
1795
|
+
this is an exact path set and not a policy.
|
|
1796
|
+
|
|
1797
|
+
``self.paths``, not ``self.workspace.paths``: the carries read the MAIN
|
|
1798
|
+
checkout's copies (their docstrings say so explicitly), and this is the
|
|
1799
|
+
checkout the merge lands in.
|
|
1800
|
+
|
|
1801
|
+
Relativized exactly as ``commit_paths`` relativizes its own operands
|
|
1802
|
+
(``resolve().relative_to(repo.resolve()).as_posix()``) — that is what makes
|
|
1803
|
+
each entry the same string the carry will later hand ``git add``, and the same
|
|
1804
|
+
key shape ``verify.dirty_paths`` returns, so the guard's membership test is an
|
|
1805
|
+
equality it cannot get subtly wrong. Resolving is load-bearing rather than
|
|
1806
|
+
defensive: through a symlinked artifacts dir the unresolved rel names the LINK
|
|
1807
|
+
while both git and ``commit_paths`` name the target.
|
|
1808
|
+
|
|
1809
|
+
A path that cannot be expressed relative to ``repo`` is dropped, not raised
|
|
1810
|
+
on: it cannot be dirty in this checkout, and ``commit_paths`` filters the same
|
|
1811
|
+
``ValueError`` and so would never commit it either. ``_ledger_seed``'s
|
|
1812
|
+
three-way catch for the same reason — an unresolvable path (the WSL UNC
|
|
1813
|
+
provider fault, a symlink loop) omits only itself.
|
|
1814
|
+
|
|
1815
|
+
TRACKED ONLY, and that is the whole boundary of the hazard rather than a
|
|
1816
|
+
precaution. What makes the carry dangerous is committing a DIVERGENCE from a
|
|
1817
|
+
baseline somebody else authored: on a tracked board, an operator's local
|
|
1818
|
+
reopen of a story row rides out under `chore(sprint-status): carry ...` with
|
|
1819
|
+
the tree left clean and nothing to read it back from. An UNTRACKED artifact
|
|
1820
|
+
has no such baseline — git reports the whole file as dirt because git has
|
|
1821
|
+
never seen it, the orchestrator has been reading that exact file as its own
|
|
1822
|
+
all along, and committing it is how a non-ignored board first reaches git at
|
|
1823
|
+
all (#350's carry). Protecting it would refuse the merge on EVERY isolated run
|
|
1824
|
+
of any project that has yet to commit its board — measured: an untracked,
|
|
1825
|
+
non-ignored board with no operator dirt anywhere ends the run
|
|
1826
|
+
`done=0 paused=True escalated=1` — which is the unattended-halt class #460 and
|
|
1827
|
+
#618 exist to remove, in exchange for a "hazard" that loses nothing (the bytes
|
|
1828
|
+
are committed, not overwritten). A gitignored artifact never reaches the
|
|
1829
|
+
question: ``dirty_paths`` does not report ignored files and ``git add`` refuses
|
|
1830
|
+
an ignored pathspec, so the carry degrades instead of committing.
|
|
1831
|
+
|
|
1832
|
+
A trackedness probe that cannot answer keeps the path, the direction
|
|
1833
|
+
``path_tracked``'s own callers degrade in: uncertainty must not be what
|
|
1834
|
+
authorizes writing an operator's bytes into the run's commit. The cost of
|
|
1835
|
+
being wrong that way is a refusal the operator can act on; the other way it is
|
|
1836
|
+
silent.
|
|
1837
|
+
"""
|
|
1838
|
+
rels: list[str] = []
|
|
1839
|
+
for artifact in (self.paths.sprint_status, self.paths.deferred_work):
|
|
1840
|
+
try:
|
|
1841
|
+
rel = artifact.resolve().relative_to(repo.resolve()).as_posix()
|
|
1842
|
+
except (OSError, RuntimeError, ValueError):
|
|
1843
|
+
continue
|
|
1844
|
+
try:
|
|
1845
|
+
tracked = verify.path_tracked(repo, rel)
|
|
1846
|
+
except verify.GitError:
|
|
1847
|
+
tracked = True
|
|
1848
|
+
if tracked:
|
|
1849
|
+
rels.append(rel)
|
|
1850
|
+
return tuple(rels)
|
|
1851
|
+
|
|
1852
|
+
def merge_local(
|
|
1853
|
+
self,
|
|
1854
|
+
task: StoryTask,
|
|
1855
|
+
unit: UnitWorkspace,
|
|
1856
|
+
*,
|
|
1857
|
+
replay: bool = False,
|
|
1858
|
+
replay_strategy: str | None = None,
|
|
1859
|
+
) -> None:
|
|
1860
|
+
"""Merge a DONE unit's branch into the target branch from the main repo."""
|
|
1861
|
+
if not replay:
|
|
1862
|
+
self._emit("pre_merge", task)
|
|
1863
|
+
scm = self.policy.scm
|
|
1864
|
+
merge_strategy = scm.merge_strategy if replay_strategy is None else replay_strategy
|
|
1865
|
+
repo = self.paths.repo_root
|
|
1866
|
+
target = self.state.target_branch
|
|
1867
|
+
source = task.commit_sha or verify.rev_parse_head(unit.path)
|
|
1868
|
+
merge_ref = unit.branch
|
|
1869
|
+
if replay:
|
|
1870
|
+
current_source = verify.rev_parse_head(unit.path)
|
|
1871
|
+
if current_source != source:
|
|
1872
|
+
reason = (
|
|
1873
|
+
f"merge replay of {unit.branch} into {target} blocked: the unit "
|
|
1874
|
+
f"branch advanced from recorded source {source} to {current_source}; "
|
|
1875
|
+
"the later commits were not produced or verified by the completed "
|
|
1876
|
+
"session and the branch was preserved for manual recovery"
|
|
1877
|
+
)
|
|
1878
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
1879
|
+
return
|
|
1880
|
+
# Pin both the collision allowlist and the merge operand to the
|
|
1881
|
+
# write-ahead SHA. The branch can move after the check; replay must
|
|
1882
|
+
# integrate only the commit the completed session actually proved.
|
|
1883
|
+
merge_ref = source
|
|
1884
|
+
# A per_worktree Unity Editor can leak asset writes into the *main*
|
|
1885
|
+
# checkout (see the unity plugin's worktree setup), dirtying the target with the very
|
|
1886
|
+
# files this branch already committed. Reconcile that first: clean only
|
|
1887
|
+
# the leaked copies of incoming files; nothing outside this branch's path set is
|
|
1888
|
+
# ever touched. Outside it two questions decide whether the merge proceeds. What
|
|
1889
|
+
# the MERGE can commit is what git has staged (#618), so an unstaged stray is
|
|
1890
|
+
# inert and is tolerated and journaled while a staged one escalates. What the RUN
|
|
1891
|
+
# can commit is the second question, and `protected` is what asks it: the
|
|
1892
|
+
# post-merge carry stages the board and the ledger BY PATHSPEC, so any dirt on
|
|
1893
|
+
# them — staged or not, whoever wrote it — would ride the run's own bookkeeping
|
|
1894
|
+
# commit. Inert-under-merge and safe-to-proceed are not the same predicate.
|
|
1895
|
+
tolerated: list[str] = []
|
|
1896
|
+
|
|
1897
|
+
def note_tolerated(paths: list[str]) -> None:
|
|
1898
|
+
"""Journal the guard's decision AND keep the paths for the arms below.
|
|
1899
|
+
|
|
1900
|
+
The event stays here, before the merge, because it records what the
|
|
1901
|
+
GUARD decided; emitting it only on success would lose the trace in
|
|
1902
|
+
exactly the run worth debugging. But "tolerated" is a claim about the
|
|
1903
|
+
path SET, and a stray outside that set by path can still clash with it
|
|
1904
|
+
structurally — a file where the merge needs a directory, or the reverse
|
|
1905
|
+
— so git may refuse the merge over a path this event just called
|
|
1906
|
+
harmless. Holding the list lets the pre-flight arm correct the record
|
|
1907
|
+
instead of leaving it asserting the run merged past a path that in fact
|
|
1908
|
+
stopped it (#623).
|
|
1909
|
+
"""
|
|
1910
|
+
tolerated.extend(paths)
|
|
1911
|
+
self.journal.append(
|
|
1912
|
+
"merge-target-tolerated",
|
|
1913
|
+
story_key=task.story_key,
|
|
1914
|
+
branch=unit.branch,
|
|
1915
|
+
paths=paths,
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
try:
|
|
1919
|
+
cleaned = verify.clean_incoming_collisions(
|
|
1920
|
+
repo,
|
|
1921
|
+
target,
|
|
1922
|
+
merge_ref,
|
|
1923
|
+
protected=self._carried_artifact_rels(repo),
|
|
1924
|
+
on_tolerated=note_tolerated,
|
|
1925
|
+
)
|
|
1926
|
+
except (verify.GitError, OSError, RuntimeError) as e:
|
|
1927
|
+
# OSError/RuntimeError join GitError because clean_incoming_collisions
|
|
1928
|
+
# mutates the checkout directly (resolve/unlink/iterdir/rmdir) — non-spawn
|
|
1929
|
+
# FS faults the #343 chokepoint cannot translate. Crashing here would
|
|
1930
|
+
# strand a DONE unit mid-merge; keep-branch escalation is this boundary.
|
|
1931
|
+
if isinstance(e, (verify.GitSpawnError, OSError, RuntimeError)):
|
|
1932
|
+
# environment fault (spawn failure or direct-FS error) — there may
|
|
1933
|
+
# be no stray files at all, so no "clean them" guidance: the inner
|
|
1934
|
+
# error is the diagnosis.
|
|
1935
|
+
reason = (
|
|
1936
|
+
f"merge of {unit.branch} into {target} blocked: could not "
|
|
1937
|
+
f"reconcile the target checkout ({e}) — fix the underlying "
|
|
1938
|
+
f"fault, then `froid-loop resume {self.state.run_id}`"
|
|
1939
|
+
)
|
|
1940
|
+
else:
|
|
1941
|
+
# The outer sentence names the HAZARD, never the mechanism: since #618
|
|
1942
|
+
# there are two, and only the inner clause knows which applies to which
|
|
1943
|
+
# path. Saying "a merge or squash would fold them" out here attributed
|
|
1944
|
+
# every refusal to the merge, including one raised because the run's own
|
|
1945
|
+
# post-merge carry would sweep a path it commits for itself.
|
|
1946
|
+
reason = (
|
|
1947
|
+
f"merge of {unit.branch} into {target} blocked: the target checkout has "
|
|
1948
|
+
f"uncommitted changes to tracked files outside this branch that this run "
|
|
1949
|
+
f"could commit under the story's name — the clause below names the paths, "
|
|
1950
|
+
f"the mechanism, and what each one needs. Commit, stash or revert them, "
|
|
1951
|
+
f"then `froid-loop resume {self.state.run_id}`. One cause is a "
|
|
1952
|
+
f"per_worktree engine Editor writing into the main checkout; another is "
|
|
1953
|
+
f"ordinary local work. {e}"
|
|
1954
|
+
)
|
|
1955
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
1956
|
+
return
|
|
1957
|
+
if cleaned:
|
|
1958
|
+
self.journal.append(
|
|
1959
|
+
"merge-target-cleaned",
|
|
1960
|
+
story_key=task.story_key,
|
|
1961
|
+
branch=unit.branch,
|
|
1962
|
+
paths=cleaned,
|
|
1963
|
+
)
|
|
1964
|
+
if not replay:
|
|
1965
|
+
# The task is already terminal and durable here. Record integration
|
|
1966
|
+
# intent immediately before git so a host loss after merge success but
|
|
1967
|
+
# before `unit-merged` can safely re-run the merge instead of losing a
|
|
1968
|
+
# gitignored ledger when the stale worktree is reclaimed.
|
|
1969
|
+
self.journal.append(
|
|
1970
|
+
"unit-merge-started",
|
|
1971
|
+
story_key=task.story_key,
|
|
1972
|
+
branch=unit.branch,
|
|
1973
|
+
target=target,
|
|
1974
|
+
strategy=merge_strategy,
|
|
1975
|
+
source=source,
|
|
1976
|
+
)
|
|
1977
|
+
try:
|
|
1978
|
+
verify.merge_branch(
|
|
1979
|
+
repo,
|
|
1980
|
+
merge_ref,
|
|
1981
|
+
strategy=merge_strategy,
|
|
1982
|
+
message=self.merge_message(task),
|
|
1983
|
+
allow_empty_squash=replay,
|
|
1984
|
+
)
|
|
1985
|
+
except verify.MergePreflightError as e:
|
|
1986
|
+
# Subclass arm, so it must precede the GitError one below. git declined
|
|
1987
|
+
# before the merge began: nothing was merged and the target checkout is
|
|
1988
|
+
# exactly as it was. Describe that STATE rather than prescribing one
|
|
1989
|
+
# remedy — the same refusal covers an untracked file the merge would
|
|
1990
|
+
# overwrite, a staged change on an incoming path, a file/directory shape
|
|
1991
|
+
# clash, and a target that cannot fast-forward — and let the appended raw
|
|
1992
|
+
# git error name the cause and the paths (#619).
|
|
1993
|
+
reason = (
|
|
1994
|
+
f"merge of {unit.branch} into {target} was refused by git before it "
|
|
1995
|
+
f"started: nothing was merged, the target checkout is unchanged, and "
|
|
1996
|
+
f"there is no conflict to resolve. The target's state clashes with the "
|
|
1997
|
+
f"incoming commit; git's own message below names the cause and the "
|
|
1998
|
+
f"paths. Clear that clash, then `froid-loop resume {self.state.run_id}`. "
|
|
1999
|
+
f"{e}"
|
|
2000
|
+
)
|
|
2001
|
+
if tolerated:
|
|
2002
|
+
# Corrective, not duplicative: only this arm knows the merge died at
|
|
2003
|
+
# git's pre-flight, and only the callback above knows which paths the
|
|
2004
|
+
# guard waved through. One of them may be the cause — git's text names
|
|
2005
|
+
# it — so pair the two rather than making either infer the other. Rides
|
|
2006
|
+
# phase 1's typed error: one discriminator, two consumers (#623).
|
|
2007
|
+
self.journal.append(
|
|
2008
|
+
"merge-preflight-refused",
|
|
2009
|
+
story_key=task.story_key,
|
|
2010
|
+
branch=unit.branch,
|
|
2011
|
+
tolerated=tolerated,
|
|
2012
|
+
error=str(e),
|
|
2013
|
+
)
|
|
2014
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2015
|
+
return # defensive: never fall through to the success teardown below
|
|
2016
|
+
except verify.MergeHalfAppliedError as e:
|
|
2017
|
+
# Sibling of the pre-flight arm above, not a subclass of it, so it is a
|
|
2018
|
+
# separate arm rather than a branch inside one — and like its neighbours it
|
|
2019
|
+
# must precede the bare `GitError` arm. git died PART-WAY through the
|
|
2020
|
+
# checkout: the pre-flight sentence above is false in its load-bearing
|
|
2021
|
+
# clause ("the target checkout is unchanged"), because the incoming files
|
|
2022
|
+
# git had already written are still there, untracked. Naming them is the
|
|
2023
|
+
# whole point — they are what makes the next attempt fail, as an
|
|
2024
|
+
# untracked-overwrite refusal over paths no earlier message mentioned.
|
|
2025
|
+
#
|
|
2026
|
+
# No `merge-preflight-refused` companion here even when `tolerated` is set:
|
|
2027
|
+
# that event is the #623 corrective for a guard that called a path harmless
|
|
2028
|
+
# and then watched git refuse over that same path. This failure is not about
|
|
2029
|
+
# the tolerated paths at all — it is incoming content failing to check out —
|
|
2030
|
+
# so pairing it with the guard's decision would assert a link that is not there.
|
|
2031
|
+
# Two residue axes, two different asks, and the operator needs whichever
|
|
2032
|
+
# ones actually apply — so the middle of this message is composed rather
|
|
2033
|
+
# than picked from a fixed set. An incoming path the target did not track
|
|
2034
|
+
# lands untracked and no restore reaches it, so it is theirs to clear; one
|
|
2035
|
+
# it DID track was modified in place and `merge_branch` has already
|
|
2036
|
+
# restored it path-scoped, unless that restore failed too.
|
|
2037
|
+
# The restore clause LEADS when both apply: it says "before anything
|
|
2038
|
+
# else" and means it — a resume dies on the tracked residue first —
|
|
2039
|
+
# so the untracked clause defers to it ("then") rather than both
|
|
2040
|
+
# claiming first place in one message.
|
|
2041
|
+
# The prescription is path-scoped for the reason the restore itself is:
|
|
2042
|
+
# only the named paths are proven git's, and a repo-wide
|
|
2043
|
+
# `git reset --hard HEAD` would flatten the operator's own uncommitted
|
|
2044
|
+
# work alongside them — the destruction the per-path attribution exists
|
|
2045
|
+
# to prevent.
|
|
2046
|
+
steps: list[str] = []
|
|
2047
|
+
if not e.restored:
|
|
2048
|
+
rewritten = (
|
|
2049
|
+
" ".join(e.rewritten) if e.rewritten else "<the paths git's message names>"
|
|
2050
|
+
)
|
|
2051
|
+
steps.append(
|
|
2052
|
+
f"The tracked files git had already rewritten could NOT be rolled "
|
|
2053
|
+
f"back — that failure is in the message below too — so {target} is "
|
|
2054
|
+
f"still holding incoming content on those paths. Restore exactly "
|
|
2055
|
+
f"those paths (`git checkout HEAD -- {rewritten}` in {target}, "
|
|
2056
|
+
f"never a repo-wide `git reset --hard`, which would flatten your "
|
|
2057
|
+
f"own uncommitted work too) before anything else."
|
|
2058
|
+
)
|
|
2059
|
+
if e.paths:
|
|
2060
|
+
steps.append(
|
|
2061
|
+
f"Some incoming files are left UNTRACKED in the checkout and no "
|
|
2062
|
+
f"restore removes them — not `git merge --abort`, not "
|
|
2063
|
+
f"`git reset --hard`: {', '.join(e.paths)}. "
|
|
2064
|
+
+ ("Clear those first, " if e.restored else "Then clear those, ")
|
|
2065
|
+
+ "checking the contents before you delete: the run can prove git "
|
|
2066
|
+
"wrote each path, not that the bytes now there are git's."
|
|
2067
|
+
)
|
|
2068
|
+
if e.restored and not e.paths:
|
|
2069
|
+
steps.append(
|
|
2070
|
+
"The tracked files git had already rewritten have been rolled "
|
|
2071
|
+
"back, so the checkout itself needs nothing from you."
|
|
2072
|
+
)
|
|
2073
|
+
reason = (
|
|
2074
|
+
f"merge of {unit.branch} into {target} failed PART-WAY THROUGH: git got "
|
|
2075
|
+
f"far enough to start writing the incoming files into {target}'s "
|
|
2076
|
+
f"checkout before it stopped, so nothing was committed and this is not a "
|
|
2077
|
+
f"clash between the target and the incoming commit. "
|
|
2078
|
+
+ " ".join(steps)
|
|
2079
|
+
+ f" Whatever residue is left refuses the NEXT attempt over those same "
|
|
2080
|
+
f"paths rather than with the error below, which is why a resume before "
|
|
2081
|
+
f"clearing it fails on the tree instead of on the cause. Then fix what "
|
|
2082
|
+
f"stopped the checkout — git's own message below names it, and a "
|
|
2083
|
+
f"required clean/smudge filter that cannot run is the measured cause — "
|
|
2084
|
+
f"and `froid-loop resume {self.state.run_id}`. {e}"
|
|
2085
|
+
)
|
|
2086
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2087
|
+
return # defensive: never fall through to the success teardown below
|
|
2088
|
+
except verify.MergeResidueUnreadError as e:
|
|
2089
|
+
# The terminal arm's caller-side half, and another sibling that must
|
|
2090
|
+
# precede the bare `GitError` arm. Neither neighbour's sentence can be
|
|
2091
|
+
# borrowed: the pre-flight arm's "the target checkout is unchanged" is
|
|
2092
|
+
# exactly the claim the dead probe can no longer back, and the
|
|
2093
|
+
# half-applied arm names residue this run never read. Say what IS known
|
|
2094
|
+
# — the merge failed, git's text below names why — and send the operator
|
|
2095
|
+
# to the one reading the run could not take, which their own `git
|
|
2096
|
+
# status` still can.
|
|
2097
|
+
reason = (
|
|
2098
|
+
f"merge of {unit.branch} into {target} failed, AND the after-the-fact "
|
|
2099
|
+
f"probe that verifies the checkout failed too, so whether {target}'s "
|
|
2100
|
+
f"checkout still holds incoming residue is UNVERIFIED. Run `git "
|
|
2101
|
+
f"status` in {target}: clear any residue it names that is not yours "
|
|
2102
|
+
f"(files from {unit.branch} left untracked or rewritten), fix what "
|
|
2103
|
+
f"stopped the merge — git's message below names it — then "
|
|
2104
|
+
f"`froid-loop resume {self.state.run_id}`. {e}"
|
|
2105
|
+
)
|
|
2106
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2107
|
+
return # defensive: never fall through to the success teardown below
|
|
2108
|
+
except verify.MergeCommitRefusedError as e:
|
|
2109
|
+
# Sibling of the arm above and equally a GitError subclass, so it too must
|
|
2110
|
+
# precede the last arm. Neither neighbour's remedy applies here: the merge
|
|
2111
|
+
# RAN and resolved, so there is no target-state clash to clear, and it
|
|
2112
|
+
# resolved cleanly, so there is no conflict to resolve either. `merge_branch`
|
|
2113
|
+
# rolled it back — `merge --abort` on the `--no-ff` leg, `reset --hard` on
|
|
2114
|
+
# the squash leg's own commit — so the checkout is back where it started and
|
|
2115
|
+
# the operator is sent to the policy that declined the commit (#619). The
|
|
2116
|
+
# unrestored wording branches on WHERE the checkout stands, because the two
|
|
2117
|
+
# legs strand differently and the first step differs with them.
|
|
2118
|
+
if e.restored:
|
|
2119
|
+
reason = (
|
|
2120
|
+
f"merge of {unit.branch} into {target} resolved cleanly, but git "
|
|
2121
|
+
f"refused to COMMIT it, so the merge was rolled back and the target "
|
|
2122
|
+
f"checkout is back as it was. There is no conflict to resolve and "
|
|
2123
|
+
f"nothing to clear from the tree: a `pre-merge-commit` or "
|
|
2124
|
+
f"`commit-msg` hook, or commit signing, declined it — git's own "
|
|
2125
|
+
f"message below names which. Fix that, then "
|
|
2126
|
+
f"`froid-loop resume {self.state.run_id}`. {e}"
|
|
2127
|
+
)
|
|
2128
|
+
elif e.staged:
|
|
2129
|
+
# The squash leg's strand: no MERGE_HEAD exists, so "recover the
|
|
2130
|
+
# merge" would be fiction — the squash result is sitting STAGED,
|
|
2131
|
+
# either because the rollback failed or because the checkout also
|
|
2132
|
+
# carries the operator's own uncommitted work, which the rollback
|
|
2133
|
+
# refuses to flatten. The exception's text says which.
|
|
2134
|
+
reason = (
|
|
2135
|
+
f"merge of {unit.branch} into {target} resolved cleanly, git "
|
|
2136
|
+
f"refused to COMMIT it, and the squash result is left STAGED in "
|
|
2137
|
+
f"{target}'s checkout — the message below says why it was not "
|
|
2138
|
+
f"rolled back for you. Stash or commit anything in {target} that "
|
|
2139
|
+
f"is yours, then clear the staged result "
|
|
2140
|
+
f"(`git reset --hard HEAD` in {target}). Only then fix whatever "
|
|
2141
|
+
f"declined the commit: a `pre-merge-commit` or `commit-msg` hook, "
|
|
2142
|
+
f"or commit signing. Then `froid-loop resume {self.state.run_id}`. "
|
|
2143
|
+
f"{e}"
|
|
2144
|
+
)
|
|
2145
|
+
else:
|
|
2146
|
+
# The abort failed too, so the restored sentence would be a lie about
|
|
2147
|
+
# the one thing the operator has to act on FIRST: a resume attempted
|
|
2148
|
+
# over a mid-merge checkout dies on the merge state, not on the
|
|
2149
|
+
# policy, and keeps doing so however well they fix the hook.
|
|
2150
|
+
reason = (
|
|
2151
|
+
f"merge of {unit.branch} into {target} resolved cleanly, git refused "
|
|
2152
|
+
f"to COMMIT it, and the abort meant to undo that failed as well — so "
|
|
2153
|
+
f"the target checkout is left MID-MERGE and has to be recovered "
|
|
2154
|
+
f"first (`git merge --abort`, or reset it to the pre-merge commit). "
|
|
2155
|
+
f"Only then fix whatever declined the commit: a `pre-merge-commit` "
|
|
2156
|
+
f"or `commit-msg` hook, or commit signing. git's own message below "
|
|
2157
|
+
f"names both failures. Then `froid-loop resume {self.state.run_id}`. "
|
|
2158
|
+
f"{e}"
|
|
2159
|
+
)
|
|
2160
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2161
|
+
return # defensive: never fall through to the success teardown below
|
|
2162
|
+
except verify.MergeConflictError as e:
|
|
2163
|
+
# genuine content conflict against the target, measured (unmerged index
|
|
2164
|
+
# stages): keep the branch for manual merge. The unit committed cleanly
|
|
2165
|
+
# (phase is already DONE, which has no legal transition), so escalate
|
|
2166
|
+
# directly.
|
|
2167
|
+
reason = (
|
|
2168
|
+
f"merge of {unit.branch} into {target} failed "
|
|
2169
|
+
f"(content conflict against the target): resolve it by hand, then "
|
|
2170
|
+
f"`froid-loop resume {self.state.run_id}`. {e}"
|
|
2171
|
+
)
|
|
2172
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2173
|
+
return # defensive: never fall through to the success teardown below
|
|
2174
|
+
except verify.GitError as e:
|
|
2175
|
+
# Every state the classification MEASURED has a typed arm above, so a
|
|
2176
|
+
# bare GitError is a state nothing measured. This arm used to claim the
|
|
2177
|
+
# most specific diagnosis — "content conflict, resolve it by hand" — for
|
|
2178
|
+
# exactly the failures it knew least about, which is how six mislabeled
|
|
2179
|
+
# git states in a row reached the operator wearing a fictional remedy
|
|
2180
|
+
# (#619). It now claims only what it knows: the merge failed, the run
|
|
2181
|
+
# cannot say what state the checkout is in, and git's text names the
|
|
2182
|
+
# cause. An unforeseen shape lands here as a vague-but-true message
|
|
2183
|
+
# rather than a precise fiction.
|
|
2184
|
+
reason = (
|
|
2185
|
+
f"merge of {unit.branch} into {target} failed, and the failure was "
|
|
2186
|
+
f"not classified: the run cannot say what state {target}'s checkout "
|
|
2187
|
+
f"is in. Run `git status` in {target} and put the checkout right — "
|
|
2188
|
+
f"git's own message below names the cause — then "
|
|
2189
|
+
f"`froid-loop resume {self.state.run_id}`. {e}"
|
|
2190
|
+
)
|
|
2191
|
+
self.keep_branch_and_escalate(task, unit, reason) # always raises RunPaused
|
|
2192
|
+
return # defensive: never fall through to the success teardown below
|
|
2193
|
+
self.journal.append(
|
|
2194
|
+
"unit-merged",
|
|
2195
|
+
story_key=task.story_key,
|
|
2196
|
+
branch=unit.branch,
|
|
2197
|
+
target=self.state.target_branch,
|
|
2198
|
+
strategy=merge_strategy,
|
|
2199
|
+
source=source,
|
|
2200
|
+
)
|
|
2201
|
+
self._emit("post_merge", task)
|
|
2202
|
+
close_unit_workspace(
|
|
2203
|
+
unit,
|
|
2204
|
+
success=True,
|
|
2205
|
+
keep_failed=scm.keep_failed,
|
|
2206
|
+
run_dir=self.run_dir,
|
|
2207
|
+
unit_key=task.story_key,
|
|
2208
|
+
delete_branch=scm.delete_branch,
|
|
2209
|
+
on_teardown_degraded=lambda msg: self.journal.append(
|
|
2210
|
+
"worktree-teardown-degraded", story_key=task.story_key, error=msg
|
|
2211
|
+
),
|
|
2212
|
+
)
|
|
2213
|
+
|
|
2214
|
+
def keep_branch_and_escalate(self, task: StoryTask, unit: UnitWorkspace, reason: str) -> None:
|
|
2215
|
+
"""Preserve a DONE unit's branch (no delete, kept for manual merge) and
|
|
2216
|
+
escalate. Shared by every merge-back failure path: a target dirtied with
|
|
2217
|
+
stray work, a merge git refused at pre-flight, a merge that died part-way
|
|
2218
|
+
through its checkout, a merge whose COMMIT git refused, a genuine content
|
|
2219
|
+
conflict, and a failure nothing classified."""
|
|
2220
|
+
close_unit_workspace(
|
|
2221
|
+
unit,
|
|
2222
|
+
success=False,
|
|
2223
|
+
keep_failed=True,
|
|
2224
|
+
run_dir=self.run_dir,
|
|
2225
|
+
unit_key=task.story_key,
|
|
2226
|
+
delete_branch=False,
|
|
2227
|
+
diff_max_file_bytes=self.failed_diff_max_bytes(),
|
|
2228
|
+
)
|
|
2229
|
+
self.escalate_unit(task, reason) # always raises RunPaused
|
|
2230
|
+
|
|
2231
|
+
def escalate_unit(self, task: StoryTask, reason: str) -> None:
|
|
2232
|
+
"""Mark a unit ESCALATED, notify, and pause the run.
|
|
2233
|
+
|
|
2234
|
+
Callers escalate outside a legal transition, before dispatch or after a
|
|
2235
|
+
completed merge attempt, so the phase is set directly rather than advanced.
|
|
2236
|
+
"""
|
|
2237
|
+
task.phase = Phase.ESCALATED
|
|
2238
|
+
self.journal.append("story-escalated", story_key=task.story_key, reason=reason)
|
|
2239
|
+
gates.notify(
|
|
2240
|
+
self.policy,
|
|
2241
|
+
self.run_dir,
|
|
2242
|
+
f"CRITICAL escalation: {task.story_key}",
|
|
2243
|
+
f"{reason} — resolve, then `froid-loop resume {self.state.run_id}`",
|
|
2244
|
+
)
|
|
2245
|
+
self._save()
|
|
2246
|
+
self._pause(reason, task.story_key)
|
|
2247
|
+
|
|
2248
|
+
def merge_message(self, task: StoryTask) -> str:
|
|
2249
|
+
return f"Merge {task.branch} into {self.state.target_branch} (froid-loop)"
|
|
2250
|
+
|
|
2251
|
+
def gc_run_worktrees(self) -> None:
|
|
2252
|
+
"""Reclaim this run's worktree scaffolding once it finishes cleanly.
|
|
2253
|
+
|
|
2254
|
+
DONE units drop their worktree at merge time; this is a safety net for a
|
|
2255
|
+
worktree leaked by a crash between merge and teardown, plus it prunes
|
|
2256
|
+
stale git admin entries and removes the now-empty run worktree dir.
|
|
2257
|
+
Worktrees deliberately kept for inspection (a kept-failed/escalated unit)
|
|
2258
|
+
are left in place and journaled so the operator can find them."""
|
|
2259
|
+
if not self.isolated:
|
|
2260
|
+
return
|
|
2261
|
+
repo = self.paths.repo_root
|
|
2262
|
+
for task in self.state.tasks.values():
|
|
2263
|
+
if task.phase == Phase.DONE and task.worktree_path:
|
|
2264
|
+
wt = Path(task.worktree_path)
|
|
2265
|
+
if wt.is_dir():
|
|
2266
|
+
discard_worktree(repo, task.worktree_path, task.branch, run_dir=self.run_dir)
|
|
2267
|
+
elif task.terminal and task.worktree_path and Path(task.worktree_path).is_dir():
|
|
2268
|
+
# kept on purpose (keep_failed): leave it, but surface where.
|
|
2269
|
+
self.journal.append(
|
|
2270
|
+
"worktree-kept", story_key=task.story_key, path=task.worktree_path
|
|
2271
|
+
)
|
|
2272
|
+
verify.worktree_prune(repo)
|
|
2273
|
+
worktrees_parent = unit_worktrees_dir(self.run_dir)
|
|
2274
|
+
if worktrees_parent.is_dir() and not any(worktrees_parent.iterdir()):
|
|
2275
|
+
worktrees_parent.rmdir()
|
|
2276
|
+
|
|
2277
|
+
def reopen_unit(self, task: StoryTask) -> UnitWorkspace:
|
|
2278
|
+
"""Reconstruct the UnitWorkspace for an in-flight unit on resume, from
|
|
2279
|
+
the worktree path + branch persisted on the task. The worktree must still
|
|
2280
|
+
be mounted — if it was pruned out from under us we cannot safely reuse it,
|
|
2281
|
+
so escalate rather than run a session in a missing directory."""
|
|
2282
|
+
wt = Path(task.worktree_path)
|
|
2283
|
+
if not wt.is_dir():
|
|
2284
|
+
self.escalate_unit(
|
|
2285
|
+
task,
|
|
2286
|
+
f"worktree for {task.story_key} is gone ({wt}); cannot resume in place",
|
|
2287
|
+
)
|
|
2288
|
+
# Spec paths are persisted relative to the worktree (model.to_dict) so
|
|
2289
|
+
# state stays portable; re-absolutize both accepted/result ownership and
|
|
2290
|
+
# the current/last attempt's dispatch ownership against the reopened tree.
|
|
2291
|
+
# Absolute outside-worktree paths pass through unchanged. The rule itself
|
|
2292
|
+
# lives on the class that creates the relative spelling, so this and
|
|
2293
|
+
# `Engine._finish_inflight`'s pre-discard re-anchor cannot drift apart.
|
|
2294
|
+
task.rebase_spec_paths_on(wt)
|
|
2295
|
+
return UnitWorkspace(
|
|
2296
|
+
workspace=Workspace(root=wt, paths=self.paths.rebased(wt)),
|
|
2297
|
+
repo_root=self.paths.repo_root,
|
|
2298
|
+
branch=task.branch,
|
|
2299
|
+
path=wt,
|
|
2300
|
+
baseline=task.baseline_commit or "",
|
|
2301
|
+
)
|