Merge pull request #762 from 0xLeathery/fix/stop-auto-reason

fix: add stop reason to every auto-mode stop
This commit is contained in:
TÂCHES 2026-03-16 20:39:50 -06:00 committed by GitHub
commit 4e562831e6
3 changed files with 54 additions and 38 deletions

View file

@ -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<void> {
export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
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;
}

View file

@ -266,7 +266,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
return;
}
await stopAuto(ctx, pi);
await stopAuto(ctx, pi, "User requested stop");
return;
}

View file

@ -1,6 +1,6 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI) => Promise<void>;
type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise<void>;
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();
},
});