From 3dbb1faa13e74288a5a9ba89d18d5f4bfca0521d Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 17:04:11 -0500 Subject: [PATCH] feat: milestone lock, signal handling, merge command, worker stub (#672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GSD_MILESTONE_LOCK in state.ts: - deriveState() filters milestoneIds to only the locked milestone - getActiveMilestoneId() short-circuits when lock is set - Each parallel worker sees only its assigned milestone Signal consumption in auto.ts: - handleAgentEnd() checks for coordinator signals before dispatching - Responds to "stop" (calls stopAuto) and "pause" (calls pauseAuto) - Only active when GSD_MILESTONE_LOCK env var is set /gsd parallel merge command: - /gsd parallel merge [mid] — merge specific or all completed milestones - Wired into commands.ts with argument completions Worker spawning stub: - spawnWorker() validates state and documents the implementation plan - Actual process forking deferred to auto-mode integration 976/976 full test suite passing, zero regressions. --- src/resources/extensions/gsd/auto.ts | 22 ++++++++++++ src/resources/extensions/gsd/commands.ts | 24 +++++++++++-- .../extensions/gsd/parallel-orchestrator.ts | 34 +++++++++++++++++++ src/resources/extensions/gsd/state.ts | 17 ++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index b2315ca64..9da662382 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -108,6 +108,7 @@ import { autoWorktreeBranch, } from "./auto-worktree.js"; import { pruneQueueOrder } from "./queue-order.js"; +import { consumeSignal } from "./session-status-io.js"; import { showNextAction } from "../shared/next-action-ui.js"; import { debugLog, debugTime, debugCount, debugPeak, enableDebug, isDebugEnabled, writeDebugSummary, getDebugLogPath } from "./debug-logger.js"; import { @@ -1252,6 +1253,27 @@ export async function handleAgentEnd( // Unit completed — clear its timeout clearUnitTimeout(); + // ── Parallel worker signal check ───────────────────────────────────── + // When running as a parallel worker (GSD_MILESTONE_LOCK set), check for + // coordinator signals before dispatching the next unit. + const milestoneLock = process.env.GSD_MILESTONE_LOCK; + if (milestoneLock) { + const signal = consumeSignal(basePath, milestoneLock); + if (signal) { + if (signal.signal === "stop") { + _handlingAgentEnd = false; + await stopAuto(ctx, pi); + return; + } + if (signal.signal === "pause") { + _handlingAgentEnd = false; + await pauseAuto(ctx, pi); + return; + } + // "resume" and "rebase" signals are handled elsewhere or no-op here + } + } + // Invalidate all caches — the unit just completed and may have // written planning files (task summaries, roadmap checkboxes, etc.) invalidateAllCaches(); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index b25d52f2a..7d0fa3ae5 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -48,6 +48,7 @@ import { pauseWorker, resumeWorker, } from "./parallel-orchestrator.js"; import { formatEligibilityReport } from "./parallel-eligibility.js"; +import { mergeAllCompleted, mergeCompletedMilestone, formatMergeResults } from "./parallel-merge.js"; import { resolveParallelConfig } from "./preferences.js"; import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; @@ -108,7 +109,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts[0] === "parallel" && parts.length <= 2) { const subPrefix = parts[1] ?? ""; - return ["start", "status", "stop", "pause", "resume"] + return ["start", "status", "stop", "pause", "resume", "merge"] .filter((cmd) => cmd.startsWith(subPrefix)) .map((cmd) => ({ value: `parallel ${cmd}`, label: cmd })); } @@ -375,8 +376,27 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (subCmd === "merge") { + const mid = rest.trim() || undefined; + if (mid) { + // Merge a specific milestone + const result = await mergeCompletedMilestone(projectRoot(), mid); + pi.sendMessage({ content: formatMergeResults([result]) }); + return; + } + // Merge all completed milestones + const workers = getWorkerStatuses(); + if (workers.length === 0) { + pi.sendMessage({ content: "No parallel workers to merge." }); + return; + } + const results = await mergeAllCompleted(projectRoot(), workers); + pi.sendMessage({ content: formatMergeResults(results) }); + return; + } + pi.sendMessage({ - content: `Unknown parallel subcommand "${subCmd}". Usage: /gsd parallel [start|status|stop|pause|resume]`, + content: `Unknown parallel subcommand "${subCmd}". Usage: /gsd parallel [start|status|stop|pause|resume|merge]`, }); return; } diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index 38cd5ba0a..d18f28226 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -162,6 +162,40 @@ export async function startParallel( return { started, errors }; } +// ─── Worker Spawning ─────────────────────────────────────────────────── + +/** + * Spawn a worker process for a milestone. + * The worker runs `gsd auto` in the milestone's worktree with + * GSD_MILESTONE_LOCK set to isolate state derivation. + * + * NOTE: This is a stub — actual process spawning requires the CLI + * entry point path and will be wired up in the auto-mode integration. + * For now, it validates the worker exists and returns false. + */ +export function spawnWorker( + basePath: string, + milestoneId: string, +): boolean { + if (!state) return false; + const worker = state.workers.get(milestoneId); + if (!worker) return false; + + // TODO: Implement actual worker spawning + // The worker process should be started with: + // - cwd: worker.worktreePath + // - env: { ...process.env, GSD_MILESTONE_LOCK: milestoneId } + // - The CLI command equivalent of `/gsd auto` + // + // When implemented, this will: + // 1. Create the worktree via createAutoWorktree(basePath, milestoneId) + // 2. Fork/exec the CLI with GSD_MILESTONE_LOCK env var + // 3. Store the ChildProcess in worker.process + // 4. Set up exit handler to update worker.state on crash/completion + + return false; +} + // ─── Stop ────────────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 1c2088df8..780e870c6 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -94,6 +94,11 @@ export function invalidateStateCache(): void { */ export async function getActiveMilestoneId(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); + // Parallel worker isolation + const milestoneLock = process.env.GSD_MILESTONE_LOCK; + if (milestoneLock) { + return milestoneIds.includes(milestoneLock) ? milestoneLock : null; + } for (const mid of milestoneIds) { const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); const content = roadmapFile ? await loadFile(roadmapFile) : null; @@ -141,6 +146,18 @@ export async function deriveState(basePath: string): Promise { async function _deriveStateImpl(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); + // ── Parallel worker isolation ────────────────────────────────────────── + // When GSD_MILESTONE_LOCK is set, this process is a parallel worker + // scoped to a single milestone. Filter the milestone list so this worker + // only sees its assigned milestone (all others are treated as if they + // don't exist). This gives each worker complete isolation without + // modifying any other state derivation logic. + const milestoneLock = process.env.GSD_MILESTONE_LOCK; + if (milestoneLock && milestoneIds.includes(milestoneLock)) { + milestoneIds.length = 0; + milestoneIds.push(milestoneLock); + } + // ── Batch-parse file cache ────────────────────────────────────────────── // When the native Rust parser is available, read every .md file under .gsd/ // in one call and build an in-memory content map keyed by absolute path.