fix(gsd): serialize workflow MCP execution state

This commit is contained in:
Jeremy 2026-04-09 12:45:34 -05:00
parent bdd7f45641
commit d667d7565c
6 changed files with 109 additions and 34 deletions

View file

@ -84,6 +84,7 @@ describe("workflow MCP tools", () => {
registerWorkflowTools(server as any);
const tool = server.tools.find((t) => t.name === "gsd_summary_save");
assert.ok(tool, "summary tool should be registered");
const originalCwd = process.cwd();
const result = await tool!.handler({
projectDir: base,
@ -95,6 +96,7 @@ describe("workflow MCP tools", () => {
const text = (result as any).content[0].text as string;
assert.match(text, /Saved SUMMARY artifact/);
assert.equal(process.cwd(), originalCwd, "workflow MCP tools should not mutate process.cwd");
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md")),
"summary file should exist on disk",

View file

@ -10,7 +10,7 @@ const SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT",
type WorkflowToolExecutors = {
SUPPORTED_SUMMARY_ARTIFACT_TYPES: readonly string[];
executeMilestoneStatus: (params: { milestoneId: string }) => Promise<unknown>;
executeMilestoneStatus: (params: { milestoneId: string }, basePath?: string) => Promise<unknown>;
executePlanMilestone: (
params: {
milestoneId: string;
@ -185,6 +185,7 @@ type WorkflowToolExecutors = {
rationale: string;
findings?: string;
},
basePath?: string,
) => Promise<unknown>;
executeSummarySave: (
params: {
@ -218,6 +219,7 @@ type WorkflowToolExecutors = {
};
let workflowToolExecutorsPromise: Promise<WorkflowToolExecutors> | null = null;
let workflowExecutionQueue: Promise<void> = Promise.resolve();
function toFileUrl(modulePath: string): string {
return pathToFileURL(resolve(modulePath)).href;
@ -272,13 +274,20 @@ interface McpToolServer {
): unknown;
}
async function withProjectDir<T>(projectDir: string, fn: () => Promise<T>): Promise<T> {
const originalCwd = process.cwd();
async function runSerializedWorkflowOperation<T>(fn: () => Promise<T>): Promise<T> {
// The shared DB adapter and workflow log base path are process-global, so
// workflow MCP mutations must not overlap within a single server process.
const prior = workflowExecutionQueue;
let release!: () => void;
workflowExecutionQueue = new Promise<void>((resolve) => {
release = resolve;
});
await prior;
try {
process.chdir(projectDir);
return await fn();
} finally {
process.chdir(originalCwd);
release();
}
}
@ -316,7 +325,7 @@ async function handleTaskComplete(
>;
};
const { executeTaskComplete } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () =>
return runSerializedWorkflowOperation(() =>
executeTaskComplete(
{
taskId,
@ -342,7 +351,7 @@ async function handleSliceComplete(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeSliceComplete } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeSliceComplete(args as any, projectDir));
return runSerializedWorkflowOperation(() => executeSliceComplete(args as any, projectDir));
}
async function handleReplanSlice(
@ -350,7 +359,7 @@ async function handleReplanSlice(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeReplanSlice } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeReplanSlice(args as any, projectDir));
return runSerializedWorkflowOperation(() => executeReplanSlice(args as any, projectDir));
}
async function handleCompleteMilestone(
@ -358,7 +367,7 @@ async function handleCompleteMilestone(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeCompleteMilestone } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeCompleteMilestone(args as any, projectDir));
return runSerializedWorkflowOperation(() => executeCompleteMilestone(args as any, projectDir));
}
async function handleValidateMilestone(
@ -366,7 +375,7 @@ async function handleValidateMilestone(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeValidateMilestone } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeValidateMilestone(args as any, projectDir));
return runSerializedWorkflowOperation(() => executeValidateMilestone(args as any, projectDir));
}
async function handleReassessRoadmap(
@ -374,7 +383,7 @@ async function handleReassessRoadmap(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeReassessRoadmap } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeReassessRoadmap(args as any, projectDir));
return runSerializedWorkflowOperation(() => executeReassessRoadmap(args as any, projectDir));
}
async function handleSaveGateResult(
@ -382,7 +391,7 @@ async function handleSaveGateResult(
args: Record<string, unknown>,
): Promise<unknown> {
const { executeSaveGateResult } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeSaveGateResult(args as any));
return runSerializedWorkflowOperation(() => executeSaveGateResult(args as any, projectDir));
}
const completeMilestoneSchema = {
@ -513,7 +522,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir, ...params } = args as { projectDir: string } & Record<string, unknown>;
const { executePlanMilestone } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executePlanMilestone(params as any, projectDir));
return runSerializedWorkflowOperation(() => executePlanMilestone(params as any, projectDir));
},
);
@ -544,7 +553,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir, ...params } = args as { projectDir: string } & Record<string, unknown>;
const { executePlanSlice } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executePlanSlice(params as any, projectDir));
return runSerializedWorkflowOperation(() => executePlanSlice(params as any, projectDir));
},
);
@ -759,7 +768,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
content: string;
};
const { executeSummarySave } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () =>
return runSerializedWorkflowOperation(() =>
executeSummarySave({ milestone_id, slice_id, task_id, artifact_type, content }, projectDir),
);
},
@ -839,7 +848,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir, milestoneId } = args as { projectDir: string; milestoneId: string };
const { executeMilestoneStatus } = await getWorkflowToolExecutors();
return withProjectDir(projectDir, () => executeMilestoneStatus({ milestoneId }));
return runSerializedWorkflowOperation(() => executeMilestoneStatus({ milestoneId }, projectDir));
},
);
}

View file

@ -75,12 +75,9 @@ export function resolveProjectRootDbPath(basePath: string): string {
return join(basePath, ".gsd", "gsd.db");
}
export async function ensureDbOpen(): Promise<boolean> {
export async function ensureDbOpen(basePath: string = process.cwd()): Promise<boolean> {
try {
const db = await import("../gsd-db.js");
if (db.isDbAvailable()) return true;
const basePath = process.cwd();
const dbPath = resolveProjectRootDbPath(basePath);
const gsdDir = join(basePath, ".gsd");
@ -194,4 +191,3 @@ export function registerDynamicTools(pi: ExtensionAPI): void {
},
} as any);
}

View file

@ -77,6 +77,36 @@ describe('ensure-db-open', () => {
}
});
test('ensureDbOpen: explicit basePath opens target project without cwd override', async () => {
const tmpDir = makeTmpDir();
const gsdDir = path.join(tmpDir, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'DECISIONS.md'), `# Decisions
| # | When | Scope | Decision | Choice | Rationale | Revisable |
|---|------|-------|----------|--------|-----------|-----------|
| D777 | M001 | architecture | Use explicit basePath | BasePath | Avoid cwd coupling | Yes |
`);
try {
closeDatabase();
} catch { /* ok */ }
const originalCwd = process.cwd();
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
const result = await ensureDbOpen(tmpDir);
assert.ok(result === true, 'ensureDbOpen should honor explicit basePath');
assert.equal(process.cwd(), originalCwd, 'ensureDbOpen should not mutate process.cwd');
assert.ok(isDbAvailable(), 'DB should be available after explicit open');
assert.ok(getDecisionById('D777') !== null, 'explicit basePath DB should be opened');
} finally {
closeDatabase();
cleanupDir(tmpDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// ensureDbOpen returns false when no .gsd/ exists
// ═══════════════════════════════════════════════════════════════════════════
@ -159,6 +189,42 @@ describe('ensure-db-open', () => {
}
});
test('ensureDbOpen: switches open database when basePath changes', async () => {
const firstDir = makeTmpDir();
const secondDir = makeTmpDir();
fs.mkdirSync(path.join(firstDir, '.gsd'), { recursive: true });
fs.mkdirSync(path.join(secondDir, '.gsd'), { recursive: true });
fs.writeFileSync(path.join(firstDir, '.gsd', 'DECISIONS.md'), `# Decisions
| # | When | Scope | Decision | Choice | Rationale | Revisable |
|---|------|-------|----------|--------|-----------|-----------|
| D101 | M001 | architecture | First DB | First | First rationale | Yes |
`);
fs.writeFileSync(path.join(secondDir, '.gsd', 'DECISIONS.md'), `# Decisions
| # | When | Scope | Decision | Choice | Rationale | Revisable |
|---|------|-------|----------|--------|-----------|-----------|
| D202 | M001 | architecture | Second DB | Second | Second rationale | Yes |
`);
try {
closeDatabase();
} catch { /* ok */ }
try {
const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
assert.equal(await ensureDbOpen(firstDir), true);
assert.ok(getDecisionById('D101') !== null, 'first DB should be active');
assert.equal(await ensureDbOpen(secondDir), true);
assert.ok(getDecisionById('D202') !== null, 'second DB should be active after switch');
assert.equal(getDecisionById('D101'), null, 'first DB should no longer be active after switch');
} finally {
closeDatabase();
cleanupDir(firstDir);
cleanupDir(secondDir);
}
});
// ═══════════════════════════════════════════════════════════════════════════
});

View file

@ -153,7 +153,7 @@ test("executeMilestoneStatus returns milestone metadata and slice counts", async
"INSERT OR REPLACE INTO tasks (milestone_id, slice_id, id, title, status) VALUES (?, ?, ?, ?, ?)",
).run("M001", "S01", "T01", "Task T01", "pending");
const result = await inProjectDir(base, () => executeMilestoneStatus({ milestoneId: "M001" }));
const result = await inProjectDir(base, () => executeMilestoneStatus({ milestoneId: "M001" }, base));
const parsed = JSON.parse(result.content[0].text);
assert.equal(parsed.milestoneId, "M001");
@ -495,7 +495,7 @@ test("executeSaveGateResult validates inputs and persists verdicts", async () =>
verdict: "pass",
rationale: "Looks good.",
findings: "No issues found.",
}));
}, base));
assert.equal(result.details.operation, "save_gate_result");
const db = _getAdapter();

View file

@ -52,7 +52,7 @@ export async function executeSummarySave(
params: SummarySaveParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot save artifact." }],
@ -157,7 +157,7 @@ export async function executeTaskComplete(
params: TaskCompleteParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete task." }],
@ -201,7 +201,7 @@ export async function executeSliceComplete(
params: SliceCompleteExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete slice." }],
@ -282,7 +282,7 @@ export async function executeCompleteMilestone(
params: CompleteMilestoneExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot complete milestone." }],
@ -320,7 +320,7 @@ export async function executeValidateMilestone(
params: ValidateMilestoneExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot validate milestone." }],
@ -358,7 +358,7 @@ export async function executeReassessRoadmap(
params: ReassessRoadmapExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot reassess roadmap." }],
@ -395,8 +395,9 @@ export async function executeReassessRoadmap(
export async function executeSaveGateResult(
params: SaveGateResultParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available." }],
@ -449,7 +450,7 @@ export async function executePlanMilestone(
params: PlanMilestoneExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan milestone." }],
@ -486,7 +487,7 @@ export async function executePlanSlice(
params: PlanSliceExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan slice." }],
@ -525,7 +526,7 @@ export async function executeReplanSlice(
params: ReplanSliceExecutorParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available. Cannot replan slice." }],
@ -566,9 +567,10 @@ export interface MilestoneStatusParams {
export async function executeMilestoneStatus(
params: MilestoneStatusParams,
basePath: string = process.cwd(),
): Promise<ToolExecutionResult> {
try {
const dbAvailable = await ensureDbOpen();
const dbAvailable = await ensureDbOpen(basePath);
if (!dbAvailable) {
return {
content: [{ type: "text", text: "Error: GSD database is not available." }],