Skip to content

Commit fe11cfa

Browse files
authored
Merge pull request #99 from eduralph/feat/94-worktree-isolation
feat(driver): isolate Do/Check in a per-cycle git worktree
2 parents 414a23e + fadf2b8 commit fe11cfa

8 files changed

Lines changed: 299 additions & 4 deletions

File tree

docs/04-do.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ The narrow `--allowedTools` is deliberate: the builder may read, edit, and run
2323
git/python — it cannot, say, open a PR. STOP discipline ([step 03](03-plan.md))
2424
is enforced by what the leaf *can't do*, not just by instruction.
2525

26+
### Isolated in a worktree (issue #94)
27+
28+
Do (and Check's gates) run against a dedicated **git worktree** off the target's
29+
base, not the host's primary checkout — so a cycle never leaves the live checkout
30+
dirty or collides with your own work there. The harness creates/resets it per cycle
31+
(per lane slot) and exposes its path as **`$PDCA_WORKTREE`**: the builder is granted
32+
access to it automatically, and bundle-scoped gate commands target it too. On by
33+
default (`[driver].worktree`); best-effort — a target that isn't a worktree-capable
34+
git checkout falls back to editing in place. (This isolation is what publish's
35+
stash/restore worked around before; with it, serial cycles get the same clean-tree
36+
guarantee lanes already had.)
37+
2638
## What Do produces
2739

2840
Three artifacts land in the bundle:

template/.gitignore.jinja

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ settings.local.json
1515
.rehearse/
1616
results/issue_selftest/
1717
*.log
18+
19+
# Per-cycle Do/Check worktrees (issue #94) — created next to the target checkout;
20+
# ignored in case a checkout lives inside the project tree.
21+
*.pdca-wt/
22+
*.pdca-wt-l*/

template/pdca.toml.jinja

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ templates_dir = "templates"
2828
# ergonomics actually bite (then prefer this over N separate workspaces).
2929
[driver]
3030
lanes = 1
31+
# Worktree isolation (issue #94). A cycle's Do/Check run in a dedicated git worktree off
32+
# the target's base, so the host's primary checkout is NEVER mutated in place. The harness
33+
# creates/resets it per cycle (per lane slot) and exposes its path as `$PDCA_WORKTREE` —
34+
# the builder edits there (granted access automatically), and gate commands should target
35+
# `$PDCA_WORKTREE` too (see the gate examples below). On by default; best-effort (a target
36+
# that isn't a worktree-capable git checkout falls back to in-place). Set false to disable.
37+
worktree = true
3138
# Close-disposition fast path (issue #60). A bundle whose brief's `Disposition hint`
3239
# matches one of these close / no-fix classes skips the builder + reviewer model leaves
3340
# (the engine's only token spend) and routes straight to sign-off, where the human
@@ -153,6 +160,9 @@ argv = ["claude", "--agent", "publisher", "--permission-mode", "acceptEdits"]
153160
# what CI re-runs via `pdca gates --working-tree`); `scope = "bundle"` needs the
154161
# bundle/patch context ($PDCA_BUNDLE is exported) and runs only locally. The
155162
# SAME commands run for the local driver and CI — single-sourced, no drift.
163+
# With worktree isolation on ([driver].worktree), $PDCA_WORKTREE is also exported —
164+
# the isolated tree Do edited; a bundle-scoped gate should test THAT, not the host
165+
# checkout (e.g. `cd "$PDCA_WORKTREE" && <run the test>`).
156166
#
157167
# TARGET-AWARE SELECTION (optional). A check may carry `target` — a label or a LIST;
158168
# it runs iff its labels are a SUBSET of the bundle's label set (subset = AND). The

template/src/pdca_harness/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ class Config:
116116
# Do+Check band. ``1`` (the default) keeps the driver strictly serial. ``[driver].lanes``
117117
# in pdca.toml; ``PDCA_LANES`` overrides for a single run (like ``PDCA_BUNDLE_ROOT``).
118118
lanes: int = 1
119+
# Worktree isolation (issue #94): run a cycle's Do/Check in a dedicated git worktree
120+
# off the target's base, so the host's primary checkout is never mutated in place.
121+
# On by default; ``[driver].worktree = false`` disables (then Do/Check edit the
122+
# checkout directly, as before). Best-effort: a target that isn't a worktree-capable
123+
# git checkout silently falls back to in-place.
124+
worktree: bool = True
119125
# Close-disposition fast path (issue #60): the disposition-hint classes that mark a
120126
# bundle as close / no-fix, so the driver skips the builder + reviewer leaves and
121127
# routes it straight to sign-off. ``[driver].close_dispositions`` in pdca.toml; the
@@ -200,6 +206,7 @@ def leaf(name: str) -> LeafConfig:
200206
if os.environ.get("PDCA_LANES"):
201207
lanes = int(os.environ["PDCA_LANES"])
202208
lanes = max(1, lanes)
209+
worktree = bool(driver_cfg.get("worktree", True)) # issue #94; on by default
203210

204211
# Close-disposition classes (issue #60): a configured list retunes the default
205212
# for an instance's tracker vocabulary; absent ⇒ the built-in default.
@@ -236,6 +243,7 @@ def leaf(name: str) -> LeafConfig:
236243
advisory_leaves=advisory_leaves,
237244
gates_runner=gates_runner,
238245
lanes=lanes,
246+
worktree=worktree,
239247
close_dispositions=close_dispositions,
240248
)
241249

template/src/pdca_harness/gates.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import sys
3939
from pathlib import Path
4040

41-
from . import brief, lane, progress
41+
from . import brief, lane, progress, worktree
4242
from .config import Config
4343

4444
# A gate that cannot RUN its mechanical check (vs. running and failing) declares so:
@@ -165,6 +165,9 @@ def _run_checks(cfg: Config, *, cwd: Path, bundle: Path | None, scopes: tuple[st
165165
return _assemble_matrix([], stub=True)
166166

167167
labels = _bundle_target(bundle, cfg.gate_target_match, cfg.gate_target_default, cfg.gate_target_flags)
168+
# Worktree isolation (issue #94): if Do ran in an isolated worktree, gates test THAT
169+
# tree — expose it as $PDCA_WORKTREE so a gate cmd targets it, not the host checkout.
170+
wt = worktree.path(bundle, cfg) if bundle is not None else None
168171
configured: list[dict] = []
169172
for chk in cfg.gates_checks:
170173
if not _applies(chk, scopes, labels):
@@ -173,7 +176,8 @@ def _run_checks(cfg: Config, *, cwd: Path, bundle: Path | None, scopes: tuple[st
173176
f"(target={chk.get('target')}, bundle labels {set(labels)})",
174177
file=sys.stderr, flush=True)
175178
continue
176-
configured.append(_run_one(chk, cwd=cwd, bundle=bundle, runner=cfg.gates_runner))
179+
configured.append(_run_one(chk, cwd=cwd, bundle=bundle, runner=cfg.gates_runner,
180+
worktree_path=wt))
177181
# Overlay the configured gate results onto the complete 5/5/1 matrix.
178182
return _assemble_matrix(configured, stub=False)
179183

@@ -197,7 +201,8 @@ def _delegated_cmd(chk: dict, runner: str) -> tuple[str, str]:
197201
return f"{runner} {subcmd}", ""
198202

199203

200-
def _run_one(chk: dict, *, cwd: Path, bundle: Path | None, runner: str = "") -> dict:
204+
def _run_one(chk: dict, *, cwd: Path, bundle: Path | None, runner: str = "",
205+
worktree_path: Path | None = None) -> dict:
201206
cmd, cmd_error = _delegated_cmd(chk, runner)
202207
gating = bool(chk.get("gating", True))
203208
label = f"{chk.get('id', '')}: {chk.get('label', '')}".strip(": ")
@@ -210,6 +215,9 @@ def _run_one(chk: dict, *, cwd: Path, bundle: Path | None, runner: str = "") ->
210215
path_line=cmd_error[:120], gating=gating, element=chk.get("tier", ""),
211216
)
212217
env = {"PDCA_BUNDLE": str(bundle)} if bundle is not None else None
218+
# Worktree isolation (issue #94): the tree Do edited; a gate cmd targets $PDCA_WORKTREE.
219+
if worktree_path is not None:
220+
env = {**(env or {}), "PDCA_WORKTREE": str(worktree_path)}
213221
# Stack mode (issue #54): when the brief names an existing PR's head to stack onto,
214222
# expose it as PDCA_BASE so the verify/repro gate establishes red→green on THAT branch
215223
# — the same branch publish commits onto and pushes to. Single-sourced from the brief,

template/src/pdca_harness/leaves.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from . import brief
4646
from . import gates
4747
from . import progress
48+
from . import worktree
4849
from .config import Config, LeafConfig
4950

5051
# build-notes.md is DELIBERATELY ABSENT from this list (independence contract).
@@ -275,21 +276,31 @@ def _stub_plan_batch(cfg: Config, ids: list[str] | None = None) -> None:
275276
# ----------------------------------------------------------------------------
276277
def do_build(d: Path, cfg: Config) -> None:
277278
if cfg.builder.mode == "command":
279+
# Isolate Do in a per-cycle worktree off the base (issue #94) so the host's
280+
# primary checkout is never mutated; expose it as $PDCA_WORKTREE + grant the
281+
# claude builder read/write there. Best-effort: None ⇒ edit in place, as before.
282+
wt = worktree.ensure(d, cfg)
283+
env = {"PDCA_WORKTREE": str(wt)} if wt else None
284+
extra = ["--add-dir", str(wt)] if wt and cfg.builder.family == "claude" else None
278285
# The builder runs from cfg.root but writes into the bundle d — watch d so the
279286
# heartbeat shows patch.diff / build-notes.md appearing as it works.
280287
_invoke(
281288
cfg.builder, cfg.root, _build_prompt(d),
282289
label=f"Do {d.name}",
283290
status=lambda: progress.bundle_activity(d, ("patch.diff", "build-notes.md")),
284291
stream_json=True, # Tier 3: show the builder's live tool-use
292+
env=env, extra_argv=extra,
285293
)
286294
return
287295
_stub_build(d, cfg)
288296

289297

290298
def _build_prompt(d: Path) -> str:
291299
return (
292-
f"You are the Do builder. Read {d}/brief.md. Build to satisfy its **Success "
300+
f"You are the Do builder. Read {d}/brief.md. If $PDCA_WORKTREE is set, make ALL "
301+
"target-source edits there — it is an isolated git worktree off the target's base "
302+
"(the host's primary checkout is NOT touched); cite path:line against it. Build to "
303+
"satisfy the brief's **Success "
293304
"criterion** (the real end result), not a narrower proxy — an item is done only "
294305
"when that end result holds, proven red→green; a green mechanical check on "
295306
"something adjacent is not done. If brief.md names a **Planning artifact** (an "
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Per-cycle git worktree isolation for Do/Check (issue #94).
2+
3+
A cycle's Do (builder edits the target in place) and Check (gates run against the
4+
working tree) otherwise mutate the host's **primary checkout**, leaving it dirty and
5+
colliding with any human work there. Instead, the harness runs Do/Check in a
6+
dedicated git **worktree** off the target's base branch, so the primary checkout is
7+
never touched. The worktree path is exposed to the builder and gate commands as
8+
``$PDCA_WORKTREE``.
9+
10+
On by default (``[driver].worktree``); **best-effort** — a target that is missing,
11+
not a git checkout, or whose base can't be resolved silently falls back to in-place
12+
(returns ``None``), so enabling it never breaks a cycle. The worktree is **reset and
13+
reused** per cycle (reset to the base before each Do), keyed by lane slot so concurrent
14+
lanes get private worktrees (never ``cp`` a worktree — its ``.git`` is an absolute
15+
pointer; each is created in place by ``git worktree add``).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import subprocess
21+
import sys
22+
from pathlib import Path
23+
24+
from . import lane
25+
from .config import Config
26+
27+
28+
def _git(repo: Path, *args: str) -> int:
29+
"""Run ``git -C repo args``, quietly; return the exit code (no raise)."""
30+
return subprocess.run(["git", "-C", str(repo), *args],
31+
capture_output=True, text=True).returncode
32+
33+
34+
def _target(d: Path, cfg: Config) -> tuple[Path, str] | None:
35+
"""``(primary_checkout, base_ref)`` for bundle ``d``, or None if it can't be resolved.
36+
37+
Single-sourced from the brief's "Repo + branch target" via the same resolution
38+
publish uses; ``base_ref`` is ``<base_remote>/<base>`` (the remote-tracking base the
39+
worktree branches off), falling back to the bare base / default branch.
40+
"""
41+
from . import publish # lazy: publish imports leaves→worktree; avoid an import cycle
42+
try:
43+
repo_spec, base, _slug = publish._resolve_target(d)
44+
except Exception: # noqa: BLE001 — resolution is best-effort
45+
return None
46+
if not repo_spec:
47+
return None
48+
primary = publish._checkout_path(cfg, repo_spec)
49+
if not (primary / ".git").exists(): # not a git checkout → can't worktree
50+
return None
51+
base_ref = f"{cfg.base_remote}/{base}" if base else cfg.default_branch
52+
return primary, base_ref
53+
54+
55+
def _wt_dir(primary: Path) -> Path:
56+
"""The worktree directory for the current lane slot — a sibling of the primary
57+
checkout (``<name>.pdca-wt`` / ``<name>.pdca-wt-l<lane>`` under concurrency)."""
58+
slot = lane.current()
59+
suffix = ".pdca-wt" + (f"-l{slot}" if slot is not None else "")
60+
return primary.parent / (primary.name + suffix)
61+
62+
63+
def path(d: Path, cfg: Config) -> Path | None:
64+
"""The active worktree for this bundle/lane if one exists on disk, else None.
65+
66+
Read-only (no git): Do calls :func:`ensure` to create/reset it; Check (gates) and
67+
the builder env read this. Returns None when worktree isolation is off or the target
68+
isn't resolvable, so callers fall back to the primary checkout.
69+
"""
70+
if not cfg.worktree:
71+
return None
72+
tgt = _target(d, cfg)
73+
if tgt is None:
74+
return None
75+
wt = _wt_dir(tgt[0])
76+
return wt if (wt / ".git").exists() else None
77+
78+
79+
def ensure(d: Path, cfg: Config) -> Path | None:
80+
"""Create or reset the per-cycle worktree off the target base; return its path.
81+
82+
Reset-and-reused: an existing worktree is hard-reset to the base and cleaned; a new
83+
one is added off the base. Best-effort — disabled, unresolved target, non-git
84+
checkout, or any git failure returns None (the cycle then runs in place, unchanged).
85+
The primary checkout is never modified (worktrees are separate working trees).
86+
"""
87+
if not cfg.worktree:
88+
return None
89+
tgt = _target(d, cfg)
90+
if tgt is None:
91+
return None
92+
primary, base_ref = tgt
93+
wt = _wt_dir(primary)
94+
try:
95+
_git(primary, "fetch", cfg.base_remote) # refresh the base; best-effort
96+
if (wt / ".git").exists():
97+
# Reuse: drop the prior cycle's edits, return to a clean base.
98+
if _git(wt, "reset", "--hard", base_ref) != 0 or _git(wt, "clean", "-fdq") != 0:
99+
print(f"worktree: could not reset {wt} to {base_ref}; running in place",
100+
file=sys.stderr)
101+
return None
102+
return wt
103+
# Create off the base. --force tolerates the base branch being checked out elsewhere.
104+
if _git(primary, "worktree", "add", "--force", str(wt), base_ref) != 0:
105+
print(f"worktree: could not create {wt} off {base_ref}; running in place",
106+
file=sys.stderr)
107+
return None
108+
return wt
109+
except Exception as exc: # noqa: BLE001 — isolation is best-effort, never fatal
110+
print(f"worktree: isolation unavailable for {d.name} ({exc}); running in place",
111+
file=sys.stderr)
112+
return None

0 commit comments

Comments
 (0)