* fix(7): raise concurrency-safety roadmap-analyze budget 2000ms → 5000ms The 50-phase roadmap-analyze wall-clock test consistently flaked on Mac under realistic load (empirical floor ~2100-3000ms), independently reproduced by three fixers on PRs #3795, #3792, #3799. Extract the magic 2000 to named constant ROADMAP_ANALYZE_BUDGET_MS with an explanatory comment. Raise to 5000ms (2.5x observed worst-case) — large enough to eliminate Mac flakes without masking real regressions. Long-term: convert to behavior-anchored assertion per PR #3803 pattern; tracked as follow-up, out of scope here. Acceptance criteria from #7: > "Replace hardcoded 2000ms with higher budget (e.g., 5000ms) or make > configurable; add comment documenting rationale; confirm no > false-negative on healthy machine." Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(7): guard executeForCjs with try/catch fallback in all CJS routers When the synckit bridge throws (worker crash, Atomics failure on Windows/Node 24, or any other transient OS-level failure), the exception propagated uncaught through the sdkHandler closure up to Node.js's unhandled-rejection handler. On Windows, the async stderr write for the rejection message may not flush before process exit, producing an empty- stderr non-zero exit that manifests as 'init failed: Command failed: ...' in workspace.test.cjs. Wrap getExecuteForCjs() in a try/catch in every CJS router that has this pattern (gsd-tools.cjs _dispatchNonFamily, init-, roadmap-, state-, validate-, verify-command-router.cjs). On catch: fall through to the CJS handler, which is the designed safety net for bridge failures and produces identical output. Verified: workspace.test.cjs (26/26), concurrency-safety.test.cjs (31/31), cjs-sdk-bridge-integration.test.cjs (4/4), and bug-3631-router-raw-flag.test.cjs (2/2) all pass locally on Mac. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
383 lines
14 KiB
JavaScript
383 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const { STATE_SUBCOMMANDS } = require('./command-aliases.generated.cjs');
|
|
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
|
|
const { output } = require('./core.cjs');
|
|
const {
|
|
tryLoadSdk,
|
|
getExecuteForCjs,
|
|
getFormatStateLoadRawStdout,
|
|
} = require('./cjs-sdk-bridge.cjs');
|
|
|
|
// Subcommands whose CJS contract is exit-non-zero (stderr) ONLY when the
|
|
// underlying STATE.md is missing — not for in-state errors like
|
|
// "field not found". CJS `cmdStateGet` calls `error('STATE.md not found')` →
|
|
// exit 1 for the missing-file case but `output({ error: 'Section or field
|
|
// "X" not found' }, raw)` → exit 0 for the missing-field case. Mutation
|
|
// commands always use output() (exit 0) even when STATE.md is missing, so
|
|
// they are absent from this set entirely.
|
|
const EXIT_ON_STATE_MD_MISSING = new Set(['state.get']);
|
|
const STATE_MD_MISSING_MESSAGE = 'STATE.md not found';
|
|
|
|
// Subcommands whose CJS contract is always exit-0 — they emit
|
|
// { error: '...' } JSON via output() for every failure case (missing STATE.md,
|
|
// missing required args, validation failures). When the SDK returns
|
|
// result.ok === false for these subcommands we must NOT call error() (exit 1);
|
|
// instead we surface the SDK error message as exit-0 JSON so callers can
|
|
// JSON.parse the response and branch on the error field.
|
|
const OUTPUT_ON_SDK_ERROR = new Set([
|
|
'state.record-metric',
|
|
'state.advance-plan',
|
|
'state.record-session',
|
|
'state.add-decision',
|
|
'state.add-blocker',
|
|
'state.resolve-blocker',
|
|
'state.update-progress',
|
|
]);
|
|
|
|
// The bridge loader verifies both `executeForCjs` and `formatStateLoadRawStdout`
|
|
// are present before returning success, so this router can call `tryLoadSdk()`
|
|
// directly without an additional capability check.
|
|
|
|
/**
|
|
* Dispatch a subcommand via the SDK sync bridge.
|
|
*
|
|
* Returns true if dispatched successfully, false if the SDK is unavailable.
|
|
* The caller must still handle result.ok=false as a hard error.
|
|
*
|
|
* @param {string} registryCommand - Registry command name (e.g. 'state.json')
|
|
* @param {string[]} registryArgs - Args for the registry handler
|
|
* @param {string} cwd - Project directory
|
|
* @param {boolean} raw - Raw output mode
|
|
* @param {Function} error - Error reporter
|
|
* @param {Function} [rawFormatter] - Optional raw output formatter (for state.load)
|
|
* @returns {boolean} true if handled, false to fall through to CJS
|
|
*/
|
|
function dispatchViaSdk(registryCommand, registryArgs, legacyArgs, cwd, raw, error, rawFormatter) {
|
|
if (!tryLoadSdk()) return false;
|
|
|
|
// When a CJS-side rawFormatter is supplied (e.g. state.load --raw → key=value
|
|
// lines), always request 'json' from the bridge so the SDK returns the typed
|
|
// data object. Passing mode: 'raw' would make the bridge pre-render to a
|
|
// string and the formatter would no-op. For subcommands without a rawFormatter,
|
|
// honor the user's --raw flag and let the bridge do default rendering.
|
|
const bridgeMode = rawFormatter ? 'json' : (raw ? 'raw' : 'json');
|
|
|
|
let result;
|
|
try {
|
|
result = getExecuteForCjs()({
|
|
registryCommand,
|
|
registryArgs,
|
|
legacyCommand: 'state',
|
|
legacyArgs,
|
|
mode: bridgeMode,
|
|
projectDir: cwd,
|
|
// Phase 6 fix: workstream is now threaded through to the native handler.
|
|
// GSDTransport no longer forces subprocess for workstream-scoped requests —
|
|
// the worker's dispatchNative closure correctly passes workstream to
|
|
// registry.dispatch() (Phase 5.1 fix), enabling native workstream dispatch.
|
|
workstream: process.env.GSD_WORKSTREAM || undefined,
|
|
});
|
|
} catch {
|
|
// Bridge threw (e.g. synckit worker crash, Atomics failure on Windows).
|
|
// Return false so the caller falls through to the CJS handler.
|
|
return false;
|
|
}
|
|
|
|
if (!result.ok) {
|
|
// Mutation subcommands whose CJS contract is always exit-0: surface the SDK
|
|
// error as JSON output (exit 0) rather than calling error() (exit 1). This
|
|
// preserves the CJS contract for callers that JSON.parse stdout and branch on
|
|
// the error field — particularly important on Windows/Node 24 where the SDK
|
|
// bridge returns result.ok===false for validation failures (e.g. missing
|
|
// required args) instead of propagating them as result.data.error objects.
|
|
if (OUTPUT_ON_SDK_ERROR.has(registryCommand)) {
|
|
const msg = result.errorDetails && result.errorDetails.message
|
|
? result.errorDetails.message
|
|
: `state ${registryCommand} failed (${result.errorKind})`;
|
|
output({ error: msg });
|
|
return true;
|
|
}
|
|
error(result.errorDetails && result.errorDetails.message
|
|
? result.errorDetails.message
|
|
: `state ${registryCommand} failed (${result.errorKind})`);
|
|
return true; // handled (error was reported)
|
|
}
|
|
|
|
// Surface STATE.md-missing as a CJS-style fatal error (exit non-zero,
|
|
// stderr) for the specific subcommands whose CJS contract uses error() not
|
|
// output() for that case. The exact "STATE.md not found" message is the
|
|
// canonical signal both CJS and SDK use — other "error" shapes (e.g.
|
|
// "Section or field X not found" from state.get with present STATE.md)
|
|
// stay as exit-0 JSON output so shell-script consumers JSON.parse the
|
|
// output and branch on the error field without process-exit handling.
|
|
if (
|
|
EXIT_ON_STATE_MD_MISSING.has(registryCommand)
|
|
&& result.data
|
|
&& typeof result.data === 'object'
|
|
&& result.data.error === STATE_MD_MISSING_MESSAGE
|
|
) {
|
|
error(result.data.error);
|
|
return true;
|
|
}
|
|
|
|
if (raw && rawFormatter) {
|
|
const rawText = rawFormatter(result.data);
|
|
const fs = require('fs');
|
|
fs.writeSync(1, rawText);
|
|
} else if (raw) {
|
|
// #3631: bridge was called with mode:'raw', so result.data is the scalar
|
|
// string the CJS path would have printed. Bypass output()'s JSON path.
|
|
output(null, true, typeof result.data === 'string' ? result.data : String(result.data ?? ''));
|
|
} else {
|
|
output(result.data);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Manifest-backed state subcommand router.
|
|
* Keeps gsd-tools.cjs thin while preserving existing command semantics.
|
|
*
|
|
* Phase 5.1: handlers that have SDK equivalents are dispatched via
|
|
* executeForCjs (the sync bridge). CJS fallback is retained for:
|
|
* - complete-phase: no SDK counterpart.
|
|
* - Any command when GSD_WORKSTREAM is active (GSDTransport forces subprocess
|
|
* for workstream requests; subprocess is disabled in the sync bridge worker).
|
|
* - Any command when the SDK is not available (build not present).
|
|
*/
|
|
function routeStateCommand({ state, args, cwd, raw, parseNamedArgs, error }) {
|
|
const parsePlans = (plans) => {
|
|
const parsedPlans = plans == null ? null : Number.parseInt(plans, 10);
|
|
if (plans != null && Number.isNaN(parsedPlans)) {
|
|
error('Invalid --plans value. Expected an integer.');
|
|
return null;
|
|
}
|
|
return parsedPlans;
|
|
};
|
|
|
|
// Phase 6 fix: workstream commands are now handled natively in the sync bridge
|
|
// worker. GSDTransport no longer forces subprocess for workstream-scoped requests;
|
|
// the worker threads workstream through to registry.dispatch() correctly.
|
|
const sdkAvailable = tryLoadSdk();
|
|
|
|
// Helper: build SDK-backed handler that falls through to CJS on SDK failure.
|
|
// cjsFallback is called when SDK is unavailable or when the subcommand has no
|
|
// SDK counterpart.
|
|
function sdkHandler(registryCommand, registryArgs, legacyArgs, rawFormatter, cjsFallback) {
|
|
if (!sdkAvailable) return cjsFallback;
|
|
return () => {
|
|
const handled = dispatchViaSdk(
|
|
registryCommand, registryArgs, legacyArgs, cwd, raw, error, rawFormatter,
|
|
);
|
|
if (!handled) cjsFallback();
|
|
};
|
|
}
|
|
|
|
routeCjsCommandFamily({
|
|
args,
|
|
subcommands: ['load', 'complete-phase', ...STATE_SUBCOMMANDS.filter((s) => s !== 'load')],
|
|
defaultSubcommand: 'load',
|
|
unsupported: {
|
|
'add-roadmap-evolution': 'state add-roadmap-evolution is SDK-only. Use: gsd-sdk query state.add-roadmap-evolution ...',
|
|
},
|
|
error,
|
|
unknownMessage: (subcommand, available) => `Unknown state subcommand: "${subcommand}". Available: ${available.join(', ')}`,
|
|
handlers: {
|
|
load: sdkHandler(
|
|
'state.load',
|
|
[],
|
|
args.slice(1),
|
|
// Resolved lazily — the formatter getter returns null until
|
|
// tryLoadSdk() runs inside dispatchViaSdk. sdkHandler only invokes
|
|
// this formatter when SDK dispatch succeeds, so by then the bridge
|
|
// has cached the formatter and the getter returns the real function.
|
|
(...formatterArgs) => getFormatStateLoadRawStdout()(...formatterArgs),
|
|
() => state.cmdStateLoad(cwd, raw),
|
|
),
|
|
json: sdkHandler(
|
|
'state.json',
|
|
[],
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateJson(cwd, raw),
|
|
),
|
|
get: sdkHandler(
|
|
'state.get',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateGet(cwd, args[2], raw),
|
|
),
|
|
update: sdkHandler(
|
|
'state.update',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateUpdate(cwd, args[2], args[3]),
|
|
),
|
|
patch: sdkHandler(
|
|
'state.patch',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const patches = {};
|
|
for (let i = 2; i < args.length; i += 2) {
|
|
const key = args[i].replace(/^--/, '');
|
|
const value = args[i + 1];
|
|
if (key && value !== undefined) {
|
|
patches[key] = value;
|
|
}
|
|
}
|
|
state.cmdStatePatch(cwd, patches, raw);
|
|
},
|
|
),
|
|
'advance-plan': sdkHandler(
|
|
'state.advance-plan',
|
|
[],
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateAdvancePlan(cwd, raw),
|
|
),
|
|
'record-metric': sdkHandler(
|
|
'state.record-metric',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { phase: p, plan, duration, tasks, files } = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']);
|
|
state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw);
|
|
},
|
|
),
|
|
'update-progress': sdkHandler(
|
|
'state.update-progress',
|
|
[],
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateUpdateProgress(cwd, raw),
|
|
),
|
|
'add-decision': sdkHandler(
|
|
'state.add-decision',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { phase: p, summary, 'summary-file': summary_file, rationale, 'rationale-file': rationale_file } = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']);
|
|
state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw);
|
|
},
|
|
),
|
|
'add-blocker': sdkHandler(
|
|
'state.add-blocker',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { text, 'text-file': text_file } = parseNamedArgs(args, ['text', 'text-file']);
|
|
state.cmdStateAddBlocker(cwd, { text, text_file }, raw);
|
|
},
|
|
),
|
|
'resolve-blocker': sdkHandler(
|
|
'state.resolve-blocker',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw),
|
|
),
|
|
'record-session': sdkHandler(
|
|
'state.record-session',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { 'stopped-at': stopped_at, 'resume-file': resume_file } = parseNamedArgs(args, ['stopped-at', 'resume-file']);
|
|
state.cmdStateRecordSession(cwd, { stopped_at, resume_file: resume_file || 'None' }, raw);
|
|
},
|
|
),
|
|
'begin-phase': sdkHandler(
|
|
'state.begin-phase',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']);
|
|
state.cmdStateBeginPhase(cwd, p, name, parsePlans(plans), raw);
|
|
},
|
|
),
|
|
'signal-waiting': sdkHandler(
|
|
'state.signal-waiting',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { type, question, options, phase: p } = parseNamedArgs(args, ['type', 'question', 'options', 'phase']);
|
|
state.cmdSignalWaiting(cwd, type, question, options, p, raw);
|
|
},
|
|
),
|
|
'signal-resume': sdkHandler(
|
|
'state.signal-resume',
|
|
[],
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdSignalResume(cwd, raw),
|
|
),
|
|
'planned-phase': sdkHandler(
|
|
'state.planned-phase',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { phase: p, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']);
|
|
state.cmdStatePlannedPhase(cwd, p, parsePlans(plans), raw);
|
|
},
|
|
),
|
|
validate: sdkHandler(
|
|
'state.validate',
|
|
[],
|
|
args.slice(1),
|
|
null,
|
|
() => state.cmdStateValidate(cwd, raw),
|
|
),
|
|
sync: sdkHandler(
|
|
'state.sync',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { verify } = parseNamedArgs(args, [], ['verify']);
|
|
state.cmdStateSync(cwd, { verify }, raw);
|
|
},
|
|
),
|
|
prune: sdkHandler(
|
|
'state.prune',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
|
|
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
|
|
},
|
|
),
|
|
// complete-phase: CJS-only — no SDK counterpart.
|
|
'complete-phase': () => {
|
|
const { phase: p } = parseNamedArgs(args, ['phase']);
|
|
state.cmdStateCompletePhase(cwd, raw, p || args[2]);
|
|
},
|
|
'milestone-switch': sdkHandler(
|
|
'state.milestone-switch',
|
|
args.slice(2),
|
|
args.slice(1),
|
|
null,
|
|
() => {
|
|
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
|
|
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
|
|
},
|
|
),
|
|
},
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
routeStateCommand,
|
|
};
|