* test(#1856): failing-first contract for the orchestrator cwd-drift guard handoff The #48 guard correctly refuses to execute waves from an agent worktree, but the refusal is a dead end: that worktree can hold committed fixes AND uncommitted work, and "re-run from the orchestrator's own worktree" silently means abandoning them. The reporter was left choosing between continuing from a blocked worktree and losing the work. The guard is shell embedded in execute-phase.md, so these tests extract the block by a stable marker and EXECUTE it against real git fixtures — the shipped text is the runtime contract. Covers the stranded-commit and dirty-tree report, the integration commands, both agent- namespaces, commit-count boundaries 0/1/2, and the constraints the guard's own comment records: it must NOT fire on an ordinary branch, on 'agentic-refactor', or on a legitimate feature worktree under .claude/worktrees/, and must degrade cleanly with no resolvable base or a detached HEAD. RED expected: the marker does not exist, so extraction fails and every case errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#1856): give the executor-worktree refusal a handoff instead of a dead end The #48 cwd-drift guard correctly refuses to execute waves from an agent worktree — its comment records why ("this is how a wrong-base merge nearly shipped ~1000 files"), and that refusal is untouched here. The defect is that it was a dead end. At the moment it fires, the worktree can hold committed product work, uncommitted product and planning changes, and the live gap-planning context. Telling the user to "re-run from the orchestrator's own worktree" silently means abandoning all of it, because the orchestrator worktree cannot see commits that live only on the agent branch. The reporter was left choosing between continuing from a blocked worktree and losing five commits plus uncommitted work. The refusal now reports what is actually stranded — the commit count and log against the resolved base, and the uncommitted files — followed by the concrete integration sequence (commit here, switch to an orchestrator-safe checkout, merge or cherry-pick, re-run) and a verify command. Nothing is claimed that is not there: a clean worktree with no commits ahead prints the plain refusal with no empty sections. Every added command is diagnostic and `|| true`-guarded, so a failure degrades to the original refusal rather than crashing before the message prints. Verified: an unresolvable base still refuses cleanly. Deliberately NOT done: auto-merging or auto-cherry-picking the agent branch. That is precisely the operation #48 exists to stop the orchestrator performing from a drifted cwd, at the moment it has least confidence about which tree is which. Reporting beats acting here. The guard block carries a `gsd:guard=orchestrator-cwd-drift` marker so the new contract test can extract and EXECUTE the shipped shell against real git fixtures rather than asserting on its characters. Fixes #1856 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#1856): report true counts, add the changeset, and document the seam Review findings, all from the isolated adversarial pass: - The dirty-file list was capped at 20 with no indication, so a worktree with 27 uncommitted files reported 20 — under-informing the user about exactly what is stranded, which is the entire point of this change. Both lists now count BEFORE truncating and print "… and N more". Verified with 25 commits / 27 dirty files. - The has-commits condition was written out twice and could drift on a future edit. Collapsed to a single _WT_HAS_COMMITS flag. - The changeset fragment existed but was untracked, so it was in neither commit on this branch and the PR gate would have failed against real history. - CONTEXT.md:122 documents this exact seam ("the orchestrator runs a cwd-drift guard at execute_waves entry…") and was not extended. Now records the handoff report, that the refusal condition and exit code are unchanged, and that every added command is diagnostic and || true-guarded. Verified NOT a defect, correcting the review's premise: the guard block does break when its line endings are CRLF, but .gitattributes:2 is `* text=auto eol=lf`, which OVERRIDES core.autocrlf and forces LF on checkout on every platform including Windows — so the shipped file is LF there too, and the installer copies it through Node without translating endings. The reproduction (mine and the reviewer's) required injecting CRLF by hand. It is also not fixable from inside the script: a \r breaks the shell parse at the block's first line, before any #1856 code runs. Neither introduced nor amplified by this change. Also noted and left as-is by design: the review flagged that #1856's "offer an explicit recovery option" could be read as requiring an interactive/automated integration rather than printed instructions. Deliberate — see the commit that added the block: auto-merging is the exact operation #48 exists to prevent the orchestrator performing from a drifted cwd. Called out in the PR body for a maintainer decision rather than silently chosen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * chore(#1856): backfill changeset PR number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
228 lines
8.1 KiB
JavaScript
228 lines
8.1 KiB
JavaScript
// allow-test-rule: runtime-contract-is-the-product see #1856
|
|
// The orchestrator cwd-drift guard (#48) is shell EMBEDDED in execute-phase.md.
|
|
// The shipped text IS the runtime contract, so these tests extract the block and
|
|
// EXECUTE it against real git fixtures rather than asserting on its characters —
|
|
// the readFileSync here is extraction for execution, not a source-grep assertion.
|
|
'use strict';
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
|
const GUARD_MARKER = 'gsd:guard=orchestrator-cwd-drift';
|
|
|
|
/**
|
|
* Pull the guard's bash block out of the workflow. Anchored on a stable marker
|
|
* comment rather than a line range so the test does not rot when the file moves.
|
|
*/
|
|
function guardScript() {
|
|
const md = fs.readFileSync(WORKFLOW, 'utf8');
|
|
const fences = md.split('```');
|
|
for (let i = 1; i < fences.length; i += 2) {
|
|
const body = fences[i].replace(/^bash\r?\n/, '');
|
|
if (body.includes(GUARD_MARKER)) return body;
|
|
}
|
|
throw new Error(
|
|
`no fenced block carrying "${GUARD_MARKER}" in ${WORKFLOW} — the guard must be ` +
|
|
'marked so this contract test can execute the shipped text.',
|
|
);
|
|
}
|
|
|
|
const git = (cwd, ...args) =>
|
|
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
/** A real repo with a base branch and one commit. */
|
|
function makeRepo() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1856-'));
|
|
git(dir, 'init', '--quiet', '--initial-branch', 'next');
|
|
git(dir, 'config', 'user.email', 'test@example.com');
|
|
git(dir, 'config', 'user.name', 'Test');
|
|
fs.writeFileSync(path.join(dir, 'base.txt'), 'base\n');
|
|
git(dir, 'add', '-A');
|
|
git(dir, 'commit', '--quiet', '-m', 'base');
|
|
return dir;
|
|
}
|
|
|
|
function commitFile(dir, name, msg) {
|
|
fs.writeFileSync(path.join(dir, name), `${name}\n`);
|
|
git(dir, 'add', '-A');
|
|
git(dir, 'commit', '--quiet', '-m', msg);
|
|
}
|
|
|
|
/** Run the extracted guard in `dir`. Never throws — returns the observed result. */
|
|
function runGuard(dir) {
|
|
const res = spawnSync('bash', ['-c', guardScript()], {
|
|
cwd: dir,
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
});
|
|
return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
}
|
|
|
|
test('#1856: refusal names the stranded commits and the dirty tree', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
git(dir, 'checkout', '--quiet', '-b', 'worktree-agent-36.12-05-090827');
|
|
for (const m of ['wire roto toggle', 'preserve roto keys', 'simplify controls',
|
|
'count semantics', 'bound regeneration']) {
|
|
commitFile(dir, m.replace(/\s/g, '-') + '.txt', `fix(36.12-05): ${m}`);
|
|
}
|
|
fs.writeFileSync(path.join(dir, 'uncommitted.txt'), 'work in progress\n');
|
|
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 1, 'the guard must still REFUSE — #48 is load-bearing');
|
|
assert.match(r.stderr, /worktree-agent-36\.12-05-090827/, 'names the branch');
|
|
|
|
// The whole point of #1856: the refusal must not be a dead end.
|
|
assert.match(
|
|
r.stderr,
|
|
/5\s+commit/i,
|
|
'must report how many commits are stranded on the agent branch — without this the ' +
|
|
'user cannot tell that switching away loses work (#1856)',
|
|
);
|
|
assert.match(
|
|
r.stderr,
|
|
/uncommitted|dirty|unstaged/i,
|
|
'must report that the worktree still has uncommitted changes (#1856)',
|
|
);
|
|
assert.match(
|
|
r.stderr,
|
|
/cherry-pick|merge/i,
|
|
'must give an integration command, not just "re-run from the orchestrator worktree"',
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: the bare agent- namespace is handled identically', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
git(dir, 'checkout', '--quiet', '-b', 'agent-a1b2c3');
|
|
commitFile(dir, 'one.txt', 'feat: one');
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 1);
|
|
assert.match(r.stderr, /agent-a1b2c3/);
|
|
assert.match(r.stderr, /1\s+commit/i);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: a clean agent worktree still refuses, without a wall of empty sections', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
git(dir, 'checkout', '--quiet', '-b', 'agent-clean');
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 1, 'still refuses');
|
|
// Nothing is stranded, so nothing must be claimed to be.
|
|
assert.doesNotMatch(
|
|
r.stderr,
|
|
/\b[1-9]\d*\s+commit/i,
|
|
'a branch with no commits ahead must not report stranded commits',
|
|
);
|
|
assert.doesNotMatch(
|
|
r.stderr,
|
|
/uncommitted changes/i,
|
|
'a clean tree must not be reported as dirty',
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: boundary — 1 and 2 commits ahead are both reported accurately', () => {
|
|
for (const n of [1, 2]) {
|
|
const dir = makeRepo();
|
|
try {
|
|
git(dir, 'checkout', '--quiet', '-b', 'agent-count');
|
|
for (let i = 0; i < n; i += 1) commitFile(dir, `c${i}.txt`, `feat: c${i}`);
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 1);
|
|
assert.match(r.stderr, new RegExp(`\\b${n}\\s+commit`, 'i'), `${n} ahead reported`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('#1856: does not fire on an ordinary orchestrator branch', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
const r = runGuard(dir); // still on `next`
|
|
assert.equal(r.status, 0, `guard must not fire on a normal branch: ${r.stderr}`);
|
|
assert.doesNotMatch(r.stderr, /refusing to execute waves/);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: does not fire on a branch that merely starts with "agent"', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
// The discriminator is the `agent-` NAMESPACE. `agentic-refactor` is an
|
|
// ordinary feature branch and must run.
|
|
git(dir, 'checkout', '--quiet', '-b', 'agentic-refactor');
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 0, `guard must not fire on agentic-refactor: ${r.stderr}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: does not fire on a legitimate feature worktree', () => {
|
|
// Recorded constraint in the guard's own comment: the discriminator is the branch
|
|
// namespace, NOT the .claude/worktrees/ path — the orchestrator may legitimately
|
|
// run from a feature worktree there, and a path check would break that.
|
|
const dir = makeRepo();
|
|
try {
|
|
const wt = path.join(dir, '.claude', 'worktrees', 'feature');
|
|
fs.mkdirSync(path.dirname(wt), { recursive: true });
|
|
git(dir, 'worktree', 'add', '--quiet', '-b', 'feat/legit', wt);
|
|
const r = runGuard(wt);
|
|
assert.equal(r.status, 0, `a feature worktree must run: ${r.stderr}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: reporting degrades to the plain refusal when no base ref resolves', () => {
|
|
// No origin, no main/next to compare against — the guard must still refuse
|
|
// cleanly rather than crash or hang before printing.
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1856-nobase-'));
|
|
try {
|
|
git(dir, 'init', '--quiet', '--initial-branch', 'agent-orphan');
|
|
git(dir, 'config', 'user.email', 'test@example.com');
|
|
git(dir, 'config', 'user.name', 'Test');
|
|
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
|
|
git(dir, 'add', '-A');
|
|
git(dir, 'commit', '--quiet', '-m', 'only');
|
|
const r = runGuard(dir);
|
|
assert.equal(r.status, 1, 'still refuses with no resolvable base');
|
|
assert.match(r.stderr, /refusing to execute waves/);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('#1856: detached HEAD does not crash the guard', () => {
|
|
const dir = makeRepo();
|
|
try {
|
|
const sha = git(dir, 'rev-parse', 'HEAD').trim();
|
|
git(dir, 'checkout', '--quiet', '--detach', sha);
|
|
const r = runGuard(dir);
|
|
// `rev-parse --abbrev-ref HEAD` yields "HEAD" when detached — not an agent
|
|
// branch, so the guard must pass through without erroring.
|
|
assert.equal(r.status, 0, `detached HEAD must not crash the guard: ${r.stderr}`);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|