Merge pull request #762 from 0xLeathery/fix/stop-auto-reason
fix: add stop reason to every auto-mode stop
This commit is contained in:
commit
4e562831e6
3 changed files with 54 additions and 38 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
}
|
||||
return;
|
||||
}
|
||||
await stopAuto(ctx, pi);
|
||||
await stopAuto(ctx, pi, "User requested stop");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue