* 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:
5
.changeset/sturdy-otters-chatter.md
Normal file
5
.changeset/sturdy-otters-chatter.md
Normal 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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user