fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard (#3979)

* test(#3972): the guard fallback must share the opt-out ladder

* fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard

* test(#3972): without a workstream the root config is the effective config

* fix(#3972): guard the whole ladder body — malformed env values degrade, never throw

* chore(#3972): changeset fragment (pr number backfilled after PR creation)

* chore(#3972): backfill changeset PR number (3979)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-28 01:53:23 -04:00
committed by GitHub
parent 45f18b444d
commit 5ad708aa4e
5 changed files with 175 additions and 43 deletions

View File

@@ -1552,3 +1552,81 @@ describe('gsd-agent-isolation-guard.js: #3582 cold tree — RuntimeBuildError su
assert.ok(out.reason.length > 0);
});
});
// ─── #3972: the guard's sentinel-absent fallback shares the opt-out ladder ───
// The guard's own config read was flat-root, so a workstream-LOCAL
// use_worktrees=false was invisible to it: with no sentinel present (fresh
// checkout) the fallback denied the sequential dispatch the config
// explicitly allowed. The ladder now lives in planning-workspace and both
// the resolver and the guard consume it.
describe('guard fallback — worktreesOptedOut ladder (#3972)', () => {
function wsFixture(rootCfg, wsCfg) {
const dir = createTempDir('gsd-3972-guard-');
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(rootCfg));
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(wsCfg));
return dir;
}
test('#3972: a workstream-local use_worktrees=false opts the fallback out', (t) => {
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude', workflow: { use_worktrees: false } });
t.after(() => cleanup(dir));
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'fixture: sentinel absent — the fallback is under test');
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 0,
`#3972: the workstream opted out — the sentinel-absent fallback must allow; got stdout=${r.stdout}`);
});
test('#3972: a root-only opt-out under a workstream also allows (the #3963 shape, guard side)', (t) => {
const dir = wsFixture({ runtime: 'claude', workflow: { use_worktrees: false } }, { runtime: 'claude' });
t.after(() => cleanup(dir));
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 0, 'the inherited root opt-out must reach the fallback too');
});
test('#3972: no opt-out anywhere still denies (unchanged)', (t) => {
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude' });
t.after(() => cleanup(dir));
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 2, 'the guard must keep demanding the harness flag when nothing opted out');
});
});
describe('worktreesOptedOut — ladder unit semantics (#3972)', () => {
const { worktreesOptedOut } = require('../gsd-core/bin/lib/planning-workspace.cjs');
test('scoped own-key wins; root inherited only under the ws gate; strict === false', (t) => {
const dir = createTempDir('gsd-3972-unit-');
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
const root = path.join(dir, '.planning', 'config.json');
const ws = path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json');
const prev = process.env['GSD_WORKSTREAM'];
t.after(() => { if (prev === undefined) delete process.env['GSD_WORKSTREAM']; else process.env['GSD_WORKSTREAM'] = prev; });
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: false } }));
process.env['GSD_WORKSTREAM'] = 'alpha';
assert.equal(worktreesOptedOut(dir), true, 'scoped own false');
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: true } }));
assert.equal(worktreesOptedOut(dir), false, 'scoped own true wins over any root');
fs.writeFileSync(ws, JSON.stringify({ runtime: 'claude' }));
fs.writeFileSync(root, JSON.stringify({ workflow: { use_worktrees: false } }));
assert.equal(worktreesOptedOut(dir), true, 'root false inherited under the ws gate');
delete process.env['GSD_WORKSTREAM'];
// Without a workstream, planningDir IS the flat root — the root's own key
// is the scoped read (the plain-project opt-out), so this answers true.
// The no-cross-inheritance contract (a GSD_PROJECT-scoped dir ignoring the
// flat root) is pinned by the resolver-level #3963 boundary test.
assert.equal(worktreesOptedOut(dir), true, 'no ws: the root config IS the effective config');
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: 'false' } }));
process.env['GSD_WORKSTREAM'] = 'alpha';
assert.equal(worktreesOptedOut(dir), false, 'string "false" never coerces');
fs.writeFileSync(ws, '{ malformed');
assert.equal(worktreesOptedOut(dir), true, 'unreadable scoped config falls to the root view under the gate');
});
});