From 93e51b04ad7dbaaca517b99a8f445553ca6a0602 Mon Sep 17 00:00:00 2001 From: Iouri Goussev Date: Fri, 20 Mar 2026 17:33:28 -0400 Subject: [PATCH] refactor: decompose autoLoop into pipeline phases (#1615) (#1659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: add PhaseResult/IterationContext/LoopState types to auto-loop No behavioral changes. Pure type additions for upcoming phase extraction. * refactor: extract runFinalize() from autoLoop body No behavioral changes. Pure structural extraction. * refactor: extract runUnitPhase() from autoLoop body No behavioral changes. Pure structural extraction. * refactor: extract runGuards() from autoLoop body No behavioral changes. Pure structural extraction. * refactor: extract runDispatch() from autoLoop body No behavioral changes. Pure structural extraction. * refactor: extract runPreDispatch() from autoLoop body No behavioral changes. Pure structural extraction. Completes autoLoop pipeline phase decomposition: runPreDispatch → runGuards → runDispatch → runUnitPhase → runFinalize * refactor: hoist loopState before autoLoop loop, drop sync-back hacks loopState was created inside the loop each iteration, requiring 3 manual sync-backs for stuckRecoveryAttempts (number copy-by-value). Hoist it before the loop so it's a true persistent mutable struct across iterations. --- src/resources/extensions/gsd/auto-loop.ts | 2060 +++++++++++---------- 1 file changed, 1100 insertions(+), 960 deletions(-) diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts index 080d92451..c45fcfafd 100644 --- a/src/resources/extensions/gsd/auto-loop.ts +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -74,6 +74,47 @@ export interface UnitResult { event?: AgentEndEvent; } +// ─── Phase pipeline types ──────────────────────────────────────────────────── + +type PhaseResult = + | { action: "continue" } + | { action: "break"; reason: string } + | { action: "next"; data: T } + +interface IterationContext { + ctx: ExtensionContext; + pi: ExtensionAPI; + s: AutoSession; + deps: LoopDeps; + prefs: GSDPreferences | undefined; + iteration: number; +} + +interface LoopState { + recentUnits: Array<{ key: string; error?: string }>; + stuckRecoveryAttempts: number; +} + +interface PreDispatchData { + state: GSDState; + mid: string; + midTitle: string; +} + +interface IterationData { + unitType: string; + unitId: string; + prompt: string; + finalPrompt: string; + pauseAfterUatDispatch: boolean; + observabilityIssues: unknown[]; + state: GSDState; + mid: string | undefined; + midTitle: string | undefined; + isRetry: boolean; + previousTier: string | undefined; +} + // ─── Per-unit one-shot promise state ──────────────────────────────────────── // // A single module-level resolve function scoped to the current unit execution. @@ -656,6 +697,1031 @@ async function closeoutAndStop( await deps.stopAuto(ctx, pi, reason); } +// ─── runPreDispatch ─────────────────────────────────────────────────────────── + +/** + * Phase 1: Pre-dispatch — resource guard, health gate, state derivation, + * milestone transition, terminal conditions. + * Returns break to exit the loop, or next with PreDispatchData on success. + */ +async function runPreDispatch( + ic: IterationContext, + loopState: LoopState, +): Promise> { + const { ctx, pi, s, deps, prefs } = ic; + + // Resource version guard + const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); + if (staleMsg) { + await deps.stopAuto(ctx, pi, staleMsg); + debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); + return { action: "break", reason: "resources-stale" }; + } + + deps.invalidateAllCaches(); + s.lastPromptCharCount = undefined; + s.lastBaselineCharCount = undefined; + + // Pre-dispatch health gate + try { + const healthGate = await deps.preDispatchHealthGate(s.basePath); + if (healthGate.fixesApplied.length > 0) { + ctx.ui.notify( + `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, + "info", + ); + } + if (!healthGate.proceed) { + ctx.ui.notify( + healthGate.reason ?? "Pre-dispatch health check failed.", + "error", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); + return { action: "break", reason: "health-gate-failed" }; + } + } catch { + // Non-fatal + } + + // Sync project root artifacts into worktree + if ( + s.originalBasePath && + s.basePath !== s.originalBasePath && + s.currentMilestoneId + ) { + deps.syncProjectRootToWorktree( + s.originalBasePath, + s.basePath, + s.currentMilestoneId, + ); + } + + // Derive state + let state = await deps.deriveState(s.basePath); + deps.syncCmuxSidebar(prefs, state); + let mid = state.activeMilestone?.id; + let midTitle = state.activeMilestone?.title; + debugLog("autoLoop", { + phase: "state-derived", + iteration: ic.iteration, + mid, + statePhase: state.phase, + }); + + // ── Milestone transition ──────────────────────────────────────────── + if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { + ctx.ui.notify( + `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, + "info", + ); + deps.sendDesktopNotification( + "GSD", + `Milestone ${s.currentMilestoneId} complete!`, + "success", + "milestone", + ); + deps.logCmuxEvent( + prefs, + `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, + "success", + ); + + const vizPrefs = prefs; + if (vizPrefs?.auto_visualize) { + ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); + } + if (vizPrefs?.auto_report !== false) { + try { + await generateMilestoneReport(s, ctx, s.currentMilestoneId!); + } catch (err) { + ctx.ui.notify( + `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, + "warning", + ); + } + } + + // Reset dispatch counters for new milestone + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitLifetimeDispatches.clear(); + loopState.recentUnits.length = 0; + loopState.stuckRecoveryAttempts = 0; + + // Worktree lifecycle on milestone transition — merge current, enter next + deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui); + + // Opt-in: create draft PR on milestone completion + if (prefs?.git?.auto_pr) { + try { + const { createDraftPR } = await import("./git-service.js"); + const prUrl = createDraftPR( + s.basePath, + s.currentMilestoneId!, + `[GSD] ${s.currentMilestoneId} complete`, + `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, + ); + if (prUrl) { + ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); + } + } catch { + // Non-fatal — PR creation is best-effort + } + } + + deps.invalidateAllCaches(); + + state = await deps.deriveState(s.basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + + if (mid) { + if (deps.getIsolationMode() !== "none") { + deps.captureIntegrationBranch(s.basePath, mid, { + commitDocs: prefs?.git?.commit_docs, + }); + } + deps.resolver.enterMilestone(mid, ctx.ui); + } else { + // mid is undefined — no milestone to capture integration branch for + } + + const pendingIds = state.registry + .filter( + (m: { status: string }) => + m.status !== "complete" && m.status !== "parked", + ) + .map((m: { id: string }) => m.id); + deps.pruneQueueOrder(s.basePath, pendingIds); + } + + if (mid) { + s.currentMilestoneId = mid; + deps.setActiveMilestoneId(s.basePath, mid); + } + + // ── Terminal conditions ────────────────────────────────────────────── + + if (!mid) { + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + s.currentUnit.type, + s.currentUnit.id, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), + ); + } + + const incomplete = state.registry.filter( + (m: { status: string }) => + m.status !== "complete" && m.status !== "parked", + ); + if (incomplete.length === 0 && state.registry.length > 0) { + // All milestones complete — merge milestone branch before stopping + if (s.currentMilestoneId) { + deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); + + // Opt-in: create draft PR on milestone completion + if (prefs?.git?.auto_pr) { + try { + const { createDraftPR } = await import("./git-service.js"); + const prUrl = createDraftPR( + s.basePath, + s.currentMilestoneId, + `[GSD] ${s.currentMilestoneId} complete`, + `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, + ); + if (prUrl) { + ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); + } + } catch { + // Non-fatal — PR creation is best-effort + } + } + } + deps.sendDesktopNotification( + "GSD", + "All milestones complete!", + "success", + "milestone", + ); + deps.logCmuxEvent( + prefs, + "All milestones complete.", + "success", + ); + await deps.stopAuto(ctx, pi, "All milestones complete"); + } else if (incomplete.length === 0 && state.registry.length === 0) { + // Empty registry — no milestones visible, likely a path resolution bug + const diag = `basePath=${s.basePath}, phase=${state.phase}`; + ctx.ui.notify( + `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, + "error", + ); + await deps.stopAuto( + ctx, + pi, + `No milestones found — check basePath resolution`, + ); + } else if (state.phase === "blocked") { + const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await deps.stopAuto(ctx, pi, blockerMsg); + ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.logCmuxEvent(prefs, blockerMsg, "error"); + } else { + const ids = incomplete.map((m: { id: string }) => m.id).join(", "); + const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${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 deps.stopAuto( + ctx, + pi, + `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, + ); + } + debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); + return { action: "break", reason: "no-active-milestone" }; + } + + if (!midTitle) { + midTitle = mid; + ctx.ui.notify( + `Milestone ${mid} has no title in roadmap — using ID as fallback.`, + "warning", + ); + } + + // Mid-merge safety check + if (deps.reconcileMergeState(s.basePath, ctx)) { + deps.invalidateAllCaches(); + state = await deps.deriveState(s.basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } + + if (!mid || !midTitle) { + const noMilestoneReason = !mid + ? "No active milestone after merge reconciliation" + : `Milestone ${mid} has no title after reconciliation`; + await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason); + debugLog("autoLoop", { + phase: "exit", + reason: "no-milestone-after-reconciliation", + }); + return { action: "break", reason: "no-milestone-after-reconciliation" }; + } + + // Terminal: complete + if (state.phase === "complete") { + // Milestone merge on complete (before closeout so branch state is clean) + if (s.currentMilestoneId) { + deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); + + // Opt-in: create draft PR on milestone completion + if (prefs?.git?.auto_pr) { + try { + const { createDraftPR } = await import("./git-service.js"); + const prUrl = createDraftPR( + s.basePath, + s.currentMilestoneId, + `[GSD] ${s.currentMilestoneId} complete`, + `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, + ); + if (prUrl) { + ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); + } + } catch { + // Non-fatal — PR creation is best-effort + } + } + } + deps.sendDesktopNotification( + "GSD", + `Milestone ${mid} complete!`, + "success", + "milestone", + ); + deps.logCmuxEvent( + prefs, + `Milestone ${mid} complete.`, + "success", + ); + await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); + debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); + return { action: "break", reason: "milestone-complete" }; + } + + // Terminal: blocked + if (state.phase === "blocked") { + const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; + await closeoutAndStop(ctx, pi, s, deps, blockerMsg); + ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); + deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); + deps.logCmuxEvent(prefs, blockerMsg, "error"); + debugLog("autoLoop", { phase: "exit", reason: "blocked" }); + return { action: "break", reason: "blocked" }; + } + + return { action: "next", data: { state, mid, midTitle } }; +} + +// ─── runDispatch ────────────────────────────────────────────────────────────── + +/** + * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks. + * Returns break/continue to control the loop, or next with IterationData on success. + */ +async function runDispatch( + ic: IterationContext, + preData: PreDispatchData, + loopState: LoopState, +): Promise> { + const { ctx, pi, s, deps, prefs } = ic; + const { state, mid, midTitle } = preData; + const STUCK_WINDOW_SIZE = 6; + + debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration }); + const dispatchResult = await deps.resolveDispatch({ + basePath: s.basePath, + mid, + midTitle, + state, + prefs, + session: s, + }); + + if (dispatchResult.action === "stop") { + await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); + debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); + return { action: "break", reason: "dispatch-stop" }; + } + + if (dispatchResult.action !== "dispatch") { + // Non-dispatch action (e.g. "skip") — re-derive state + await new Promise((r) => setImmediate(r)); + return { action: "continue" }; + } + + let unitType = dispatchResult.unitType; + let unitId = dispatchResult.unitId; + let prompt = dispatchResult.prompt; + const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; + + // ── Sliding-window stuck detection with graduated recovery ── + const derivedKey = `${unitType}/${unitId}`; + + if (!s.pendingVerificationRetry) { + loopState.recentUnits.push({ key: derivedKey }); + if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) loopState.recentUnits.shift(); + + const stuckSignal = detectStuck(loopState.recentUnits); + if (stuckSignal) { + debugLog("autoLoop", { + phase: "stuck-check", + unitType, + unitId, + reason: stuckSignal.reason, + recoveryAttempts: loopState.stuckRecoveryAttempts, + }); + + if (loopState.stuckRecoveryAttempts === 0) { + // Level 1: try verifying the artifact, then cache invalidation + retry + loopState.stuckRecoveryAttempts++; + const artifactExists = deps.verifyExpectedArtifact( + unitType, + unitId, + s.basePath, + ); + if (artifactExists) { + debugLog("autoLoop", { + phase: "stuck-recovery", + level: 1, + action: "artifact-found", + }); + ctx.ui.notify( + `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, + "info", + ); + deps.invalidateAllCaches(); + return { action: "continue" }; + } + ctx.ui.notify( + `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, + "warning", + ); + deps.invalidateAllCaches(); + } else { + // Level 2: hard stop — genuinely stuck + debugLog("autoLoop", { + phase: "stuck-detected", + unitType, + unitId, + reason: stuckSignal.reason, + }); + await deps.stopAuto( + ctx, + pi, + `Stuck: ${stuckSignal.reason}`, + ); + ctx.ui.notify( + `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, + "error", + ); + return { action: "break", reason: "stuck-detected" }; + } + } else { + // Progress detected — reset recovery counter + if (loopState.stuckRecoveryAttempts > 0) { + debugLog("autoLoop", { + phase: "stuck-counter-reset", + from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "", + to: derivedKey, + }); + loopState.stuckRecoveryAttempts = 0; + } + } + } + + // Pre-dispatch hooks + const preDispatchResult = deps.runPreDispatchHooks( + unitType, + unitId, + prompt, + s.basePath, + ); + if (preDispatchResult.firedHooks.length > 0) { + ctx.ui.notify( + `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, + "info", + ); + } + if (preDispatchResult.action === "skip") { + ctx.ui.notify( + `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, + "info", + ); + await new Promise((r) => setImmediate(r)); + return { action: "continue" }; + } + if (preDispatchResult.action === "replace") { + prompt = preDispatchResult.prompt ?? prompt; + if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; + } else if (preDispatchResult.prompt) { + prompt = preDispatchResult.prompt; + } + + const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( + s.basePath, + deps.getMainBranch(s.basePath), + unitType, + unitId, + ); + if (priorSliceBlocker) { + await deps.stopAuto(ctx, pi, priorSliceBlocker); + debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); + return { action: "break", reason: "prior-slice-blocker" }; + } + + const observabilityIssues = await deps.collectObservabilityWarnings( + ctx, + s.basePath, + unitType, + unitId, + ); + + return { + action: "next", + data: { + unitType, unitId, prompt, finalPrompt: prompt, + pauseAfterUatDispatch, observabilityIssues, + state, mid, midTitle, + isRetry: false, previousTier: undefined, + }, + }; +} + +// ─── runGuards ──────────────────────────────────────────────────────────────── + +/** + * Phase 2: Guards — budget ceiling, context window, secrets re-check. + * Returns break to exit the loop, or next to proceed to dispatch. + */ +async function runGuards( + ic: IterationContext, + mid: string, +): Promise { + const { ctx, pi, s, deps, prefs } = ic; + + // Budget ceiling guard + const budgetCeiling = prefs?.budget_ceiling; + if (budgetCeiling !== undefined && budgetCeiling > 0) { + const currentLedger = deps.getLedger() as { units: unknown } | null; + const totalCost = currentLedger + ? deps.getProjectTotals(currentLedger.units).cost + : 0; + const budgetPct = totalCost / budgetCeiling; + const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); + const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( + s.lastBudgetAlertLevel, + budgetPct, + ); + const enforcement = prefs?.budget_enforcement ?? "pause"; + const budgetEnforcementAction = deps.getBudgetEnforcementAction( + enforcement, + budgetPct, + ); + + // Data-driven threshold check — loop descending, fire first match + const threshold = BUDGET_THRESHOLDS.find( + (t) => newBudgetAlertLevel >= t.pct, + ); + if (threshold) { + s.lastBudgetAlertLevel = + newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; + + if (threshold.pct === 100 && budgetEnforcementAction !== "none") { + // 100% — special enforcement logic (halt/pause/warn) + const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; + if (budgetEnforcementAction === "halt") { + deps.sendDesktopNotification("GSD", msg, "error", "budget"); + await deps.stopAuto(ctx, pi, "Budget ceiling reached"); + debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); + return { action: "break", reason: "budget-halt" }; + } + if (budgetEnforcementAction === "pause") { + ctx.ui.notify( + `${msg} Pausing auto-mode — /gsd auto to override and continue.`, + "warning", + ); + deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.logCmuxEvent(prefs, msg, "warning"); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); + return { action: "break", reason: "budget-pause" }; + } + ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); + deps.sendDesktopNotification("GSD", msg, "warning", "budget"); + deps.logCmuxEvent(prefs, msg, "warning"); + } else if (threshold.pct < 100) { + // Sub-100% — simple notification + const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`; + ctx.ui.notify(msg, threshold.notifyLevel); + deps.sendDesktopNotification( + "GSD", + msg, + threshold.notifyLevel, + "budget", + ); + deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); + } + } else if (budgetAlertLevel === 0) { + s.lastBudgetAlertLevel = 0; + } + } else { + s.lastBudgetAlertLevel = 0; + } + + // Context window guard + const contextThreshold = prefs?.context_pause_threshold ?? 0; + if (contextThreshold > 0 && s.cmdCtx) { + const contextUsage = s.cmdCtx.getContextUsage(); + if ( + contextUsage && + contextUsage.percent !== null && + contextUsage.percent >= contextThreshold + ) { + const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; + ctx.ui.notify( + `${msg} Run /gsd auto to continue (will start fresh session).`, + "warning", + ); + deps.sendDesktopNotification( + "GSD", + `Context ${contextUsage.percent}% — paused`, + "warning", + "attention", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "context-window" }); + return { action: "break", reason: "context-window" }; + } + } + + // Secrets re-check gate + try { + const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await deps.collectSecretsFromManifest( + s.basePath, + mid, + ctx, + ); + if ( + result && + result.applied && + result.skipped && + result.existingSkipped + ) { + ctx.ui.notify( + `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, + "info", + ); + } else { + ctx.ui.notify("Secrets collection skipped.", "info"); + } + } + } catch (err) { + ctx.ui.notify( + `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, + "warning", + ); + } + + return { action: "next", data: undefined as void }; +} + +// ─── runUnitPhase ───────────────────────────────────────────────────────────── + +/** + * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify. + * Returns break or next with unitStartedAt for downstream phases. + */ +async function runUnitPhase( + ic: IterationContext, + iterData: IterationData, + loopState: LoopState, + sidecarItem?: SidecarItem, +): Promise> { + const { ctx, pi, s, deps, prefs } = ic; + const { unitType, unitId, prompt, observabilityIssues, state, mid } = iterData; + + debugLog("autoLoop", { + phase: "unit-execution", + iteration: ic.iteration, + unitType, + unitId, + }); + + // Detect retry and capture previous tier for escalation + const isRetry = !!( + s.currentUnit && + s.currentUnit.type === unitType && + s.currentUnit.id === unitId + ); + const previousTier = s.currentUnitRouting?.tier; + + s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + deps.captureAvailableSkills(); + deps.writeUnitRuntimeRecord( + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: s.currentUnit.startedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }, + ); + + // Status bar + progress widget + ctx.ui.setStatus("gsd-auto", "auto"); + if (mid) + deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); + deps.updateProgressWidget(ctx, unitType, unitId, state); + + deps.ensurePreconditions(unitType, unitId, s.basePath, state); + + // Prompt injection + let finalPrompt = prompt; + + if (s.pendingVerificationRetry) { + const retryCtx = s.pendingVerificationRetry; + s.pendingVerificationRetry = null; + const capped = + retryCtx.failureContext.length > MAX_RECOVERY_CHARS + ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...failure context truncated]" + : retryCtx.failureContext; + finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; + } + + if (s.pendingCrashRecovery) { + const capped = + s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS + ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...recovery briefing truncated to prevent memory exhaustion]" + : s.pendingCrashRecovery; + finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; + s.pendingCrashRecovery = null; + } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { + const diagnostic = deps.getDeepDiagnostic(s.basePath); + if (diagnostic) { + const cappedDiag = + diagnostic.length > MAX_RECOVERY_CHARS + ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...diagnostic truncated to prevent memory exhaustion]" + : diagnostic; + finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; + } + } + + const repairBlock = + deps.buildObservabilityRepairBlock(observabilityIssues); + if (repairBlock) { + finalPrompt = `${finalPrompt}${repairBlock}`; + } + + // Prompt char measurement + s.lastPromptCharCount = finalPrompt.length; + s.lastBaselineCharCount = undefined; + if (deps.isDbAvailable()) { + try { + const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js"); + const [decisionsContent, requirementsContent, projectContent] = + await Promise.all([ + inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"), + inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"), + inlineGsdRootFile(s.basePath, "project.md", "Project"), + ]); + s.lastBaselineCharCount = + (decisionsContent?.length ?? 0) + + (requirementsContent?.length ?? 0) + + (projectContent?.length ?? 0); + } catch { + // Non-fatal + } + } + + // Cache-optimize prompt section ordering + try { + finalPrompt = deps.reorderForCaching(finalPrompt); + } catch (reorderErr) { + const msg = + reorderErr instanceof Error ? reorderErr.message : String(reorderErr); + process.stderr.write( + `[gsd] prompt reorder failed (non-fatal): ${msg}\n`, + ); + } + + // Select and apply model (with tier escalation on retry — normal units only) + const modelResult = await deps.selectAndApplyModel( + ctx, + pi, + unitType, + unitId, + s.basePath, + prefs, + s.verbose, + s.autoModeStartModel, + sidecarItem ? undefined : { isRetry, previousTier }, + ); + s.currentUnitRouting = + modelResult.routing as AutoSession["currentUnitRouting"]; + + // Start unit supervision + deps.clearUnitTimeout(); + deps.startUnitSupervision({ + s, + ctx, + pi, + unitType, + unitId, + prefs, + buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), + buildRecoveryContext: () => ({}), + pauseAuto: deps.pauseAuto, + }); + + // Session + send + await + const sessionFile = deps.getSessionFile(ctx); + deps.updateSessionLock( + deps.lockBase(), + unitType, + unitId, + s.completedUnits.length, + sessionFile, + ); + deps.writeLock( + deps.lockBase(), + unitType, + unitId, + s.completedUnits.length, + sessionFile, + ); + + debugLog("autoLoop", { + phase: "runUnit-start", + iteration: ic.iteration, + unitType, + unitId, + }); + const unitResult = await runUnit( + ctx, + pi, + s, + unitType, + unitId, + finalPrompt, + ); + debugLog("autoLoop", { + phase: "runUnit-end", + iteration: ic.iteration, + unitType, + unitId, + status: unitResult.status, + }); + + // Tag the most recent window entry with error info for stuck detection + if (unitResult.status === "error" || unitResult.status === "cancelled") { + const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1]; + if (lastEntry) { + lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`; + } + } else if (unitResult.event?.messages?.length) { + const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1]; + const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg); + if (/error|fail|exception/i.test(msgStr)) { + const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1]; + if (lastEntry) { + lastEntry.error = msgStr.slice(0, 200); + } + } + } + + if (unitResult.status === "cancelled") { + ctx.ui.notify( + `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, + "warning", + ); + await deps.stopAuto(ctx, pi, "Session creation failed"); + debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); + return { action: "break", reason: "session-failed" }; + } + + // ── Immediate unit closeout (metrics, activity log, memory) ──────── + // Run right after runUnit() returns so telemetry is never lost to a + // crash between iterations. + await deps.closeoutUnit( + ctx, + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(unitType, unitId), + ); + + if (s.currentUnitRouting) { + deps.recordOutcome( + unitType, + s.currentUnitRouting.tier as "light" | "standard" | "heavy", + true, // success assumed; dispatch will re-dispatch if artifact missing + ); + } + + const isHookUnit = unitType.startsWith("hook/"); + const artifactVerified = + isHookUnit || + deps.verifyExpectedArtifact(unitType, unitId, s.basePath); + if (artifactVerified) { + s.completedUnits.push({ + type: unitType, + id: unitId, + startedAt: s.currentUnit.startedAt, + finishedAt: Date.now(), + }); + if (s.completedUnits.length > 200) { + s.completedUnits = s.completedUnits.slice(-200); + } + // Flush completed-units to disk so the record survives crashes + try { + const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); + const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`); + atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2)); + } catch { /* non-fatal: disk flush failure */ } + + deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId); + s.unitDispatchCount.delete(`${unitType}/${unitId}`); + s.unitRecoveryCount.delete(`${unitType}/${unitId}`); + } + + return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } }; +} + +// ─── runFinalize ────────────────────────────────────────────────────────────── + +/** + * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard. + * Returns break/continue/next to control the outer loop. + */ +async function runFinalize( + ic: IterationContext, + iterData: IterationData, + sidecarItem?: SidecarItem, +): Promise { + const { ctx, pi, s, deps } = ic; + const { pauseAfterUatDispatch } = iterData; + + debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration }); + + // Clear unit timeout (unit completed) + deps.clearUnitTimeout(); + + // Post-unit context for pre/post verification + const postUnitCtx: PostUnitContext = { + s, + ctx, + pi, + buildSnapshotOpts: deps.buildSnapshotOpts, + lockBase: deps.lockBase, + stopAuto: deps.stopAuto, + pauseAuto: deps.pauseAuto, + updateProgressWidget: deps.updateProgressWidget, + }; + + // Pre-verification processing (commit, doctor, state rebuild, etc.) + // Sidecar items use lightweight pre-verification opts + const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem + ? sidecarItem.kind === "hook" + ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true } + : { skipSettleDelay: true, skipStateRebuild: true } + : undefined; + const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts); + if (preResult === "dispatched") { + debugLog("autoLoop", { + phase: "exit", + reason: "pre-verification-dispatched", + }); + return { action: "break", reason: "pre-verification-dispatched" }; + } + + if (pauseAfterUatDispatch) { + ctx.ui.notify( + "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", + "info", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); + return { action: "break", reason: "uat-pause" }; + } + + // Verification gate + // Hook sidecar items skip verification entirely. + // Non-hook sidecar items run verification but skip retries (just continue). + const skipVerification = sidecarItem?.kind === "hook"; + if (!skipVerification) { + const verificationResult = await deps.runPostUnitVerification( + { s, ctx, pi }, + deps.pauseAuto, + ); + + if (verificationResult === "pause") { + debugLog("autoLoop", { phase: "exit", reason: "verification-pause" }); + return { action: "break", reason: "verification-pause" }; + } + + if (verificationResult === "retry") { + if (sidecarItem) { + // Sidecar verification retries are skipped — just continue + debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration: ic.iteration }); + } else { + // s.pendingVerificationRetry was set by runPostUnitVerification. + // Continue the loop — next iteration will inject the retry context into the prompt. + debugLog("autoLoop", { phase: "verification-retry", iteration: ic.iteration }); + return { action: "continue" }; + } + } + } + + // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) + const postResult = await deps.postUnitPostVerification(postUnitCtx); + + if (postResult === "stopped") { + debugLog("autoLoop", { + phase: "exit", + reason: "post-verification-stopped", + }); + return { action: "break", reason: "post-verification-stopped" }; + } + + if (postResult === "step-wizard") { + // Step mode — exit the loop (caller handles wizard) + debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); + return { action: "break", reason: "step-wizard" }; + } + + return { action: "next", data: undefined as void }; +} + // ─── autoLoop ──────────────────────────────────────────────────────────────── /** @@ -674,11 +1740,7 @@ export async function autoLoop( ): Promise { debugLog("autoLoop", { phase: "enter" }); let iteration = 0; - // ── Sliding-window stuck detection ── - const recentUnits: Array<{ key: string; error?: string }> = []; - const STUCK_WINDOW_SIZE = 6; - let stuckRecoveryAttempts = 0; - + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; let consecutiveErrors = 0; while (s.active) { @@ -740,973 +1802,51 @@ export async function autoLoop( } } - // Variables shared between the sidecar and normal paths - let unitType: string; - let unitId: string; - let prompt: string; - let pauseAfterUatDispatch = false; - let state: GSDState; - let mid: string | undefined; - let midTitle: string | undefined; - let observabilityIssues: unknown[] = []; + const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration }; + let iterData: IterationData; if (!sidecarItem) { - // ── Phase 1: Pre-dispatch ─────────────────────────────────────────── + // ── Phase 1: Pre-dispatch ───────────────────────────────────────── + const preDispatchResult = await runPreDispatch(ic, loopState); + if (preDispatchResult.action === "break") break; + if (preDispatchResult.action === "continue") continue; - // Resource version guard - const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); - if (staleMsg) { - await deps.stopAuto(ctx, pi, staleMsg); - debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); - break; - } + const preData = preDispatchResult.data; - deps.invalidateAllCaches(); - s.lastPromptCharCount = undefined; - s.lastBaselineCharCount = undefined; - - // Pre-dispatch health gate - try { - const healthGate = await deps.preDispatchHealthGate(s.basePath); - if (healthGate.fixesApplied.length > 0) { - ctx.ui.notify( - `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, - "info", - ); - } - if (!healthGate.proceed) { - ctx.ui.notify( - healthGate.reason ?? "Pre-dispatch health check failed.", - "error", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); - break; - } - } catch { - // Non-fatal - } - - // Sync project root artifacts into worktree - if ( - s.originalBasePath && - s.basePath !== s.originalBasePath && - s.currentMilestoneId - ) { - deps.syncProjectRootToWorktree( - s.originalBasePath, - s.basePath, - s.currentMilestoneId, - ); - } - - // Derive state - state = await deps.deriveState(s.basePath); - deps.syncCmuxSidebar(prefs, state); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - debugLog("autoLoop", { - phase: "state-derived", - iteration, - mid, - statePhase: state.phase, - }); - - // ── Milestone transition ──────────────────────────────────────────── - if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { - ctx.ui.notify( - `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, - "info", - ); - deps.sendDesktopNotification( - "GSD", - `Milestone ${s.currentMilestoneId} complete!`, - "success", - "milestone", - ); - deps.logCmuxEvent( - prefs, - `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, - "success", - ); - - const vizPrefs = prefs; - if (vizPrefs?.auto_visualize) { - ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); - } - if (vizPrefs?.auto_report !== false) { - try { - await generateMilestoneReport(s, ctx, s.currentMilestoneId!); - } catch (err) { - ctx.ui.notify( - `Report generation failed: ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - } - } - - // Reset dispatch counters for new milestone - s.unitDispatchCount.clear(); - s.unitRecoveryCount.clear(); - s.unitLifetimeDispatches.clear(); - recentUnits.length = 0; - stuckRecoveryAttempts = 0; - - // Worktree lifecycle on milestone transition — merge current, enter next - deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui); - - // Opt-in: create draft PR on milestone completion - if (prefs?.git?.auto_pr) { - try { - const { createDraftPR } = await import("./git-service.js"); - const prUrl = createDraftPR( - s.basePath, - s.currentMilestoneId!, - `[GSD] ${s.currentMilestoneId} complete`, - `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, - ); - if (prUrl) { - ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); - } - } catch { - // Non-fatal — PR creation is best-effort - } - } - - deps.invalidateAllCaches(); - - state = await deps.deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - - if (mid) { - if (deps.getIsolationMode() !== "none") { - deps.captureIntegrationBranch(s.basePath, mid, { - commitDocs: prefs?.git?.commit_docs, - }); - } - deps.resolver.enterMilestone(mid, ctx.ui); - } else { - // mid is undefined — no milestone to capture integration branch for - } - - const pendingIds = state.registry - .filter( - (m: { status: string }) => - m.status !== "complete" && m.status !== "parked", - ) - .map((m: { id: string }) => m.id); - deps.pruneQueueOrder(s.basePath, pendingIds); - } - - if (mid) { - s.currentMilestoneId = mid; - deps.setActiveMilestoneId(s.basePath, mid); - } - - // ── Terminal conditions ────────────────────────────────────────────── - - if (!mid) { - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - s.currentUnit.type, - s.currentUnit.id, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), - ); - } - - const incomplete = state.registry.filter( - (m: { status: string }) => - m.status !== "complete" && m.status !== "parked", - ); - if (incomplete.length === 0 && state.registry.length > 0) { - // All milestones complete — merge milestone branch before stopping - if (s.currentMilestoneId) { - deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); - - // Opt-in: create draft PR on milestone completion - if (prefs?.git?.auto_pr) { - try { - const { createDraftPR } = await import("./git-service.js"); - const prUrl = createDraftPR( - s.basePath, - s.currentMilestoneId, - `[GSD] ${s.currentMilestoneId} complete`, - `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, - ); - if (prUrl) { - ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); - } - } catch { - // Non-fatal — PR creation is best-effort - } - } - } - deps.sendDesktopNotification( - "GSD", - "All milestones complete!", - "success", - "milestone", - ); - deps.logCmuxEvent( - prefs, - "All milestones complete.", - "success", - ); - await deps.stopAuto(ctx, pi, "All milestones complete"); - } else if (incomplete.length === 0 && state.registry.length === 0) { - // Empty registry — no milestones visible, likely a path resolution bug - const diag = `basePath=${s.basePath}, phase=${state.phase}`; - ctx.ui.notify( - `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `No milestones found — check basePath resolution`, - ); - } else if (state.phase === "blocked") { - const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; - await deps.stopAuto(ctx, pi, blockerMsg); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); - deps.logCmuxEvent(prefs, blockerMsg, "error"); - } else { - const ids = incomplete.map((m: { id: string }) => m.id).join(", "); - const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${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 deps.stopAuto( - ctx, - pi, - `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, - ); - } - debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); - break; - } - - if (!midTitle) { - midTitle = mid; - ctx.ui.notify( - `Milestone ${mid} has no title in roadmap — using ID as fallback.`, - "warning", - ); - } - - // Mid-merge safety check - if (deps.reconcileMergeState(s.basePath, ctx)) { - deps.invalidateAllCaches(); - state = await deps.deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; - } - - if (!mid || !midTitle) { - const noMilestoneReason = !mid - ? "No active milestone after merge reconciliation" - : `Milestone ${mid} has no title after reconciliation`; - await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason); - debugLog("autoLoop", { - phase: "exit", - reason: "no-milestone-after-reconciliation", - }); - break; - } - - // Terminal: complete - if (state.phase === "complete") { - // Milestone merge on complete (before closeout so branch state is clean) - if (s.currentMilestoneId) { - deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); - - // Opt-in: create draft PR on milestone completion - if (prefs?.git?.auto_pr) { - try { - const { createDraftPR } = await import("./git-service.js"); - const prUrl = createDraftPR( - s.basePath, - s.currentMilestoneId, - `[GSD] ${s.currentMilestoneId} complete`, - `Milestone ${s.currentMilestoneId} completed by GSD auto-mode.\n\nSee .gsd/${s.currentMilestoneId}/ for details.`, - ); - if (prUrl) { - ctx.ui.notify(`Draft PR created: ${prUrl}`, "info"); - } - } catch { - // Non-fatal — PR creation is best-effort - } - } - } - deps.sendDesktopNotification( - "GSD", - `Milestone ${mid} complete!`, - "success", - "milestone", - ); - deps.logCmuxEvent( - prefs, - `Milestone ${mid} complete.`, - "success", - ); - await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); - debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); - break; - } - - // Terminal: blocked - if (state.phase === "blocked") { - const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; - await closeoutAndStop(ctx, pi, s, deps, blockerMsg); - ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); - deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention"); - deps.logCmuxEvent(prefs, blockerMsg, "error"); - debugLog("autoLoop", { phase: "exit", reason: "blocked" }); - break; - } - - // ── Phase 2: Guards ───────────────────────────────────────────────── - - // Budget ceiling guard - const budgetCeiling = prefs?.budget_ceiling; - if (budgetCeiling !== undefined && budgetCeiling > 0) { - const currentLedger = deps.getLedger() as { units: unknown } | null; - const totalCost = currentLedger - ? deps.getProjectTotals(currentLedger.units).cost - : 0; - const budgetPct = totalCost / budgetCeiling; - const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); - const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( - s.lastBudgetAlertLevel, - budgetPct, - ); - const enforcement = prefs?.budget_enforcement ?? "pause"; - const budgetEnforcementAction = deps.getBudgetEnforcementAction( - enforcement, - budgetPct, - ); - - // Data-driven threshold check — loop descending, fire first match - const threshold = BUDGET_THRESHOLDS.find( - (t) => newBudgetAlertLevel >= t.pct, - ); - if (threshold) { - s.lastBudgetAlertLevel = - newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"]; - - if (threshold.pct === 100 && budgetEnforcementAction !== "none") { - // 100% — special enforcement logic (halt/pause/warn) - const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; - if (budgetEnforcementAction === "halt") { - deps.sendDesktopNotification("GSD", msg, "error", "budget"); - await deps.stopAuto(ctx, pi, "Budget ceiling reached"); - debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); - break; - } - if (budgetEnforcementAction === "pause") { - ctx.ui.notify( - `${msg} Pausing auto-mode — /gsd auto to override and continue.`, - "warning", - ); - deps.sendDesktopNotification("GSD", msg, "warning", "budget"); - deps.logCmuxEvent(prefs, msg, "warning"); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); - break; - } - ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); - deps.sendDesktopNotification("GSD", msg, "warning", "budget"); - deps.logCmuxEvent(prefs, msg, "warning"); - } else if (threshold.pct < 100) { - // Sub-100% — simple notification - const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`; - ctx.ui.notify(msg, threshold.notifyLevel); - deps.sendDesktopNotification( - "GSD", - msg, - threshold.notifyLevel, - "budget", - ); - deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); - } - } else if (budgetAlertLevel === 0) { - s.lastBudgetAlertLevel = 0; - } - } else { - s.lastBudgetAlertLevel = 0; - } - - // Context window guard - const contextThreshold = prefs?.context_pause_threshold ?? 0; - if (contextThreshold > 0 && s.cmdCtx) { - const contextUsage = s.cmdCtx.getContextUsage(); - if ( - contextUsage && - contextUsage.percent !== null && - contextUsage.percent >= contextThreshold - ) { - const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; - ctx.ui.notify( - `${msg} Run /gsd auto to continue (will start fresh session).`, - "warning", - ); - deps.sendDesktopNotification( - "GSD", - `Context ${contextUsage.percent}% — paused`, - "warning", - "attention", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "context-window" }); - break; - } - } - - // Secrets re-check gate - try { - const manifestStatus = await deps.getManifestStatus(s.basePath, mid, s.originalBasePath); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await deps.collectSecretsFromManifest( - s.basePath, - mid, - ctx, - ); - if ( - result && - result.applied && - result.skipped && - result.existingSkipped - ) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } - } - } catch (err) { - ctx.ui.notify( - `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`, - "warning", - ); - } - - // ── Phase 3: Dispatch resolution ──────────────────────────────────── - - debugLog("autoLoop", { phase: "dispatch-resolve", iteration }); - const dispatchResult = await deps.resolveDispatch({ - basePath: s.basePath, - mid, - midTitle: midTitle!, - state, - prefs, - session: s, - }); - - if (dispatchResult.action === "stop") { - await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); - debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); - break; - } - - if (dispatchResult.action !== "dispatch") { - // Non-dispatch action (e.g. "skip") — re-derive state - await new Promise((r) => setImmediate(r)); - continue; - } - - unitType = dispatchResult.unitType; - unitId = dispatchResult.unitId; - prompt = dispatchResult.prompt; - pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; - - // ── Sliding-window stuck detection with graduated recovery ── - const derivedKey = `${unitType}/${unitId}`; - - if (!s.pendingVerificationRetry) { - recentUnits.push({ key: derivedKey }); - if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift(); - - const stuckSignal = detectStuck(recentUnits); - if (stuckSignal) { - debugLog("autoLoop", { - phase: "stuck-check", - unitType, - unitId, - reason: stuckSignal.reason, - recoveryAttempts: stuckRecoveryAttempts, - }); - - if (stuckRecoveryAttempts === 0) { - // Level 1: try verifying the artifact, then cache invalidation + retry - stuckRecoveryAttempts++; - const artifactExists = deps.verifyExpectedArtifact( - unitType, - unitId, - s.basePath, - ); - if (artifactExists) { - debugLog("autoLoop", { - phase: "stuck-recovery", - level: 1, - action: "artifact-found", - }); - ctx.ui.notify( - `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, - "info", - ); - deps.invalidateAllCaches(); - continue; - } - ctx.ui.notify( - `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, - "warning", - ); - deps.invalidateAllCaches(); - } else { - // Level 2: hard stop — genuinely stuck - debugLog("autoLoop", { - phase: "stuck-detected", - unitType, - unitId, - reason: stuckSignal.reason, - }); - await deps.stopAuto( - ctx, - pi, - `Stuck: ${stuckSignal.reason}`, - ); - ctx.ui.notify( - `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, - "error", - ); - break; - } - } else { - // Progress detected — reset recovery counter - if (stuckRecoveryAttempts > 0) { - debugLog("autoLoop", { - phase: "stuck-counter-reset", - from: recentUnits[recentUnits.length - 2]?.key ?? "", - to: derivedKey, - }); - stuckRecoveryAttempts = 0; - } - } - } - - // Pre-dispatch hooks - const preDispatchResult = deps.runPreDispatchHooks( - unitType, - unitId, - prompt, - s.basePath, - ); - if (preDispatchResult.firedHooks.length > 0) { - ctx.ui.notify( - `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, - "info", - ); - } - if (preDispatchResult.action === "skip") { - ctx.ui.notify( - `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, - "info", - ); - await new Promise((r) => setImmediate(r)); - continue; - } - if (preDispatchResult.action === "replace") { - prompt = preDispatchResult.prompt ?? prompt; - if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; - } else if (preDispatchResult.prompt) { - prompt = preDispatchResult.prompt; - } - - const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( - s.basePath, - deps.getMainBranch(s.basePath), - unitType, - unitId, - ); - if (priorSliceBlocker) { - await deps.stopAuto(ctx, pi, priorSliceBlocker); - debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); - break; - } - - observabilityIssues = await deps.collectObservabilityWarnings( - ctx, - s.basePath, - unitType, - unitId, - ); - - // Derive state for shared use in execution phase - // (state, mid, midTitle already set above) + // ── Phase 2: Guards ─────────────────────────────────────────────── + const guardsResult = await runGuards(ic, preData.mid); + if (guardsResult.action === "break") break; + // ── Phase 3: Dispatch ───────────────────────────────────────────── + const dispatchResult = await runDispatch(ic, preData, loopState); + if (dispatchResult.action === "break") break; + if (dispatchResult.action === "continue") continue; + iterData = dispatchResult.data; } else { // ── Sidecar path: use values from the sidecar item directly ── - unitType = sidecarItem.unitType; - unitId = sidecarItem.unitId; - prompt = sidecarItem.prompt; - // Derive minimal state for progress widget / execution context - state = await deps.deriveState(s.basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; + const sidecarState = await deps.deriveState(s.basePath); + iterData = { + unitType: sidecarItem.unitType, + unitId: sidecarItem.unitId, + prompt: sidecarItem.prompt, + finalPrompt: sidecarItem.prompt, + pauseAfterUatDispatch: false, + observabilityIssues: [], + state: sidecarState, + mid: sidecarState.activeMilestone?.id, + midTitle: sidecarState.activeMilestone?.title, + isRetry: false, previousTier: undefined, + }; } - // ── Phase 4: Unit execution ───────────────────────────────────────── - - debugLog("autoLoop", { - phase: "unit-execution", - iteration, - unitType, - unitId, - }); - - // Detect retry and capture previous tier for escalation - const isRetry = !!( - s.currentUnit && - s.currentUnit.type === unitType && - s.currentUnit.id === unitId - ); - const previousTier = s.currentUnitRouting?.tier; - - s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; - deps.captureAvailableSkills(); - deps.writeUnitRuntimeRecord( - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: s.currentUnit.startedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }, - ); - - // Status bar + progress widget - ctx.ui.setStatus("gsd-auto", "auto"); - if (mid) - deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); - deps.updateProgressWidget(ctx, unitType, unitId, state); - - deps.ensurePreconditions(unitType, unitId, s.basePath, state); - - // Prompt injection - let finalPrompt = prompt; - - if (s.pendingVerificationRetry) { - const retryCtx = s.pendingVerificationRetry; - s.pendingVerificationRetry = null; - const capped = - retryCtx.failureContext.length > MAX_RECOVERY_CHARS - ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...failure context truncated]" - : retryCtx.failureContext; - finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; - } - - if (s.pendingCrashRecovery) { - const capped = - s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS - ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...recovery briefing truncated to prevent memory exhaustion]" - : s.pendingCrashRecovery; - finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; - s.pendingCrashRecovery = null; - } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { - const diagnostic = deps.getDeepDiagnostic(s.basePath); - if (diagnostic) { - const cappedDiag = - diagnostic.length > MAX_RECOVERY_CHARS - ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...diagnostic truncated to prevent memory exhaustion]" - : diagnostic; - finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; - } - } - - const repairBlock = - deps.buildObservabilityRepairBlock(observabilityIssues); - if (repairBlock) { - finalPrompt = `${finalPrompt}${repairBlock}`; - } - - // Prompt char measurement - s.lastPromptCharCount = finalPrompt.length; - s.lastBaselineCharCount = undefined; - if (deps.isDbAvailable()) { - try { - const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js"); - const [decisionsContent, requirementsContent, projectContent] = - await Promise.all([ - inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"), - inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"), - inlineGsdRootFile(s.basePath, "project.md", "Project"), - ]); - s.lastBaselineCharCount = - (decisionsContent?.length ?? 0) + - (requirementsContent?.length ?? 0) + - (projectContent?.length ?? 0); - } catch { - // Non-fatal - } - } - - // Cache-optimize prompt section ordering - try { - finalPrompt = deps.reorderForCaching(finalPrompt); - } catch (reorderErr) { - const msg = - reorderErr instanceof Error ? reorderErr.message : String(reorderErr); - process.stderr.write( - `[gsd] prompt reorder failed (non-fatal): ${msg}\n`, - ); - } - - // Select and apply model (with tier escalation on retry — normal units only) - const modelResult = await deps.selectAndApplyModel( - ctx, - pi, - unitType, - unitId, - s.basePath, - prefs, - s.verbose, - s.autoModeStartModel, - sidecarItem ? undefined : { isRetry, previousTier }, - ); - s.currentUnitRouting = - modelResult.routing as AutoSession["currentUnitRouting"]; - - // Start unit supervision - deps.clearUnitTimeout(); - deps.startUnitSupervision({ - s, - ctx, - pi, - unitType, - unitId, - prefs, - buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), - buildRecoveryContext: () => ({}), - pauseAuto: deps.pauseAuto, - }); - - // Session + send + await - const sessionFile = deps.getSessionFile(ctx); - deps.updateSessionLock( - deps.lockBase(), - unitType, - unitId, - s.completedUnits.length, - sessionFile, - ); - deps.writeLock( - deps.lockBase(), - unitType, - unitId, - s.completedUnits.length, - sessionFile, - ); - - debugLog("autoLoop", { - phase: "runUnit-start", - iteration, - unitType, - unitId, - }); - const unitResult = await runUnit( - ctx, - pi, - s, - unitType, - unitId, - finalPrompt, - ); - debugLog("autoLoop", { - phase: "runUnit-end", - iteration, - unitType, - unitId, - status: unitResult.status, - }); - - // Tag the most recent window entry with error info for stuck detection - if (unitResult.status === "error" || unitResult.status === "cancelled") { - const lastEntry = recentUnits[recentUnits.length - 1]; - if (lastEntry) { - lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`; - } - } else if (unitResult.event?.messages?.length) { - const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1]; - const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg); - if (/error|fail|exception/i.test(msgStr)) { - const lastEntry = recentUnits[recentUnits.length - 1]; - if (lastEntry) { - lastEntry.error = msgStr.slice(0, 200); - } - } - } - - if (unitResult.status === "cancelled") { - ctx.ui.notify( - `Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, - "warning", - ); - await deps.stopAuto(ctx, pi, "Session creation failed"); - debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); - break; - } - - // ── Immediate unit closeout (metrics, activity log, memory) ──────── - // Run right after runUnit() returns so telemetry is never lost to a - // crash between iterations. - await deps.closeoutUnit( - ctx, - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(unitType, unitId), - ); - - if (s.currentUnitRouting) { - deps.recordOutcome( - unitType, - s.currentUnitRouting.tier as "light" | "standard" | "heavy", - true, // success assumed; dispatch will re-dispatch if artifact missing - ); - } - - const isHookUnit = unitType.startsWith("hook/"); - const artifactVerified = - isHookUnit || - deps.verifyExpectedArtifact(unitType, unitId, s.basePath); - if (artifactVerified) { - s.completedUnits.push({ - type: unitType, - id: unitId, - startedAt: s.currentUnit.startedAt, - finishedAt: Date.now(), - }); - if (s.completedUnits.length > 200) { - s.completedUnits = s.completedUnits.slice(-200); - } - // Flush completed-units to disk so the record survives crashes - try { - const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); - const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`); - atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2)); - } catch { /* non-fatal: disk flush failure */ } - - deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId); - s.unitDispatchCount.delete(`${unitType}/${unitId}`); - s.unitRecoveryCount.delete(`${unitType}/${unitId}`); - } + const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem); + if (unitPhaseResult.action === "break") break; // ── Phase 5: Finalize ─────────────────────────────────────────────── - debugLog("autoLoop", { phase: "finalize", iteration }); - - // Clear unit timeout (unit completed) - deps.clearUnitTimeout(); - - // Post-unit context for pre/post verification - const postUnitCtx: PostUnitContext = { - s, - ctx, - pi, - buildSnapshotOpts: deps.buildSnapshotOpts, - lockBase: deps.lockBase, - stopAuto: deps.stopAuto, - pauseAuto: deps.pauseAuto, - updateProgressWidget: deps.updateProgressWidget, - }; - - // Pre-verification processing (commit, doctor, state rebuild, etc.) - // Sidecar items use lightweight pre-verification opts - const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem - ? sidecarItem.kind === "hook" - ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true } - : { skipSettleDelay: true, skipStateRebuild: true } - : undefined; - const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts); - if (preResult === "dispatched") { - debugLog("autoLoop", { - phase: "exit", - reason: "pre-verification-dispatched", - }); - break; - } - - if (pauseAfterUatDispatch) { - ctx.ui.notify( - "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", - "info", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); - break; - } - - // Verification gate - // Hook sidecar items skip verification entirely. - // Non-hook sidecar items run verification but skip retries (just continue). - const skipVerification = sidecarItem?.kind === "hook"; - if (!skipVerification) { - const verificationResult = await deps.runPostUnitVerification( - { s, ctx, pi }, - deps.pauseAuto, - ); - - if (verificationResult === "pause") { - debugLog("autoLoop", { phase: "exit", reason: "verification-pause" }); - break; - } - - if (verificationResult === "retry") { - if (sidecarItem) { - // Sidecar verification retries are skipped — just continue - debugLog("autoLoop", { phase: "sidecar-verification-retry-skipped", iteration }); - } else { - // s.pendingVerificationRetry was set by runPostUnitVerification. - // Continue the loop — next iteration will inject the retry context into the prompt. - debugLog("autoLoop", { phase: "verification-retry", iteration }); - continue; - } - } - } - - // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) - const postResult = await deps.postUnitPostVerification(postUnitCtx); - - if (postResult === "stopped") { - debugLog("autoLoop", { - phase: "exit", - reason: "post-verification-stopped", - }); - break; - } - - if (postResult === "step-wizard") { - // Step mode — exit the loop (caller handles wizard) - debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); - break; - } + const finalizeResult = await runFinalize(ic, iterData, sidecarItem); + if (finalizeResult.action === "break") break; + if (finalizeResult.action === "continue") continue; consecutiveErrors = 0; // Iteration completed successfully debugLog("autoLoop", { phase: "iteration-complete", iteration });