enhance(#4570): allow disabling planner stall detection (#4585)

* enhance(#4570): allow disabling planner stall detection

* docs(#4570): add changelog fragment

Emitted-Drift-Ack-Growth: plan-phase.md — the explicit opt-out gate covers all five planner and checker spawn classes
Emitted-Drift-Ack-Growth: settings-advanced.md — the toggle prompt and bounded-recovery warning expose the new setting

* chore(#4570): refresh compact-content baseline

* fix(#4570): preserve default-on watchdog fallback

* docs(#4570): qualify the chunked-mode orchestrator rules with the toggle

The two chunked-planning-mode stall-watch imperatives read as
unconditional, with the opt-out stated only in a following bullet. State
the PLANNER_STALL_DETECTION_ENABLED condition inline, matching the three
sites already qualified in plan-phase.md.

* chore(#4570): refresh compact-content baselines against rebased next

* fix(#4570): sync planner stall launcher

Keep the stall-detection helper aligned with the canonical runtime launcher.

* chore(#4570): refresh compact baseline after rebase

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-23 19:46:23 -03:00
committed by GitHub
parent 17bf25a3d2
commit 1e3e1f7cd8
21 changed files with 417 additions and 53 deletions

View File

@@ -28,9 +28,9 @@
"reductionPct": 13.53
},
"plan-phase": {
"offTokens": 28000,
"onTokens": 24649,
"reductionPct": 11.97
"offTokens": 28406,
"onTokens": 25055,
"reductionPct": 11.8
},
"verify-work": {
"offTokens": 13445,
@@ -39,8 +39,8 @@
}
},
"aggregate": {
"offTokens": 108858,
"onTokens": 91988,
"reductionPct": 15.5
"offTokens": 109264,
"onTokens": 92394,
"reductionPct": 15.44
}
}

View File

@@ -23,9 +23,9 @@
"reductionPct": 20.79
},
"agents/gsd-code-fixer.md": {
"offTokens": 10740,
"onTokens": 6690,
"reductionPct": 37.71
"offTokens": 10835,
"onTokens": 6781,
"reductionPct": 37.42
},
"agents/gsd-code-reviewer.md": {
"offTokens": 4408,
@@ -38,9 +38,9 @@
"reductionPct": 9.76
},
"agents/gsd-debug-session-manager.md": {
"offTokens": 5027,
"onTokens": 4738,
"reductionPct": 5.75
"offTokens": 5088,
"onTokens": 4799,
"reductionPct": 5.68
},
"agents/gsd-doc-classifier.md": {
"offTokens": 2901,
@@ -73,9 +73,9 @@
"reductionPct": 17.83
},
"agents/gsd-eval-auditor.md": {
"offTokens": 2941,
"onTokens": 2808,
"reductionPct": 4.52
"offTokens": 3002,
"onTokens": 2869,
"reductionPct": 4.43
},
"agents/gsd-eval-planner.md": {
"offTokens": 1674,
@@ -93,9 +93,9 @@
"reductionPct": 42.1
},
"agents/gsd-intel-updater.md": {
"offTokens": 4353,
"onTokens": 4115,
"reductionPct": 5.47
"offTokens": 4414,
"onTokens": 4176,
"reductionPct": 5.39
},
"agents/gsd-mempalace-curator.md": {
"offTokens": 1160,
@@ -113,14 +113,14 @@
"reductionPct": 15.37
},
"agents/gsd-project-researcher.md": {
"offTokens": 5464,
"onTokens": 5135,
"reductionPct": 6.02
"offTokens": 5525,
"onTokens": 5196,
"reductionPct": 5.95
},
"agents/gsd-research-synthesizer.md": {
"offTokens": 3122,
"onTokens": 2917,
"reductionPct": 6.57
"offTokens": 3183,
"onTokens": 2978,
"reductionPct": 6.44
},
"agents/gsd-roadmapper.md": {
"offTokens": 6008,
@@ -133,9 +133,9 @@
"reductionPct": 10.54
},
"agents/gsd-ui-auditor.md": {
"offTokens": 4145,
"offTokens": 6729,
"onTokens": 3926,
"reductionPct": 5.28
"reductionPct": 41.66
},
"agents/gsd-ui-checker.md": {
"offTokens": 4499,
@@ -143,9 +143,9 @@
"reductionPct": 32.25
},
"agents/gsd-ui-researcher.md": {
"offTokens": 5432,
"onTokens": 4815,
"reductionPct": 11.36
"offTokens": 5493,
"onTokens": 4876,
"reductionPct": 11.23
},
"agents/gsd-user-profiler.md": {
"offTokens": 1859,
@@ -163,14 +163,14 @@
"reductionPct": 33.87
},
"gsd-core/workflows/help/modes/full.md": {
"offTokens": 9950,
"onTokens": 6579,
"reductionPct": 33.88
"offTokens": 9933,
"onTokens": 6573,
"reductionPct": 33.83
}
},
"aggregate": {
"offTokens": 118958,
"onTokens": 94602,
"reductionPct": 20.47
"offTokens": 121986,
"onTokens": 95053,
"reductionPct": 22.08
}
}

View File

@@ -47,14 +47,21 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const fc = require('fast-check');
const { cleanup, readFileNormalized, readWorkflowCombined } = require('./helpers.cjs');
const { cleanup, readFileNormalized, readWorkflowCombined, runGsdTools } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
const STALL_HELPERS_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md');
const CHUNKED_PLANNING_MODE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
const CONFIG_SCHEMA_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
const CONFIG_DEFAULTS_MANIFEST_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'config-defaults.manifest.json');
const CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md');
const PT_BR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'pt-BR', 'CONFIGURATION.md');
const ZH_CN_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'CONFIGURATION.md');
const JA_JP_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ja-JP', 'CONFIGURATION.md');
const KO_KR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ko-KR', 'CONFIGURATION.md');
const ZH_CN_PLANNING_CONFIG_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'references', 'planning-config.md');
const SETTINGS_ADVANCED_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'settings-advanced.md');
function readPlanPhase() {
return readFileNormalized(PLAN_PHASE_PATH);
@@ -461,6 +468,148 @@ describe('bug #2650 config schema — planner.stall_* keys mirror executor.stall
});
});
describe('enhancement #4570 config contract — planner stall detection has a typed default-on opt-out', () => {
function makeProject(t, config = {}) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-config-'));
t.after(() => cleanup(tmp));
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
return tmp;
}
test('central schema and canonical defaults manifest register planner.stall_detection_enabled=true', () => {
const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_MANIFEST_PATH, 'utf8'));
const defaults = JSON.parse(fs.readFileSync(CONFIG_DEFAULTS_MANIFEST_PATH, 'utf8'));
assert.ok(schema.validKeys.includes('planner.stall_detection_enabled'));
assert.equal(defaults.planner?.stall_detection_enabled, true);
assert.equal(schema.validKeys.includes('executor.stall_detection_enabled'), false,
'the planner opt-out must not introduce an executor sibling outside approved scope');
});
test('config-get defaults absent values to true; config-set false round-trips as boolean false', (t) => {
const tmp = makeProject(t);
const env = { HOME: tmp, USERPROFILE: tmp };
const absent = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
assert.equal(absent.success, true, absent.error);
assert.equal(absent.output, 'true');
const noConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-no-config-'));
t.after(() => cleanup(noConfig));
const absentFile = runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'],
noConfig,
{ HOME: noConfig, USERPROFILE: noConfig },
);
assert.equal(absentFile.success, true, absentFile.error);
assert.equal(absentFile.output, 'true');
const set = runGsdTools(['config-set', 'planner.stall_detection_enabled', 'false'], tmp, env);
assert.equal(set.success, true, set.error);
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf8'));
assert.equal(onDisk.planner.stall_detection_enabled, false);
assert.equal(typeof onDisk.planner.stall_detection_enabled, 'boolean');
const roundTrip = runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
assert.equal(roundTrip.success, true, roundTrip.error);
assert.equal(roundTrip.output, 'false');
});
test('property: every non-boolean CLI value is rejected without modifying config', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: true }, sentinel: 'preserve' });
const configPath = path.join(tmp, '.planning', 'config.json');
const before = fs.readFileSync(configPath, 'utf8');
const printable = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]{}._- ';
const invalidValue = fc.array(fc.constantFrom(...printable), { minLength: 1, maxLength: 16 })
.map((chars) => chars.join(''))
.filter((value) => !['true', 'false', 'null'].includes(value));
fc.assert(
fc.property(invalidValue, (value) => {
const result = runGsdTools(
['config-set', 'planner.stall_detection_enabled', value],
tmp,
{ HOME: tmp, USERPROFILE: tmp },
);
return result.success === false && fs.readFileSync(configPath, 'utf8') === before;
}),
{ numRuns: 20 },
);
});
test('hand-edited non-booleans fail safely to true on both config-get and Config Loader reads', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: 'false' } });
const result = runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'],
tmp,
{ HOME: tmp, USERPROFILE: tmp },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output, 'true', 'a string "false" must not disable the watchdog');
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, true);
});
test('manifest skew cannot make a non-boolean planner setting disable detection', () => {
const { resolvePlannerStallDetectionEnabled } = require('../gsd-core/bin/lib/config-loader.cjs');
for (const value of [undefined, null, 'false', 0, {}]) {
assert.equal(resolvePlannerStallDetectionEnabled(value), true);
}
});
test('root, GSD_PROJECT, and workstream reads retain canonical scope precedence', (t) => {
const tmp = makeProject(t, { planner: { stall_detection_enabled: false } });
const projectDir = path.join(tmp, '.planning', 'product-a');
const workstreamDir = path.join(tmp, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(workstreamDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
const home = { HOME: tmp, USERPROFILE: tmp };
assert.equal(runGsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, home).output, 'false');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_PROJECT: 'product-a' },
).output, 'true');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_WORKSTREAM: 'alpha' },
).output, 'true');
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, false);
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, true);
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{}}\n');
assert.equal(runGsdTools(
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
{ ...home, GSD_WORKSTREAM: 'alpha' },
).output, 'false', 'an omitted workstream value must inherit the root value');
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, false);
});
test('English and enumerating localized docs state default, CLI opt-out, effect, and recovery loss', () => {
for (const docsPath of [CONFIGURATION_DOCS_PATH, PT_BR_CONFIGURATION_DOCS_PATH, ZH_CN_CONFIGURATION_DOCS_PATH, JA_JP_CONFIGURATION_DOCS_PATH, KO_KR_CONFIGURATION_DOCS_PATH]) {
const docs = fs.readFileSync(docsPath, 'utf8');
assert.match(docs, /`planner\.stall_detection_enabled`\s*\|\s*boolean\s*\|\s*`true`/);
assert.match(docs, /config-set planner\.stall_detection_enabled false/);
assert.match(docs, /runtime-native|nativa do runtime|运行时原生|ランタイムネイティブ|런타임 네이티브/i);
assert.match(docs, /bounded recovery|recupera[cç][aã]o limitada|有界恢复|有界な復旧|제한된 복구/i);
}
assert.match(fs.readFileSync(ZH_CN_PLANNING_CONFIG_PATH, 'utf8'), /planner\.stall_detection_enabled/);
});
test('advanced settings warns about recovery loss before offering to persist false', () => {
const settings = fs.readFileSync(SETTINGS_ADVANCED_PATH, 'utf8');
assert.match(settings, /planner\.stall_detection_enabled/);
assert.match(settings, /default:\s*`true`/);
assert.match(settings, /bounded automatic recovery[\s\S]{0,500}false/i);
assert.match(settings, /config-set planner\.stall_detection_enabled false/);
});
});
describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch in the background with bounded stall surveillance', () => {
let workflow;
@@ -475,10 +624,32 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch
test('stall-detection-helpers.md resolves PLANNER_STALL_INTERVAL_MINUTES / PLANNER_STALL_THRESHOLD_MINUTES from config', () => {
const helpersDoc = readStallHelpersDoc();
assert.match(helpersDoc, /PLANNER_STALL_DETECTION_ENABLED=.*planner\.stall_detection_enabled/);
assert.match(helpersDoc, /PLANNER_STALL_INTERVAL_MINUTES=.*planner\.stall_detect_interval_minutes/);
assert.match(helpersDoc, /PLANNER_STALL_THRESHOLD_MINUTES=.*planner\.stall_threshold_minutes/);
});
test('invalid or absent toggle values normalize to default-on; only boolean false disables', (t) => {
const helpersBash = extractStallHelpersBash();
for (const [stored, expected] of [[undefined, 'true'], [true, 'true'], [false, 'false'], ['false', 'true'], [0, 'true'], [null, 'true']]) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4570-resolve-'));
t.after(() => cleanup(tmp));
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
const config = stored === undefined ? {} : { planner: { stall_detection_enabled: stored } };
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config)}\n`);
const result = runBashScript(
`${helpersBash}\nprintf '%s\\n' "$PLANNER_STALL_DETECTION_ENABLED"\n`,
[],
{
cwd: tmp,
env: { ...process.env, HOME: tmp, USERPROFILE: tmp, RUNTIME_DIR: REPO_ROOT, GSD_PROJECT: '', GSD_WORKSTREAM: '' },
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), expected, `stored ${JSON.stringify(stored)} resolved incorrectly`);
}
});
test('standard planner spawn (step 8) dispatches with run_in_background=true and calls gsd_stall_watch', () => {
const idx = workflow.indexOf('## 8. Spawn gsd-planner Agent');
assert.notEqual(idx, -1);
@@ -593,6 +764,34 @@ describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch
`expected exactly 5 gsd_stall_watch "$TS" "{outputFile}" spawn-site invocations across plan-phase.md + steps/*.md, found ${callCount}`);
});
test('all five spawn classes gate background surveillance and retain a runtime-native blocking result path', () => {
const mainSections = [
['standard planner', '## 8. Spawn gsd-planner Agent', '## 9. Handle Planner Return'],
['plan-checker', '## 10. Spawn gsd-plan-checker Agent', '## 11. Handle Checker Return'],
['revision planner', '## 12. Revision Loop', '## 12.5. Plan Bounce'],
];
const chunkedDoc = readChunkedPlanningMode();
const sections = mainSections.map(([label, start, end]) => {
const startAt = workflow.indexOf(start);
return [label, workflow.slice(startAt, workflow.indexOf(end, startAt))];
});
sections.push(
['chunked outline', chunkedDoc.slice(
chunkedDoc.indexOf('### 8.5.1 Outline Phase'),
chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'),
)],
['chunked per-plan', chunkedDoc.slice(chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'))],
);
for (const [label, section] of sections) {
assert.match(section, /PLANNER_STALL_DETECTION_ENABLED/, `${label}: missing toggle gate`);
assert.match(section, /`true`[\s\S]*run_in_background=true[\s\S]*gsd_stall_watch/,
`${label}: default-on branch must retain background watcher behavior`);
assert.match(section, /`false`[\s\S]{0,700}(?:omit|without) `?run_in_background`?[\s\S]{0,700}(?:ordinary|runtime-native)[\s\S]{0,500}(?:return|result)/i,
`${label}: explicit-off branch must omit backgrounding and await the real runtime result`);
}
});
test('step 7.99 documents that {outputFile} must be bound from the real Agent() return (not passed literally)', () => {
const idx = workflow.indexOf('## 7.99. Bounded Stall-Detection Helpers');
assert.notEqual(idx, -1);

View File

@@ -62,6 +62,9 @@ const REPO_ROOT = path.join(__dirname, '..');
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md');
const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL);
const PLAN_PHASE_REL = path.join('gsd-core', 'workflows', 'plan-phase.md');
const CHUNKED_PLAN_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
const STALL_HELPERS_REL = path.join('gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md');
// plan-phase.md was the original #2930 pilot but was reverted to unmarked
// (chore/2930 retarget: it sits 36 B under the ADR-857 Phase-6 PRE_PHASE6
// gate and cannot absorb marker overhead) — it was a genuinely unmarked
@@ -284,6 +287,22 @@ test('noSectionMarkerLeaksIntoEmittedArtifacts', (t) => {
false,
`${runtime}: emitted execute-phase.md still contains a gsd:section marker token`,
);
// #4570: assert against the real installed workflow bytes for every runtime,
// after composition and runtime conversion. The background parameter itself
// is runtime vocabulary (`run_in_background` becomes `background` on Hermes),
// so parity is pinned on the shared gate and its two semantic branches.
for (const relativePath of [PLAN_PHASE_REL, CHUNKED_PLAN_REL, STALL_HELPERS_REL]) {
const installedPath = path.join(configDir, relativePath);
assert.ok(fs.existsSync(installedPath), `${runtime}: emitted ${relativePath} is missing`);
const installed = fs.readFileSync(installedPath, 'utf8');
assert.match(installed, /PLANNER_STALL_DETECTION_ENABLED/,
`${runtime}: ${relativePath} lost the planner stall-detection gate during conversion`);
assert.match(installed, /gsd_stall_watch/,
`${runtime}: ${relativePath} lost the default-on watcher branch during conversion`);
assert.match(installed, /runtime-native/,
`${runtime}: ${relativePath} lost the explicit-off ordinary completion branch during conversion`);
}
cleanup(root);
}
});