fix(gsd): serialize workflow MCP execution state
This commit is contained in:
parent
bdd7f45641
commit
d667d7565c
6 changed files with 109 additions and 34 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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." }],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue