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:
parent
db1032f580
commit
3dbb1faa13
4 changed files with 95 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue