fix(#3737): fold the use_worktrees opt-out into the dispatch-isolation sentinel (#3938)

* test(#3737): use_worktrees=false opt-out must win in the dispatch-isolation sentinel

* fix(#3737): fold the workflow.use_worktrees opt-out into the dispatch-isolation record

* test(#3737): guard allows sequential dispatch under the opt-out sentinel

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

* chore(#3737): backfill changeset PR number (3938)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 13:20:25 -04:00
committed by GitHub
parent 1e67ec9737
commit 11cdc19e39
3 changed files with 158 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3938
---
**`workflow.use_worktrees=false` now actually wins for executor dispatch** — `query dispatch-isolation` folds the project opt-out into the isolation sentinel it records, so a plain re-query can no longer re-persist the host's worktree capability over the mandated `none` record and have the isolation guard deny the sequential dispatch the project configured. (#3737)

View File

@@ -1706,6 +1706,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
}
}
/**
* #3737 — strict read of the project-level worktree opt-out from
* `.planning/config.json`. True ONLY when `workflow.use_worktrees` is the
* boolean `false`; an absent key, unreadable/malformed config, or any
* non-boolean value (including the string "false") degrades to false —
* worktrees are ON by default, so the degraded answer is "not opted out".
* Direct file read, deliberately NOT loadConfig: this resolver backs
* sentinel writes and must never trigger config normalization/rewrites
* (same discipline as resolveDispatchIsolationDecision's resolveRuntime
* comment above). Never throws.
*/
function projectWorktreesOptedOut(cwd) {
try {
const { planningDir } = require('./lib/planning-workspace.cjs');
const cfgPath = require('path').join(planningDir(cwd), 'config.json');
const cfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf8'));
return cfg != null && typeof cfg === 'object'
&& cfg.workflow != null && typeof cfg.workflow === 'object'
&& cfg.workflow.use_worktrees === false;
} catch {
return false;
}
}
function routeDispatchIsolation({ args, cwd, raw, error }) {
// #2584 Phase 3 (#2627): typed query exposing the negotiated
// `dispatch.isolation` to the execute-phase wave scheduler, so the
@@ -1779,6 +1803,25 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
? args[planIdx + 1]
: null;
// #3737: the project-level opt-out (workflow.use_worktrees === false) is
// decided HERE, before the sentinel write — not only in the workflow
// shell blocks that run after this resolve. Pre-fix, any plain re-query
// (config re-read, wave transition, second plan dispatch) re-persisted
// the naturally-resolved host capability over the `--force-isolation
// none` record the dispatch-isolation reference mandates, and the guard
// then denied the sequential dispatch the config explicitly asked for.
// Applied AFTER --force-isolation so the documented rule holds: the
// opt-out wins on every host, over both the natural resolution and any
// force. Strict `=== false`: the default is worktrees ON, so an absent
// key, an unreadable/malformed config, or a non-boolean value degrades
// to "not opted out" (mirrors readConfigJsonBoolean's no-coercion
// discipline in lib/init.cjs).
if (projectWorktreesOptedOut(cwd)) {
isolation = 'none';
harnessFlag = null;
exec = null;
}
// Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
// Never allowed to affect this query's own stdout contract or throw.
try {

View File

@@ -937,6 +937,116 @@ describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional
assert.equal(read.phase, '2');
assert.equal(read.plan, 'p1');
});
// #3737 — the project-level opt-out (workflow.use_worktrees === false) is
// decided by the workflow shell AFTER the resolve, so pre-fix any plain
// re-query re-persisted the naturally-resolved host capability over the
// mandated `--force-isolation none` record, and the guard then denied the
// sequential dispatch the config explicitly asked for.
function writeUseWorktrees(dir, value) {
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: value } }),
);
}
test('#3737: use_worktrees=false — a plain re-query records none and does not clobber the forced record', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
const forced = runGsdTools(
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'none'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(forced.success, true, forced.error);
assert.equal(forced.output.trim(), 'none');
// The workflow's own re-record step, then the plain re-query that pre-fix
// flipped the sentinel back to harness-worktree (#3737 reproduction).
const requery = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(requery.success, true, requery.error);
assert.equal(requery.output.trim(), 'none', '#3737: the opt-out must win on every host');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none', '#3737: a plain re-query must not re-persist the host capability over the opt-out');
assert.equal(sentinel.harness_flag, null);
});
test('#3737: use_worktrees=false resolves and records none on the first plain query (--json agrees)', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
const result = runGsdTools(
['query', 'dispatch-isolation', '--json'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
const parsed = JSON.parse(result.output);
assert.equal(parsed.isolation, 'none');
assert.equal(parsed.harnessFlag, null);
assert.equal(parsed.exec, null);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.harness_flag, null);
});
test('#3737: use_worktrees=true keeps the natural harness-worktree record', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, true);
const result = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree');
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
test('#3737: a non-boolean "false" string is not an opt-out (strict === false)', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, 'false');
const result = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree', 'a string "false" must degrade to the default (worktrees on), never coerce');
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
test('#3737: end-to-end — an opted-out project records none and the guard ALLOWS the sequential dispatch', (t) => {
const dir = createTempProject('gsd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
// The workflow's resolve step: a plain query (no force) records the
// opt-out decision — the record the issue says must survive re-queries.
const result = runGsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ GSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'none');
// The guard fires on the sequential inline Agent() dispatch (no
// isolation kwarg) and must NOT deny it (#3737's user-visible symptom).
const r = runHook(agentPayload(), dir);
assert.equal(r.status, 0, `guard denied a sequential dispatch under the opt-out sentinel: stdout=${r.stdout} stderr=${r.stderr}`);
});
});
describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => {