* test(#3659): baseref-head suppress must be mode-aware regression rows * fix(#3659): make baseref-head suppress mode-aware and thread isolation mode * fix(#3659): review fixes - stale advice purge, message pins, mode alias * fix(#3659): pick-interceptable emit seam, ack merge, writeSync pin * test(#3659): rewrite set-baseref pin, fix writeSync row stub * chore(#3659): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/rapid-quails-sing.md
Normal file
5
.changeset/rapid-quails-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3736
|
||||
---
|
||||
**Worktree executors no longer fork from the wrong base on long-lived branches** — `worktree.baseRef:"head"` no longer silences the pre-dispatch base check on harness-managed runtimes: the check now compares HEAD against the actual fork base and auto-degrades to sequential execution before dispatch when they diverge, instead of letting every isolated executor die at the exit-42 guard. The suppress now applies only where GSD itself creates worktrees (where the setting is honored by construction). (#3659)
|
||||
@@ -1001,11 +1001,12 @@ node gsd-tools.cjs worktree base-check
|
||||
node gsd-tools.cjs worktree set-baseref
|
||||
```
|
||||
|
||||
**`worktree base-check`** reads `worktree.baseRef` from a three-layer cascade — `.claude/settings.local.json`, then `.claude/settings.json`, then the user/global `settings.json` under `CLAUDE_CONFIG_DIR` (or `~/.claude`) — and compares the current `HEAD` SHA against `origin/HEAD`. Project-level settings take precedence over the user/global layer, so a machine-wide `worktree.baseRef:"head"` set via `/config` is honored when no project override exists. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. Possible `reason` values:
|
||||
**`worktree base-check`** reads `worktree.baseRef` from a three-layer cascade — `.claude/settings.local.json`, then `.claude/settings.json`, then the user/global `settings.json` under `CLAUDE_CONFIG_DIR` (or `~/.claude`) — and compares the current `HEAD` SHA against `origin/HEAD`. Project-level settings take precedence over the user/global layer, so a machine-wide `worktree.baseRef:"head"` set via `/config` is honored when no project override exists. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. `--mode` declares who creates the isolated worktree (#3659): `harness-worktree` (the default — the runtime harness forks it and does **not** read project-settings `baseRef`, #48) or `orchestrator-worktree` (GSD itself runs `git worktree add` with an explicit start-point and honors `"head"`); invalid values fail closed with an error. Possible `reason` values:
|
||||
|
||||
| `reason` | `shouldDegrade` | Meaning |
|
||||
|---|---|---|
|
||||
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set; no mismatch possible |
|
||||
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set and `--mode orchestrator-worktree` declares GSD-managed worktrees — the fork base is the orchestrator HEAD by construction |
|
||||
| `baseref-head-ignored-by-harness` | `true` | `worktree.baseRef:"head"` is set but HEAD differs from `origin/HEAD` in harness (default) mode — the harness does not read the setting (#48), so the run degrades to sequential (#3659) |
|
||||
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
|
||||
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
|
||||
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
|
||||
|
||||
@@ -409,7 +409,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `worktree.baseRef` | string | (unset) | Controls which ref the worktree-based parallel executor uses as the base when creating new phase/wave worktrees. When unset, the executor bases new worktrees on the repository default branch (`origin/HEAD`); if the current branch has diverged, execute-phase auto-degrades to sequential execution rather than halting (as of v1.4.0). Set to `"head"` to base new worktrees on the local `HEAD` instead — the appropriate choice when working on a branch that has diverged from the default branch, as it prevents the exit-42 base-mismatch halt and allows wave-based parallel execution to proceed normally. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
|
||||
| `worktree.baseRef` | string | (unset) | Controls which ref the worktree-based parallel executor uses as the base when creating new phase/wave worktrees. When unset, the executor bases new worktrees on the repository default branch (`origin/HEAD`); if the current branch has diverged, execute-phase auto-degrades to sequential execution rather than halting (as of v1.4.0). Set to `"head"` to base new worktrees on the local `HEAD` instead. **Where it applies (#48/#3659):** honored on runtimes where GSD itself creates the worktrees (Codex, OpenCode, Kimi, Kimi Code) — there it restores wave-based parallel execution on diverged branches. On harness-isolated runtimes (Claude Code, Cursor) the harness does **not** read this setting (verified 5/5 in #48; upstream claude-code#44965): the base check compares against the real fork base regardless and auto-degrades to sequential execution before dispatch when `HEAD` has diverged, so the exit-42 halt is a last-resort backstop rather than the only guard. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
|
||||
|
||||
### Executor isolation per runtime
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ When you run `/gsd-execute-phase` or `/gsd-quick` on a branch that is ahead of t
|
||||
|
||||
```
|
||||
⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890).
|
||||
Running this phase sequentially on the main working tree.
|
||||
To keep parallel worktrees, set worktree.baseRef:"head" in
|
||||
.claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.
|
||||
Running this phase sequentially on the main working tree. Parallel worktrees
|
||||
return once HEAD is merged/pushed so origin/HEAD matches it.
|
||||
(worktree.baseRef:"head" applies only where GSD itself creates the worktree —
|
||||
the runtime harness does not read it; #48, #3659.)
|
||||
```
|
||||
|
||||
The phase or quick task runs to completion sequentially; nothing is blocked. This is the runtime mitigation (`/gsd-execute-phase`: #683/#1369; `/gsd-quick`: #1941).
|
||||
@@ -51,9 +52,20 @@ Use this option when:
|
||||
|
||||
---
|
||||
|
||||
## Option 2 — Permanent fix: set `worktree.baseRef: "head"` (recommended)
|
||||
## Option 2 — Where `worktree.baseRef: "head"` actually applies (runtimes where GSD creates the worktrees)
|
||||
|
||||
This option restores parallel worktree execution on diverged branches. It tells Claude Code to fork executor worktrees from your current `HEAD` instead of `origin/HEAD`, so the plan files and branch-only commits are present in every worktree.
|
||||
**What this setting can and cannot do (#48, #3659):** on runtimes whose own harness creates isolated
|
||||
worktrees (Claude Code's `Agent(isolation="worktree")`), the harness forks from the repository
|
||||
default branch and **does not read project-settings `baseRef`** — verified 5/5 in #48; tracked
|
||||
upstream at claude-code#44965/#43535. On those runtimes, setting `baseRef:"head"` does **not**
|
||||
restore parallel worktrees on a diverged branch, and since #3659 it no longer silences the
|
||||
pre-dispatch check: GSD compares `HEAD` against the real fork base and auto-degrades to sequential
|
||||
execution before dispatch instead of letting every executor die at the exit-42 guard.
|
||||
|
||||
The setting **is** honored where GSD itself runs `git worktree add <path> <start-point>` — the
|
||||
orchestrator-managed isolation used on runtimes with a headless exec surface (Codex, OpenCode,
|
||||
Kimi, Kimi Code). There `baseRef:"head"` really does fork from your current `HEAD`, the check
|
||||
suppresses on it (`reason: "baseref-head"`), and parallel execution works on any branch.
|
||||
|
||||
Run the convenience command from your project root:
|
||||
|
||||
@@ -63,13 +75,18 @@ node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
|
||||
|
||||
This writes `worktree.baseRef: "head"` into `.claude/settings.local.json` in your project root. It is no-clobber: if you already have an explicit `baseRef` set to something else, it leaves your value in place and tells you.
|
||||
|
||||
To verify the result:
|
||||
To verify the result on a harness-isolated runtime (Claude Code, Cursor), pass the dispatch mode so
|
||||
the check evaluates the base the harness will actually use:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check --mode harness-worktree
|
||||
```
|
||||
|
||||
The output is JSON. When `shouldDegrade` is `false` and `reason` is `"baseref-head"`, parallel worktrees will work on any branch.
|
||||
The output is JSON. On a diverged branch expect `shouldDegrade: true` with
|
||||
`reason: "baseref-head-ignored-by-harness"` — GSD will run the phase sequentially; parallel
|
||||
worktrees return once `HEAD` is merged/pushed so `origin/HEAD` matches it. On a GSD-managed runtime,
|
||||
`--mode orchestrator-worktree` returns `shouldDegrade: false` with `reason: "baseref-head"` and
|
||||
parallel worktrees work on any branch.
|
||||
|
||||
Alternatively, set the value by hand in `.claude/settings.local.json`:
|
||||
|
||||
@@ -81,13 +98,12 @@ Alternatively, set the value by hand in `.claude/settings.local.json`:
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). You can also apply or re-apply it manually at any time with `gsd-tools worktree set-baseref` — for example, if you toggled worktrees on after the initial install.
|
||||
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). This remains useful for GSD-managed runtimes and harmless elsewhere — post-#3659 it never silences the check on harness-managed ones.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- You regularly work on long-lived or milestone branches
|
||||
- You want parallel phase execution (faster, lower context-window pressure)
|
||||
- You are a solo developer or team working on a feature branch for an extended period
|
||||
- Your runtime uses GSD-managed worktrees (Codex, OpenCode, Kimi, Kimi Code) and you regularly work on long-lived or milestone branches
|
||||
- You want parallel phase execution there (faster, lower context-window pressure)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,19 +24,16 @@
|
||||
# Unset per-wave manifest so wave N+1 creates a fresh one (#3384, #1369).
|
||||
unset WAVE_WORKTREE_MANIFEST
|
||||
|
||||
# Between-wave base refresh (#1369): after wave N merges and tracking commits, HEAD has
|
||||
# advanced. Re-assert worktree.baseRef:"head" (idempotent — no-op if already set) so the
|
||||
# Claude Code harness re-reads the live HEAD on the next Agent(isolation="worktree") call
|
||||
# rather than using a cached session-start commit as the fork base.
|
||||
# Between-wave base re-check (#1369, #3659): after wave N merges and tracking commits,
|
||||
# HEAD has advanced. Re-asserting worktree.baseRef:"head" is deliberately NOT done here —
|
||||
# the runtime harness does not read project-settings baseRef (#48), so in
|
||||
# harness-worktree mode the setting cannot influence the fork base. The safety re-check
|
||||
# below compares HEAD against the REAL fork base and degrades the remaining waves
|
||||
# whenever they diverge, avoiding the base-mismatch FATAL in executor agents.
|
||||
if [ "$ISOLATION" = "harness-worktree" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
gsd_run query worktree.set-baseref 2>/dev/null || true
|
||||
|
||||
# Safety re-check: evaluate degradation AFTER the wave N commits. If HEAD has diverged
|
||||
# from origin/HEAD and baseRef is NOT "head", degrade remaining waves to sequential to
|
||||
# avoid the base-mismatch FATAL in executor agents.
|
||||
_BETWEEN_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || echo "false")
|
||||
_BETWEEN_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || echo "false")
|
||||
if [ "$_BETWEEN_DEGRADE" = "true" ]; then
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick message 2>/dev/null || true)
|
||||
[ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
|
||||
printf 'Degrading to sequential mode for remaining waves: HEAD advanced past worktree fork base after wave %s merge (#1369).\n' "${N}" >&2
|
||||
# Both must move together (#2652): dispatch keys on ISOLATION.
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
```bash
|
||||
if [ "$ISOLATION" = "harness-worktree" ] && [ "${USE_WORKTREES:-true}" != "false" ]; then
|
||||
_WAVE_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
_WAVE_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_WAVE_DEGRADE" = "true" ]; then
|
||||
_WAVE_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
_WAVE_DEGRADE_MSG=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick message 2>/dev/null || true)
|
||||
[ -n "$_WAVE_DEGRADE_MSG" ] && printf '%s\n' "$_WAVE_DEGRADE_MSG" >&2
|
||||
echo "⚠ [#1369] Worktree fork base diverged from orchestrator HEAD (wave merges advanced HEAD past origin/HEAD). Auto-degrading to sequential mode for this wave to avoid base-mismatch halts." >&2
|
||||
# Both must move together (#2652): dispatch keys on ISOLATION.
|
||||
@@ -27,11 +27,13 @@
|
||||
|
||||
If `shouldDegrade` is `true`, override `USE_WORKTREES=false` for **this wave only** —
|
||||
all plans in this wave execute sequentially on the main working tree. Later waves re-run
|
||||
this check and may re-enable worktree isolation if `origin/HEAD` is updated (e.g. via
|
||||
`git fetch` or `worktree.baseRef:"head"` config).
|
||||
this check and may re-enable worktree isolation once `origin/HEAD` matches HEAD again
|
||||
(e.g. via `git fetch` or a push that advances it).
|
||||
|
||||
**To avoid this degrade across all waves:** set `worktree.baseRef:"head"` in
|
||||
`.claude/settings.local.json` (or run `gsd-tools worktree set-baseref`). This tells
|
||||
Claude Code to fork from the live HEAD instead of `origin/HEAD`, so each wave's new
|
||||
worktrees always start from the correct post-merge base. See #683 for the base-ref
|
||||
configuration detail.
|
||||
**Why `worktree.baseRef:"head"` does not avoid this degrade (#48, #3659):** the runtime
|
||||
harness does not read project-settings `baseRef` — an isolated dispatch always forks from
|
||||
`origin/HEAD` regardless of the setting, so the check compares against the real fork base
|
||||
and degrades whenever HEAD has diverged. Parallel worktrees return once HEAD is
|
||||
merged/pushed so `origin/HEAD` matches it. The setting still restores parallel execution
|
||||
on runtimes where GSD itself creates the worktrees (orchestrator-managed isolation:
|
||||
Codex, OpenCode, Kimi, Kimi Code). See #683 for the base-ref configuration detail.
|
||||
|
||||
@@ -111,9 +111,9 @@ stays active as a backstop in both cases.
|
||||
|
||||
```bash
|
||||
if [ "$ISOLATION" = "harness-worktree" ]; then
|
||||
_DIAG_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
_DIAG_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_DIAG_SHOULD_DEGRADE" = "true" ]; then
|
||||
_DIAG_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
_DIAG_DEGRADE_MSG=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick message 2>/dev/null || true)
|
||||
[ -n "$_DIAG_DEGRADE_MSG" ] && printf '%s\n' "$_DIAG_DEGRADE_MSG" >&2
|
||||
echo "⚠ [#2649] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for diagnosis to avoid a base-mismatch halt." >&2
|
||||
ISOLATION=none
|
||||
|
||||
@@ -128,7 +128,7 @@ fi
|
||||
|
||||
When `USE_WORKTREES` is `false`, `ISOLATION` is forced to `none`: executors run sequentially on the main working tree. The per-plan decision below has no effect when worktrees are project-disabled.
|
||||
|
||||
`USE_WORKTREES` and `ISOLATION` are also reset for the run when `worktree base-check` detects the orchestrator HEAD has diverged from the worktree fork base (#683 — e.g. an unmerged milestone branch). This runs for **any** isolated run, not only Claude: fork-base divergence is a property of the repository, so it degrades a GSD-created worktree exactly as a harness-created one. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. To restore parallel worktree execution, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (or run `gsd_run worktree set-baseref`) — this makes the fork base track the live HEAD instead of a fixed remote ref. The `worktree-branch-check` exit-42 guard inside each executor remains in place as a backstop.
|
||||
`USE_WORKTREES` and `ISOLATION` are also reset for the run when `worktree base-check` detects the orchestrator HEAD has diverged from the worktree fork base (#683 — e.g. an unmerged milestone branch). This runs for **any** isolated run, not only Claude: fork-base divergence is a property of the repository, so it degrades a GSD-created worktree exactly as a harness-created one. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. Setting `worktree.baseRef:"head"` restores parallel execution only where GSD itself creates the worktrees (orchestrator-managed runtimes — Codex, OpenCode, Kimi, Kimi Code); harness-isolated runtimes (Claude Code, Cursor) do not read the setting (#48, verified 5/5; upstream claude-code#44965), so there the check compares against the real fork base and parallel execution returns once HEAD is merged/pushed so `origin/HEAD` matches it (#3659). The `worktree-branch-check` exit-42 guard inside each executor remains in place as a backstop.
|
||||
|
||||
Read context window size for adaptive prompt enrichment:
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ fi
|
||||
[ "$ISOLATION" != "none" ] && gsd_run query worktree.reap-orphans 2>/dev/null || true
|
||||
# Auto-degrade if HEAD diverged from the fork base (#683) — both isolation models.
|
||||
if [ "$ISOLATION" != "none" ]; then
|
||||
_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_SHOULD_DEGRADE" = "true" ]; then
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick message 2>/dev/null || true)
|
||||
[ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
|
||||
USE_WORKTREES=false
|
||||
ISOLATION=none
|
||||
|
||||
@@ -140,7 +140,7 @@ Otherwise: Apply checkpoint-based routing below.
|
||||
|
||||
> **Runtime-aware dispatch (#2508 Phase 4).** GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via `gsd_run query resolve-dispatch-type --requested <role> --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_<ROLE>}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
|
||||
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → **before spawning, run the #2649 pre-dispatch worktree base-check** (mirrors execute-phase #683/#1369 and quick #1941): if `ISOLATION = "harness-worktree"`, run `gsd_run query worktree.base-check --pick shouldDegrade`; if it returns `true`, print its `--pick message` to stderr, emit the `⚠ [#2649] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this plan to avoid a base-mismatch halt.` warning, and treat `ISOLATION` as `"none"` for this dispatch (spawn without `{harnessFlag}`), **then re-record the degrade before spawning** — run `gsd_run query dispatch-isolation --raw --force-isolation none >/dev/null 2>&1 || true`. That re-record is mandatory, not bookkeeping: the resolve step already persisted `harness-worktree` to the run-scoped sentinel, the degrade above happens where the resolver cannot see it, and the shipped `PreToolUse` isolation guard (#3045) reads that sentinel at the instant of the `Agent()` call — a stale `harness-worktree` against a dispatch that correctly omits `{harnessFlag}` is denied with `exit 2`, so the plan does not run at all. See `Re-record after every degrade` in `gsd-core/references/dispatch-isolation-gate.md`. Claude Code's `isolation="worktree"` forks from `origin/HEAD`, not live local HEAD; without this gate a plan whose commit advanced local HEAD past a stale `origin/HEAD` hits the verify-only guard's `exit 42` mid-execution with no auto-degrade. → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, honor checkpoint gate semantics (#3370) — gate="blocking" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate="blocking-human" always surfaces to a human; add no instruction overriding that protocol — report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `{harnessFlag}` only when `ISOLATION = "harness-worktree"` and the #2649 base-check did not degrade** — never hardcode `isolation="worktree"`, which is Claude Code's own literal and wrong on any other harness-worktree host. **When dispatching with `{harnessFlag}`, embed the `<worktree_branch_check>` block from `gsd-core/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard is **verify-only and fail-closed** (#48) and stays active as a backstop whether or not the base-check degraded: it asserts a per-agent `agent-*` / `worktree-agent-*` branch and the exact base, forbids `git update-ref` self-recovery (#2924), and on any mismatch prints `FATAL:` and `exit 42` so the orchestrator can recover — the sub-agent never rewrites a worktree it did not create. This supersedes the former self-recovery (#2015), whose destructive base rewrite could fail silently under a deny rule; the base-drift it addressed affects all platforms, and base correction is now the orchestrator's responsibility.
|
||||
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → **before spawning, run the #2649 pre-dispatch worktree base-check** (mirrors execute-phase #683/#1369 and quick #1941): if `ISOLATION = "harness-worktree"`, run `gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade` (#3659: the mode is what stops `worktree.baseRef:"head"` from suppressing the comparison — the harness does not honor that setting); if it returns `true`, print its `--pick message` to stderr, emit the `⚠ [#2649] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this plan to avoid a base-mismatch halt.` warning, and treat `ISOLATION` as `"none"` for this dispatch (spawn without `{harnessFlag}`), **then re-record the degrade before spawning** — run `gsd_run query dispatch-isolation --raw --force-isolation none >/dev/null 2>&1 || true`. That re-record is mandatory, not bookkeeping: the resolve step already persisted `harness-worktree` to the run-scoped sentinel, the degrade above happens where the resolver cannot see it, and the shipped `PreToolUse` isolation guard (#3045) reads that sentinel at the instant of the `Agent()` call — a stale `harness-worktree` against a dispatch that correctly omits `{harnessFlag}` is denied with `exit 2`, so the plan does not run at all. See `Re-record after every degrade` in `gsd-core/references/dispatch-isolation-gate.md`. Claude Code's `isolation="worktree"` forks from `origin/HEAD`, not live local HEAD; without this gate a plan whose commit advanced local HEAD past a stale `origin/HEAD` hits the verify-only guard's `exit 42` mid-execution with no auto-degrade. → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, honor checkpoint gate semantics (#3370) — gate="blocking" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate="blocking-human" always surfaces to a human; add no instruction overriding that protocol — report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `{harnessFlag}` only when `ISOLATION = "harness-worktree"` and the #2649 base-check did not degrade** — never hardcode `isolation="worktree"`, which is Claude Code's own literal and wrong on any other harness-worktree host. **When dispatching with `{harnessFlag}`, embed the `<worktree_branch_check>` block from `gsd-core/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard is **verify-only and fail-closed** (#48) and stays active as a backstop whether or not the base-check degraded: it asserts a per-agent `agent-*` / `worktree-agent-*` branch and the exact base, forbids `git update-ref` self-recovery (#2924), and on any mismatch prints `FATAL:` and `exit 42` so the orchestrator can recover — the sub-agent never rewrites a worktree it did not create. This supersedes the former self-recovery (#2015), whose destructive base rewrite could fail silently under a deny rule; the base-drift it addressed affects all platforms, and base correction is now the orchestrator's responsibility.
|
||||
|
||||
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution. **Segments run unisolated on the main working tree by design** — each continues where the previous one stopped — so dispatch them WITHOUT `{harnessFlag}`, and only after the `ISOLATION=none` re-record above has run (#2652/#3045).
|
||||
|
||||
|
||||
@@ -381,9 +381,9 @@ immediately before capturing `EXPECTED_BASE` so it reflects the most current loc
|
||||
|
||||
```bash
|
||||
if [ "$ISOLATION" = "harness-worktree" ] && [ "${USE_WORKTREES:-true}" != "false" ]; then
|
||||
_QUICK_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
_QUICK_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_QUICK_SHOULD_DEGRADE" = "true" ]; then
|
||||
_QUICK_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
_QUICK_DEGRADE_MSG=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick message 2>/dev/null || true)
|
||||
[ -n "$_QUICK_DEGRADE_MSG" ] && printf '%s\n' "$_QUICK_DEGRADE_MSG" >&2
|
||||
echo "⚠ [#1941] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this quick task to avoid a base-mismatch halt." >&2
|
||||
USE_WORKTREES=false
|
||||
|
||||
@@ -87,15 +87,24 @@ function parseJsonc(text: string): unknown {
|
||||
|
||||
type ExecGitFn = typeof execGitSeam;
|
||||
|
||||
// Who creates the isolated worktree — the two worktree-creating members of
|
||||
// host-integration.cts's DispatchIsolation vocabulary ('none' creates none and
|
||||
// never reaches this check).
|
||||
type BaseCheckIsolationMode = 'harness-worktree' | 'orchestrator-worktree';
|
||||
|
||||
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
||||
|
||||
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
|
||||
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so ${forkRef} matches it. (worktree.baseRef:"head" applies only where GSD itself creates the worktree — the runtime harness does not read it; #48, #3659.)`;
|
||||
}
|
||||
|
||||
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. Parallel worktrees return once origin/HEAD resolves and matches HEAD. See #683, #3659.`;
|
||||
|
||||
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" would silence this check without verifying the base — it skips the comparison rather than resolving it. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
|
||||
function buildMsgBaserefHeadIgnored(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
|
||||
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but the runtime harness does not honor it for isolated dispatch — the fork base stays ${forkRef} (${shortSha(forkSha)}) while HEAD is ${shortSha(headSha)} (#48; upstream claude-code#44965). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so ${forkRef} matches it, or on runtimes where GSD itself manages worktree creation. See #3659.`;
|
||||
}
|
||||
|
||||
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" silences this check only where GSD itself creates the worktree (orchestrator-managed runtimes) — in harness mode it never applied (#48, #3659). Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
|
||||
|
||||
/**
|
||||
* Returns true when an execGit result indicates the subprocess was killed by
|
||||
@@ -241,9 +250,22 @@ export function resolveEffectiveBaseRef(
|
||||
*/
|
||||
export function cmdWorktreeBaseCheck(
|
||||
cwd: string,
|
||||
_args: string[],
|
||||
args: string[],
|
||||
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void; userClaudeDir?: string | null }
|
||||
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
|
||||
// --mode threads the dispatch isolation mode through to the evaluation
|
||||
// (#3659): 'harness-worktree' (default) or 'orchestrator-worktree'. Invalid
|
||||
// or missing values after --mode fail closed — a silently defaulted typo
|
||||
// would re-open the hole the flag exists to close.
|
||||
let isolationMode: BaseCheckIsolationMode = 'harness-worktree';
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
if (modeIdx !== -1) {
|
||||
const value = args[modeIdx + 1];
|
||||
if (value !== 'harness-worktree' && value !== 'orchestrator-worktree') {
|
||||
throw new Error(`worktree base-check: --mode must be harness-worktree or orchestrator-worktree, got ${JSON.stringify(value ?? null)}`);
|
||||
}
|
||||
isolationMode = value;
|
||||
}
|
||||
const claudeDir = path.join(cwd, '.claude');
|
||||
const userClaudeDir = Object.prototype.hasOwnProperty.call(deps ?? {}, 'userClaudeDir')
|
||||
? (deps as { userClaudeDir?: string | null }).userClaudeDir
|
||||
@@ -257,8 +279,22 @@ export function cmdWorktreeBaseCheck(
|
||||
cwd,
|
||||
effectiveBaseRef,
|
||||
execGit: deps?.execGit,
|
||||
isolationMode,
|
||||
});
|
||||
// Default emit goes through fs.writeSync(1, …), NOT process.stdout.write:
|
||||
// the CLI's --pick capture intercepts writeSync, and command substitution
|
||||
// is a pipe — via process.stdout.write a `$(gsd-tools … --pick x)` capture
|
||||
// received the full JSON instead of the picked field, so the workflow
|
||||
// auto-degrade guards never matched (#3659 review). Short-count loop per
|
||||
// io.cjs writeAllSync's rationale (a non-blocking pipe can accept partial
|
||||
// writes).
|
||||
const write = deps?.write ?? ((s: string) => {
|
||||
const buf = Buffer.from(s, 'utf8');
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
offset += fs.writeSync(1, buf, offset, buf.length - offset);
|
||||
}
|
||||
});
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
write(JSON.stringify(result, null, 2) + '\n');
|
||||
return result;
|
||||
}
|
||||
@@ -323,7 +359,20 @@ export function cmdWorktreeSetBaseRef(
|
||||
baseRef: 'head' as const,
|
||||
file,
|
||||
};
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
// Default emit goes through fs.writeSync(1, …), NOT process.stdout.write:
|
||||
// the CLI's --pick capture intercepts writeSync, and command substitution
|
||||
// is a pipe — via process.stdout.write a `$(gsd-tools … --pick x)` capture
|
||||
// received the full JSON instead of the picked field, so the workflow
|
||||
// auto-degrade guards never matched (#3659 review). Short-count loop per
|
||||
// io.cjs writeAllSync's rationale (a non-blocking pipe can accept partial
|
||||
// writes).
|
||||
const write = deps?.write ?? ((s: string) => {
|
||||
const buf = Buffer.from(s, 'utf8');
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
offset += fs.writeSync(1, buf, offset, buf.length - offset);
|
||||
}
|
||||
});
|
||||
write(JSON.stringify(output, null, 2) + '\n');
|
||||
return output;
|
||||
}
|
||||
@@ -340,6 +389,15 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
execGit?: ExecGitFn;
|
||||
effectiveBaseRef?: string | null;
|
||||
cwd?: string;
|
||||
/**
|
||||
* Who creates the isolated worktree (#3659). 'harness-worktree' (default):
|
||||
* the runtime harness forks it and does NOT route through project-settings
|
||||
* baseRef (#48, verified 5/5; upstream claude-code#44965) — 'head' must not
|
||||
* suppress the comparison. 'orchestrator-worktree': GSD itself runs
|
||||
* `git worktree add <path> <start-point>` with the orchestrator HEAD, so
|
||||
* 'head' is honored by construction and still suppresses.
|
||||
*/
|
||||
isolationMode?: BaseCheckIsolationMode;
|
||||
}): {
|
||||
shouldDegrade: boolean;
|
||||
reason: string;
|
||||
@@ -364,12 +422,19 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
const cwd = deps?.cwd;
|
||||
const cwdOpts = cwd ? { cwd } : {};
|
||||
|
||||
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
|
||||
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
|
||||
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
|
||||
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
|
||||
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
|
||||
if (deps?.effectiveBaseRef === 'head') {
|
||||
// a. baseRef 'head' suppresses ONLY where GSD controls the fork start-point
|
||||
// (#3659). The former unconditional suppress trusted the harness to honor the
|
||||
// setting; #48 verified 5/5 that the Agent-isolation dispatch path never
|
||||
// routes through project settings (upstream claude-code#44965), so in
|
||||
// harness mode the fork base is always origin/HEAD and 'head' must fall
|
||||
// through to the same comparison the fresh path runs. In
|
||||
// orchestrator-worktree mode GSD itself runs `git worktree add <path>
|
||||
// <start-point>` with the orchestrator HEAD — 'head' is honored by
|
||||
// construction there and the suppress is correct. Any non-"head" value
|
||||
// (including "fresh" and absent/null) has fresh/origin-HEAD semantics and is
|
||||
// evaluated against origin/HEAD as before. (Reference: #683, #48, #3659.)
|
||||
const headIgnoredByHarness = deps?.effectiveBaseRef === 'head';
|
||||
if (headIgnoredByHarness && (deps?.isolationMode ?? 'harness-worktree') === 'orchestrator-worktree') {
|
||||
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
||||
}
|
||||
|
||||
@@ -445,6 +510,10 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
if (forkSha === headSha) {
|
||||
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
||||
}
|
||||
if (headIgnoredByHarness) {
|
||||
const message = buildMsgBaserefHeadIgnored(headSha, forkRef, forkSha);
|
||||
return { shouldDegrade: true, reason: 'baseref-head-ignored-by-harness', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
||||
}
|
||||
const message = buildMsgDiverged(headSha, forkRef, forkSha);
|
||||
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"execute-phase.md": {
|
||||
"reason": "#2856 \u2014 step 5.75 now dispatches every `kind == \"step\"` at execute:wave:post. That point previously dispatched only contribution and gate, so any registered step was declared and silently never run; the capability validator refuses to generate a registry containing such a step. Also adds the validate-ref.command-before-shell warning matching the sibling gate-dispatch line. Supersedes the spent #3370 fragment entry (merged into next, so its ripple is already absorbed at the base and it can no longer clear anything), which also named execute-phase.md and would otherwise double-ack the same path \u2014 the same supersede that entry itself performed on the spent #3324 fragment."
|
||||
"reason": "#2856 \u2014 step 5.75 now dispatches every `kind == \"step\"` at execute:wave:post. That point previously dispatched only contribution and gate, so any registered step was declared and silently never run; the capability validator refuses to generate a registry containing such a step. Also adds the validate-ref.command-before-shell warning matching the sibling gate-dispatch line. Supersedes the spent #3370 fragment entry (merged into next, so its ripple is already absorbed at the base and it can no longer clear anything), which also named execute-phase.md and would otherwise double-ack the same path \u2014 the same supersede that entry itself performed on the spent #3324 fragment. #3659 corrects the auto-degrade paragraph's restore-advice to the orchestrator/harness split (+~300B): worktree.baseRef:\"head\" restores parallel execution only where GSD itself creates worktrees; harness-isolated runtimes do not read the setting (#48, upstream claude-code#44965). Deliberate growth."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path."
|
||||
"execute-plan.md": "#3370: the Pattern A dispatch prompt spec gained the gate-semantics clause (gate=\"blocking\" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate=\"blocking-human\" always surfaces to a human; add no instruction overriding that protocol), closing the identically-shaped dispatch-time gap on the single-plan path named in the issue. Growth ~248 bytes (38913 -> 39161, still under the DEFAULT 40 KiB ceiling). Supersedes the spent #2652 fragment (merged into next), which also named execute-plan.md and would otherwise double-ack the same path. #3659 appends --mode \"$ISOLATION\" to the #2649 pre-dispatch base-check and corrects the baseRef restore-advice to the orchestrator/harness split (+154B; the harness does not read baseRef, #48). Deliberate growth."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"audit-fix.md": {
|
||||
"reason": "#3602: the issue's file-level grep audit missed this file — its gsd-executor spawn is dispatch-shaped. Gains an EXECUTOR_MODEL resolve-model binding, the #2517 marker + rule block, and model= on the executor Agent block. Deliberate growth, not converter drift."
|
||||
"reason": "#3602: the issue's file-level grep audit missed this file \u2014 its gsd-executor spawn is dispatch-shaped. Gains an EXECUTOR_MODEL resolve-model binding, the #2517 marker + rule block, and model= on the executor Agent block. Deliberate growth, not converter drift."
|
||||
},
|
||||
"diagnose-issues.md": {
|
||||
"reason": "#3602: same class — gsd-debugger spawned with no model resolution. Gains a DEBUGGER_MODEL resolve-model assignment in the pre-spawn block, the #2517 marker + rule block, and model=\"{DEBUGGER_MODEL}\" on the debugger Agent block. Deliberate growth, not converter drift."
|
||||
"reason": "#3602: same class \u2014 gsd-debugger spawned with no model resolution. Gains a DEBUGGER_MODEL resolve-model assignment in the pre-spawn block, the #2517 marker + rule block, and model=\"{DEBUGGER_MODEL}\" on the debugger Agent block. Deliberate growth, not converter drift. #3659 appends --mode \"$ISOLATION\" to both #2649 base-check calls (+40B): the isolation mode now gates the baseref-head suppress (harness ignores the setting, #48). Deliberate growth."
|
||||
},
|
||||
"profile-user.md": {
|
||||
"reason": "#3602: same class via a Task-tool prose spawn the issue's Agent()-shaped audit could not see. Gains a PROFILER_MODEL resolve-model binding, the #2517 marker + rule block, and a model=\"{PROFILER_MODEL}\" pass/omit instruction on the Task-tool spawn. Deliberate growth, not converter drift."
|
||||
},
|
||||
"docs-update.md": {
|
||||
"reason": "#3602 (isolated adversarial review finding): the --verify-only step's gsd-doc-verifier spawn had no model resolution — doc_writer_model covers only the writer spawns, and docs-init emits no verifier field. Gains a DOC_VERIFIER_MODEL resolve-model assignment in the init block, a model=\"{DOC_VERIFIER_MODEL}\" pass/omit instruction at the verifier spawn, and the #2517 blockquote's variable list extended. Deliberate growth, not converter drift."
|
||||
"reason": "#3602 (isolated adversarial review finding): the --verify-only step's gsd-doc-verifier spawn had no model resolution \u2014 doc_writer_model covers only the writer spawns, and docs-init emits no verifier field. Gains a DOC_VERIFIER_MODEL resolve-model assignment in the init block, a model=\"{DOC_VERIFIER_MODEL}\" pass/omit instruction at the verifier spawn, and the #2517 blockquote's variable list extended. Deliberate growth, not converter drift."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"quick.md": {
|
||||
"reason": "#3606: the execute:post consumer gains generic step dispatch (deferral to loop-hook-dispatch.md) before the code-review specialization, so refactor-trigger's ref.command step and any other registered execute:post steps actually run on /gsd:quick runs instead of being filtered out by the one-skill narrow. Deliberate growth, not converter drift."
|
||||
"reason": "#3606: the execute:post consumer gains generic step dispatch (deferral to loop-hook-dispatch.md) before the code-review specialization, so refactor-trigger's ref.command step and any other registered execute:post steps actually run on /gsd:quick runs instead of being filtered out by the one-skill narrow. Deliberate growth, not converter drift. #3659 appends --mode \"$ISOLATION\" to both #1941 base-check calls (+40B): the isolation mode now gates the baseref-head suppress \u2014 the runtime harness does not read project-settings baseRef (#48), so harness mode must compare instead of suppressing. Deliberate growth."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,9 +784,17 @@ describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
|
||||
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
|
||||
});
|
||||
|
||||
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
|
||||
test('step 7c runs the mode-threaded base-check and does NOT re-assert set-baseref (#3659)', () => {
|
||||
// The former pin required the set-baseref re-assert, whose stated mechanism
|
||||
// (#1369: "so the Claude Code harness re-reads the live HEAD") was fiction —
|
||||
// the harness does not read project-settings baseRef (#48, verified 5/5;
|
||||
// upstream claude-code#44965). Rewritten per the #3659 sanction: the
|
||||
// between-wave re-check threads --mode and never re-asserts the dead call.
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
|
||||
assert.ok(content.includes('worktree.base-check --mode "$ISOLATION"'),
|
||||
'step 7c must thread the isolation mode through the base-check');
|
||||
assert.ok(!content.includes('worktree.set-baseref'),
|
||||
'step 7c must not re-assert set-baseref — the harness never read it (#48/#3659)');
|
||||
});
|
||||
|
||||
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
|
||||
|
||||
@@ -267,11 +267,22 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
};
|
||||
}
|
||||
|
||||
test('effectiveBaseRef="head" → no degrade, reason baseref-head, execGit never called', () => {
|
||||
// #3659 rows share the diverged-HEAD stub shape — one builder keeps the
|
||||
// four fixtures from drifting apart.
|
||||
function makeDivergedExecGit(headSha, forkSha) {
|
||||
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
||||
return makeExecGit({
|
||||
'rev-parse HEAD': ok(headSha),
|
||||
'rev-parse --verify --quiet origin/HEAD': ok(forkSha),
|
||||
});
|
||||
}
|
||||
|
||||
test('effectiveBaseRef="head" + orchestrator mode → no degrade, reason baseref-head, execGit never called (#3659)', () => {
|
||||
let called = false;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'orchestrator-worktree',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
@@ -279,7 +290,64 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(result.headSha, null);
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
assert.strictEqual(called, false, 'execGit must not be called when effectiveBaseRef is head');
|
||||
assert.strictEqual(called, false, 'orchestrator mode: GSD controls the fork start-point, head is honored by construction');
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + harness mode (default) + diverged HEAD → degrade, reason baseref-head-ignored-by-harness (#3659)', () => {
|
||||
// #48 verified 5/5 that the harness dispatch path never routes through
|
||||
// project-settings baseRef — with head set on a diverged branch the check
|
||||
// must compare and degrade, not trust the setting.
|
||||
const HEAD_SHA = '11111111223344aa11111111223344aa11111111';
|
||||
const FORK_SHA = '99999999223344bb99999999223344bb99999999';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
effectiveBaseRef: 'head',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'head must not suppress the comparison in harness mode (#3659)');
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + explicit harness-worktree mode + diverged → degrade (#3659)', () => {
|
||||
const HEAD_SHA = 'aaaa1111223344ccaaaa1111223344ccaaaa1111';
|
||||
const FORK_SHA = 'bbbb1111223344ddbbbb1111223344ddbbbb1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'harness-worktree',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
});
|
||||
|
||||
test('effectiveBaseRef="head" + harness + HEAD == origin/HEAD → no degrade, reason head-matches-fork (#3659)', () => {
|
||||
const SAME_SHA = 'cccc1111223344eecccc1111223344eecccc1111';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeExecGit({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'harness-worktree',
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'when the harness fork base happens to equal HEAD there is no mismatch to degrade for');
|
||||
assert.strictEqual(result.reason, 'head-matches-fork');
|
||||
});
|
||||
|
||||
test('harness head-diverge message cites the verified harness limitation (#3659)', () => {
|
||||
const HEAD_SHA = 'dddd1111223344ffdddd1111223344ffdddd1111';
|
||||
const FORK_SHA = 'eeee1111223344abeeceeee1111223344abeeceee';
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
|
||||
effectiveBaseRef: 'head',
|
||||
});
|
||||
assert.ok(result.message !== null, 'divergence under head must carry the explanatory message');
|
||||
assert.ok(result.message.includes('#48'), 'message must cite the verified harness limitation');
|
||||
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
|
||||
});
|
||||
|
||||
test('git rev-parse HEAD fails → no degrade, reason no-head', () => {
|
||||
@@ -414,7 +482,7 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
});
|
||||
|
||||
test('headAbsenceVerified is null (not applicable) for a reason other than no-head', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({ effectiveBaseRef: 'head' });
|
||||
const result = evaluateWorktreeBaseDegrade({ effectiveBaseRef: 'head', isolationMode: 'orchestrator-worktree' });
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
assert.strictEqual(result.headAbsenceVerified, null);
|
||||
});
|
||||
@@ -449,8 +517,10 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
// Verify message contains the short SHAs and the issue reference
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
// Verify message contains the short SHAs and the corrected remediation
|
||||
// (#3659: the old text advised setting baseRef:"head", which the harness
|
||||
// does not read).
|
||||
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so origin/HEAD matches it. (worktree.baseRef:"head" applies only where GSD itself creates the worktree — the runtime harness does not read it; #48, #3659.)`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
});
|
||||
|
||||
@@ -487,7 +557,7 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
assert.strictEqual(result.forkRef, null);
|
||||
assert.strictEqual(result.forkSha, null);
|
||||
const expectedMsg = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
const expectedMsg = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. Parallel worktrees return once origin/HEAD resolves and matches HEAD. See #683, #3659.`;
|
||||
assert.strictEqual(result.message, expectedMsg);
|
||||
});
|
||||
|
||||
@@ -541,7 +611,7 @@ describe('cmdWorktreeBaseCheck', () => {
|
||||
};
|
||||
}
|
||||
|
||||
test('baseRef=head in settings → shouldDegrade false, reason baseref-head; write emits valid JSON', () => {
|
||||
test('baseRef=head in settings + --mode orchestrator-worktree → shouldDegrade false, reason baseref-head; write emits valid JSON (#3659)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
let written = '';
|
||||
@@ -555,13 +625,84 @@ describe('cmdWorktreeBaseCheck', () => {
|
||||
// Hermetic: point userClaudeDir at a non-existent path so real ~/.claude is never read
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
const result = cmdWorktreeBaseCheck(cwd, ['--mode', 'orchestrator-worktree'], deps);
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
const parsed = JSON.parse(written);
|
||||
assert.deepStrictEqual(parsed, result);
|
||||
});
|
||||
|
||||
test('baseRef=head in settings + default (harness) mode + diverged HEAD → shouldDegrade true (#3659)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
const HEAD_SHA = 'fade1111223344cafade1111223344cafade1111';
|
||||
const FORK_SHA = 'bead1111223344dbbead1111223344dbbead1111';
|
||||
const deps = {
|
||||
readFile: (p) => {
|
||||
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
return null;
|
||||
},
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'settings head must not suppress the harness-mode comparison (#3659)');
|
||||
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
|
||||
});
|
||||
|
||||
test('--mode rejects invalid values — no silent default that would re-open the #3659 hole', () => {
|
||||
const cwd = '/repo';
|
||||
const deps = {
|
||||
readFile: () => null,
|
||||
execGit: makeExecGitCheck({}),
|
||||
write: () => {},
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
};
|
||||
assert.throws(
|
||||
() => cmdWorktreeBaseCheck(cwd, ['--mode', 'bogus-mode'], deps),
|
||||
/--mode must be harness-worktree or orchestrator-worktree/
|
||||
);
|
||||
assert.throws(
|
||||
() => cmdWorktreeBaseCheck(cwd, ['--mode'], deps),
|
||||
/--mode must be harness-worktree or orchestrator-worktree/
|
||||
);
|
||||
});
|
||||
|
||||
test('default emit goes through fs.writeSync(1, …) — the seam --pick intercepts (#3659)', (t) => {
|
||||
// The CLI's --pick capture patches fs.writeSync, not process.stdout.write;
|
||||
// under $(…) command substitution the stdout.write default made --pick
|
||||
// emit the full JSON, so the workflow auto-degrade guards never matched.
|
||||
const fds = [];
|
||||
const chunks = [];
|
||||
const original = fs.writeSync;
|
||||
fs.writeSync = (fd, buf, offset, length) => {
|
||||
fds.push(fd);
|
||||
const start = offset ?? 0;
|
||||
const end = length ?? (typeof buf === 'string' ? buf.length : buf.length);
|
||||
chunks.push(typeof buf === 'string' ? buf.slice(start, end) : buf.toString('utf8', start, end));
|
||||
return end - start;
|
||||
};
|
||||
t.after(() => { fs.writeSync = original; });
|
||||
const result = cmdWorktreeBaseCheck('/repo', [], {
|
||||
readFile: () => null,
|
||||
execGit: makeExecGitCheck({
|
||||
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', signal: null, error: null },
|
||||
}),
|
||||
userClaudeDir: '/nonexistent-hermetic-user-dir',
|
||||
// no deps.write — the default seam under test
|
||||
});
|
||||
assert.ok(fds.length > 0, 'default emit must call fs.writeSync');
|
||||
assert.ok(fds.every((fd) => fd === 1), 'every write must target fd 1 (stdout)');
|
||||
const emitted = chunks.join('');
|
||||
assert.ok(emitted.includes('"reason"'), 'emitted payload is the JSON result');
|
||||
assert.strictEqual(result.reason, 'no-head');
|
||||
});
|
||||
|
||||
test('diverged shas → shouldDegrade true; captured JSON parses correctly', () => {
|
||||
const cwd = '/repo';
|
||||
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
|
||||
@@ -1000,9 +1141,11 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
const cwd = '/repo';
|
||||
const claudeDir = '/repo/.claude';
|
||||
|
||||
test('(e positive) user/global head + phase lane → shouldDegrade:false (KEY REGRESSION)', () => {
|
||||
// This is the exact bug: user set worktree.baseRef:"head" in their global settings,
|
||||
// but without the fix that setting was invisible and the phase lane triggered degrade.
|
||||
test('(e positive) user/global head + phase lane + orchestrator mode → shouldDegrade:false (KEY REGRESSION #1013)', () => {
|
||||
// This is the exact bug #1013 fixed: user set worktree.baseRef:"head" in their global
|
||||
// settings, but the setting was invisible and the phase lane triggered degrade. The
|
||||
// suppress now applies only where GSD manages the fork (--mode orchestrator-worktree),
|
||||
// which is also what keeps this cascade proof meaningful post-#3659.
|
||||
const deps = {
|
||||
execGit: makePhaseLaneExecGit(HEAD_SHA),
|
||||
readFile: (p) => {
|
||||
@@ -1018,12 +1161,34 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
|
||||
write: () => {},
|
||||
userClaudeDir: USER_CLAUDE_DIR,
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
const result = cmdWorktreeBaseCheck(cwd, ['--mode', 'orchestrator-worktree'], deps);
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
'user/global worktree.baseRef:"head" must suppress degrade on a phase lane');
|
||||
'user/global worktree.baseRef:"head" must suppress degrade on a phase lane where GSD manages the fork');
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
});
|
||||
|
||||
test('user/global head + phase lane + default (harness) mode → shouldDegrade:true (#3659)', () => {
|
||||
// The mirror of the KEY REGRESSION row: in harness mode the setting cannot
|
||||
// suppress anything (#48), so the same lane degrades.
|
||||
const deps = {
|
||||
execGit: makePhaseLaneExecGit(HEAD_SHA),
|
||||
readFile: (p) => {
|
||||
if (p === path.join(claudeDir, 'settings.local.json')) return null;
|
||||
if (p === path.join(claudeDir, 'settings.json')) return null;
|
||||
if (p === path.join(USER_CLAUDE_DIR, 'settings.json')) {
|
||||
return JSON.stringify({ worktree: { baseRef: 'head' } });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
write: () => {},
|
||||
userClaudeDir: USER_CLAUDE_DIR,
|
||||
};
|
||||
const result = cmdWorktreeBaseCheck(cwd, [], deps);
|
||||
assert.strictEqual(result.shouldDegrade, true,
|
||||
'harness mode: head must not suppress the lane degrade (#3659)');
|
||||
assert.strictEqual(result.reason, 'fork-ref-unknown');
|
||||
});
|
||||
|
||||
test('(e negative) NO user/global head + same phase lane → shouldDegrade:true (proves lane degrades)', () => {
|
||||
// Without a user/global head, the phase lane must still degrade (proves the positive test is real)
|
||||
const deps = {
|
||||
|
||||
Reference in New Issue
Block a user