From 5d14a9cde2420cca9c8b69f5e9acab2e492bf910 Mon Sep 17 00:00:00 2001 From: Iouri Goussev Date: Sat, 21 Mar 2026 10:40:38 -0400 Subject: [PATCH] refactor: split auto-loop.ts monolith into auto/ directory modules (#1682) Fixes #1684 --- src/resources/extensions/gsd/auto-loop.ts | 1899 +---------------- .../extensions/gsd/auto/detect-stuck.ts | 60 + .../extensions/gsd/auto/loop-deps.ts | 281 +++ src/resources/extensions/gsd/auto/loop.ts | 195 ++ src/resources/extensions/gsd/auto/phases.ts | 1144 ++++++++++ src/resources/extensions/gsd/auto/resolve.ts | 88 + src/resources/extensions/gsd/auto/run-unit.ts | 123 ++ src/resources/extensions/gsd/auto/types.ts | 99 + .../gsd/tests/agent-end-retry.test.ts | 14 +- .../all-milestones-complete-merge.test.ts | 6 +- .../extensions/gsd/tests/auto-loop.test.ts | 69 +- .../milestone-transition-worktree.test.ts | 18 +- .../gsd/tests/sidecar-queue.test.ts | 2 +- 13 files changed, 2068 insertions(+), 1930 deletions(-) create mode 100644 src/resources/extensions/gsd/auto/detect-stuck.ts create mode 100644 src/resources/extensions/gsd/auto/loop-deps.ts create mode 100644 src/resources/extensions/gsd/auto/loop.ts create mode 100644 src/resources/extensions/gsd/auto/phases.ts create mode 100644 src/resources/extensions/gsd/auto/resolve.ts create mode 100644 src/resources/extensions/gsd/auto/run-unit.ts create mode 100644 src/resources/extensions/gsd/auto/types.ts diff --git a/src/resources/extensions/gsd/auto-loop.ts b/src/resources/extensions/gsd/auto-loop.ts index c45fcfafd..a938419c8 100644 --- a/src/resources/extensions/gsd/auto-loop.ts +++ b/src/resources/extensions/gsd/auto-loop.ts @@ -1,1892 +1,15 @@ /** - * auto-loop.ts — Linear loop execution backbone for auto-mode. + * auto-loop.ts — Barrel re-export for the auto-loop pipeline modules. * - * Replaces the recursive dispatchNextUnit → handleAgentEnd → dispatchNextUnit - * pattern with a while loop. The agent_end event resolves a promise instead - * of recursing. - * - * MAINTENANCE RULE: Module-level mutable state is limited to `_currentResolve` - * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for - * session rotation). No queue — stale agent_end events are dropped. + * The implementation has been split into focused modules under auto/. + * This file preserves the original public API so external consumers + * (auto.ts, auto-timeout-recovery.ts, agent-end-recovery.ts, tests) + * continue to work without changes. */ -import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent"; - -import type { AutoSession, SidecarItem } from "./auto/session.js"; -import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js"; -import type { GSDPreferences } from "./preferences.js"; -import type { SessionLockStatus } from "./session-lock.js"; -import type { GSDState } from "./types.js"; -import type { CloseoutOptions } from "./auto-unit-closeout.js"; -import type { PostUnitContext, PreVerificationOpts } from "./auto-post-unit.js"; -import type { - VerificationContext, - VerificationResult, -} from "./auto-verification.js"; -import type { DispatchAction } from "./auto-dispatch.js"; -import type { WorktreeResolver } from "./worktree-resolver.js"; -import { debugLog } from "./debug-logger.js"; -import { gsdRoot } from "./paths.js"; -import { atomicWriteSync } from "./atomic-write.js"; -import { join } from "node:path"; -import type { CmuxLogLevel } from "../cmux/index.js"; - -/** - * Maximum total loop iterations before forced stop. Prevents runaway loops - * when units alternate IDs (bypassing the same-unit stuck detector). - * A milestone with 20 slices × 5 tasks × 3 phases ≈ 300 units. 500 gives - * generous headroom including retries and sidecar work. - */ -const MAX_LOOP_ITERATIONS = 500; -/** Maximum characters of failure/crash context included in recovery prompts. */ -const MAX_RECOVERY_CHARS = 50_000; - -/** Data-driven budget threshold notifications (descending). The 100% entry - * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire - * a simple notification. */ -const BUDGET_THRESHOLDS: Array<{ - pct: number; - label: string; - notifyLevel: "info" | "warning" | "error"; - cmuxLevel: "progress" | "warning" | "error"; -}> = [ - { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" }, - { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" }, - { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" }, - { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" }, -]; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** - * Minimal shape of the event parameter from pi.on("agent_end", ...). - * The full event has more fields, but the loop only needs messages. - */ -export interface AgentEndEvent { - messages: unknown[]; -} - -/** - * Result of a single unit execution (one iteration of the loop). - */ -export interface UnitResult { - status: "completed" | "cancelled" | "error"; - 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. -// No queue — if an agent_end arrives with no pending resolver, it is dropped -// (logged as warning). This is simpler and safer than the previous session- -// scoped pendingResolve + pendingAgentEndQueue pattern. - -let _currentResolve: ((result: UnitResult) => void) | null = null; -let _sessionSwitchInFlight = false; - -// ─── resolveAgentEnd ───────────────────────────────────────────────────────── - -/** - * Called from the agent_end event handler in index.ts to resolve the - * in-flight unit promise. One-shot: the resolver is nulled before calling - * to prevent double-resolution from model fallback retries. - * - * If no resolver exists (event arrived between loop iterations or during - * session switch), the event is dropped with a debug warning. - */ -export function resolveAgentEnd(event: AgentEndEvent): void { - if (_sessionSwitchInFlight) { - debugLog("resolveAgentEnd", { status: "ignored-during-switch" }); - return; - } - if (_currentResolve) { - debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true }); - const r = _currentResolve; - _currentResolve = null; - r({ status: "completed", event }); - } else { - debugLog("resolveAgentEnd", { - status: "no-pending-resolve", - warning: "agent_end with no pending unit", - }); - } -} - -export function isSessionSwitchInFlight(): boolean { - return _sessionSwitchInFlight; -} - -// ─── resetPendingResolve (test helper) ─────────────────────────────────────── - -/** - * Reset module-level promise state. Only exported for test cleanup — - * production code should never call this. - */ -export function _resetPendingResolve(): void { - _currentResolve = null; - _sessionSwitchInFlight = false; -} - -/** - * No-op for backward compatibility with tests that previously set the - * active session. The module no longer holds a session reference. - */ -export function _setActiveSession(_session: AutoSession | null): void { - // No-op — kept for test backward compatibility -} - -// ─── detectStuck ───────────────────────────────────────────────────────────── - -type WindowEntry = { key: string; error?: string }; - -/** - * Analyze a sliding window of recent unit dispatches for stuck patterns. - * Returns a signal with reason if stuck, null otherwise. - * - * Rule 1: Same error string twice in a row → stuck immediately. - * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior). - * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck. - */ -export function detectStuck( - window: readonly WindowEntry[], -): { stuck: true; reason: string } | null { - if (window.length < 2) return null; - - const last = window[window.length - 1]; - const prev = window[window.length - 2]; - - // Rule 1: Same error repeated consecutively - if (last.error && prev.error && last.error === prev.error) { - return { - stuck: true, - reason: `Same error repeated: ${last.error.slice(0, 200)}`, - }; - } - - // Rule 2: Same unit 3+ consecutive times - if (window.length >= 3) { - const lastThree = window.slice(-3); - if (lastThree.every((u) => u.key === last.key)) { - return { - stuck: true, - reason: `${last.key} derived 3 consecutive times without progress`, - }; - } - } - - // Rule 3: Oscillation (A→B→A→B in last 4) - if (window.length >= 4) { - const w = window.slice(-4); - if ( - w[0].key === w[2].key && - w[1].key === w[3].key && - w[0].key !== w[1].key - ) { - return { - stuck: true, - reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`, - }; - } - } - - return null; -} - -// ─── runUnit ───────────────────────────────────────────────────────────────── - -/** - * Execute a single unit: create a new session, send the prompt, and await - * the agent_end promise. Returns a UnitResult describing what happened. - * - * The promise is one-shot: resolveAgentEnd() is the only way to resolve it. - * On session creation failure or timeout, returns { status: 'cancelled' } - * without awaiting the promise. - */ -export async function runUnit( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - unitType: string, - unitId: string, - prompt: string, -): Promise { - debugLog("runUnit", { phase: "start", unitType, unitId }); - - // ── Session creation with timeout ── - debugLog("runUnit", { phase: "session-create", unitType, unitId }); - - let sessionResult: { cancelled: boolean }; - let sessionTimeoutHandle: ReturnType | undefined; - _sessionSwitchInFlight = true; - try { - const sessionPromise = s.cmdCtx!.newSession().finally(() => { - _sessionSwitchInFlight = false; - }); - const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => { - sessionTimeoutHandle = setTimeout( - () => resolve({ cancelled: true }), - NEW_SESSION_TIMEOUT_MS, - ); - }); - sessionResult = await Promise.race([sessionPromise, timeoutPromise]); - } catch (sessionErr) { - if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); - const msg = - sessionErr instanceof Error ? sessionErr.message : String(sessionErr); - debugLog("runUnit", { - phase: "session-error", - unitType, - unitId, - error: msg, - }); - return { status: "cancelled" }; - } - if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); - - if (sessionResult.cancelled) { - debugLog("runUnit-session-timeout", { unitType, unitId }); - return { status: "cancelled" }; - } - - if (!s.active) { - return { status: "cancelled" }; - } - - // ── Create the agent_end promise (per-unit one-shot) ── - // This happens after newSession completes so session-switch agent_end events - // from the previous session cannot resolve the new unit. - _sessionSwitchInFlight = false; - const unitPromise = new Promise((resolve) => { - _currentResolve = resolve; - }); - - // Ensure cwd matches basePath before dispatch (#1389). - // async_bash and background jobs can drift cwd away from the worktree. - // Realigning here prevents commits from landing on the wrong branch. - try { - if (process.cwd() !== s.basePath) { - process.chdir(s.basePath); - } - } catch { /* non-fatal — chdir may fail if dir was removed */ } - - // ── Send the prompt ── - debugLog("runUnit", { phase: "send-message", unitType, unitId }); - - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - - // ── Await agent_end ── - debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId }); - const result = await unitPromise; - debugLog("runUnit", { - phase: "agent-end-received", - unitType, - unitId, - status: result.status, - }); - - // Discard trailing follow-up messages (e.g. async_job_result notifications) - // from the completed unit. Without this, queued follow-ups trigger wasteful - // LLM turns before the next session can start (#1642). - // clearQueue() lives on AgentSession but isn't part of the typed - // ExtensionCommandContext interface — call it via runtime check. - try { - const cmdCtxAny = s.cmdCtx as Record | null; - if (typeof cmdCtxAny?.clearQueue === "function") { - (cmdCtxAny.clearQueue as () => unknown)(); - } - } catch { - // Non-fatal — clearQueue may not be available in all contexts - } - - return result; -} - -// ─── LoopDeps ──────────────────────────────────────────────────────────────── - -/** - * Dependencies injected by the caller (auto.ts startAuto) so autoLoop - * can access private functions from auto.ts without exporting them. - */ -export interface LoopDeps { - lockBase: () => string; - buildSnapshotOpts: ( - unitType: string, - unitId: string, - ) => CloseoutOptions & Record; - stopAuto: ( - ctx?: ExtensionContext, - pi?: ExtensionAPI, - reason?: string, - ) => Promise; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - clearUnitTimeout: () => void; - updateProgressWidget: ( - ctx: ExtensionContext, - unitType: string, - unitId: string, - state: GSDState, - ) => void; - syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void; - logCmuxEvent: ( - preferences: GSDPreferences | undefined, - message: string, - level?: CmuxLogLevel, - ) => void; - - // State and cache functions - invalidateAllCaches: () => void; - deriveState: (basePath: string) => Promise; - loadEffectiveGSDPreferences: () => - | { preferences?: GSDPreferences } - | undefined; - - // Pre-dispatch health gate - preDispatchHealthGate: ( - basePath: string, - ) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>; - - // Worktree sync - syncProjectRootToWorktree: ( - originalBase: string, - basePath: string, - milestoneId: string | null, - ) => void; - - // Resource version guard - checkResourcesStale: (version: string | null) => string | null; - - // Session lock - validateSessionLock: (basePath: string) => SessionLockStatus; - updateSessionLock: ( - basePath: string, - unitType: string, - unitId: string, - completedUnits: number, - sessionFile?: string, - ) => void; - handleLostSessionLock: ( - ctx?: ExtensionContext, - lockStatus?: SessionLockStatus, - ) => void; - - // Milestone transition functions - sendDesktopNotification: ( - title: string, - body: string, - kind: string, - category: string, - ) => void; - setActiveMilestoneId: (basePath: string, mid: string) => void; - pruneQueueOrder: (basePath: string, pendingIds: string[]) => void; - isInAutoWorktree: (basePath: string) => boolean; - shouldUseWorktreeIsolation: () => boolean; - mergeMilestoneToMain: ( - basePath: string, - milestoneId: string, - roadmapContent: string, - ) => { pushed: boolean }; - teardownAutoWorktree: (basePath: string, milestoneId: string) => void; - createAutoWorktree: (basePath: string, milestoneId: string) => string; - captureIntegrationBranch: ( - basePath: string, - mid: string, - opts?: { commitDocs?: boolean }, - ) => void; - getIsolationMode: () => string; - getCurrentBranch: (basePath: string) => string; - autoWorktreeBranch: (milestoneId: string) => string; - resolveMilestoneFile: ( - basePath: string, - milestoneId: string, - fileType: string, - ) => string | null; - reconcileMergeState: (basePath: string, ctx: ExtensionContext) => boolean; - - // Budget/context/secrets - getLedger: () => unknown; - getProjectTotals: (units: unknown) => { cost: number }; - formatCost: (cost: number) => string; - getBudgetAlertLevel: (pct: number) => number; - getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number; - getBudgetEnforcementAction: (enforcement: string, pct: number) => string; - getManifestStatus: ( - basePath: string, - mid: string | undefined, - projectRoot?: string, - ) => Promise<{ pending: unknown[] } | null>; - collectSecretsFromManifest: ( - basePath: string, - mid: string | undefined, - ctx: ExtensionContext, - ) => Promise<{ - applied: unknown[]; - skipped: unknown[]; - existingSkipped: unknown[]; - } | null>; - - // Dispatch - resolveDispatch: (dctx: { - basePath: string; - mid: string; - midTitle: string; - state: GSDState; - prefs: GSDPreferences | undefined; - session?: AutoSession; - }) => Promise; - runPreDispatchHooks: ( - unitType: string, - unitId: string, - prompt: string, - basePath: string, - ) => { - firedHooks: string[]; - action: string; - prompt?: string; - unitType?: string; - }; - getPriorSliceCompletionBlocker: ( - basePath: string, - mainBranch: string, - unitType: string, - unitId: string, - ) => string | null; - getMainBranch: (basePath: string) => string; - collectObservabilityWarnings: ( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, - ) => Promise; - buildObservabilityRepairBlock: (issues: unknown[]) => string | null; - - // Unit closeout + runtime records - closeoutUnit: ( - ctx: ExtensionContext, - basePath: string, - unitType: string, - unitId: string, - startedAt: number, - opts?: CloseoutOptions & Record, - ) => Promise; - verifyExpectedArtifact: ( - unitType: string, - unitId: string, - basePath: string, - ) => boolean; - clearUnitRuntimeRecord: ( - basePath: string, - unitType: string, - unitId: string, - ) => void; - writeUnitRuntimeRecord: ( - basePath: string, - unitType: string, - unitId: string, - startedAt: number, - record: Record, - ) => void; - recordOutcome: (unitType: string, tier: string, success: boolean) => void; - writeLock: ( - lockBase: string, - unitType: string, - unitId: string, - completedCount: number, - sessionFile?: string, - ) => void; - captureAvailableSkills: () => void; - ensurePreconditions: ( - unitType: string, - unitId: string, - basePath: string, - state: GSDState, - ) => void; - updateSliceProgressCache: ( - basePath: string, - mid: string, - sliceId?: string, - ) => void; - - // Model selection + supervision - selectAndApplyModel: ( - ctx: ExtensionContext, - pi: ExtensionAPI, - unitType: string, - unitId: string, - basePath: string, - prefs: GSDPreferences | undefined, - verbose: boolean, - startModel: { provider: string; id: string } | null, - retryContext?: { isRetry: boolean; previousTier?: string }, - ) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>; - startUnitSupervision: (sctx: { - s: AutoSession; - ctx: ExtensionContext; - pi: ExtensionAPI; - unitType: string; - unitId: string; - prefs: GSDPreferences | undefined; - buildSnapshotOpts: () => CloseoutOptions & Record; - buildRecoveryContext: () => unknown; - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; - }) => void; - - // Prompt helpers - getDeepDiagnostic: (basePath: string) => string | null; - isDbAvailable: () => boolean; - reorderForCaching: (prompt: string) => string; - - // Filesystem - existsSync: (path: string) => boolean; - readFileSync: (path: string, encoding: string) => string; - atomicWriteSync: (path: string, content: string) => void; - - // Git - GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown; - - // WorktreeResolver - resolver: WorktreeResolver; - - // Post-unit processing - postUnitPreVerification: ( - pctx: PostUnitContext, - opts?: PreVerificationOpts, - ) => Promise<"dispatched" | "continue">; - runPostUnitVerification: ( - vctx: VerificationContext, - pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, - ) => Promise; - postUnitPostVerification: ( - pctx: PostUnitContext, - ) => Promise<"continue" | "step-wizard" | "stopped">; - - // Session manager - getSessionFile: (ctx: ExtensionContext) => string; -} - -// ─── generateMilestoneReport ────────────────────────────────────────────────── - -/** - * Generate and write an HTML milestone report snapshot. - * Extracted from the milestone-transition block in autoLoop. - */ -async function generateMilestoneReport( - s: AutoSession, - ctx: ExtensionContext, - milestoneId: string, -): Promise { - const { loadVisualizerData } = await importExtensionModule(import.meta.url, "./visualizer-data.js"); - const { generateHtmlReport } = await importExtensionModule(import.meta.url, "./export-html.js"); - const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "./reports.js"); - const { basename } = await import("node:path"); - - const snapData = await loadVisualizerData(s.basePath); - const completedMs = snapData.milestones.find( - (m: { id: string }) => m.id === milestoneId, - ); - const msTitle = completedMs?.title ?? milestoneId; - const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; - const projName = basename(s.basePath); - const doneSlices = snapData.milestones.reduce( - (acc: number, m: { slices: { done: boolean }[] }) => - acc + m.slices.filter((sl: { done: boolean }) => sl.done).length, - 0, - ); - const totalSlices = snapData.milestones.reduce( - (acc: number, m: { slices: unknown[] }) => acc + m.slices.length, - 0, - ); - const outPath = writeReportSnapshot({ - basePath: s.basePath, - html: generateHtmlReport(snapData, { - projectName: projName, - projectPath: s.basePath, - gsdVersion, - milestoneId, - indexRelPath: "index.html", - }), - milestoneId, - milestoneTitle: msTitle, - kind: "milestone", - projectName: projName, - projectPath: s.basePath, - gsdVersion, - totalCost: snapData.totals?.cost ?? 0, - totalTokens: snapData.totals?.tokens.total ?? 0, - totalDuration: snapData.totals?.duration ?? 0, - doneSlices, - totalSlices, - doneMilestones: snapData.milestones.filter( - (m: { status: string }) => m.status === "complete", - ).length, - totalMilestones: snapData.milestones.length, - phase: snapData.phase, - }); - ctx.ui.notify( - `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, - "info", - ); -} - -// ─── closeoutAndStop ────────────────────────────────────────────────────────── - -/** - * If a unit is in-flight, close it out, then stop auto-mode. - * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop. - */ -async function closeoutAndStop( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - deps: LoopDeps, - reason: string, -): Promise { - 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), - ); - } - 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 ──────────────────────────────────────────────────────────────── - -/** - * Main auto-mode execution loop. Iterates: derive → dispatch → guards → - * runUnit → finalize → repeat. Exits when s.active becomes false or a - * terminal condition is reached. - * - * This is the linear replacement for the recursive - * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain. - */ -export async function autoLoop( - ctx: ExtensionContext, - pi: ExtensionAPI, - s: AutoSession, - deps: LoopDeps, -): Promise { - debugLog("autoLoop", { phase: "enter" }); - let iteration = 0; - const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; - let consecutiveErrors = 0; - - while (s.active) { - iteration++; - debugLog("autoLoop", { phase: "loop-top", iteration }); - - if (iteration > MAX_LOOP_ITERATIONS) { - debugLog("autoLoop", { - phase: "exit", - reason: "max-iterations", - iteration, - }); - await deps.stopAuto( - ctx, - pi, - `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`, - ); - break; - } - - if (!s.cmdCtx) { - debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" }); - break; - } - - try { - // ── Blanket try/catch: one bad iteration must not kill the session - const prefs = deps.loadEffectiveGSDPreferences()?.preferences; - - // ── Check sidecar queue before deriveState ── - let sidecarItem: SidecarItem | undefined; - if (s.sidecarQueue.length > 0) { - sidecarItem = s.sidecarQueue.shift()!; - debugLog("autoLoop", { - phase: "sidecar-dequeue", - kind: sidecarItem.kind, - unitType: sidecarItem.unitType, - unitId: sidecarItem.unitId, - }); - } - - const sessionLockBase = deps.lockBase(); - if (sessionLockBase) { - const lockStatus = deps.validateSessionLock(sessionLockBase); - if (!lockStatus.valid) { - debugLog("autoLoop", { - phase: "session-lock-invalid", - reason: lockStatus.failureReason ?? "unknown", - existingPid: lockStatus.existingPid, - expectedPid: lockStatus.expectedPid, - }); - deps.handleLostSessionLock(ctx, lockStatus); - debugLog("autoLoop", { - phase: "exit", - reason: "session-lock-lost", - detail: lockStatus.failureReason ?? "unknown", - }); - break; - } - } - - const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration }; - let iterData: IterationData; - - if (!sidecarItem) { - // ── Phase 1: Pre-dispatch ───────────────────────────────────────── - const preDispatchResult = await runPreDispatch(ic, loopState); - if (preDispatchResult.action === "break") break; - if (preDispatchResult.action === "continue") continue; - - const preData = preDispatchResult.data; - - // ── 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 ── - 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, - }; - } - - const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem); - if (unitPhaseResult.action === "break") break; - - // ── Phase 5: Finalize ─────────────────────────────────────────────── - - 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 }); - } catch (loopErr) { - // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── - consecutiveErrors++; - const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); - debugLog("autoLoop", { - phase: "iteration-error", - iteration, - consecutiveErrors, - error: msg, - }); - - if (consecutiveErrors >= 3) { - // 3+ consecutive: hard stop — something is fundamentally broken - ctx.ui.notify( - `Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `${consecutiveErrors} consecutive iteration failures`, - ); - break; - } else if (consecutiveErrors === 2) { - // 2nd consecutive: try invalidating caches + re-deriving state - ctx.ui.notify( - `Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`, - "warning", - ); - deps.invalidateAllCaches(); - } else { - // 1st error: log and retry — transient failures happen - ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning"); - } - } - } - - _currentResolve = null; - debugLog("autoLoop", { phase: "exit", totalIterations: iteration }); -} +export { autoLoop } from "./auto/loop.js"; +export { resolveAgentEnd, isSessionSwitchInFlight, _resetPendingResolve, _setActiveSession } from "./auto/resolve.js"; +export { detectStuck } from "./auto/detect-stuck.js"; +export { runUnit } from "./auto/run-unit.js"; +export type { LoopDeps } from "./auto/loop-deps.js"; +export type { AgentEndEvent, UnitResult } from "./auto/types.js"; diff --git a/src/resources/extensions/gsd/auto/detect-stuck.ts b/src/resources/extensions/gsd/auto/detect-stuck.ts new file mode 100644 index 000000000..4d6cba5d2 --- /dev/null +++ b/src/resources/extensions/gsd/auto/detect-stuck.ts @@ -0,0 +1,60 @@ +/** + * auto/detect-stuck.ts — Sliding-window stuck detection for the auto-loop. + * + * Leaf node in the import DAG. + */ + +import type { WindowEntry } from "./types.js"; + +/** + * Analyze a sliding window of recent unit dispatches for stuck patterns. + * Returns a signal with reason if stuck, null otherwise. + * + * Rule 1: Same error string twice in a row → stuck immediately. + * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior). + * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck. + */ +export function detectStuck( + window: readonly WindowEntry[], +): { stuck: true; reason: string } | null { + if (window.length < 2) return null; + + const last = window[window.length - 1]; + const prev = window[window.length - 2]; + + // Rule 1: Same error repeated consecutively + if (last.error && prev.error && last.error === prev.error) { + return { + stuck: true, + reason: `Same error repeated: ${last.error.slice(0, 200)}`, + }; + } + + // Rule 2: Same unit 3+ consecutive times + if (window.length >= 3) { + const lastThree = window.slice(-3); + if (lastThree.every((u) => u.key === last.key)) { + return { + stuck: true, + reason: `${last.key} derived 3 consecutive times without progress`, + }; + } + } + + // Rule 3: Oscillation (A→B→A→B in last 4) + if (window.length >= 4) { + const w = window.slice(-4); + if ( + w[0].key === w[2].key && + w[1].key === w[3].key && + w[0].key !== w[1].key + ) { + return { + stuck: true, + reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`, + }; + } + } + + return null; +} diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts new file mode 100644 index 000000000..83efeec5e --- /dev/null +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -0,0 +1,281 @@ +/** + * auto/loop-deps.ts — LoopDeps interface for dependency injection into autoLoop. + * + * Leaf node in the import DAG (type-only). + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession } from "./session.js"; +import type { GSDPreferences } from "../preferences.js"; +import type { GSDState } from "../types.js"; +import type { SessionLockStatus } from "../session-lock.js"; +import type { CloseoutOptions } from "../auto-unit-closeout.js"; +import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js"; +import type { + VerificationContext, + VerificationResult, +} from "../auto-verification.js"; +import type { DispatchAction } from "../auto-dispatch.js"; +import type { WorktreeResolver } from "../worktree-resolver.js"; +import type { CmuxLogLevel } from "../../cmux/index.js"; + +/** + * Dependencies injected by the caller (auto.ts startAuto) so autoLoop + * can access private functions from auto.ts without exporting them. + */ +export interface LoopDeps { + lockBase: () => string; + buildSnapshotOpts: ( + unitType: string, + unitId: string, + ) => CloseoutOptions & Record; + stopAuto: ( + ctx?: ExtensionContext, + pi?: ExtensionAPI, + reason?: string, + ) => Promise; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; + clearUnitTimeout: () => void; + updateProgressWidget: ( + ctx: ExtensionContext, + unitType: string, + unitId: string, + state: GSDState, + ) => void; + syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void; + logCmuxEvent: ( + preferences: GSDPreferences | undefined, + message: string, + level?: CmuxLogLevel, + ) => void; + + // State and cache functions + invalidateAllCaches: () => void; + deriveState: (basePath: string) => Promise; + loadEffectiveGSDPreferences: () => + | { preferences?: GSDPreferences } + | undefined; + + // Pre-dispatch health gate + preDispatchHealthGate: ( + basePath: string, + ) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>; + + // Worktree sync + syncProjectRootToWorktree: ( + originalBase: string, + basePath: string, + milestoneId: string | null, + ) => void; + + // Resource version guard + checkResourcesStale: (version: string | null) => string | null; + + // Session lock + validateSessionLock: (basePath: string) => SessionLockStatus; + updateSessionLock: ( + basePath: string, + unitType: string, + unitId: string, + completedUnits: number, + sessionFile?: string, + ) => void; + handleLostSessionLock: ( + ctx?: ExtensionContext, + lockStatus?: SessionLockStatus, + ) => void; + + // Milestone transition functions + sendDesktopNotification: ( + title: string, + body: string, + kind: string, + category: string, + ) => void; + setActiveMilestoneId: (basePath: string, mid: string) => void; + pruneQueueOrder: (basePath: string, pendingIds: string[]) => void; + isInAutoWorktree: (basePath: string) => boolean; + shouldUseWorktreeIsolation: () => boolean; + mergeMilestoneToMain: ( + basePath: string, + milestoneId: string, + roadmapContent: string, + ) => { pushed: boolean }; + teardownAutoWorktree: (basePath: string, milestoneId: string) => void; + createAutoWorktree: (basePath: string, milestoneId: string) => string; + captureIntegrationBranch: ( + basePath: string, + mid: string, + opts?: { commitDocs?: boolean }, + ) => void; + getIsolationMode: () => string; + getCurrentBranch: (basePath: string) => string; + autoWorktreeBranch: (milestoneId: string) => string; + resolveMilestoneFile: ( + basePath: string, + milestoneId: string, + fileType: string, + ) => string | null; + reconcileMergeState: (basePath: string, ctx: ExtensionContext) => boolean; + + // Budget/context/secrets + getLedger: () => unknown; + getProjectTotals: (units: unknown) => { cost: number }; + formatCost: (cost: number) => string; + getBudgetAlertLevel: (pct: number) => number; + getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number; + getBudgetEnforcementAction: (enforcement: string, pct: number) => string; + getManifestStatus: ( + basePath: string, + mid: string | undefined, + projectRoot?: string, + ) => Promise<{ pending: unknown[] } | null>; + collectSecretsFromManifest: ( + basePath: string, + mid: string | undefined, + ctx: ExtensionContext, + ) => Promise<{ + applied: unknown[]; + skipped: unknown[]; + existingSkipped: unknown[]; + } | null>; + + // Dispatch + resolveDispatch: (dctx: { + basePath: string; + mid: string; + midTitle: string; + state: GSDState; + prefs: GSDPreferences | undefined; + session?: AutoSession; + }) => Promise; + runPreDispatchHooks: ( + unitType: string, + unitId: string, + prompt: string, + basePath: string, + ) => { + firedHooks: string[]; + action: string; + prompt?: string; + unitType?: string; + }; + getPriorSliceCompletionBlocker: ( + basePath: string, + mainBranch: string, + unitType: string, + unitId: string, + ) => string | null; + getMainBranch: (basePath: string) => string; + collectObservabilityWarnings: ( + ctx: ExtensionContext, + basePath: string, + unitType: string, + unitId: string, + ) => Promise; + buildObservabilityRepairBlock: (issues: unknown[]) => string | null; + + // Unit closeout + runtime records + closeoutUnit: ( + ctx: ExtensionContext, + basePath: string, + unitType: string, + unitId: string, + startedAt: number, + opts?: CloseoutOptions & Record, + ) => Promise; + verifyExpectedArtifact: ( + unitType: string, + unitId: string, + basePath: string, + ) => boolean; + clearUnitRuntimeRecord: ( + basePath: string, + unitType: string, + unitId: string, + ) => void; + writeUnitRuntimeRecord: ( + basePath: string, + unitType: string, + unitId: string, + startedAt: number, + record: Record, + ) => void; + recordOutcome: (unitType: string, tier: string, success: boolean) => void; + writeLock: ( + lockBase: string, + unitType: string, + unitId: string, + completedCount: number, + sessionFile?: string, + ) => void; + captureAvailableSkills: () => void; + ensurePreconditions: ( + unitType: string, + unitId: string, + basePath: string, + state: GSDState, + ) => void; + updateSliceProgressCache: ( + basePath: string, + mid: string, + sliceId?: string, + ) => void; + + // Model selection + supervision + selectAndApplyModel: ( + ctx: ExtensionContext, + pi: ExtensionAPI, + unitType: string, + unitId: string, + basePath: string, + prefs: GSDPreferences | undefined, + verbose: boolean, + startModel: { provider: string; id: string } | null, + retryContext?: { isRetry: boolean; previousTier?: string }, + ) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>; + startUnitSupervision: (sctx: { + s: AutoSession; + ctx: ExtensionContext; + pi: ExtensionAPI; + unitType: string; + unitId: string; + prefs: GSDPreferences | undefined; + buildSnapshotOpts: () => CloseoutOptions & Record; + buildRecoveryContext: () => unknown; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; + }) => void; + + // Prompt helpers + getDeepDiagnostic: (basePath: string) => string | null; + isDbAvailable: () => boolean; + reorderForCaching: (prompt: string) => string; + + // Filesystem + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + atomicWriteSync: (path: string, content: string) => void; + + // Git + GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown; + + // WorktreeResolver + resolver: WorktreeResolver; + + // Post-unit processing + postUnitPreVerification: ( + pctx: PostUnitContext, + opts?: PreVerificationOpts, + ) => Promise<"dispatched" | "continue">; + runPostUnitVerification: ( + vctx: VerificationContext, + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, + ) => Promise; + postUnitPostVerification: ( + pctx: PostUnitContext, + ) => Promise<"continue" | "step-wizard" | "stopped">; + + // Session manager + getSessionFile: (ctx: ExtensionContext) => string; +} diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts new file mode 100644 index 000000000..8436587fa --- /dev/null +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -0,0 +1,195 @@ +/** + * auto/loop.ts — Main auto-mode execution loop. + * + * Iterates: derive → dispatch → guards → runUnit → finalize → repeat. + * Exits when s.active becomes false or a terminal condition is reached. + * + * Imports from: auto/types, auto/resolve, auto/phases + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession, SidecarItem } from "./session.js"; +import type { LoopDeps } from "./loop-deps.js"; +import { + MAX_LOOP_ITERATIONS, + type LoopState, + type IterationContext, + type IterationData, +} from "./types.js"; +import { _clearCurrentResolve } from "./resolve.js"; +import { + runPreDispatch, + runDispatch, + runGuards, + runUnitPhase, + runFinalize, +} from "./phases.js"; +import { debugLog } from "../debug-logger.js"; + +/** + * Main auto-mode execution loop. Iterates: derive → dispatch → guards → + * runUnit → finalize → repeat. Exits when s.active becomes false or a + * terminal condition is reached. + * + * This is the linear replacement for the recursive + * dispatchNextUnit → handleAgentEnd → dispatchNextUnit chain. + */ +export async function autoLoop( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + deps: LoopDeps, +): Promise { + debugLog("autoLoop", { phase: "enter" }); + let iteration = 0; + const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 }; + let consecutiveErrors = 0; + + while (s.active) { + iteration++; + debugLog("autoLoop", { phase: "loop-top", iteration }); + + if (iteration > MAX_LOOP_ITERATIONS) { + debugLog("autoLoop", { + phase: "exit", + reason: "max-iterations", + iteration, + }); + await deps.stopAuto( + ctx, + pi, + `Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`, + ); + break; + } + + if (!s.cmdCtx) { + debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" }); + break; + } + + try { + // ── Blanket try/catch: one bad iteration must not kill the session + const prefs = deps.loadEffectiveGSDPreferences()?.preferences; + + // ── Check sidecar queue before deriveState ── + let sidecarItem: SidecarItem | undefined; + if (s.sidecarQueue.length > 0) { + sidecarItem = s.sidecarQueue.shift()!; + debugLog("autoLoop", { + phase: "sidecar-dequeue", + kind: sidecarItem.kind, + unitType: sidecarItem.unitType, + unitId: sidecarItem.unitId, + }); + } + + const sessionLockBase = deps.lockBase(); + if (sessionLockBase) { + const lockStatus = deps.validateSessionLock(sessionLockBase); + if (!lockStatus.valid) { + debugLog("autoLoop", { + phase: "session-lock-invalid", + reason: lockStatus.failureReason ?? "unknown", + existingPid: lockStatus.existingPid, + expectedPid: lockStatus.expectedPid, + }); + deps.handleLostSessionLock(ctx, lockStatus); + debugLog("autoLoop", { + phase: "exit", + reason: "session-lock-lost", + detail: lockStatus.failureReason ?? "unknown", + }); + break; + } + } + + const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration }; + let iterData: IterationData; + + if (!sidecarItem) { + // ── Phase 1: Pre-dispatch ───────────────────────────────────────── + const preDispatchResult = await runPreDispatch(ic, loopState); + if (preDispatchResult.action === "break") break; + if (preDispatchResult.action === "continue") continue; + + const preData = preDispatchResult.data; + + // ── 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 ── + 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, + }; + } + + const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem); + if (unitPhaseResult.action === "break") break; + + // ── Phase 5: Finalize ─────────────────────────────────────────────── + + 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 }); + } catch (loopErr) { + // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── + consecutiveErrors++; + const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); + debugLog("autoLoop", { + phase: "iteration-error", + iteration, + consecutiveErrors, + error: msg, + }); + + if (consecutiveErrors >= 3) { + // 3+ consecutive: hard stop — something is fundamentally broken + ctx.ui.notify( + `Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`, + "error", + ); + await deps.stopAuto( + ctx, + pi, + `${consecutiveErrors} consecutive iteration failures`, + ); + break; + } else if (consecutiveErrors === 2) { + // 2nd consecutive: try invalidating caches + re-deriving state + ctx.ui.notify( + `Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`, + "warning", + ); + deps.invalidateAllCaches(); + } else { + // 1st error: log and retry — transient failures happen + ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning"); + } + } + } + + _clearCurrentResolve(); + debugLog("autoLoop", { phase: "exit", totalIterations: iteration }); +} diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts new file mode 100644 index 000000000..f73220917 --- /dev/null +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -0,0 +1,1144 @@ +/** + * auto/phases.ts — Pipeline phases for the auto-loop. + * + * Contains: runPreDispatch, runDispatch, runGuards, runUnitPhase, runFinalize, + * plus internal helpers generateMilestoneReport and closeoutAndStop. + * + * Imports from: auto/types, auto/detect-stuck, auto/run-unit, auto/loop-deps + */ + +import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession, SidecarItem } from "./session.js"; +import type { LoopDeps } from "./loop-deps.js"; +import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js"; +import { + MAX_RECOVERY_CHARS, + BUDGET_THRESHOLDS, + type PhaseResult, + type IterationContext, + type LoopState, + type PreDispatchData, + type IterationData, +} from "./types.js"; +import { detectStuck } from "./detect-stuck.js"; +import { runUnit } from "./run-unit.js"; +import { debugLog } from "../debug-logger.js"; +import { gsdRoot } from "../paths.js"; +import { atomicWriteSync } from "../atomic-write.js"; +import { join } from "node:path"; + +// ─── generateMilestoneReport ────────────────────────────────────────────────── + +/** + * Generate and write an HTML milestone report snapshot. + * Extracted from the milestone-transition block in autoLoop. + */ +async function generateMilestoneReport( + s: AutoSession, + ctx: ExtensionContext, + milestoneId: string, +): Promise { + const { loadVisualizerData } = await importExtensionModule(import.meta.url, "../visualizer-data.js"); + const { generateHtmlReport } = await importExtensionModule(import.meta.url, "../export-html.js"); + const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "../reports.js"); + const { basename } = await import("node:path"); + + const snapData = await loadVisualizerData(s.basePath); + const completedMs = snapData.milestones.find( + (m: { id: string }) => m.id === milestoneId, + ); + const msTitle = completedMs?.title ?? milestoneId; + const gsdVersion = process.env.GSD_VERSION ?? "0.0.0"; + const projName = basename(s.basePath); + const doneSlices = snapData.milestones.reduce( + (acc: number, m: { slices: { done: boolean }[] }) => + acc + m.slices.filter((sl: { done: boolean }) => sl.done).length, + 0, + ); + const totalSlices = snapData.milestones.reduce( + (acc: number, m: { slices: unknown[] }) => acc + m.slices.length, + 0, + ); + const outPath = writeReportSnapshot({ + basePath: s.basePath, + html: generateHtmlReport(snapData, { + projectName: projName, + projectPath: s.basePath, + gsdVersion, + milestoneId, + indexRelPath: "index.html", + }), + milestoneId, + milestoneTitle: msTitle, + kind: "milestone", + projectName: projName, + projectPath: s.basePath, + gsdVersion, + totalCost: snapData.totals?.cost ?? 0, + totalTokens: snapData.totals?.tokens.total ?? 0, + totalDuration: snapData.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: snapData.milestones.filter( + (m: { status: string }) => m.status === "complete", + ).length, + totalMilestones: snapData.milestones.length, + phase: snapData.phase, + }); + ctx.ui.notify( + `Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, + "info", + ); +} + +// ─── closeoutAndStop ────────────────────────────────────────────────────────── + +/** + * If a unit is in-flight, close it out, then stop auto-mode. + * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop. + */ +async function closeoutAndStop( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + deps: LoopDeps, + reason: string, +): Promise { + 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), + ); + } + 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. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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 }; +} diff --git a/src/resources/extensions/gsd/auto/resolve.ts b/src/resources/extensions/gsd/auto/resolve.ts new file mode 100644 index 000000000..af9a21fc8 --- /dev/null +++ b/src/resources/extensions/gsd/auto/resolve.ts @@ -0,0 +1,88 @@ +/** + * auto/resolve.ts — Per-unit one-shot promise state and resolution. + * + * Module-level mutable state: `_currentResolve` and `_sessionSwitchInFlight`. + * Setter functions are exported because ES modules can't mutate `let` vars + * across module boundaries. + * + * Imports from: auto/types + */ + +import type { UnitResult, AgentEndEvent } from "./types.js"; +import type { AutoSession } from "./session.js"; +import { debugLog } from "../debug-logger.js"; + +// ─── Per-unit one-shot promise state ──────────────────────────────────────── +// +// A single module-level resolve function scoped to the current unit execution. +// No queue — if an agent_end arrives with no pending resolver, it is dropped +// (logged as warning). This is simpler and safer than the previous session- +// scoped pendingResolve + pendingAgentEndQueue pattern. + +let _currentResolve: ((result: UnitResult) => void) | null = null; +let _sessionSwitchInFlight = false; + +// ─── Setters (needed for cross-module mutation) ───────────────────────────── + +export function _setCurrentResolve(fn: ((result: UnitResult) => void) | null): void { + _currentResolve = fn; +} + +export function _setSessionSwitchInFlight(v: boolean): void { + _sessionSwitchInFlight = v; +} + +export function _clearCurrentResolve(): void { + _currentResolve = null; +} + +// ─── resolveAgentEnd ───────────────────────────────────────────────────────── + +/** + * Called from the agent_end event handler in index.ts to resolve the + * in-flight unit promise. One-shot: the resolver is nulled before calling + * to prevent double-resolution from model fallback retries. + * + * If no resolver exists (event arrived between loop iterations or during + * session switch), the event is dropped with a debug warning. + */ +export function resolveAgentEnd(event: AgentEndEvent): void { + if (_sessionSwitchInFlight) { + debugLog("resolveAgentEnd", { status: "ignored-during-switch" }); + return; + } + if (_currentResolve) { + debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true }); + const r = _currentResolve; + _currentResolve = null; + r({ status: "completed", event }); + } else { + debugLog("resolveAgentEnd", { + status: "no-pending-resolve", + warning: "agent_end with no pending unit", + }); + } +} + +export function isSessionSwitchInFlight(): boolean { + return _sessionSwitchInFlight; +} + +// ─── resetPendingResolve (test helper) ─────────────────────────────────────── + +/** + * Reset module-level promise state. Only exported for test cleanup — + * production code should never call this. + */ +export function _resetPendingResolve(): void { + _currentResolve = null; + _sessionSwitchInFlight = false; +} + +/** + * No-op for backward compatibility with tests that previously set the + * active session. The module no longer holds a session reference. + */ +export function _setActiveSession(_session: AutoSession | null): void { + // No-op — kept for test backward compatibility +} diff --git a/src/resources/extensions/gsd/auto/run-unit.ts b/src/resources/extensions/gsd/auto/run-unit.ts new file mode 100644 index 000000000..bf268461d --- /dev/null +++ b/src/resources/extensions/gsd/auto/run-unit.ts @@ -0,0 +1,123 @@ +/** + * auto/run-unit.ts — Single unit execution: session create → prompt → await agent_end. + * + * Imports from: auto/types, auto/resolve + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession } from "./session.js"; +import { NEW_SESSION_TIMEOUT_MS } from "./session.js"; +import type { UnitResult } from "./types.js"; +import { _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js"; +import { debugLog } from "../debug-logger.js"; + +/** + * Execute a single unit: create a new session, send the prompt, and await + * the agent_end promise. Returns a UnitResult describing what happened. + * + * The promise is one-shot: resolveAgentEnd() is the only way to resolve it. + * On session creation failure or timeout, returns { status: 'cancelled' } + * without awaiting the promise. + */ +export async function runUnit( + ctx: ExtensionContext, + pi: ExtensionAPI, + s: AutoSession, + unitType: string, + unitId: string, + prompt: string, +): Promise { + debugLog("runUnit", { phase: "start", unitType, unitId }); + + // ── Session creation with timeout ── + debugLog("runUnit", { phase: "session-create", unitType, unitId }); + + let sessionResult: { cancelled: boolean }; + let sessionTimeoutHandle: ReturnType | undefined; + _setSessionSwitchInFlight(true); + try { + const sessionPromise = s.cmdCtx!.newSession().finally(() => { + _setSessionSwitchInFlight(false); + }); + const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => { + sessionTimeoutHandle = setTimeout( + () => resolve({ cancelled: true }), + NEW_SESSION_TIMEOUT_MS, + ); + }); + sessionResult = await Promise.race([sessionPromise, timeoutPromise]); + } catch (sessionErr) { + if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); + const msg = + sessionErr instanceof Error ? sessionErr.message : String(sessionErr); + debugLog("runUnit", { + phase: "session-error", + unitType, + unitId, + error: msg, + }); + return { status: "cancelled" }; + } + if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle); + + if (sessionResult.cancelled) { + debugLog("runUnit-session-timeout", { unitType, unitId }); + return { status: "cancelled" }; + } + + if (!s.active) { + return { status: "cancelled" }; + } + + // ── Create the agent_end promise (per-unit one-shot) ── + // This happens after newSession completes so session-switch agent_end events + // from the previous session cannot resolve the new unit. + _setSessionSwitchInFlight(false); + const unitPromise = new Promise((resolve) => { + _setCurrentResolve(resolve); + }); + + // Ensure cwd matches basePath before dispatch (#1389). + // async_bash and background jobs can drift cwd away from the worktree. + // Realigning here prevents commits from landing on the wrong branch. + try { + if (process.cwd() !== s.basePath) { + process.chdir(s.basePath); + } + } catch { /* non-fatal — chdir may fail if dir was removed */ } + + // ── Send the prompt ── + debugLog("runUnit", { phase: "send-message", unitType, unitId }); + + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: s.verbose }, + { triggerTurn: true }, + ); + + // ── Await agent_end ── + debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId }); + const result = await unitPromise; + debugLog("runUnit", { + phase: "agent-end-received", + unitType, + unitId, + status: result.status, + }); + + // Discard trailing follow-up messages (e.g. async_job_result notifications) + // from the completed unit. Without this, queued follow-ups trigger wasteful + // LLM turns before the next session can start (#1642). + // clearQueue() lives on AgentSession but isn't part of the typed + // ExtensionCommandContext interface — call it via runtime check. + try { + const cmdCtxAny = s.cmdCtx as Record | null; + if (typeof cmdCtxAny?.clearQueue === "function") { + (cmdCtxAny.clearQueue as () => unknown)(); + } + } catch { + // Non-fatal — clearQueue may not be available in all contexts + } + + return result; +} diff --git a/src/resources/extensions/gsd/auto/types.ts b/src/resources/extensions/gsd/auto/types.ts new file mode 100644 index 000000000..06605c5b8 --- /dev/null +++ b/src/resources/extensions/gsd/auto/types.ts @@ -0,0 +1,99 @@ +/** + * auto/types.ts — Constants and types shared across auto-loop modules. + * + * Leaf node in the import DAG — no imports from auto/. + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; + +import type { AutoSession } from "./session.js"; +import type { GSDPreferences } from "../preferences.js"; +import type { GSDState } from "../types.js"; +import type { CmuxLogLevel } from "../../cmux/index.js"; +import type { LoopDeps } from "./loop-deps.js"; + +/** + * Maximum total loop iterations before forced stop. Prevents runaway loops + * when units alternate IDs (bypassing the same-unit stuck detector). + * A milestone with 20 slices × 5 tasks × 3 phases ≈ 300 units. 500 gives + * generous headroom including retries and sidecar work. + */ +export const MAX_LOOP_ITERATIONS = 500; +/** Maximum characters of failure/crash context included in recovery prompts. */ +export const MAX_RECOVERY_CHARS = 50_000; + +/** Data-driven budget threshold notifications (descending). The 100% entry + * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire + * a simple notification. */ +export const BUDGET_THRESHOLDS: Array<{ + pct: number; + label: string; + notifyLevel: "info" | "warning" | "error"; + cmuxLevel: "progress" | "warning" | "error"; +}> = [ + { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" }, + { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" }, + { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" }, + { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" }, +]; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * Minimal shape of the event parameter from pi.on("agent_end", ...). + * The full event has more fields, but the loop only needs messages. + */ +export interface AgentEndEvent { + messages: unknown[]; +} + +/** + * Result of a single unit execution (one iteration of the loop). + */ +export interface UnitResult { + status: "completed" | "cancelled" | "error"; + event?: AgentEndEvent; +} + +// ─── Phase pipeline types ──────────────────────────────────────────────────── + +export type PhaseResult = + | { action: "continue" } + | { action: "break"; reason: string } + | { action: "next"; data: T } + +export interface IterationContext { + ctx: ExtensionContext; + pi: ExtensionAPI; + s: AutoSession; + deps: LoopDeps; + prefs: GSDPreferences | undefined; + iteration: number; +} + +export interface LoopState { + recentUnits: Array<{ key: string; error?: string }>; + stuckRecoveryAttempts: number; +} + +export interface PreDispatchData { + state: GSDState; + mid: string; + midTitle: string; +} + +export 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; +} + +export type WindowEntry = { key: string; error?: string }; diff --git a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts index 2ce2e5fd0..305bbf79b 100644 --- a/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +++ b/src/resources/extensions/gsd/tests/agent-end-retry.test.ts @@ -14,30 +14,30 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const AUTO_TS_PATH = join(__dirname, "..", "auto.ts"); -const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts"); +const AUTO_RESOLVE_TS_PATH = join(__dirname, "..", "auto", "resolve.ts"); const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts"); function getAutoTsSource(): string { return readFileSync(AUTO_TS_PATH, "utf-8"); } -function getAutoLoopTsSource(): string { - return readFileSync(AUTO_LOOP_TS_PATH, "utf-8"); +function getAutoResolveTsSource(): string { + return readFileSync(AUTO_RESOLVE_TS_PATH, "utf-8"); } function getSessionTsSource(): string { return readFileSync(SESSION_TS_PATH, "utf-8"); } -test("auto-loop.ts declares _currentResolve for per-unit one-shot promises", () => { - const source = getAutoLoopTsSource(); +test("auto/resolve.ts declares _currentResolve for per-unit one-shot promises", () => { + const source = getAutoResolveTsSource(); assert.ok( source.includes("_currentResolve"), - "auto-loop.ts must declare _currentResolve for the per-unit resolve function", + "auto/resolve.ts must declare _currentResolve for the per-unit resolve function", ); assert.ok( source.includes("_sessionSwitchInFlight"), - "auto-loop.ts must declare _sessionSwitchInFlight guard", + "auto/resolve.ts must declare _sessionSwitchInFlight guard", ); }); diff --git a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts index ff8c393f2..58cc118e0 100644 --- a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +++ b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts @@ -78,7 +78,7 @@ function createMilestoneArtifacts(dir: string, mid: string): void { // ─── Source-level: verify the merge code exists in the "all complete" path ──── test("auto-loop 'all milestones complete' path merges before stopping (#962)", () => { - const loopSrc = readFileSync(join(__dirname, "..", "auto-loop.ts"), "utf-8"); + const loopSrc = readFileSync(join(__dirname, "..", "auto", "phases.ts"), "utf-8"); const resolverSrc = readFileSync( join(__dirname, "..", "worktree-resolver.ts"), "utf-8", @@ -88,7 +88,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", ( const incompleteIdx = loopSrc.indexOf("incomplete.length === 0"); assert.ok( incompleteIdx > -1, - "auto-loop.ts should have 'incomplete.length === 0' check", + "auto/phases.ts should have 'incomplete.length === 0' check", ); // The merge call must appear BETWEEN the incomplete check and the stopAuto call. @@ -99,7 +99,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", ( assert.ok( blockAfterIncomplete.includes("deps.resolver.mergeAndExit"), - "auto-loop.ts should call resolver.mergeAndExit in the 'all milestones complete' path", + "auto/phases.ts should call resolver.mergeAndExit in the 'all milestones complete' path", ); // The merge should come before stopAuto in this block diff --git a/src/resources/extensions/gsd/tests/auto-loop.test.ts b/src/resources/extensions/gsd/tests/auto-loop.test.ts index d1070021d..5bc553f0c 100644 --- a/src/resources/extensions/gsd/tests/auto-loop.test.ts +++ b/src/resources/extensions/gsd/tests/auto-loop.test.ts @@ -247,20 +247,20 @@ test("auto-loop.ts exports autoLoop, runUnit, resolveAgentEnd", async () => { ); }); -test("auto-loop.ts contains a while keyword", () => { +test("auto/loop.ts contains a while keyword", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( src.includes("while"), - "auto-loop.ts should contain a while keyword (loop or placeholder)", + "auto/loop.ts should contain a while keyword (loop or placeholder)", ); }); -test("auto-loop.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => { +test("auto/resolve.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "resolve.ts"), "utf-8", ); // The one-shot pattern requires: save ref, null the variable, then call @@ -893,18 +893,18 @@ test("autoLoop exits when no active milestone found", async (t) => { test("autoLoop exports LoopDeps type", async () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop-deps.ts"), "utf-8", ); assert.ok( src.includes("export interface LoopDeps"), - "auto-loop.ts should export LoopDeps interface", + "auto/loop-deps.ts should export LoopDeps interface", ); }); test("autoLoop signature accepts deps parameter", async () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( @@ -915,7 +915,7 @@ test("autoLoop signature accepts deps parameter", async () => { test("autoLoop contains while (s.active) loop", () => { const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "loop.ts"), "utf-8", ); assert.ok( @@ -926,22 +926,47 @@ test("autoLoop contains while (s.active) loop", () => { // ── T03: End-to-end wiring structural assertions ───────────────────────────── -test("auto-loop.ts exports autoLoop, runUnit, and resolveAgentEnd", () => { - const src = readFileSync( +test("auto-loop.ts barrel re-exports autoLoop, runUnit, and resolveAgentEnd", () => { + const barrel = readFileSync( resolve(import.meta.dirname, "..", "auto-loop.ts"), "utf-8", ); assert.ok( - src.includes("export async function autoLoop"), - "must export autoLoop", + barrel.includes("autoLoop"), + "barrel must re-export autoLoop", ); assert.ok( - src.includes("export async function runUnit"), - "must export runUnit", + barrel.includes("runUnit"), + "barrel must re-export runUnit", ); assert.ok( - src.includes("export function resolveAgentEnd"), - "must export resolveAgentEnd", + barrel.includes("resolveAgentEnd"), + "barrel must re-export resolveAgentEnd", + ); + // Verify the actual function declarations exist in the submodules + const loopSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "loop.ts"), + "utf-8", + ); + assert.ok( + loopSrc.includes("export async function autoLoop"), + "auto/loop.ts must define autoLoop", + ); + const runUnitSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "run-unit.ts"), + "utf-8", + ); + assert.ok( + runUnitSrc.includes("export async function runUnit"), + "auto/run-unit.ts must define runUnit", + ); + const resolveSrc = readFileSync( + resolve(import.meta.dirname, "..", "auto", "resolve.ts"), + "utf-8", + ); + assert.ok( + resolveSrc.includes("export function resolveAgentEnd"), + "auto/resolve.ts must define resolveAgentEnd", ); }); @@ -1341,23 +1366,23 @@ test("detectStuck: truncates long error strings", () => { }); test("stuck detection: logs debug output with stuck-detected phase", () => { - // Structural test: verify the auto-loop.ts source contains + // Structural test: verify auto/phases.ts contains // stuck-detected and stuck-counter-reset debug log phases, plus detectStuck const src = readFileSync( - resolve(import.meta.dirname, "..", "auto-loop.ts"), + resolve(import.meta.dirname, "..", "auto", "phases.ts"), "utf-8", ); assert.ok( src.includes('"stuck-detected"'), - "auto-loop.ts must log phase: 'stuck-detected' when stuck detection fires", + "auto/phases.ts must log phase: 'stuck-detected' when stuck detection fires", ); assert.ok( src.includes('"stuck-counter-reset"'), - "auto-loop.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit", + "auto/phases.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit", ); assert.ok( src.includes("detectStuck"), - "auto-loop.ts must use detectStuck for sliding window analysis", + "auto/phases.ts must use detectStuck for sliding window analysis", ); }); diff --git a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts index d66b9126f..74514725f 100644 --- a/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts @@ -122,23 +122,23 @@ test("worktree swap on milestone transition: merge old, create new", () => { // ─── Verify the transition code path exists in auto.ts ────────────────────── -test("auto-loop.ts milestone transition block contains worktree lifecycle", () => { - const autoSrc = readFileSync( - join(__dirname, "..", "auto-loop.ts"), +test("auto/phases.ts milestone transition block contains worktree lifecycle", () => { + const phasesSrc = readFileSync( + join(__dirname, "..", "auto", "phases.ts"), "utf-8", ); // The resolver handles worktree merge + enter inside the milestone transition block assert.ok( - autoSrc.includes("Worktree lifecycle on milestone transition"), - "auto-loop.ts should contain the worktree lifecycle comment marker", + phasesSrc.includes("Worktree lifecycle on milestone transition"), + "auto/phases.ts should contain the worktree lifecycle comment marker", ); assert.ok( - autoSrc.includes("resolver.mergeAndExit") && autoSrc.includes("mid !== s.currentMilestoneId"), - "auto-loop.ts should call resolver.mergeAndExit during milestone transition", + phasesSrc.includes("resolver.mergeAndExit") && phasesSrc.includes("mid !== s.currentMilestoneId"), + "auto/phases.ts should call resolver.mergeAndExit during milestone transition", ); assert.ok( - autoSrc.includes("resolver.enterMilestone"), - "auto-loop.ts should call resolver.enterMilestone for incoming milestone", + phasesSrc.includes("resolver.enterMilestone"), + "auto/phases.ts should call resolver.enterMilestone for incoming milestone", ); }); diff --git a/src/resources/extensions/gsd/tests/sidecar-queue.test.ts b/src/resources/extensions/gsd/tests/sidecar-queue.test.ts index 7446c6722..a5035058a 100644 --- a/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +++ b/src/resources/extensions/gsd/tests/sidecar-queue.test.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts"); const POST_UNIT_TS_PATH = join(__dirname, "..", "auto-post-unit.ts"); -const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts"); +const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto", "loop.ts"); function getSessionTsSource(): string { return readFileSync(SESSION_TS_PATH, "utf-8");