* test(#4588): observed fork-from-HEAD must suppress the stale-origin degrade (failing first) * fix(#4588): observe fork-from-HEAD from prior harness worktrees before degrading * fix(#4588): a throwing probe git call is an inconclusive observation, not a crash * test(#4588): name the observed reason in the inconclusive-row assertions * test(#4588): hermetic state I/O in the inconclusive-row fixtures * chore(#4588): changeset fragment * test(#4588): contained cache I/O, emit-payload assertions (review fold-ins) * fix(#4588): review fold-ins — invalidation fixture, hermetic confirm row, gate comment, cache+scope rows * chore(#4588): backfill changeset PR number (4868) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/kind-bears-march.md
Normal file
5
.changeset/kind-bears-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4868
|
||||
---
|
||||
**Harness-worktree waves no longer degrade to sequential on a stale origin/HEAD when the fork base is confirmed from a prior worktree** — the worktree base-check now observes the harness's actual fork behavior from a clean prior harness worktree at the orchestrator HEAD before degrading; every unobservable case still degrades exactly as before. (#4588)
|
||||
@@ -399,7 +399,7 @@ export function cmdWorktreeSetBaseRef(
|
||||
export function classifyGitHead(deps?: {
|
||||
execGit?: ExecGitFn;
|
||||
cwd?: string;
|
||||
}): { status: 'present'; headSha: string } | { status: 'definitive-absence' | 'ambiguous-absence' | 'indeterminate'; headSha: null } {
|
||||
}): { status: 'definitive-absence'; headSha: null } | { status: 'ambiguous-absence'; headSha: null } | { status: 'indeterminate'; headSha: null } | { status: 'present'; headSha: string } {
|
||||
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
|
||||
const cwdOpts = deps?.cwd ? { cwd: deps.cwd } : {};
|
||||
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
||||
@@ -419,6 +419,110 @@ export function classifyGitHead(deps?: {
|
||||
return { status: 'present', headSha: headStdout };
|
||||
}
|
||||
|
||||
/**
|
||||
* #4588 (decision A2) — observe, from documented git metadata, whether the
|
||||
* harness forks its worktrees from the orchestrator HEAD.
|
||||
*
|
||||
* Substrate: the harness's own prior worktrees. A linked worktree under
|
||||
* `<repo>/.claude/worktrees/agent-*` that is still CLEAN (no tracked
|
||||
* modifications; untracked review notes are fine) and whose HEAD equals the
|
||||
* current orchestrator HEAD can only exist if the harness forked from HEAD
|
||||
* after that commit was made — an origin/HEAD fork would have landed on an
|
||||
* older commit once the orchestrator advanced. That combination is therefore
|
||||
* POSITIVE evidence of fork-from-HEAD, and it is the only configuration that
|
||||
* counts: every other observation (dirty worktree, different HEAD, no
|
||||
* worktrees, git failure) is inconclusive and fails closed to the caller's
|
||||
* existing flow.
|
||||
*
|
||||
* The verdict is cached at `<cwd>/.gsd/harness-fork-probe.json` keyed by the
|
||||
* orchestrator HEAD (the decision's keying): trusted only while the
|
||||
* orchestrator HEAD is unchanged; any HEAD move re-probes. Cache I/O failures
|
||||
* are swallowed — the probe re-runs instead (#3659 fail-closed posture).
|
||||
*/
|
||||
export function observeHarnessForkFromHead(deps: {
|
||||
execGit?: ExecGitFn;
|
||||
cwd?: string;
|
||||
headSha: string;
|
||||
stateRead?: (file: string) => string | null;
|
||||
stateWrite?: (file: string, content: string) => void;
|
||||
}): { confirmed: boolean; source: 'cache' | 'probe' | 'none'; worktreePath: string | null; worktreeHead: string | null } {
|
||||
const execGit = deps.execGit ?? execGitSeam;
|
||||
const cwd = deps.cwd ?? '.';
|
||||
const cacheFile = path.join(cwd, '.gsd', 'harness-fork-probe.json');
|
||||
const stateRead = deps.stateRead ?? ((file: string) => {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const stateWrite = deps.stateWrite ?? ((file: string, content: string) => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content, 'utf8');
|
||||
} catch {
|
||||
// A cache write must never break the check that produced the verdict.
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Cache — trusted only while the orchestrator HEAD is unchanged.
|
||||
try {
|
||||
const raw = stateRead(cacheFile);
|
||||
if (raw) {
|
||||
const cached = JSON.parse(raw) as { headSha?: string; verdict?: string; worktreePath?: string | null; worktreeHead?: string | null };
|
||||
if (cached.headSha === deps.headSha && cached.verdict === 'fork-from-head-confirmed') {
|
||||
return { confirmed: true, source: 'cache', worktreePath: cached.worktreePath ?? null, worktreeHead: cached.worktreeHead ?? null };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt cache — re-probe (fail open INTO the probe, which itself fails closed).
|
||||
}
|
||||
|
||||
// 2. Probe: list linked worktrees, keep the harness's own, require a clean
|
||||
// one whose HEAD equals the orchestrator HEAD. ANY failure — timeout,
|
||||
// non-zero exit, or a throwing execGit — is an inconclusive observation,
|
||||
// never a crash: the caller falls through to its existing flow (#4588).
|
||||
try {
|
||||
const list = execGit(['worktree', 'list', '--porcelain'], { cwd });
|
||||
if (isExecGitTimeout(list) || list.exitCode !== 0) {
|
||||
return { confirmed: false, source: 'none', worktreePath: null, worktreeHead: null };
|
||||
}
|
||||
const candidates = String(list.stdout || '')
|
||||
.split('\n\n')
|
||||
.map((block) => block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length).trim())
|
||||
.filter((p): p is string => !!p && p.includes('/.claude/worktrees/agent-'));
|
||||
for (const wtPath of candidates) {
|
||||
const status = execGit(['-C', wtPath, 'status', '--porcelain'], { cwd });
|
||||
if (isExecGitTimeout(status) || status.exitCode !== 0) continue;
|
||||
const trackedDirty = String(status.stdout || '')
|
||||
.split('\n')
|
||||
.some((l) => l.trim() !== '' && !l.startsWith('?? '));
|
||||
if (trackedDirty) continue;
|
||||
const wtHeadResult = execGit(['-C', wtPath, 'rev-parse', 'HEAD'], { cwd });
|
||||
if (isExecGitTimeout(wtHeadResult) || wtHeadResult.exitCode !== 0) continue;
|
||||
const wtHead = wtHeadResult.stdout ? wtHeadResult.stdout.trim() : '';
|
||||
if (wtHead && wtHead === deps.headSha) {
|
||||
try {
|
||||
stateWrite(cacheFile, `${JSON.stringify({
|
||||
headSha: deps.headSha,
|
||||
worktreePath: wtPath,
|
||||
worktreeHead: wtHead,
|
||||
verdict: 'fork-from-head-confirmed',
|
||||
probedAt: new Date().toISOString(),
|
||||
})}\n`);
|
||||
} catch {
|
||||
// Cache write is best-effort; the verdict stands for this dispatch.
|
||||
}
|
||||
return { confirmed: true, source: 'probe', worktreePath: wtPath, worktreeHead: wtHead };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A throwing execGit (unexpected stub shape, seam surprise) is an
|
||||
// inconclusive observation, not a crash — the #3659 comparison governs.
|
||||
}
|
||||
return { confirmed: false, source: 'none', worktreePath: null, worktreeHead: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
||||
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
||||
@@ -440,6 +544,14 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
* 'head' is honored by construction and still suppresses.
|
||||
*/
|
||||
isolationMode?: BaseCheckIsolationMode;
|
||||
/**
|
||||
* #4588 (decision A2) cache I/O for the observed fork-from-HEAD verdict.
|
||||
* Defaults read/write `<cwd>/.gsd/harness-fork-probe.json`; inject for tests.
|
||||
* The cache is keyed by the orchestrator HEAD and is trusted only while that
|
||||
* HEAD is unchanged.
|
||||
*/
|
||||
probeStateRead?: (file: string) => string | null;
|
||||
probeStateWrite?: (file: string, content: string) => void;
|
||||
}): {
|
||||
shouldDegrade: boolean;
|
||||
reason: string;
|
||||
@@ -507,6 +619,37 @@ export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
}
|
||||
const headSha = head.headSha;
|
||||
|
||||
// b2. #4588 (decision A2): OBSERVED fork-from-HEAD confirmation. A clean
|
||||
// prior harness worktree sitting exactly at the orchestrator HEAD is
|
||||
// positive evidence the harness forks from HEAD — in harness mode that
|
||||
// supersedes the origin/HEAD comparison for this dispatch (the stale
|
||||
// origin/HEAD the comparison would degrade on is not where the harness
|
||||
// forks). Fail-closed: every non-confirming observation falls through to
|
||||
// the exact pre-#4588 flow below. The mode gate below (not branch a)
|
||||
// excludes orchestrator-worktree mode — branch a only returns when
|
||||
// worktree.baseRef:"head" is set — and probeStateRead/Write default to the
|
||||
// .gsd cache file under cwd.
|
||||
if ((deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree') {
|
||||
const observed = observeHarnessForkFromHead({
|
||||
execGit,
|
||||
cwd: deps?.cwd,
|
||||
headSha,
|
||||
stateRead: deps?.probeStateRead,
|
||||
stateWrite: deps?.probeStateWrite,
|
||||
});
|
||||
if (observed.confirmed) {
|
||||
return {
|
||||
shouldDegrade: false,
|
||||
reason: 'fork-from-head-observed',
|
||||
message: null,
|
||||
headSha,
|
||||
forkRef: null,
|
||||
forkSha: null,
|
||||
headAbsenceVerified: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
||||
let forkRef: string | null = null;
|
||||
let forkSha: string | null = null;
|
||||
|
||||
@@ -18,6 +18,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { makeFaultyGit } = require('./helpers/faulty-deps.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const MODULE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
|
||||
@@ -518,8 +520,8 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkRef, 'origin/HEAD');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
@@ -544,8 +546,8 @@ describe('evaluateWorktreeBaseDegrade', () => {
|
||||
assert.strictEqual(result.forkRef, 'origin/next');
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
// HEAD != FORK_SHA in this fixture → degrade
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
assert.ok(result.message !== null);
|
||||
assert.ok(result.message.includes('origin/next'));
|
||||
});
|
||||
@@ -1020,8 +1022,8 @@ describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () =>
|
||||
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA + '\r\n', stderr: '', signal: null, error: null },
|
||||
}),
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork');
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
// After trimming, headSha and forkSha should be clean
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
assert.strictEqual(result.forkSha, FORK_SHA);
|
||||
@@ -1473,3 +1475,299 @@ describe('#4734: classifyGitHead — single owner of the HEAD-resolution classes
|
||||
assert.strictEqual(status.headSha, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #4588 A2: observed fork-from-HEAD confirmation (probe + cache, fail-closed) ──
|
||||
|
||||
describe('#4588 A2: a clean prior harness worktree at the orchestrator HEAD confirms fork-from-HEAD', () => {
|
||||
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
|
||||
const WT_PATH = '/repo/.claude/worktrees/agent-x';
|
||||
const ORIGIN_SHA = 'eeee1111223344abeeceeee1111223344abeeceee';
|
||||
|
||||
// Worktree-listing stub: one harness worktree at WT_PATH; origin/HEAD DIVERGED
|
||||
// from HEAD so the pre-#4588 flow would degrade (head-diverged-from-fork).
|
||||
function makeWorktreeExecGit({ clean = true, wtHead = HEAD_SHA, listTimeout = false } = {}) {
|
||||
return function stubExecGit(args, _opts) {
|
||||
const key = args.join(' ');
|
||||
if (key === 'worktree list --porcelain') {
|
||||
if (listTimeout) {
|
||||
const err = new Error('spawnSync git ETIMEDOUT');
|
||||
err.code = 'ETIMEDOUT';
|
||||
return { exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: err };
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: `worktree /repo\nbranch refs/heads/main\n\nworktree ${WT_PATH}\nbranch refs/heads/agent-x\n`,
|
||||
stderr: '', signal: null, error: null,
|
||||
};
|
||||
}
|
||||
if (key === `-C ${WT_PATH} status --porcelain`) {
|
||||
return { exitCode: 0, stdout: clean ? '' : ' M tracked.txt', stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === `-C ${WT_PATH} rev-parse HEAD`) {
|
||||
return { exitCode: 0, stdout: `${wtHead}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse HEAD') {
|
||||
return { exitCode: 0, stdout: `${HEAD_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') {
|
||||
return { exitCode: 0, stdout: `${ORIGIN_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') {
|
||||
return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
|
||||
}
|
||||
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
}
|
||||
|
||||
test('a clean prior harness worktree at the orchestrator HEAD confirms fork-from-HEAD (no degrade)', () => {
|
||||
// DIVERGED origin/HEAD: the #3659 flow degrades here unless the probe
|
||||
// observes that the harness forks from HEAD. All state I/O is in-memory —
|
||||
// the rows in this describe must stay hermetic (a default fs cache under
|
||||
// the stub cwd is shared state across tests, and a real fs write at a
|
||||
// root-level stub path pollutes root CI machines).
|
||||
let cache = null;
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeWorktreeExecGit(),
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => cache,
|
||||
probeStateWrite: (_file, content) => { cache = content; },
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false, 'a clean worktree at the orchestrator HEAD is positive evidence of fork-from-HEAD');
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
assert.strictEqual(result.headSha, HEAD_SHA);
|
||||
});
|
||||
|
||||
test('the probe cache serves a matching verdict without re-probing', () => {
|
||||
const stateRead = (_file) => JSON.stringify({
|
||||
headSha: HEAD_SHA,
|
||||
worktreePath: WT_PATH,
|
||||
worktreeHead: HEAD_SHA,
|
||||
verdict: 'fork-from-head-confirmed',
|
||||
probedAt: '2026-09-18T00:00:00.000Z',
|
||||
});
|
||||
// The stub answers rev-parse HEAD (classifyGitHead needs it before the
|
||||
// cache is consulted) and refuses the worktree LIST: if the probe ran,
|
||||
// this throws and fails.
|
||||
const execGit = (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'rev-parse HEAD') {
|
||||
return { exitCode: 0, stdout: `${HEAD_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'worktree list --porcelain') {
|
||||
throw new Error('probe must not run when the cache matches');
|
||||
}
|
||||
throw new Error(`unexpected execGit call: ${key}`);
|
||||
};
|
||||
const result = evaluateWorktreeBaseDegrade({ execGit, cwd: '/repo', probeStateRead: stateRead });
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
|
||||
test('the cache is invalidated by an orchestrator HEAD move', () => {
|
||||
const NEW_HEAD = '11223344aabbccdd11223344aabbccdd11223344';
|
||||
const stateRead = (_file) => JSON.stringify({
|
||||
headSha: HEAD_SHA,
|
||||
worktreePath: WT_PATH,
|
||||
worktreeHead: HEAD_SHA,
|
||||
verdict: 'fork-from-head-confirmed',
|
||||
probedAt: '2026-09-18T00:00:00.000Z',
|
||||
});
|
||||
// The cached entry is keyed to the OLD orchestrator HEAD while the
|
||||
// orchestrator HEAD has MOVED (rev-parse HEAD answers NEW_HEAD) and the
|
||||
// worktree still sits at the old commit — the cache must be ignored, the
|
||||
// probe re-run, and the flow fall through to the fork comparison.
|
||||
const execGit = (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'rev-parse HEAD') {
|
||||
return { exitCode: 0, stdout: `${NEW_HEAD}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'worktree list --porcelain') {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: `worktree /repo\nbranch refs/heads/main\n\nworktree ${WT_PATH}\nbranch refs/heads/agent-x\n`,
|
||||
stderr: '', signal: null, error: null,
|
||||
};
|
||||
}
|
||||
if (key === `-C ${WT_PATH} status --porcelain`) {
|
||||
return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === `-C ${WT_PATH} rev-parse HEAD`) {
|
||||
return { exitCode: 0, stdout: `${HEAD_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') {
|
||||
return { exitCode: 0, stdout: `${ORIGIN_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') {
|
||||
return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
|
||||
}
|
||||
throw new Error(`unexpected execGit call: ${key}`);
|
||||
};
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit,
|
||||
cwd: '/repo',
|
||||
probeStateRead: stateRead,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', 'stale cache must not suppress the #3659 comparison');
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
});
|
||||
|
||||
test('a dirty harness worktree is not evidence', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeWorktreeExecGit({ clean: false }),
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true, `unobserved fork base → the #3659 comparison still governs (reason=${result.reason})`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
});
|
||||
|
||||
test('a harness worktree at a different commit is not evidence', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeWorktreeExecGit({ wtHead: ORIGIN_SHA }),
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
});
|
||||
|
||||
test('no harness worktrees changes nothing', () => {
|
||||
const execGit = makeWorktreeExecGit();
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args, opts) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'worktree list --porcelain') {
|
||||
return { exitCode: 0, stdout: 'worktree /repo\nbranch refs/heads/main\n', stderr: '', signal: null, error: null };
|
||||
}
|
||||
return execGit(args, opts);
|
||||
},
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', `reason=${result.reason}`);
|
||||
});
|
||||
|
||||
test('a probe timeout fails closed to the existing flow', () => {
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: makeWorktreeExecGit({ listTimeout: true }),
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, true);
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', 'a probe timeout must fall through to the comparison, not skip it');
|
||||
});
|
||||
|
||||
test('untracked-only worktrees count as clean', () => {
|
||||
const execGit = makeWorktreeExecGit();
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: (args, opts) => {
|
||||
const key = args.join(' ');
|
||||
if (key === `-C ${WT_PATH} status --porcelain`) {
|
||||
return { exitCode: 0, stdout: '?? pr-review-notes.md', stderr: '', signal: null, error: null };
|
||||
}
|
||||
return execGit(args, opts);
|
||||
},
|
||||
cwd: '/repo',
|
||||
probeStateRead: () => null,
|
||||
probeStateWrite: () => {},
|
||||
});
|
||||
assert.strictEqual(result.shouldDegrade, false, 'untracked review notes do not disqualify the observation');
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
|
||||
test('orchestrator-worktree mode never probes (the suppress predates the probe)', () => {
|
||||
const refusing = () => { throw new Error('probe must not run in orchestrator-worktree mode'); };
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
execGit: refusing,
|
||||
cwd: '/repo',
|
||||
effectiveBaseRef: 'head',
|
||||
isolationMode: 'orchestrator-worktree',
|
||||
});
|
||||
assert.strictEqual(result.reason, 'baseref-head');
|
||||
assert.strictEqual(result.shouldDegrade, false);
|
||||
});
|
||||
|
||||
test('a corrupt cache is re-probed, not trusted', () => {
|
||||
const stateRead = (_file) => '{ this is not json';
|
||||
let probed = false;
|
||||
const execGit = (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'worktree list --porcelain') {
|
||||
probed = true;
|
||||
return { exitCode: 0, stdout: 'worktree /repo\nbranch refs/heads/main\n', stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse HEAD') {
|
||||
return { exitCode: 0, stdout: `${HEAD_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') {
|
||||
return { exitCode: 0, stdout: `${ORIGIN_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') {
|
||||
return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
|
||||
}
|
||||
throw new Error(`unexpected execGit call: ${key}`);
|
||||
};
|
||||
const result = evaluateWorktreeBaseDegrade({ execGit, cwd: '/repo', probeStateRead: stateRead });
|
||||
assert.ok(probed, 'the probe must run past a corrupt cache');
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', 'corrupt cache → re-probe → the comparison governs');
|
||||
});
|
||||
|
||||
test('worktrees outside the harness directory are not candidates', () => {
|
||||
const execGit = (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === 'worktree list --porcelain') {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: `worktree /repo\nbranch refs/heads/main\n\nworktree /elsewhere/agent-y\nbranch refs/heads/agent-y\n`,
|
||||
stderr: '', signal: null, error: null,
|
||||
};
|
||||
}
|
||||
if (key === 'rev-parse HEAD') {
|
||||
return { exitCode: 0, stdout: `${HEAD_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'rev-parse --verify --quiet origin/HEAD') {
|
||||
return { exitCode: 0, stdout: `${ORIGIN_SHA}\n`, stderr: '', signal: null, error: null };
|
||||
}
|
||||
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') {
|
||||
return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
|
||||
}
|
||||
throw new Error(`unexpected execGit call: ${key}`);
|
||||
};
|
||||
const result = evaluateWorktreeBaseDegrade({ execGit, cwd: '/repo' });
|
||||
assert.strictEqual(result.reason, 'head-diverged-from-fork', 'a non-harness worktree must not confirm the observation');
|
||||
});
|
||||
|
||||
test('end-to-end: a real repo with a clean harness worktree at HEAD passes the base-check', (t) => {
|
||||
const { createTempGitProject } = require('./helpers.cjs');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
const dir = createTempGitProject('gsd-4588-e2e-');
|
||||
t.after(() => cleanup(dir));
|
||||
const wtPath = path.join(dir, '.claude', 'worktrees', 'agent-e2e');
|
||||
fs.mkdirSync(path.dirname(wtPath), { recursive: true });
|
||||
gitOrThrow(['worktree', 'add', '-b', 'agent-e2e', wtPath, 'HEAD'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
||||
|
||||
// The command emits its JSON via fs.writeSync(1, …) — capture through the
|
||||
// approved mock mechanism and assert on the EMITTED payload too (the
|
||||
// workflow-facing contract), not just the return value.
|
||||
const emitted = [];
|
||||
const writeSyncMock = t.mock.method(fs, 'writeSync', (fd, buf, ...rest) => {
|
||||
void fd; void rest;
|
||||
emitted.push(typeof buf === 'string' ? buf : Buffer.from(buf).toString('utf8'));
|
||||
return (typeof buf === 'string' ? buf : Buffer.from(buf)).length;
|
||||
});
|
||||
const result = cmdWorktreeBaseCheck(dir, ['--mode', 'harness-worktree']);
|
||||
assert.ok(writeSyncMock.mock.calls.length > 0, 'the check must emit its JSON payload');
|
||||
const emittedJson = JSON.parse(emitted.join(''));
|
||||
assert.strictEqual(emittedJson.reason, 'fork-from-head-observed', 'the emitted workflow payload must carry the observation');
|
||||
assert.strictEqual(emittedJson.shouldDegrade, false);
|
||||
assert.strictEqual(result.shouldDegrade, false,
|
||||
`a clean harness worktree at HEAD must confirm fork-from-HEAD; got reason=${result.reason}`);
|
||||
assert.strictEqual(result.reason, 'fork-from-head-observed');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user