Files
msd-core/tests/bug-3212-execute-phase-stall-safe-resume.test.cjs
Tom Boucher 2de2d185fa feat(3536): Configuration Module via shared manifests + generator (Phase 2 of #3524)
Phase 2 of the CJS↔SDK hard-seam migration (parent #3524).
Eliminates the structural drift surface that produced bug class

After this phase, neither bin/lib/ nor sdk/src/ defines
CONFIG_DEFAULTS, VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, or the
four legacy-key normalizations inline. All come from one canonical
source: the Configuration Module (sdk/src/configuration/index.ts)
+ two JSON manifests (sdk/shared/config-{defaults,schema}.manifest.json).
The CJS mirror is generator-emitted (get-shit-done/bin/lib/configuration.generated.cjs)
with a CI freshness check (sdk/scripts/check-configuration-fresh.mjs).

- sdk/shared/config-defaults.manifest.json — canonical nested defaults,
  union of CJS + SDK keys (includes security_*, post_planning_gaps,
  agent_skills, mode, every git/workflow/hooks sub-section).
- sdk/shared/config-schema.manifest.json — VALID_CONFIG_KEYS array,
  RUNTIME_STATE_KEYS array, DYNAMIC_KEY_PATTERNS array with source
  strings (regex reconstructed at runtime).
- sdk/src/configuration/index.ts — source of truth. Exports
  loadConfig (pure read), normalizeLegacyKeys (pure, idempotent,
  returns Normalization[]), mergeDefaults (deep-merge), migrateOnDisk
  (explicit opt-in disk writeback), plus CONFIG_DEFAULTS,
  VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS.
- sdk/src/configuration/index.test.ts — 29 vitest pinning tests.
- sdk/scripts/gen-configuration.mjs — generator (Function.prototype.toString()
  inspection of compiled SDK dist, plus brace-balanced text scan for
  internal helpers, matching the Phase 1 pattern).
- sdk/scripts/check-configuration-fresh.mjs — CI freshness gate.
- tests/configuration-generator.test.cjs — 27 parity assertions
  (CJS-generated == SDK source).
- tests/configuration-migrate-config.test.cjs — 3 cases for the new
  gsd-tools migrate-config subcommand.

- bin/lib/core.cjs: CONFIG_DEFAULTS literal now sources values from
  CANONICAL_CONFIG_DEFAULTS (the manifest), with a thin flat
  projection at the load boundary to preserve the existing
  flat-shape return contract for the ~21 CJS test files and 100+
  consumers. All four legacy-key migration blocks (branching_strategy,
  sub_repos, multiRepo, depth — historically lines 351-358, 388-397,
  401-408, 416-423) collapse to a single normalizeLegacyKeys call
  in each code path. The inline platformWriteSync writeback stays
  for now to preserve sync loadConfig semantics; the new async
  migrateOnDisk is reachable via gsd-tools migrate-config.
- bin/lib/config-schema.cjs: 135 → 31 lines. Re-exports from the
  generated Module.
- bin/lib/config.cjs: adds cmdMigrateConfig handler (calls
  migrateOnDisk on the explicit user-driven path).
- bin/gsd-tools.cjs: wires migrate-config into command dispatch.

- sdk/src/config.ts: re-exports CONFIG_DEFAULTS and mergeDefaults
  from the Module. loadConfig now calls normalizeLegacyKeys before
  mergeDefaults (replaces the inline branching_strategy graft).
- sdk/src/query/config-schema.ts: 160 → 36 lines. Re-exports from
  the Module.

- tests/config-schema-sdk-parity.test.cjs: refactored from
  "CJS Set equals SDK Set" (trivially true post-migration) to
  "both sides source from the manifest" — structural plus runtime
  invariant.
- Four other tests that text-grepped source files for valid keys
  (plan-review-convergence, bug-3212, bug-2492, feat-3210) are
  updated to use runtime VALID_CONFIG_KEYS.has() or manifest JSON
  lookups.

- CONTEXT.md: new Configuration Module entry with full Interface
  contract.
- Root package.json: check:configuration-fresh proxy script.
- sdk/package.json: gen:configuration + check:configuration-fresh.
- .githooks/pre-commit: configuration drift block.
- .github/workflows/test.yml: configuration drift step after the
  alias drift check.

- 9201 CJS tests pass (baseline pre-cycle: 9195; +6 net new tests
  across migrate-config + parity refactor)
- 1872 SDK vitest tests pass
- 29 Configuration Module vitest fixtures
- 27 CJS/SDK parity fixtures
- Net diff: +388 / −519 = 131-line reduction across the seven cycles,
  despite adding the new Module, manifests, generator, freshness
  check, and two new test files.

1. SDK CONFIG_DEFAULTS now includes manifest-canonical keys
   (resolve_model_ids: false, context_window: 200000, phase_naming,
   claude_md_path, git.create_tag, workflow.security_*,
   workflow.code_review_*, planning.*, hooks.workflow_guard, ship.*).
   Consumers accessing via [key: string]: unknown index get
   the manifest default instead of undefined.
2. SDK mergeDefaults is now proper recursive deep-merge instead of
   spread-per-section. Overlay { workflow: { research: false } }
   now preserves sibling workflow keys; previously it replaced
   the entire workflow section with only research + the section's
   defaults. Semantically identical for the common case;
   strictly better for partial nested overrides.
3. New gsd-tools migrate-config CLI subcommand for the explicit,
   opt-in on-disk migration path.

Closes #3536.
2026-05-15 00:02:56 -04:00

103 lines
5.3 KiB
JavaScript

'use strict';
// allow-test-rule: source-text-is-product [#3212]
// The bug is in workflow/config contracts consumed by agents at runtime.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.join(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
}
function runGsd(args, cwd) {
return spawnSync(process.execPath, [path.join(ROOT, 'get-shit-done/bin/gsd-tools.cjs'), ...args], {
cwd,
encoding: 'utf8',
});
}
describe('bug #3212 execute-phase stall detection and safe resume', () => {
test('config schemas register executor stall detector keys', () => {
// After Cycle 5 (#3536), both CJS and SDK source from the manifest.
// Use the CJS runtime Set for CJS; use the manifest directly for SDK-side
// verification (since config-schema.ts no longer has inline literals).
const { VALID_CONFIG_KEYS: cjsKeys } = require('../get-shit-done/bin/lib/config-schema.cjs');
const manifest = JSON.parse(read('sdk/shared/config-schema.manifest.json'));
const manifestKeys = new Set(manifest.validKeys);
for (const key of ['executor.stall_detect_interval_minutes', 'executor.stall_threshold_minutes']) {
assert.ok(cjsKeys.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`);
assert.ok(manifestKeys.has(key), `Manifest validKeys must include ${key} (SDK sources from manifest)`);
}
});
test('configuration docs describe stall detector defaults', () => {
const docs = read('docs/CONFIGURATION.md');
assert.match(docs, /`executor\.stall_detect_interval_minutes`\s*\|\s*number\s*\|\s*`5`/);
assert.match(docs, /`executor\.stall_threshold_minutes`\s*\|\s*number\s*\|\s*`10`/);
});
test('config-get returns schema defaults for executor stall detector keys', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3212-'));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
fs.mkdirSync(path.join(tmp, '.planning'));
fs.writeFileSync(path.join(tmp, '.planning/config.json'), '{}\n');
const interval = runGsd(['config-get', 'executor.stall_detect_interval_minutes', '--raw'], tmp);
const threshold = runGsd(['config-get', 'executor.stall_threshold_minutes', '--raw'], tmp);
assert.equal(interval.status, 0, interval.stderr);
assert.equal(interval.stdout.trim(), '5');
assert.equal(threshold.status, 0, threshold.stderr);
assert.equal(threshold.stdout.trim(), '10');
});
test('execute-phase verifies partial-plan drift before dispatch', () => {
const workflow = read('get-shit-done/workflows/execute-phase.md');
assert.match(workflow, /<step name="safe_resume_gate"/, 'execute-phase must define a safe_resume_gate step');
assert.match(workflow, /git log --oneline --grep="\$\{CURRENT_PLAN_ID\}"/, 'safe resume gate must check commits for the current plan id');
assert.match(workflow, /SUMMARY.md is missing/, 'safe resume gate must detect production commits with missing SUMMARY.md');
assert.match(workflow, /close out manually/, 'safe resume gate must offer manual close-out recovery');
assert.match(workflow, /re-execute from scratch/, 'safe resume gate must offer re-execute recovery');
assert.match(workflow, /mark-and-skip/, 'safe resume gate must offer mark-and-skip recovery');
});
test('execute-phase has configurable executor stall surveillance after dispatch', () => {
const workflow = read('get-shit-done/workflows/execute-phase.md');
assert.match(workflow, /EXECUTOR_STALL_INTERVAL_MINUTES=.*executor\.stall_detect_interval_minutes/);
assert.match(workflow, /EXECUTOR_STALL_THRESHOLD_MINUTES=.*executor\.stall_threshold_minutes/);
assert.match(workflow, /DISPATCH_TS=/, 'execute-phase must record dispatch timestamp');
assert.match(workflow, /EXPECTED_BRANCH=/, 'execute-phase must record expected branch');
assert.match(workflow, /git log "\$\{EXPECTED_BRANCH\}" --since="\$\{DISPATCH_TS\}"/, 'stall check must inspect branch commits since dispatch');
assert.match(workflow, /continue waiting/, 'stall warning must offer continue waiting');
assert.match(workflow, /kill and retry/, 'stall warning must offer kill and retry');
assert.match(workflow, /kill and switch to inline execution/, 'stall warning must offer inline fallback');
});
test('execute-plan documents atomic close-out invariant', () => {
const workflow = read('get-shit-done/workflows/execute-plan.md');
assert.match(workflow, /<atomic_close_out_invariant>/, 'execute-plan must contain a formal atomic close-out invariant');
assert.match(workflow, /production-code commit\(s\) -> SUMMARY commit -> STATE\/ROADMAP update/, 'invariant must name the legal close-out sequence');
assert.match(workflow, /only legal half-state is mid-production-commits/, 'invariant must define the only legal half-state');
});
test('forensics includes the partial-plan drift detector', () => {
const workflow = read('get-shit-done/workflows/forensics.md');
assert.match(workflow, /Partial-plan Drift Detection/);
assert.match(workflow, /commits exist but SUMMARY.md is missing/);
assert.match(workflow, /safe-resume verifier/);
});
});