feat: milestone lock, signal handling, merge command, worker stub (#672)

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.
This commit is contained in:
Jeremy McSpadden 2026-03-16 17:04:11 -05:00 committed by Lex Christopherson
parent db1032f580
commit 3dbb1faa13
4 changed files with 95 additions and 2 deletions

View file

@ -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();

View file

@ -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;
}

View file

@ -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 ──────────────────────────────────────────────────────────────────
/**

View file

@ -94,6 +94,11 @@ export function invalidateStateCache(): void {
*/
export async function getActiveMilestoneId(basePath: string): Promise<string | null> {
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<GSDState> {
async function _deriveStateImpl(basePath: string): Promise<GSDState> {
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.