fix(#3050): consolidate the spawn-timeout predicate and propagate the unresolved-root reason (#3060)
* chore(#3050): changeset and review artifacts for the follow-up Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3050): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir, isSpawnTimeout } from './shell-command-projection.cjs';
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
@@ -923,11 +923,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// exit is a real I/O failure, not a missing file.
|
||||
const rmResult = execGit(['rm', '--cached', '--ignore-unmatch', file], { cwd });
|
||||
if (rmResult.exitCode !== 0) {
|
||||
const rmErr: NodeJS.ErrnoException | null = rmResult.error;
|
||||
stagingFailures.push({
|
||||
file,
|
||||
error: rmResult.stderr || rmResult.stdout,
|
||||
timed_out: rmResult.signal === 'SIGTERM' && rmErr?.code === 'ETIMEDOUT',
|
||||
timed_out: isSpawnTimeout(rmResult),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -938,17 +937,13 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
if (addResult.exitCode === 0) {
|
||||
stagedPaths.push(file);
|
||||
} else {
|
||||
// `SpawnResultOutput.error` is typed `Error | null`; widen to the errno
|
||||
// shape by ANNOTATION rather than assertion — `Error` is assignable to
|
||||
// `NodeJS.ErrnoException` (its extra fields are optional), so an `as`
|
||||
// cast here trips no-unnecessary-type-assertion.
|
||||
const addErr: NodeJS.ErrnoException | null = addResult.error;
|
||||
stagingFailures.push({
|
||||
file,
|
||||
error: addResult.stderr || addResult.stdout,
|
||||
// The projection exposes a timeout distinctly (#2608 AC5); this is the
|
||||
// same SIGTERM+ETIMEDOUT idiom worktree-safety.cts uses.
|
||||
timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
|
||||
// The projection exposes a timeout distinctly (#2608 AC5); this uses
|
||||
// the shared isSpawnTimeout predicate (shell-command-projection.cts)
|
||||
// also used by worktree-safety.cts and worktree-base-ref.cts (#3050).
|
||||
timed_out: isSpawnTimeout(addResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1127,11 +1122,10 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
|
||||
if (addResult.exitCode === 0) {
|
||||
stagedRelPaths.push(relativePath);
|
||||
} else {
|
||||
const addErr: NodeJS.ErrnoException | null = addResult.error;
|
||||
subStagingFailures.push({
|
||||
file,
|
||||
error: addResult.stderr || addResult.stdout,
|
||||
timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
|
||||
timed_out: isSpawnTimeout(addResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +499,27 @@ interface SpawnResultOutput {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a spawn/exec result indicates the subprocess was killed
|
||||
* by a timeout, i.e. it never completed and reported a real answer. This is
|
||||
* the single shared definition of "did this subprocess time out" — worktree
|
||||
* safety (src/worktree-safety.cts) and worktree base-ref detection
|
||||
* (src/worktree-base-ref.cts) both call this instead of maintaining their
|
||||
* own copies (#3050 — "Generative Fix Divergence").
|
||||
*
|
||||
* Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
|
||||
* cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
|
||||
* 'SIGTERM'` check some earlier code paired with it is platform-fragile —
|
||||
* Windows does not necessarily report SIGTERM the same way — and pairing it
|
||||
* in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
|
||||
* fails to trip the guard) on that platform. There is no false-positive risk
|
||||
* from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
|
||||
* `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
|
||||
*/
|
||||
export function isSpawnTimeout(result: { error?: unknown }): boolean {
|
||||
return (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
|
||||
}
|
||||
|
||||
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
|
||||
@@ -615,9 +636,9 @@ export function resolveGsdToolsPath(): string {
|
||||
* NEVER throws. Degrades to `{ ok:false, ... }` on:
|
||||
* - a missing/invalid "family" (validated locally, no subprocess spawned)
|
||||
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
|
||||
* - a wall-clock timeout (`timedOut:true`, mirroring the
|
||||
* `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
|
||||
* by worktree-safety.cts)
|
||||
* - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
|
||||
* predicate defined above in this file — also used by worktree-safety.cts
|
||||
* and worktree-base-ref.cts)
|
||||
* - any other unanticipated throw from the underlying spawn (defensive
|
||||
* try/catch — execTool itself is spawnSync-based and does not throw).
|
||||
*/
|
||||
@@ -674,16 +695,9 @@ export function dispatchGsdCommand({
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the established `result.error && (result.error as
|
||||
// NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
|
||||
// narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
|
||||
// to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
|
||||
// trips a typescript-eslint no-unnecessary-type-assertion false positive for
|
||||
// this exact narrowing shape (all of ErrnoException's extra fields over Error
|
||||
// are optional).
|
||||
const timedOut = result.signal === 'SIGTERM'
|
||||
&& result.error !== null
|
||||
&& (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT';
|
||||
// Delegates to the single shared predicate defined above in this file
|
||||
// (#3050 — "Generative Fix Divergence") instead of a local inline copy.
|
||||
const timedOut = isSpawnTimeout(result);
|
||||
|
||||
return {
|
||||
ok: result.exitCode === 0 && !timedOut,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { execGit as execGitSeam } from './shell-command-projection.cjs';
|
||||
import { execGit as execGitSeam, isSpawnTimeout } from './shell-command-projection.cjs';
|
||||
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
@@ -98,18 +98,19 @@ function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSh
|
||||
|
||||
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_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD timed out or could not complete). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683, #3050.`;
|
||||
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.`;
|
||||
|
||||
/**
|
||||
* Returns true when an execGit result indicates the subprocess was killed by
|
||||
* a timeout (SIGTERM + ETIMEDOUT), mirroring the idiom already established in
|
||||
* worktree-safety.cts's execGitDefault. A timeout means the command genuinely
|
||||
* could not complete — it must never be treated the same as a clean non-zero
|
||||
* exit (e.g. "not a git repository"), which DID complete and reported a real
|
||||
* answer.
|
||||
* a timeout. A timeout means the command genuinely could not complete — it
|
||||
* must never be treated the same as a clean non-zero exit (e.g. "not a git
|
||||
* repository"), which DID complete and reported a real answer.
|
||||
*
|
||||
* Delegates to the single shared predicate in shell-command-projection.cts
|
||||
* (#3050 — "Generative Fix Divergence"); do not reimplement this locally.
|
||||
*/
|
||||
function isExecGitTimeout(result: { signal: string | null; error: unknown }): boolean {
|
||||
return result.signal === 'SIGTERM' && (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
|
||||
return isSpawnTimeout(result);
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
@@ -372,9 +373,29 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
||||
if (headResult.exitCode !== 0 || !headStdout) {
|
||||
// exit 128 is git's definitive "not a git repository" answer — it completed
|
||||
// and genuinely reported no HEAD. Only this specific, confirmed outcome
|
||||
// stays a benign non-degrade; every other non-success outcome below is
|
||||
// NOT a definitive answer from git and must fail closed (#3050).
|
||||
if (headResult.exitCode === 128) {
|
||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
// Exit 0 with empty stdout is pinned as benign no-degrade by an existing
|
||||
// regression guard (tests/worktree-base-ref.test.cjs — "git rev-parse HEAD
|
||||
// returns empty stdout"). Left unchanged deliberately; flagged in the
|
||||
// #3050 review for a product-intent call rather than silently flipped.
|
||||
if (headResult.exitCode === 0 && !headStdout) {
|
||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
if (headResult.exitCode !== 0) {
|
||||
// Any other non-success outcome (e.g. exit 127 — git missing — or any
|
||||
// other non-zero, non-128 exit) is not a definitive "not a repo" answer.
|
||||
// Fail closed instead of silently treating it as benign.
|
||||
// (`!headStdout` was previously OR'd in here but is unreachable: the
|
||||
// exitCode===0 && !headStdout case is already handled above, and every
|
||||
// other branch here has exitCode!==0 already true — #3050 review.)
|
||||
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
const headSha = headStdout;
|
||||
|
||||
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execGit as execGitSeam, posixNormalize } from './shell-command-projection.cjs';
|
||||
import { execGit as execGitSeam, posixNormalize, isSpawnTimeout } from './shell-command-projection.cjs';
|
||||
|
||||
// Default timeout for worktree-related git subprocess calls.
|
||||
// 10 s is generous enough for normal git operations on large repos while still
|
||||
@@ -38,11 +38,13 @@ type ExecGitFn = (args: string[], opts?: { cwd?: string; timeout?: number }) =>
|
||||
* (args, opts) shape — see worktree-safety-policy.test.cjs.
|
||||
*
|
||||
* Return shape: { exitCode, stdout, stderr, timedOut, error, signal }
|
||||
* - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT
|
||||
* - timedOut: derived via the shared `isSpawnTimeout` predicate
|
||||
* (shell-command-projection.cts) — true when spawnSync's `error.code`
|
||||
* is `ETIMEDOUT`; does not require `signal === 'SIGTERM'` (#3050).
|
||||
*/
|
||||
function execGitDefault(args: string[], opts: { cwd?: string; timeout?: number } = {}): GitResult {
|
||||
const result = execGitSeam(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
|
||||
const timedOut = result.signal === 'SIGTERM' && (result.error as NodeJS.ErrnoException)?.code === 'ETIMEDOUT';
|
||||
const timedOut = isSpawnTimeout(result);
|
||||
return { ...result, timedOut };
|
||||
}
|
||||
|
||||
@@ -1800,15 +1802,22 @@ void parseWorktreeListPaths;
|
||||
// ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
|
||||
|
||||
/**
|
||||
* Resolve the main worktree root when running inside a git worktree.
|
||||
* In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
|
||||
* Returns the main worktree path, or cwd if not in a worktree.
|
||||
* Resolve the main worktree root when running inside a git worktree, along
|
||||
* with the `reason` that produced it (#3050). Callers MUST inspect `reason`
|
||||
* before trusting `root` unconditionally — a `reason` of `git_timed_out`
|
||||
* means the git subprocess used to distinguish "linked worktree" from
|
||||
* "not a repo" never completed, so `root` is a best-effort fallback (cwd),
|
||||
* not a confirmed worktree root. Degrading to cwd rather than throwing is
|
||||
* intentional (return degraded result on timeout; do not throw) — but the
|
||||
* reason must still reach the caller so it can surface the risk instead of
|
||||
* silently trusting the wrong root.
|
||||
*/
|
||||
function resolveWorktreeRoot(cwd: string): string {
|
||||
function resolveWorktreeRoot(cwd: string, deps: WorktreeDeps = {}): { root: string; reason: string } {
|
||||
const context = resolveWorktreeContext(cwd, {
|
||||
existsSync: fs.existsSync,
|
||||
existsSync: deps.existsSync || fs.existsSync,
|
||||
execGit: deps.execGit,
|
||||
});
|
||||
return context.effectiveRoot;
|
||||
return { root: context.effectiveRoot, reason: context.reason };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user