diff --git a/.changeset/sturdy-otters-chatter.md b/.changeset/sturdy-otters-chatter.md new file mode 100644 index 000000000..70bd6d0e2 --- /dev/null +++ b/.changeset/sturdy-otters-chatter.md @@ -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) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 1760e4e16..058c101f4 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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 { diff --git a/tests/gsd-agent-isolation-guard.test.cjs b/tests/gsd-agent-isolation-guard.test.cjs index a6cb75f38..763ff020c 100644 --- a/tests/gsd-agent-isolation-guard.test.cjs +++ b/tests/gsd-agent-isolation-guard.test.cjs @@ -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)', () => {