fix(#3659): make the worktree base-check trust evidence, not baseRef (#3736)

* 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:
Tom Boucher
2026-08-21 04:58:33 -04:00
committed by GitHub
parent 1f76861202
commit 2b42b28687
18 changed files with 339 additions and 76 deletions

View File

@@ -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 };
}