diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index df1d565bb..04fb51654 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -554,11 +554,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void await dispatchNextUnit(ctx, pi); } catch (retryErr) { const message = retryErr instanceof Error ? retryErr.message : String(retryErr); - ctx.ui.notify( - `Dispatch gap recovery failed: ${message}. Stopping auto-mode.`, - "error", - ); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`); return; } @@ -566,17 +562,14 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void // (no sendMessage called → no timeout set), auto-mode is permanently // stalled. Stop cleanly instead of leaving it active but idle (#537). if (active && !unitTimeoutHandle && !wrapupWarningHandle) { - ctx.ui.notify( - "Auto-mode stalled — no dispatchable unit found after retry. Stopping. Run /gsd auto to restart.", - "warning", - ); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry"); } }, DISPATCH_GAP_TIMEOUT_MS); } -export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise { +export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise { if (!active && !paused) return; + const reasonSuffix = reason ? ` — ${reason}` : ""; clearUnitTimeout(); if (lockBase()) clearLock(lockBase()); clearSkillSnapshot(); @@ -628,11 +621,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi if (ledger && ledger.units.length > 0) { const totals = getProjectTotals(ledger.units); ctx?.ui.notify( - `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, + `Auto-mode stopped${reasonSuffix}. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`, "info", ); } else { - ctx?.ui.notify("Auto-mode stopped.", "info"); + ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info"); } // Sync disk state so next resume starts from accurate state @@ -1508,7 +1501,7 @@ export async function handleAgentEnd( const result = await cmdCtx!.newSession(); if (result.cancelled) { resetHookState(); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, "Hook session cancelled"); return; } const sessionFile = ctx.sessionManager.getSessionFile(); @@ -1806,7 +1799,15 @@ async function showStepWizard( // If no active milestone or everything is complete, stop if (!mid || state.phase === "complete") { - await stopAuto(ctx, pi); + const incomplete = state.registry.filter(m => m.status !== "complete"); + if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") { + const ids = incomplete.map(m => m.id).join(", "); + const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; + ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error"); + await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`); + } else { + await stopAuto(ctx, pi, state.phase === "complete" ? "All work complete" : "No active milestone"); + } return; } @@ -1929,8 +1930,7 @@ async function dispatchNextUnit( // doesn't provide. Stop gracefully instead of crashing. const staleMsg = checkResourcesStale(); if (staleMsg) { - await stopAuto(ctx, pi); - ctx.ui.notify(staleMsg, "error"); + await stopAuto(ctx, pi, staleMsg); return; } @@ -2077,8 +2077,25 @@ async function dispatchNextUnit( snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); } - sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone"); - await stopAuto(ctx, pi); + + const incomplete = state.registry.filter(m => m.status !== "complete"); + if (incomplete.length === 0) { + // Genuinely all complete + sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone"); + await stopAuto(ctx, pi, "All milestones complete"); + } else if (state.phase === "blocked") { + // Milestones exist but are dependency-blocked + const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await stopAuto(ctx, pi, blockerMsg); + ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); + sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + } else { + // Milestones with remaining work exist but none became active — unexpected + const ids = incomplete.map(m => m.id).join(", "); + const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; + ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error"); + await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`); + } return; } @@ -2086,8 +2103,8 @@ async function dispatchNextUnit( // The !mid check above returns early if mid is falsy; midTitle comes from // the same object so it should always be present when mid is. if (!midTitle) { - await stopAuto(ctx, pi); - return; + midTitle = mid; // Defensive fallback: use milestone ID as title + ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning"); } // ── Mid-merge safety check: detect leftover merge state from a prior session ── @@ -2105,7 +2122,10 @@ async function dispatchNextUnit( snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); } - await stopAuto(ctx, pi); + const noMilestoneReason = !mid + ? "No active milestone after merge reconciliation" + : `Milestone ${mid} has no title after reconciliation`; + await stopAuto(ctx, pi, noMilestoneReason); return; } @@ -2180,7 +2200,7 @@ async function dispatchNextUnit( } } sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone"); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, `Milestone ${mid} complete`); return; } @@ -2190,8 +2210,8 @@ async function dispatchNextUnit( snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); } - await stopAuto(ctx, pi); const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await stopAuto(ctx, pi, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); sendDesktopNotification("GSD", blockerMsg, "error", "attention"); return; @@ -2217,9 +2237,8 @@ async function dispatchNextUnit( const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`; lastBudgetAlertLevel = newBudgetAlertLevel; if (budgetEnforcementAction === "halt") { - ctx.ui.notify(`${msg} Stopping auto-mode.`, "error"); sendDesktopNotification("GSD", msg, "error", "budget"); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, "Budget ceiling reached"); return; } if (budgetEnforcementAction === "pause") { @@ -2302,8 +2321,7 @@ async function dispatchNextUnit( snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) }); saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); } - await stopAuto(ctx, pi); - ctx.ui.notify(dispatchResult.reason, dispatchResult.level); + await stopAuto(ctx, pi, dispatchResult.reason); return; } @@ -2343,8 +2361,7 @@ async function dispatchNextUnit( const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId); if (priorSliceBlocker) { - await stopAuto(ctx, pi); - ctx.ui.notify(priorSliceBlocker, "error"); + await stopAuto(ctx, pi, priorSliceBlocker); return; } @@ -2461,9 +2478,9 @@ async function dispatchNextUnit( } saveActivityLog(ctx, basePath, unitType, unitId); const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`); ctx.ui.notify( - `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles). Stopping.${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`, + `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`, "error", ); return; @@ -2556,7 +2573,7 @@ async function dispatchNextUnit( const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); const remediation = buildLoopRemediationSteps(unitType, unitId, basePath); - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`); sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error"); ctx.ui.notify( `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`, @@ -2697,8 +2714,7 @@ async function dispatchNextUnit( // Fresh session const result = await cmdCtx!.newSession(); if (result.cancelled) { - await stopAuto(ctx, pi); - ctx.ui.notify("Auto-mode stopped.", "info"); + await stopAuto(ctx, pi, "Session cancelled"); return; } diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 238709585..8e97c4997 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -266,7 +266,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } return; } - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, "User requested stop"); return; } diff --git a/src/resources/extensions/gsd/exit-command.ts b/src/resources/extensions/gsd/exit-command.ts index ff41dcdec..0cb9e0216 100644 --- a/src/resources/extensions/gsd/exit-command.ts +++ b/src/resources/extensions/gsd/exit-command.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI) => Promise; +type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise; export function registerExitCommand( pi: ExtensionAPI, @@ -11,7 +11,7 @@ export function registerExitCommand( handler: async (_args: string, ctx: ExtensionCommandContext) => { // Stop auto-mode first so locks and activity state are cleaned up before shutdown. const stopAuto = deps.stopAuto ?? (await import("./auto.js")).stopAuto; - await stopAuto(ctx, pi); + await stopAuto(ctx, pi, "Graceful exit"); ctx.shutdown(); }, });