From 25d5f6083603b0ec22799c37820627f354e66f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Tue, 17 Mar 2026 22:26:05 -0600 Subject: [PATCH] refactor: decompose auto.ts into 6 focused modules (#1088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 6 cohesive modules from the 3,476-line auto.ts god file, reducing it to 1,732 lines while preserving all external import paths. New modules: - auto-timers.ts (223 lines): Unit supervision timers — soft timeout, idle watchdog, hard timeout, context-pressure monitor - auto-idempotency.ts (150 lines): Completed-key checks, skip loop detection, phantom loop handling, fallback persistence - auto-stuck-detection.ts (220 lines): Dispatch count tracking, lifetime cap, MAX_UNIT_DISPATCHES loop detection, stub recovery. Uses return values instead of calling stopAuto/dispatchNextUnit. - auto-verification.ts (195 lines): Post-unit typecheck/lint/test gate, runtime error capture, dependency audit, auto-fix retry logic - auto-post-unit.ts (585 lines): Split into postUnitPreVerification and postUnitPostVerification — commit, doctor, state rebuild, worktree sync, DB dual-write, hooks, triage, quick-tasks - auto-start.ts (472 lines): Fresh session bootstrap — git/state init, crash lock detection, debug init, worktree setup, DB lifecycle All extracted functions receive AutoSession + context as parameters. No circular dependencies — new modules import from leaf dependencies only, never from ./auto.js. All public exports from auto.ts are preserved so external import paths continue to work unchanged. Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/auto-idempotency.ts | 150 ++ .../extensions/gsd/auto-post-unit.ts | 586 +++++ src/resources/extensions/gsd/auto-start.ts | 472 ++++ .../extensions/gsd/auto-stuck-detection.ts | 220 ++ src/resources/extensions/gsd/auto-timers.ts | 223 ++ .../extensions/gsd/auto-verification.ts | 195 ++ src/resources/extensions/gsd/auto.ts | 1988 +---------------- 7 files changed, 1966 insertions(+), 1868 deletions(-) create mode 100644 src/resources/extensions/gsd/auto-idempotency.ts create mode 100644 src/resources/extensions/gsd/auto-post-unit.ts create mode 100644 src/resources/extensions/gsd/auto-start.ts create mode 100644 src/resources/extensions/gsd/auto-stuck-detection.ts create mode 100644 src/resources/extensions/gsd/auto-timers.ts create mode 100644 src/resources/extensions/gsd/auto-verification.ts diff --git a/src/resources/extensions/gsd/auto-idempotency.ts b/src/resources/extensions/gsd/auto-idempotency.ts new file mode 100644 index 000000000..2923ed7ff --- /dev/null +++ b/src/resources/extensions/gsd/auto-idempotency.ts @@ -0,0 +1,150 @@ +/** + * Idempotency checks for auto-mode unit dispatch. + * + * Handles completed-key membership, artifact cross-validation, + * consecutive skip counting, phantom skip loop detection, key eviction, + * and fallback persistence. + * + * Extracted from dispatchNextUnit() in auto.ts. Pure decision logic + * with set mutations — does NOT call dispatchNextUnit or stopAuto. + */ + +import { invalidateAllCaches } from "./cache.js"; +import { + verifyExpectedArtifact, + persistCompletedKey, + removePersistedKey, +} from "./auto-recovery.js"; +import { resolveMilestoneFile } from "./paths.js"; +import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js"; +import type { AutoSession } from "./auto/session.js"; + +export interface IdempotencyContext { + s: AutoSession; + unitType: string; + unitId: string; + basePath: string; + /** Notification callback */ + notify: (message: string, level: "info" | "warning" | "error") => void; +} + +export type IdempotencyResult = + | { action: "skip"; reason: string } + | { action: "rerun"; reason: string } + | { action: "proceed" } + | { action: "stop"; reason: string }; + +/** + * Check whether a unit should be skipped (already completed), rerun + * (stale completion record), or dispatched normally. + * + * Mutates s.completedKeySet, s.unitConsecutiveSkips, s.unitLifetimeDispatches, + * and s.recentlyEvictedKeys as needed. + */ +export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult { + const { s, unitType, unitId, basePath, notify } = ictx; + const idempotencyKey = `${unitType}/${unitId}`; + + // ── Primary path: key exists in completed set ── + if (s.completedKeySet.has(idempotencyKey)) { + const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath); + if (artifactExists) { + // Guard against infinite skip loops + const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; + s.unitConsecutiveSkips.set(idempotencyKey, skipCount); + if (skipCount > MAX_CONSECUTIVE_SKIPS) { + // Cross-check: verify the unit's milestone is still active (#790) + const skippedMid = unitId.split("/")[0]; + const skippedMilestoneComplete = skippedMid + ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY") + : false; + if (skippedMilestoneComplete) { + s.unitConsecutiveSkips.delete(idempotencyKey); + invalidateAllCaches(); + notify( + `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, + "info", + ); + return { action: "skip", reason: "phantom-loop-cleared" }; + } + s.unitConsecutiveSkips.delete(idempotencyKey); + s.completedKeySet.delete(idempotencyKey); + s.recentlyEvictedKeys.add(idempotencyKey); + removePersistedKey(basePath, idempotencyKey); + invalidateAllCaches(); + notify( + `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`, + "warning", + ); + return { action: "skip", reason: "evicted" }; + } + // Count toward lifetime cap + const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip); + if (lifeSkip > MAX_LIFETIME_DISPATCHES) { + return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` }; + } + notify( + `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`, + "info", + ); + return { action: "skip", reason: "completed" }; + } else { + // Stale completion record — artifact missing. Remove and re-run. + s.completedKeySet.delete(idempotencyKey); + removePersistedKey(basePath, idempotencyKey); + notify( + `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`, + "warning", + ); + return { action: "rerun", reason: "stale-key" }; + } + } + + // ── Fallback: key missing but artifact exists ── + if (verifyExpectedArtifact(unitType, unitId, basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) { + persistCompletedKey(basePath, idempotencyKey); + s.completedKeySet.add(idempotencyKey); + invalidateAllCaches(); + // Same consecutive-skip guard as the primary path + const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; + s.unitConsecutiveSkips.set(idempotencyKey, skipCount2); + if (skipCount2 > MAX_CONSECUTIVE_SKIPS) { + const skippedMid2 = unitId.split("/")[0]; + const skippedMilestoneComplete2 = skippedMid2 + ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY") + : false; + if (skippedMilestoneComplete2) { + s.unitConsecutiveSkips.delete(idempotencyKey); + invalidateAllCaches(); + notify( + `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, + "info", + ); + return { action: "skip", reason: "phantom-loop-cleared" }; + } + s.unitConsecutiveSkips.delete(idempotencyKey); + s.completedKeySet.delete(idempotencyKey); + removePersistedKey(basePath, idempotencyKey); + invalidateAllCaches(); + notify( + `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`, + "warning", + ); + return { action: "skip", reason: "evicted" }; + } + // Count toward lifetime cap + const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2); + if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) { + return { action: "stop", reason: `Hard loop: ${unitType} ${unitId} (skip cycle)` }; + } + notify( + `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`, + "info", + ); + return { action: "skip", reason: "fallback-persisted" }; + } + + return { action: "proceed" }; +} diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts new file mode 100644 index 000000000..12444750e --- /dev/null +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -0,0 +1,586 @@ +/** + * Post-unit processing for handleAgentEnd — auto-commit, doctor run, + * state rebuild, worktree sync, DB dual-write, hooks, triage, and + * quick-task dispatch. + * + * Split into two functions called sequentially by handleAgentEnd with + * the verification gate between them: + * 1. postUnitPreVerification() — commit, doctor, state rebuild, worktree sync, artifact verification + * 2. postUnitPostVerification() — DB dual-write, hooks, triage, quick-tasks + * + * Extracted from handleAgentEnd() in auto.ts. + */ + +import type { ExtensionContext, ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent"; +import { deriveState } from "./state.js"; +import { loadFile, parseSummary, resolveAllOverrides } from "./files.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { + resolveSliceFile, + resolveTaskFile, + resolveMilestoneFile, + gsdRoot, +} from "./paths.js"; +import { invalidateAllCaches } from "./cache.js"; +import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; +import { + autoCommitCurrentBranch, + type TaskCommitContext, +} from "./worktree.js"; +import { + verifyExpectedArtifact, + persistCompletedKey, + removePersistedKey, +} from "./auto-recovery.js"; +import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js"; +import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences } from "./preferences.js"; +import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; +import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js"; +import { syncStateToProjectRoot } from "./auto-worktree-sync.js"; +import { resetRewriteCircuitBreaker } from "./auto-dispatch.js"; +import { isDbAvailable } from "./gsd-db.js"; +import { consumeSignal } from "./session-status-io.js"; +import { + checkPostUnitHooks, + getActiveHook, + resetHookState, + isRetryPending, + consumeRetryTrigger, + persistHookState, +} from "./post-unit-hooks.js"; +import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js"; +import { writeLock } from "./crash-recovery.js"; +import { debugLog } from "./debug-logger.js"; +import type { AutoSession } from "./auto/session.js"; +import type { WidgetStateAccessors, AutoDashboardData } from "./auto-dashboard.js"; +import { + updateProgressWidget as _updateProgressWidget, + updateSliceProgressCache, + unitVerb, + hideFooter, +} from "./auto-dashboard.js"; +import { join } from "node:path"; + +/** Throttle STATE.md rebuilds — at most once per 30 seconds */ +const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; + +export interface PostUnitContext { + s: AutoSession; + ctx: ExtensionContext; + pi: ExtensionAPI; + buildSnapshotOpts: (unitType: string, unitId: string) => CloseoutOptions & Record; + lockBase: () => string; + stopAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string) => Promise; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; + updateProgressWidget: (ctx: ExtensionContext, unitType: string, unitId: string, state: import("./types.js").GSDState) => void; +} + +/** + * Pre-verification processing: parallel worker signal check, cache invalidation, + * auto-commit, doctor run, state rebuild, worktree sync, artifact verification. + * + * Returns "dispatched" if a signal caused stop/pause, "continue" to proceed. + */ +export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"dispatched" | "continue"> { + const { s, ctx, pi, buildSnapshotOpts, stopAuto, pauseAuto } = pctx; + + // ── Parallel worker signal check ── + const milestoneLock = process.env.GSD_MILESTONE_LOCK; + if (milestoneLock) { + const signal = consumeSignal(s.basePath, milestoneLock); + if (signal) { + if (signal.signal === "stop") { + await stopAuto(ctx, pi); + return "dispatched"; + } + if (signal.signal === "pause") { + await pauseAuto(ctx, pi); + return "dispatched"; + } + } + } + + // Invalidate all caches + invalidateAllCaches(); + + // Small delay to let files settle + await new Promise(r => setTimeout(r, 500)); + + // Auto-commit + if (s.currentUnit) { + try { + let taskContext: TaskCommitContext | undefined; + + if (s.currentUnit.type === "execute-task") { + const parts = s.currentUnit.id.split("/"); + const [mid, sid, tid] = parts; + if (mid && sid && tid) { + const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); + if (summaryPath) { + try { + const summaryContent = await loadFile(summaryPath); + if (summaryContent) { + const summary = parseSummary(summaryContent); + taskContext = { + taskId: `${sid}/${tid}`, + taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, + oneLiner: summary.oneLiner || undefined, + keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, + }; + } + } catch { + // Non-fatal + } + } + } + } + + const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext); + if (commitMsg) { + ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); + } + } catch { + // Non-fatal + } + + // Doctor: fix mechanical bookkeeping + try { + const scopeParts = s.currentUnit.id.split("/").slice(0, 2); + const doctorScope = scopeParts.join("/"); + const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); + const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; + const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); + } + + // Proactive health tracking + const summary = summarizeDoctorIssues(report.issues); + recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length); + + // Check if we should escalate to LLM-assisted heal + if (summary.errors > 0) { + const unresolvedErrors = report.issues + .filter(i => i.severity === "error" && !i.fixable) + .map(i => ({ code: i.code, message: i.message, unitId: i.unitId })); + const escalation = checkHealEscalation(summary.errors, unresolvedErrors); + if (escalation.shouldEscalate) { + ctx.ui.notify( + `Doctor heal escalation: ${escalation.reason}. Dispatching LLM-assisted heal.`, + "warning", + ); + try { + const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js"); + const { dispatchDoctorHeal } = await import("./commands.js"); + const actionable = report.issues.filter(i => i.severity === "error"); + const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true }); + const structuredIssues = formatDoctorIssuesForPrompt(actionable); + dispatchDoctorHeal(pi, doctorScope, reportText, structuredIssues); + } catch { + // Non-fatal + } + } + } + } catch { + // Non-fatal + } + + // Throttled STATE.md rebuild + const now = Date.now(); + if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) { + try { + await rebuildState(s.basePath); + s.lastStateRebuildAt = now; + autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id); + } catch { + // Non-fatal + } + } + + // Prune dead bg-shell processes + try { + const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js"); + pruneDeadProcesses(); + } catch { + // Non-fatal + } + + // Sync worktree state back to project root + if (s.originalBasePath && s.originalBasePath !== s.basePath) { + try { + syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId); + } catch { + // Non-fatal + } + } + + // Rewrite-docs completion + if (s.currentUnit.type === "rewrite-docs") { + try { + await resolveAllOverrides(s.basePath); + resetRewriteCircuitBreaker(); + ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); + } catch { + // Non-fatal + } + } + + // Post-triage: execute actionable resolutions + if (s.currentUnit.type === "triage-captures") { + try { + const { executeTriageResolutions } = await import("./triage-resolution.js"); + const state = await deriveState(s.basePath); + const mid = state.activeMilestone?.id; + const sid = state.activeSlice?.id; + + if (mid && sid) { + const triageResult = executeTriageResolutions(s.basePath, mid, sid); + + if (triageResult.injected > 0) { + ctx.ui.notify( + `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, + "info", + ); + } + if (triageResult.replanned > 0) { + ctx.ui.notify( + `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, + "info", + ); + } + if (triageResult.quickTasks.length > 0) { + for (const qt of triageResult.quickTasks) { + s.pendingQuickTasks.push(qt); + } + ctx.ui.notify( + `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, + "info", + ); + } + for (const action of triageResult.actions) { + process.stderr.write(`gsd-triage: ${action}\n`); + } + } + } catch (err) { + process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`); + } + } + + // Artifact verification and completion persistence + let triggerArtifactVerified = false; + if (!s.currentUnit.type.startsWith("hook/")) { + try { + triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); + if (triggerArtifactVerified) { + const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; + if (!s.completedKeySet.has(completionKey)) { + persistCompletedKey(s.basePath, completionKey); + s.completedKeySet.add(completionKey); + } + invalidateAllCaches(); + } + } catch { + // Non-fatal + } + } else { + // Hook unit completed — finalize its runtime record + try { + writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, { + phase: "finalized", + progressCount: 1, + lastProgressKind: "hook-completed", + }); + clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); + } catch { + // Non-fatal + } + } + } + + return "continue"; +} + +/** + * Post-verification processing: DB dual-write, post-unit hooks, triage + * capture dispatch, quick-task dispatch. + * + * Returns: + * - "dispatched" — a hook/triage/quick-task was dispatched (sendMessage sent) + * - "continue" — proceed to normal dispatchNextUnit + * - "step-wizard" — step mode, show wizard instead + * - "stopped" — stopAuto was called + */ +export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"dispatched" | "continue" | "step-wizard" | "stopped"> { + const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx; + + // ── DB dual-write ── + if (isDbAvailable()) { + try { + const { migrateFromMarkdown } = await import("./md-importer.js"); + migrateFromMarkdown(s.basePath); + } catch (err) { + process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`); + } + } + + // ── Post-unit hooks ── + if (s.currentUnit && !s.stepMode) { + const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); + if (hookUnit) { + const hookStartedAt = Date.now(); + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); + } + s.currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; + writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: hookStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + + const state = await deriveState(s.basePath); + updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state); + const hookState = getActiveHook(); + ctx.ui.notify( + `Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`, + "info", + ); + + // Switch model if the hook specifies one + if (hookUnit.model) { + const availableModels = ctx.modelRegistry.getAvailable(); + const match = availableModels.find(m => + m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model, + ); + if (match) { + try { + await pi.setModel(match); + } catch { /* non-fatal */ } + } + } + + const result = await s.cmdCtx!.newSession(); + if (result.cancelled) { + resetHookState(); + await stopAuto(ctx, pi, "Hook session cancelled"); + return "stopped"; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile); + persistHookState(s.basePath); + + // Start supervision timers for hook units + const supervisor = resolveAutoSupervisorConfig(); + const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, { + phase: "timeout", + timeoutAt: Date.now(), + }); + } + ctx.ui.notify( + `Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, + "warning", + ); + resetHookState(); + await pauseAuto(ctx, pi); + }, hookHardTimeoutMs); + + if (!s.active) return "stopped"; + pi.sendMessage( + { customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose }, + { triggerTurn: true }, + ); + return "dispatched"; + } + + // Check if a hook requested a retry of the trigger unit + if (isRetryPending()) { + const trigger = consumeRetryTrigger(); + if (trigger) { + const triggerKey = `${trigger.unitType}/${trigger.unitId}`; + s.completedKeySet.delete(triggerKey); + removePersistedKey(s.basePath, triggerKey); + ctx.ui.notify( + `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`, + "info", + ); + // Fall through to normal dispatch + } + } + } + + // ── Triage check ── + if ( + !s.stepMode && + s.currentUnit && + !s.currentUnit.type.startsWith("hook/") && + s.currentUnit.type !== "triage-captures" && + s.currentUnit.type !== "quick-task" + ) { + try { + if (hasPendingCaptures(s.basePath)) { + const pending = loadPendingCaptures(s.basePath); + if (pending.length > 0) { + const state = await deriveState(s.basePath); + const mid = state.activeMilestone?.id; + const sid = state.activeSlice?.id; + + if (mid && sid) { + let currentPlan = ""; + let roadmapContext = ""; + const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); + if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; + const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP"); + if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; + + const capturesList = pending.map(c => + `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` + ).join("\n"); + + const prompt = loadPrompt("triage-captures", { + pendingCaptures: capturesList, + currentPlan: currentPlan || "(no active slice plan)", + roadmapContext: roadmapContext || "(no active roadmap)", + }); + + ctx.ui.notify( + `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, + "info", + ); + + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); + } + + const triageUnitType = "triage-captures"; + const triageUnitId = `${mid}/${sid}/triage`; + const triageStartedAt = Date.now(); + s.currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt }; + writeUnitRuntimeRecord(s.basePath, triageUnitType, triageUnitId, triageStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: triageStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + updateProgressWidget(ctx, triageUnitType, triageUnitId, state); + + const result = await s.cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return "stopped"; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile); + + const supervisor = resolveAutoSupervisorConfig(); + const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + ctx.ui.notify( + `Triage unit exceeded timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, triageTimeoutMs); + + if (!s.active) return "stopped"; + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: s.verbose }, + { triggerTurn: true }, + ); + return "dispatched"; + } + } + } + } catch { + // Triage check failure is non-fatal + } + } + + // ── Quick-task dispatch ── + if ( + !s.stepMode && + s.pendingQuickTasks.length > 0 && + s.currentUnit && + s.currentUnit.type !== "quick-task" + ) { + try { + const capture = s.pendingQuickTasks.shift()!; + const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); + const { markCaptureExecuted } = await import("./captures.js"); + const prompt = buildQuickTaskPrompt(capture); + + ctx.ui.notify( + `Executing quick-task: ${capture.id} — "${capture.text}"`, + "info", + ); + + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); + } + + const qtUnitType = "quick-task"; + const qtUnitId = `${s.currentMilestoneId}/${capture.id}`; + const qtStartedAt = Date.now(); + s.currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt }; + writeUnitRuntimeRecord(s.basePath, qtUnitType, qtUnitId, qtStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: qtStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + const state = await deriveState(s.basePath); + updateProgressWidget(ctx, qtUnitType, qtUnitId, state); + + const result = await s.cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return "stopped"; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile); + + markCaptureExecuted(s.basePath, capture.id); + + const supervisor = resolveAutoSupervisorConfig(); + const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + s.unitTimeoutHandle = setTimeout(async () => { + s.unitTimeoutHandle = null; + if (!s.active) return; + ctx.ui.notify( + `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, qtTimeoutMs); + + if (!s.active) return "stopped"; + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: s.verbose }, + { triggerTurn: true }, + ); + return "dispatched"; + } catch { + // Non-fatal — proceed to normal dispatch + } + } + + // Step mode → show wizard instead of dispatch + if (s.stepMode) { + return "step-wizard"; + } + + return "continue"; +} diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts new file mode 100644 index 000000000..09416fec3 --- /dev/null +++ b/src/resources/extensions/gsd/auto-start.ts @@ -0,0 +1,472 @@ +/** + * Auto-mode bootstrap — fresh-start initialization path. + * + * Git/state bootstrap, crash lock detection, debug init, worktree recovery, + * guided flow gate, session init, worktree lifecycle, DB lifecycle, + * preflight validation. + * + * Extracted from startAuto() in auto.ts. The resume path (s.paused) + * remains in auto.ts — this module handles only the fresh-start path. + */ + +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@gsd/pi-coding-agent"; +import { deriveState } from "./state.js"; +import { loadFile, getManifestStatus } from "./files.js"; +import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js"; +import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; +import { + gsdRoot, + resolveMilestoneFile, + milestonesDir, +} from "./paths.js"; +import { invalidateAllCaches } from "./cache.js"; +import { synthesizeCrashRecovery } from "./session-forensics.js"; +import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js"; +import { selfHealRuntimeRecords } from "./auto-recovery.js"; +import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; +import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js"; +import { GitServiceImpl } from "./git-service.js"; +import { + captureIntegrationBranch, + detectWorktreeName, + setActiveMilestoneId, +} from "./worktree.js"; +import { + createAutoWorktree, + enterAutoWorktree, + getAutoWorktreePath, + isInAutoWorktree, +} from "./auto-worktree.js"; +import { readResourceVersion } from "./auto-worktree-sync.js"; +import { initMetrics, getLedger } from "./metrics.js"; +import { initRoutingHistory } from "./routing-history.js"; +import { restoreHookState, resetHookState, clearPersistedHookState } from "./post-unit-hooks.js"; +import { resetProactiveHealing } from "./doctor-proactive.js"; +import { snapshotSkills } from "./skill-discovery.js"; +import { isDbAvailable } from "./gsd-db.js"; +import { loadPersistedKeys } from "./auto-recovery.js"; +import { hideFooter } from "./auto-dashboard.js"; +import { debugLog, enableDebug, isDebugEnabled, getDebugLogPath } from "./debug-logger.js"; +import type { AutoSession } from "./auto/session.js"; +import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { sep as pathSep } from "node:path"; + +export interface BootstrapDeps { + shouldUseWorktreeIsolation: () => boolean; + registerSigtermHandler: (basePath: string) => void; + lockBase: () => string; +} + +/** + * Bootstrap a fresh auto-mode session. Handles everything from git init + * through secrets collection, returning when ready for the first + * dispatchNextUnit call. + * + * Returns false if the bootstrap aborted (e.g., guided flow returned, + * concurrent session detected). Returns true when ready to dispatch. + */ +export async function bootstrapAutoSession( + s: AutoSession, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, + base: string, + verboseMode: boolean, + requestedStepMode: boolean, + deps: BootstrapDeps, +): Promise { + const { shouldUseWorktreeIsolation, registerSigtermHandler, lockBase } = deps; + + // Ensure git repo exists + if (!nativeIsRepo(base)) { + const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; + nativeInit(base, mainBranch); + } + + // Ensure .gitignore has baseline patterns + const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; + const commitDocs = gitPrefs?.commit_docs; + const manageGitignore = gitPrefs?.manage_gitignore; + ensureGitignore(base, { commitDocs, manageGitignore }); + if (manageGitignore !== false) untrackRuntimeFiles(base); + + // Bootstrap .gsd/ if it doesn't exist + const gsdDir = join(base, ".gsd"); + if (!existsSync(gsdDir)) { + mkdirSync(join(gsdDir, "milestones"), { recursive: true }); + if (commitDocs !== false) { + try { + nativeAddAll(base); + nativeCommit(base, "chore: init gsd"); + } catch { /* nothing to commit */ } + } + } + + // Initialize GitServiceImpl + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + + // Check for crash from previous session + const crashLock = readCrashLock(base); + if (crashLock) { + if (isLockProcessAlive(crashLock)) { + ctx.ui.notify( + `Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`, + "error", + ); + return false; + } + const recoveredMid = crashLock.unitId.split("/")[0]; + const milestoneAlreadyComplete = recoveredMid + ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY") + : false; + + if (milestoneAlreadyComplete) { + ctx.ui.notify( + `Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`, + "info", + ); + } else { + const activityDir = join(gsdRoot(base), "activity"); + const recovery = synthesizeCrashRecovery( + base, crashLock.unitType, crashLock.unitId, + crashLock.sessionFile, activityDir, + ); + if (recovery && recovery.trace.toolCallCount > 0) { + s.pendingCrashRecovery = recovery.prompt; + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, + "warning", + ); + } else { + ctx.ui.notify( + `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`, + "warning", + ); + } + } + clearLock(base); + } + + // ── Debug mode ── + if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") { + enableDebug(base); + } + if (isDebugEnabled()) { + const { isNativeParserAvailable } = await import("./native-parser-bridge.js"); + debugLog("debug-start", { + platform: process.platform, + arch: process.arch, + node: process.version, + model: ctx.model?.id ?? "unknown", + provider: ctx.model?.provider ?? "unknown", + nativeParser: isNativeParserAvailable(), + cwd: base, + }); + ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info"); + } + + // Invalidate caches before initial state derivation + invalidateAllCaches(); + + // Clean stale runtime unit files for completed milestones (#887) + try { + const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); + if (existsSync(runtimeUnitsDir)) { + for (const file of readdirSync(runtimeUnitsDir)) { + if (!file.endsWith(".json")) continue; + const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); + if (!midMatch) continue; + const mid = midMatch[1]; + if (resolveMilestoneFile(base, mid, "SUMMARY")) { + try { unlinkSync(join(runtimeUnitsDir, file)); } catch (e) { debugLog("stale-unit-cleanup-failed", { file, error: e instanceof Error ? e.message : String(e) }); } + } + } + } + } catch (e) { debugLog("stale-unit-dir-cleanup-failed", { error: e instanceof Error ? e.message : String(e) }); } + + let state = await deriveState(base); + + // Stale worktree state recovery (#654) + if ( + state.activeMilestone && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) + ) { + const wtPath = getAutoWorktreePath(base, state.activeMilestone.id); + if (wtPath) { + state = await deriveState(wtPath); + } + } + + // Milestone branch recovery (#601) + let hasSurvivorBranch = false; + if ( + state.activeMilestone && + (state.phase === "pre-planning" || state.phase === "needs-discussion") && + shouldUseWorktreeIsolation() && + !detectWorktreeName(base) && + !base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`) + ) { + const milestoneBranch = `milestone/${state.activeMilestone.id}`; + const { nativeBranchExists } = await import("./native-git-bridge.js"); + hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); + if (hasSurvivorBranch) { + ctx.ui.notify( + `Found prior session branch ${milestoneBranch}. Resuming.`, + "info", + ); + } + } + + if (!hasSurvivorBranch) { + // No active work — start a new milestone via discuss flow + if (!state.activeMilestone || state.phase === "complete") { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + + invalidateAllCaches(); + const postState = await deriveState(base); + if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") { + state = postState; + } else if (postState.activeMilestone && postState.phase === "pre-planning") { + const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (hasContext) { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", + "warning", + ); + return false; + } + } else { + return false; + } + } + + // Active milestone exists but has no roadmap + if (state.phase === "pre-planning") { + const mid = state.activeMilestone!.id; + const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); + const hasContext = !!(contextFile && await loadFile(contextFile)); + if (!hasContext) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + + invalidateAllCaches(); + const postState = await deriveState(base); + if (postState.activeMilestone && postState.phase !== "pre-planning") { + state = postState; + } else { + ctx.ui.notify( + "Discussion completed but milestone context is still missing. Run /gsd to try again.", + "warning", + ); + return false; + } + } + } + } + + // Unreachable safety check + if (!state.activeMilestone) { + const { showSmartEntry } = await import("./guided-flow.js"); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + return false; + } + + // ── Initialize session state ── + s.active = true; + s.stepMode = requestedStepMode; + s.verbose = verboseMode; + s.cmdCtx = ctx; + s.basePath = base; + s.unitDispatchCount.clear(); + s.unitRecoveryCount.clear(); + s.unitConsecutiveSkips.clear(); + s.lastBudgetAlertLevel = 0; + s.unitLifetimeDispatches.clear(); + s.completedKeySet.clear(); + loadPersistedKeys(base, s.completedKeySet); + resetHookState(); + restoreHookState(base); + resetProactiveHealing(); + s.autoStartTime = Date.now(); + s.resourceVersionOnStart = readResourceVersion(); + s.completedUnits = []; + s.pendingQuickTasks = []; + s.currentUnit = null; + s.currentMilestoneId = state.activeMilestone?.id ?? null; + s.originalModelId = ctx.model?.id ?? null; + s.originalModelProvider = ctx.model?.provider ?? null; + + // Register SIGTERM handler + registerSigtermHandler(base); + + // Capture integration branch + if (s.currentMilestoneId) { + if (getIsolationMode() !== "none") { + captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs }); + } + setActiveMilestoneId(base, s.currentMilestoneId); + } + + // ── Auto-worktree setup ── + s.originalBasePath = base; + + const isUnderGsdWorktrees = (p: string): boolean => { + const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; + if (p.includes(marker)) return true; + const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`; + return p.endsWith(worktreesSuffix); + }; + + if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { + try { + const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId); + if (existingWtPath) { + const wtPath = enterAutoWorktree(base, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info"); + } else { + const wtPath = createAutoWorktree(base, s.currentMilestoneId); + s.basePath = wtPath; + s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); + ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info"); + } + registerSigtermHandler(s.originalBasePath); + + // Load completed keys from BOTH locations + if (s.basePath !== s.originalBasePath) { + loadPersistedKeys(s.basePath, s.completedKeySet); + } + } catch (err) { + ctx.ui.notify( + `Auto-worktree setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`, + "warning", + ); + } + } + + // ── DB lifecycle ── + const gsdDbPath = join(s.basePath, ".gsd", "gsd.db"); + const gsdDirPath = join(s.basePath, ".gsd"); + if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) { + const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md")); + const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md")); + const hasMilestones = existsSync(join(gsdDirPath, "milestones")); + if (hasDecisions || hasRequirements || hasMilestones) { + try { + const { openDatabase: openDb } = await import("./gsd-db.js"); + const { migrateFromMarkdown } = await import("./md-importer.js"); + openDb(gsdDbPath); + migrateFromMarkdown(s.basePath); + } catch (err) { + process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`); + } + } + } + if (existsSync(gsdDbPath) && !isDbAvailable()) { + try { + const { openDatabase: openDb } = await import("./gsd-db.js"); + openDb(gsdDbPath); + } catch (err) { + process.stderr.write(`gsd-db: failed to open existing database: ${(err as Error).message}\n`); + } + } + + // Initialize metrics + initMetrics(s.basePath); + + // Initialize routing history + initRoutingHistory(s.basePath); + + // Capture session's model at auto-mode start (#650) + const currentModel = ctx.model; + if (currentModel) { + s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id }; + } + + // Snapshot installed skills + if (resolveSkillDiscoveryMode() !== "off") { + snapshotSkills(); + } + + ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); + ctx.ui.setFooter(hideFooter); + const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; + const pendingCount = state.registry.filter(m => m.status !== 'complete').length; + const scopeMsg = pendingCount > 1 + ? `Will loop through ${pendingCount} milestones.` + : "Will loop until milestone complete."; + ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); + + // Write initial lock file + writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); + + // Secrets collection gate + const mid = state.activeMilestone!.id; + try { + const manifestStatus = await getManifestStatus(base, mid); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await collectSecretsFromManifest(base, 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", + ); + } + + // Self-heal: clear stale runtime records + await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); + + // Self-heal: remove stale .git/index.lock + try { + const gitLockFile = join(base, ".git", "index.lock"); + if (existsSync(gitLockFile)) { + const lockAge = Date.now() - statSync(gitLockFile).mtimeMs; + if (lockAge > 60_000) { + unlinkSync(gitLockFile); + ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info"); + } + } + } catch (e) { debugLog("git-lock-cleanup-failed", { error: e instanceof Error ? e.message : String(e) }); } + + // Pre-flight: validate milestone queue + try { + const msDir = join(base, ".gsd", "milestones"); + if (existsSync(msDir)) { + const milestoneIds = readdirSync(msDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name)) + .map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name); + if (milestoneIds.length > 1) { + const issues: string[] = []; + for (const id of milestoneIds) { + const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT"); + if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`); + } + if (issues.length > 0) { + ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => ` ⚠ ${i}`).join("\n")}`, "warning"); + } else { + ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info"); + } + } + } + } catch { /* non-fatal */ } + + return true; +} diff --git a/src/resources/extensions/gsd/auto-stuck-detection.ts b/src/resources/extensions/gsd/auto-stuck-detection.ts new file mode 100644 index 000000000..183436d81 --- /dev/null +++ b/src/resources/extensions/gsd/auto-stuck-detection.ts @@ -0,0 +1,220 @@ +/** + * Stuck detection and loop recovery for auto-mode unit dispatch. + * + * Tracks dispatch counts per unit, enforces lifetime caps, and attempts + * stub/artifact recovery before stopping. + * + * Extracted from dispatchNextUnit() in auto.ts. Returns action values + * instead of calling stopAuto/dispatchNextUnit — the caller handles + * control flow. + */ + +import type { ExtensionContext } from "@gsd/pi-coding-agent"; +import { + inspectExecuteTaskDurability, +} from "./unit-runtime.js"; +import { + verifyExpectedArtifact, + diagnoseExpectedArtifact, + skipExecuteTask, + persistCompletedKey, + buildLoopRemediationSteps, +} from "./auto-recovery.js"; +import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; +import { saveActivityLog } from "./activity-log.js"; +import { invalidateAllCaches } from "./cache.js"; +import { sendDesktopNotification } from "./notifications.js"; +import { debugLog } from "./debug-logger.js"; +import { + resolveMilestonePath, + resolveSlicePath, + resolveTasksDir, + buildTaskFileName, +} from "./paths.js"; +import { + MAX_UNIT_DISPATCHES, + STUB_RECOVERY_THRESHOLD, + MAX_LIFETIME_DISPATCHES, +} from "./auto/session.js"; +import type { AutoSession } from "./auto/session.js"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface StuckContext { + s: AutoSession; + ctx: ExtensionContext; + unitType: string; + unitId: string; + basePath: string; + buildSnapshotOpts: () => CloseoutOptions & Record; +} + +export type StuckResult = + | { action: "proceed" } + | { action: "recovered"; dispatchAgain: true } + | { action: "stop"; reason: string; notifyMessage?: string }; + +/** + * Check dispatch counts, enforce lifetime cap and MAX_UNIT_DISPATCHES, + * attempt stub/artifact recovery. Returns an action for the caller. + */ +export async function checkStuckAndRecover(sctx: StuckContext): Promise { + const { s, ctx, unitType, unitId, basePath, buildSnapshotOpts } = sctx; + const dispatchKey = `${unitType}/${unitId}`; + const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0; + + // Real dispatch reached — clear the consecutive-skip counter for this unit. + s.unitConsecutiveSkips.delete(dispatchKey); + + debugLog("dispatch-unit", { + type: unitType, + id: unitId, + cycle: prevCount + 1, + lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1, + }); + + // Hard lifetime cap — survives counter resets from loop-recovery/self-repair. + const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1; + s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount); + if (lifetimeCount > MAX_LIFETIME_DISPATCHES) { + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); + } else { + saveActivityLog(ctx, s.basePath, unitType, unitId); + } + const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); + return { + action: "stop", + reason: `Hard loop: ${unitType} ${unitId}`, + notifyMessage: `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`, + }; + } + + if (prevCount >= MAX_UNIT_DISPATCHES) { + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); + } else { + saveActivityLog(ctx, s.basePath, unitType, unitId); + } + + // Final reconciliation pass for execute-task + if (unitType === "execute-task") { + const [mid, sid, tid] = unitId.split("/"); + if (mid && sid && tid) { + const status = await inspectExecuteTaskDurability(basePath, unitId); + if (status) { + const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount); + if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) { + ctx.ui.notify( + `Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`, + "warning", + ); + const reconciledKey = `${unitType}/${unitId}`; + persistCompletedKey(basePath, reconciledKey); + s.completedKeySet.add(reconciledKey); + s.unitDispatchCount.delete(dispatchKey); + invalidateAllCaches(); + return { action: "recovered", dispatchAgain: true }; + } + } + } + } + + // General reconciliation: artifact appeared on last attempt + if (verifyExpectedArtifact(unitType, unitId, basePath)) { + ctx.ui.notify( + `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`, + "info", + ); + persistCompletedKey(basePath, dispatchKey); + s.completedKeySet.add(dispatchKey); + s.unitDispatchCount.delete(dispatchKey); + invalidateAllCaches(); + return { action: "recovered", dispatchAgain: true }; + } + + // Last resort for complete-milestone: generate stub summary + if (unitType === "complete-milestone") { + try { + const mPath = resolveMilestonePath(basePath, unitId); + if (mPath) { + const stubPath = join(mPath, `${unitId}-SUMMARY.md`); + if (!existsSync(stubPath)) { + writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`); + ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning"); + persistCompletedKey(basePath, dispatchKey); + s.completedKeySet.add(dispatchKey); + s.unitDispatchCount.delete(dispatchKey); + invalidateAllCaches(); + return { action: "recovered", dispatchAgain: true }; + } + } + } catch { /* non-fatal — fall through to normal stop */ } + } + + const expected = diagnoseExpectedArtifact(unitType, unitId, basePath); + const remediation = buildLoopRemediationSteps(unitType, unitId, basePath); + sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error"); + return { + action: "stop", + reason: `Loop: ${unitType} ${unitId}`, + notifyMessage: `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`, + }; + } + + s.unitDispatchCount.set(dispatchKey, prevCount + 1); + + if (prevCount > 0) { + // Adaptive self-repair: each retry attempts a different remediation step. + if (unitType === "execute-task") { + const status = await inspectExecuteTaskDurability(basePath, unitId); + const [mid, sid, tid] = unitId.split("/"); + if (status && mid && sid && tid) { + if (status.summaryExists && !status.taskChecked) { + const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0); + if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) { + ctx.ui.notify( + `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`, + "warning", + ); + const repairedKey = `${unitType}/${unitId}`; + persistCompletedKey(basePath, repairedKey); + s.completedKeySet.add(repairedKey); + s.unitDispatchCount.delete(dispatchKey); + invalidateAllCaches(); + return { action: "recovered", dispatchAgain: true }; + } + } else if (prevCount >= STUB_RECOVERY_THRESHOLD && !status.summaryExists) { + const tasksDir = resolveTasksDir(basePath, mid, sid); + const sDir = resolveSlicePath(basePath, mid, sid); + const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); + if (targetDir) { + if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); + const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY")); + if (!existsSync(summaryPath)) { + const stubContent = [ + `# PARTIAL RECOVERY — attempt ${prevCount + 1} of ${MAX_UNIT_DISPATCHES}`, + ``, + `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) has not yet produced a real summary.`, + `This placeholder was written by auto-mode after ${prevCount} dispatch attempts.`, + ``, + `The next agent session will retry this task. Replace this file with real work when done.`, + ].join("\n"); + writeFileSync(summaryPath, stubContent, "utf-8"); + ctx.ui.notify( + `Stub recovery (attempt ${prevCount + 1}/${MAX_UNIT_DISPATCHES}): ${unitId} stub summary placeholder written. Retrying with recovery context.`, + "warning", + ); + } + } + } + } + } + ctx.ui.notify( + `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`, + "warning", + ); + } + + return { action: "proceed" }; +} diff --git a/src/resources/extensions/gsd/auto-timers.ts b/src/resources/extensions/gsd/auto-timers.ts new file mode 100644 index 000000000..3b7964811 --- /dev/null +++ b/src/resources/extensions/gsd/auto-timers.ts @@ -0,0 +1,223 @@ +/** + * Unit supervision timers — soft timeout warning, idle watchdog, + * hard timeout, and context-pressure monitor. + * + * Extracted from dispatchNextUnit() in auto.ts. All timers are set up + * via startUnitSupervision() and torn down by the caller via clearUnitTimeout(). + */ + +import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent"; +import { readUnitRuntimeRecord, writeUnitRuntimeRecord } from "./unit-runtime.js"; +import { resolveAutoSupervisorConfig } from "./preferences.js"; +import type { GSDPreferences } from "./preferences.js"; +import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js"; +import { + getInFlightToolCount, + getOldestInFlightToolStart, +} from "./auto-tool-tracking.js"; +import { detectWorkingTreeActivity } from "./auto-supervisor.js"; +import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; +import { saveActivityLog } from "./activity-log.js"; +import { recoverTimedOutUnit, type RecoveryContext } from "./auto-timeout-recovery.js"; +import type { AutoSession } from "./auto/session.js"; + +export interface SupervisionContext { + s: AutoSession; + ctx: ExtensionContext; + pi: ExtensionAPI; + unitType: string; + unitId: string; + prefs: GSDPreferences | undefined; + buildSnapshotOpts: () => CloseoutOptions & Record; + buildRecoveryContext: () => RecoveryContext; + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise; +} + +/** + * Set up all four supervision timers for the current unit: + * 1. Soft timeout warning (wrapup) + * 2. Idle watchdog (progress polling, stuck tool detection) + * 3. Hard timeout (pause + recovery) + * 4. Context-pressure monitor (continue-here) + */ +export function startUnitSupervision(sctx: SupervisionContext): void { + const { s, ctx, pi, unitType, unitId, prefs, buildSnapshotOpts, buildRecoveryContext, pauseAuto } = sctx; + + const supervisor = resolveAutoSupervisorConfig(); + const softTimeoutMs = (supervisor.soft_timeout_minutes ?? 0) * 60 * 1000; + const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000; + const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000; + + // ── 1. Soft timeout warning ── + s.wrapupWarningHandle = setTimeout(() => { + s.wrapupWarningHandle = null; + if (!s.active || !s.currentUnit) return; + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { + phase: "wrapup-warning-sent", + wrapupWarningSent: true, + }); + pi.sendMessage( + { + customType: "gsd-auto-wrapup", + display: s.verbose, + content: [ + "**TIME BUDGET WARNING — keep going only if progress is real.**", + "This unit crossed the soft time budget.", + "If you are making progress, continue. If not, switch to wrap-up mode now:", + "1. rerun the minimal required verification", + "2. write or update the required durable artifacts", + "3. mark task or slice state on disk correctly", + "4. leave precise resume notes if anything remains unfinished", + ].join("\n"), + }, + { triggerTurn: true }, + ); + }, softTimeoutMs); + + // ── 2. Idle watchdog ── + s.idleWatchdogHandle = setInterval(async () => { + try { + if (!s.active || !s.currentUnit) return; + const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); + if (!runtime) return; + if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; + + // Agent has tool calls currently executing — not idle, just waiting. + // But only suppress recovery if the tool started recently. + if (getInFlightToolCount() > 0) { + const oldestStart = getOldestInFlightToolStart()!; + const toolAgeMs = Date.now() - oldestStart; + if (toolAgeMs < idleTimeoutMs) { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { + lastProgressAt: Date.now(), + lastProgressKind: "tool-in-flight", + }); + return; + } + ctx.ui.notify( + `Stalled tool detected: a tool has been in-flight for ${Math.round(toolAgeMs / 60000)}min. Treating as hung — attempting idle recovery.`, + "warning", + ); + } + + // Check if the agent is producing work on disk. + if (detectWorkingTreeActivity(s.basePath)) { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { + lastProgressAt: Date.now(), + lastProgressKind: "filesystem-activity", + }); + return; + } + + if (s.currentUnit) { + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); + } else { + saveActivityLog(ctx, s.basePath, unitType, unitId); + } + + const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext()); + if (recovery === "recovered") return; + + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { + phase: "paused", + }); + ctx.ui.notify( + `Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[idle-watchdog] Unhandled error: ${message}`); + try { + ctx.ui.notify(`Idle watchdog error: ${message}`, "warning"); + } catch { /* best effort */ } + } + }, 15000); + + // ── 3. Hard timeout ── + s.unitTimeoutHandle = setTimeout(async () => { + try { + s.unitTimeoutHandle = null; + if (!s.active) return; + if (s.currentUnit) { + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { + phase: "timeout", + timeoutAt: Date.now(), + }); + await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts()); + } else { + saveActivityLog(ctx, s.basePath, unitType, unitId); + } + + const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext()); + if (recovery === "recovered") return; + + ctx.ui.notify( + `Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[hard-timeout] Unhandled error: ${message}`); + try { + ctx.ui.notify(`Hard timeout error: ${message}`, "warning"); + } catch { /* best effort */ } + } + }, hardTimeoutMs); + + // ── 4. Context-pressure continue-here monitor ── + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; + } + const executorContextWindow = resolveExecutorContextWindow( + ctx.modelRegistry as Parameters[0], + prefs as Parameters[1], + ctx.model?.contextWindow, + ); + const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent; + s.continueHereHandle = setInterval(() => { + if (!s.active || !s.currentUnit || !s.cmdCtx) return; + const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); + if (runtime?.continueHereFired) return; + + const contextUsage = s.cmdCtx.getContextUsage(); + if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return; + + writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, { + continueHereFired: true, + }); + + if (s.verbose) { + ctx.ui.notify( + `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, + "info", + ); + } + + pi.sendMessage( + { + customType: "gsd-auto-wrapup", + display: s.verbose, + content: [ + "**CONTEXT BUDGET WARNING — wrap up this unit now.**", + `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`, + "The next unit needs a fresh context to work effectively. Wrap up now:", + "1. Finish any in-progress file writes", + "2. Write or update the required durable artifacts (summary, checkboxes)", + "3. Mark task state on disk correctly", + "4. Leave precise resume notes if anything remains unfinished", + "Do NOT start new sub-tasks or investigations.", + ].join("\n"), + }, + { triggerTurn: true }, + ); + + if (s.continueHereHandle) { + clearInterval(s.continueHereHandle); + s.continueHereHandle = null; + } + }, 15_000); +} diff --git a/src/resources/extensions/gsd/auto-verification.ts b/src/resources/extensions/gsd/auto-verification.ts new file mode 100644 index 000000000..fc9a524ed --- /dev/null +++ b/src/resources/extensions/gsd/auto-verification.ts @@ -0,0 +1,195 @@ +/** + * Post-unit verification gate for auto-mode. + * + * Runs typecheck/lint/test checks, captures runtime errors, performs + * dependency audits, handles auto-fix retry logic, and writes + * verification evidence JSON. + * + * Extracted from handleAgentEnd() in auto.ts. Returns a sentinel + * value instead of calling return/pauseAuto directly — the caller + * checks the result and handles control flow. + */ + +import type { ExtensionContext, ExtensionAPI } from "@gsd/pi-coding-agent"; +import { loadFile, parsePlan } from "./files.js"; +import { resolveSliceFile, resolveSlicePath } from "./paths.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { + runVerificationGate, + formatFailureContext, + captureRuntimeErrors, + runDependencyAudit, +} from "./verification-gate.js"; +import { writeVerificationJSON } from "./verification-evidence.js"; +import { removePersistedKey } from "./auto-recovery.js"; +import type { AutoSession, PendingVerificationRetry } from "./auto/session.js"; +import { join } from "node:path"; + +export interface VerificationContext { + s: AutoSession; + ctx: ExtensionContext; + pi: ExtensionAPI; +} + +export type VerificationResult = "continue" | "retry" | "pause"; + +/** + * Run the verification gate for the current execute-task unit. + * Returns: + * - "continue" — gate passed (or no checks configured), proceed normally + * - "retry" — gate failed with retries remaining, dispatchNextUnit already called + * - "pause" — gate failed with retries exhausted, pauseAuto already called + */ +export async function runPostUnitVerification( + vctx: VerificationContext, + dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise, + startDispatchGapWatchdog: (ctx: ExtensionContext, pi: ExtensionAPI) => void, + pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise, +): Promise { + const { s, ctx, pi } = vctx; + + if (!s.currentUnit || s.currentUnit.type !== "execute-task") { + return "continue"; + } + + try { + const effectivePrefs = loadEffectiveGSDPreferences(); + const prefs = effectivePrefs?.preferences; + + // Read task plan verify field + const parts = s.currentUnit.id.split("/"); + let taskPlanVerify: string | undefined; + if (parts.length >= 3) { + const [mid, sid, tid] = parts; + const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); + if (planFile) { + const planContent = await loadFile(planFile); + if (planContent) { + const slicePlan = parsePlan(planContent); + const taskEntry = slicePlan?.tasks?.find(t => t.id === tid); + taskPlanVerify = taskEntry?.verify; + } + } + } + + const result = runVerificationGate({ + basePath: s.basePath, + unitId: s.currentUnit.id, + cwd: s.basePath, + preferenceCommands: prefs?.verification_commands, + taskPlanVerify, + }); + + // Capture runtime errors + const runtimeErrors = await captureRuntimeErrors(); + if (runtimeErrors.length > 0) { + result.runtimeErrors = runtimeErrors; + if (runtimeErrors.some(e => e.blocking)) { + result.passed = false; + } + } + + // Dependency audit + const auditWarnings = runDependencyAudit(s.basePath); + if (auditWarnings.length > 0) { + result.auditWarnings = auditWarnings; + process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`); + for (const w of auditWarnings) { + process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`); + } + } + + // Auto-fix retry preferences + const autoFixEnabled = prefs?.verification_auto_fix !== false; + const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2; + const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; + + if (result.checks.length > 0) { + const passCount = result.checks.filter(c => c.exitCode === 0).length; + const total = result.checks.length; + if (result.passed) { + ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`); + } else { + const failures = result.checks.filter(c => c.exitCode !== 0); + const failNames = failures.map(f => f.command).join(", "); + ctx.ui.notify(`Verification gate: FAILED — ${failNames}`); + process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`); + for (const f of failures) { + process.stderr.write(` ${f.command} exited ${f.exitCode}\n`); + if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`); + } + } + } + + // Log blocking runtime errors + if (result.runtimeErrors?.some(e => e.blocking)) { + const blockingErrors = result.runtimeErrors.filter(e => e.blocking); + process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`); + for (const err of blockingErrors) { + process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`); + } + } + + // Write verification evidence JSON + const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; + if (parts.length >= 3) { + try { + const [mid, sid, tid] = parts; + const sDir = resolveSlicePath(s.basePath, mid, sid); + if (sDir) { + const tasksDir = join(sDir, "tasks"); + if (result.passed) { + writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id); + } else { + const nextAttempt = attempt + 1; + writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries); + } + } + } catch (evidenceErr) { + process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`); + } + } + + // ── Auto-fix retry logic ── + if (result.passed) { + s.verificationRetryCount.delete(s.currentUnit.id); + s.pendingVerificationRetry = null; + return "continue"; + } else if (autoFixEnabled && attempt + 1 <= maxRetries) { + const nextAttempt = attempt + 1; + s.verificationRetryCount.set(s.currentUnit.id, nextAttempt); + s.pendingVerificationRetry = { + unitId: s.currentUnit.id, + failureContext: formatFailureContext(result), + attempt: nextAttempt, + }; + ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning"); + s.completedKeySet.delete(completionKey); + removePersistedKey(s.basePath, completionKey); + // Dispatch retry immediately + try { + await dispatchNextUnit(ctx, pi); + } catch (retryDispatchErr) { + const msg = retryDispatchErr instanceof Error ? retryDispatchErr.message : String(retryDispatchErr); + ctx.ui.notify(`Verification retry dispatch error: ${msg}`, "error"); + startDispatchGapWatchdog(ctx, pi); + } + return "retry"; + } else { + // Gate failed, retries exhausted + const exhaustedAttempt = attempt + 1; + s.verificationRetryCount.delete(s.currentUnit.id); + s.pendingVerificationRetry = null; + ctx.ui.notify( + `Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`, + "error", + ); + await pauseAuto(ctx, pi); + return "pause"; + } + } catch (err) { + // Gate errors are non-fatal + process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`); + return "continue"; + } +} diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 7ce720edc..d0665748a 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -70,7 +70,6 @@ import { checkResourcesStale, escapeStaleWorktree, } from "./auto-worktree-sync.js"; -// complexity-classifier + model-router imports moved to auto-model-selection.ts import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js"; import { checkPostUnitHooks, @@ -83,7 +82,6 @@ import { restoreHookState, clearPersistedHookState, } from "./post-unit-hooks.js"; -// observability-validator imports moved to auto-observability.ts import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; import { @@ -149,7 +147,6 @@ import { reconcileMergeState, } from "./auto-recovery.js"; import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js"; -// Prompt builders moved to auto-direct-dispatch.ts (only used there now) import { type AutoDashboardData, updateProgressWidget as _updateProgressWidget, @@ -170,6 +167,14 @@ import { import { isDbAvailable } from "./gsd-db.js"; import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js"; +// ── Extracted modules ────────────────────────────────────────────────────── +import { startUnitSupervision, type SupervisionContext } from "./auto-timers.js"; +import { checkIdempotency, type IdempotencyContext } from "./auto-idempotency.js"; +import { checkStuckAndRecover, type StuckContext } from "./auto-stuck-detection.js"; +import { runPostUnitVerification, type VerificationContext } from "./auto-verification.js"; +import { postUnitPreVerification, postUnitPostVerification, type PostUnitContext } from "./auto-post-unit.js"; +import { bootstrapAutoSession, type BootstrapDeps } from "./auto-start.js"; + // Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts // ─── Session State ───────────────────────────────────────────────────────── @@ -421,8 +426,6 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void s.dispatchGapHandle = null; if (!s.active || !s.cmdCtx) return; - // Auto-mode is active but no unit was dispatched — the state machine stalled. - // Re-derive state and attempt a fresh dispatch. if (s.verbose) { ctx.ui.notify( "Dispatch gap detected — re-evaluating state.", @@ -438,9 +441,6 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void return; } - // If dispatchNextUnit returned normally but still didn't dispatch a unit - // (no sendMessage called → no timeout set), auto-mode is permanently - // stalled. Stop cleanly instead of leaving it s.active but idle (#537). if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) { await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry"); } @@ -461,12 +461,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason deregisterSigtermHandler(); // ── Auto-worktree: exit worktree and reset s.basePath on stop ── - // Preserve the milestone branch so the next /gsd auto can re-enter - // where it left off. The branch is only deleted during milestone - // completion (mergeMilestoneToMain) after the work has been squash-merged. if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) { try { - // Auto-commit any dirty state before leaving so work isn't lost try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: e instanceof Error ? e.message : String(e) }); } teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true }); s.basePath = s.originalBasePath; @@ -488,10 +484,6 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason } catch (e) { debugLog("db-close-failed", { error: e instanceof Error ? e.message : String(e) }); } } - // Always restore cwd to project root on stop (#608). - // Even if isInAutoWorktree returned false (e.g., module state was already - // cleared by mergeMilestoneToMain), the process cwd may still be inside - // the worktree directory. Force it back to s.originalBasePath. if (s.originalBasePath) { s.basePath = s.originalBasePath; try { process.chdir(s.basePath); } catch { /* best-effort */ } @@ -508,12 +500,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason ctx?.ui.notify(`Auto-mode stopped${reasonSuffix}.`, "info"); } - // Sync disk state so next resume starts from accurate state if (s.basePath) { try { await rebuildState(s.basePath); } catch (e) { debugLog("stop-rebuild-state-failed", { error: e instanceof Error ? e.message : String(e) }); } } - // Write debug summary before resetting state if (isDebugEnabled()) { const logPath = writeDebugSummary(); if (logPath) { @@ -554,7 +544,6 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); - // Restore the user's original model if (pi && ctx && s.originalModelId && s.originalModelProvider) { const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId); if (original) await pi.setModel(original); @@ -574,22 +563,16 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro if (!s.active) return; clearUnitTimeout(); - // Capture the current session file before clearing state — used for - // recovery briefing on resume so the next agent knows what already happened. s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null; if (lockBase()) clearLock(lockBase()); - // Remove SIGTERM handler registered at auto-mode start deregisterSigtermHandler(); s.active = false; s.paused = true; s.pendingVerificationRetry = null; s.verificationRetryCount.clear(); - // Preserve: s.unitDispatchCount, s.currentUnit, s.basePath, s.verbose, s.cmdCtx, - // s.completedUnits, s.autoStartTime, s.currentMilestoneId, s.originalModelId - // — all needed for resume and dashboard display ctx?.ui.setStatus("gsd-auto", "paused"); ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); @@ -611,31 +594,23 @@ export async function startAuto( const requestedStepMode = options?.step ?? false; // Escape stale worktree cwd from a previous milestone (#608). - // After milestone merge + worktree removal, the process cwd may still point - // inside .gsd/worktrees// — detect and chdir back to project root. base = escapeStaleWorktree(base); // If resuming from paused state, just re-activate and dispatch next unit. - // The conversation is still intact — no need to reinitialize everything. if (s.paused) { s.paused = false; s.active = true; s.verbose = verboseMode; - // Allow switching between step/auto on resume s.stepMode = requestedStepMode; s.cmdCtx = ctx; s.basePath = base; s.unitDispatchCount.clear(); s.unitLifetimeDispatches.clear(); s.unitConsecutiveSkips.clear(); - // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); - // Ensure milestone ID is set on git service for integration branch resolution if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId); - // ── Auto-worktree: re-enter worktree on resume if not already inside ── - // Skip if already inside a worktree (manual /worktree) to prevent nesting. - // Skip entirely in branch or none isolation mode (#531). + // ── Auto-worktree: re-enter worktree on resume ── if (s.currentMilestoneId && shouldUseWorktreeIsolation() && s.originalBasePath && !isInAutoWorktree(s.basePath) && !detectWorktreeName(s.basePath) && !detectWorktreeName(s.originalBasePath)) { try { const existingWtPath = getAutoWorktreePath(s.originalBasePath, s.currentMilestoneId); @@ -645,7 +620,6 @@ export async function startAuto( s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info"); } else { - // Worktree was deleted while paused — recreate it. const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId); s.basePath = wtPath; s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); @@ -659,15 +633,12 @@ export async function startAuto( } } - // Re-register SIGTERM handler for the resumed session (use original base for lock) registerSigtermHandler(lockBase()); ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); - // Restore hook state from disk in case session was interrupted restoreHookState(s.basePath); - // Rebuild disk state before resuming — user interaction during pause may have changed files try { await rebuildState(s.basePath); } catch (e) { debugLog("resume-rebuild-state-failed", { error: e instanceof Error ? e.message : String(e) }); } try { const report = await runGSDDoctor(s.basePath, { fix: true }); @@ -675,12 +646,9 @@ export async function startAuto( ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info"); } } catch (e) { debugLog("resume-doctor-failed", { error: e instanceof Error ? e.message : String(e) }); } - // Self-heal: clear stale runtime records where artifacts already exist await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); invalidateAllCaches(); - // Synthesize recovery briefing from the paused session so the next agent - // knows what already happened (reuses crash recovery infrastructure). if (s.pausedSessionFile) { const activityDir = join(gsdRoot(s.basePath), "activity"); const recovery = synthesizeCrashRecovery( @@ -699,462 +667,21 @@ export async function startAuto( s.pausedSessionFile = null; } - // Write lock on resume so cross-process status detection works (#723). writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length); await dispatchNextUnit(ctx, pi); return; } - // Ensure git repo exists — GSD needs it for commits and state tracking - if (!nativeIsRepo(base)) { - const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main"; - nativeInit(base, mainBranch); - } - - // Ensure .gitignore has baseline patterns - const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git; - const commitDocs = gitPrefs?.commit_docs; - const manageGitignore = gitPrefs?.manage_gitignore; - ensureGitignore(base, { commitDocs, manageGitignore }); - if (manageGitignore !== false) untrackRuntimeFiles(base); - - // Bootstrap .gsd/ if it doesn't exist - const gsdDir = join(base, ".gsd"); - if (!existsSync(gsdDir)) { - mkdirSync(join(gsdDir, "milestones"), { recursive: true }); - // Only commit .gsd/ init when commit_docs is not explicitly false - if (commitDocs !== false) { - try { - nativeAddAll(base); - nativeCommit(base, "chore: init gsd"); - } catch { /* nothing to commit */ } - } - } - - // Initialize GitServiceImpl — s.basePath is set and git repo confirmed - s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); - - // Check for crash from previous session - const crashLock = readCrashLock(base); - if (crashLock) { - if (isLockProcessAlive(crashLock)) { - // The lock belongs to a process that is still running — not a crash. - // Warn the user and abort to avoid two concurrent auto-mode sessions. - ctx.ui.notify( - `Another auto-mode session (PID ${crashLock.pid}) appears to be running.\nStop it with \`kill ${crashLock.pid}\` before starting a new session.`, - "error", - ); - return; - } - // Stale lock from a dead process — validate before synthesizing recovery context. - // If the recovered unit belongs to a fully-completed milestone (SUMMARY exists), - // discard recovery context to prevent phantom skip loops (#790). - const recoveredMid = crashLock.unitId.split("/")[0]; - const milestoneAlreadyComplete = recoveredMid - ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY") - : false; - - if (milestoneAlreadyComplete) { - ctx.ui.notify( - `Crash recovery: discarding stale context for ${crashLock.unitId} — milestone ${recoveredMid} is already complete.`, - "info", - ); - } else { - const activityDir = join(gsdRoot(base), "activity"); - const recovery = synthesizeCrashRecovery( - base, crashLock.unitType, crashLock.unitId, - crashLock.sessionFile, activityDir, - ); - if (recovery && recovery.trace.toolCallCount > 0) { - s.pendingCrashRecovery = recovery.prompt; - ctx.ui.notify( - `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`, - "warning", - ); - } else { - ctx.ui.notify( - `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`, - "warning", - ); - } - } - clearLock(base); - } - - // ── Debug mode: env-var activation ────────────────────────────────────── - if (!isDebugEnabled() && process.env.GSD_DEBUG === "1") { - enableDebug(base); - } - if (isDebugEnabled()) { - const { isNativeParserAvailable } = await import("./native-parser-bridge.js"); - debugLog("debug-start", { - platform: process.platform, - arch: process.arch, - node: process.version, - model: ctx.model?.id ?? "unknown", - provider: ctx.model?.provider ?? "unknown", - nativeParser: isNativeParserAvailable(), - cwd: base, - }); - ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info"); - } - - // Invalidate all caches before initial state derivation to ensure we read - // fresh disk state. Without this, a stale cache from a prior session (e.g. - // after a discussion that wrote new artifacts) may cause deriveState to - // return pre-planning when the roadmap already exists (#800). - invalidateAllCaches(); - - // ── Clean stale runtime unit files for completed milestones (#887) ─────── - // After resource-update restart, stale runtime/units/*.json files from - // previously completed milestones can cause deriveState to resume the wrong - // milestone. If a milestone has a SUMMARY file, its unit files are stale. - try { - const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units"); - if (existsSync(runtimeUnitsDir)) { - for (const file of readdirSync(runtimeUnitsDir)) { - if (!file.endsWith(".json")) continue; - const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/); - if (!midMatch) continue; - const mid = midMatch[1]; - if (resolveMilestoneFile(base, mid, "SUMMARY")) { - try { unlinkSync(join(runtimeUnitsDir, file)); } catch (e) { debugLog("stale-unit-cleanup-failed", { file, error: e instanceof Error ? e.message : String(e) }); } - } - } - } - } catch (e) { debugLog("stale-unit-dir-cleanup-failed", { error: e instanceof Error ? e.message : String(e) }); } - - let state = await deriveState(base); - - // ── Stale worktree state recovery (#654) ───────────────────────────────── - // When auto-mode was previously stopped and restarted, the project root's - // .gsd/ directory may have stale metadata (completed units showing as - // incomplete). If an auto-worktree exists for the active milestone, it has - // the current state — re-derive from there to avoid re-dispatching - // finished work. - if ( - state.activeMilestone && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) - ) { - const wtPath = getAutoWorktreePath(base, state.activeMilestone.id); - if (wtPath) { - state = await deriveState(wtPath); - } - } - - // ── Milestone branch recovery (#601) ───────────────────────────────────── - // When auto-mode was previously stopped, the milestone branch is preserved - // but the worktree is removed. The project root (integration branch) may - // not have the roadmap/artifacts — they live on the milestone branch. - // If state looks like pre-planning but a milestone branch exists with prior - // work, skip the early-return checks and let worktree setup + dispatch - // handle it correctly from the branch's state. - let hasSurvivorBranch = false; - if ( - state.activeMilestone && - (state.phase === "pre-planning" || state.phase === "needs-discussion") && - shouldUseWorktreeIsolation() && - !detectWorktreeName(base) && - !base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`) - ) { - const milestoneBranch = `milestone/${state.activeMilestone.id}`; - const { nativeBranchExists } = await import("./native-git-bridge.js"); - hasSurvivorBranch = nativeBranchExists(base, milestoneBranch); - if (hasSurvivorBranch) { - ctx.ui.notify( - `Found prior session branch ${milestoneBranch}. Resuming.`, - "info", - ); - } - } - - if (!hasSurvivorBranch) { - // No active work at all — start a new milestone via the discuss flow. - // After discussion completes, checkAutoStartAfterDiscuss() (fired from - // agent_end) will detect the new CONTEXT.md and restart auto mode. - // If the LLM didn't follow the discussion protocol (e.g. started editing - // files directly for a simple task), we re-derive state and either proceed - // with what was created or notify the user clearly (#609). - if (!state.activeMilestone || state.phase === "complete") { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - - // Re-derive state after discussion — the LLM may have created artifacts - // even if it didn't follow the full protocol. - invalidateAllCaches(); - const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "complete" && postState.phase !== "pre-planning") { - state = postState; - } else if (postState.activeMilestone && postState.phase === "pre-planning") { - const contextFile = resolveMilestoneFile(base, postState.activeMilestone.id, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (hasContext) { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but no milestone context was written. Run /gsd to try the discussion again, or /gsd auto after creating the milestone manually.", - "warning", - ); - return; - } - } else { - return; - } - } - - // Active milestone exists but has no roadmap — check if context exists. - // If context was pre-written (multi-milestone planning), auto-mode can - // research and plan it. If no context either, need user discussion. - if (state.phase === "pre-planning") { - const mid = state.activeMilestone!.id; - const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); - const hasContext = !!(contextFile && await loadFile(contextFile)); - if (!hasContext) { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - - // Same re-derive pattern as above - invalidateAllCaches(); - const postState = await deriveState(base); - if (postState.activeMilestone && postState.phase !== "pre-planning") { - state = postState; - } else { - ctx.ui.notify( - "Discussion completed but milestone context is still missing. Run /gsd to try again.", - "warning", - ); - return; - } - } - // Has context, no roadmap — auto-mode will research + plan it - } - } - - // At this point activeMilestone is guaranteed non-null: either - // hasSurvivorBranch is true (which requires activeMilestone) or - // the !activeMilestone early-return above would have fired. - if (!state.activeMilestone) { - // Unreachable — satisfies TypeScript's null check - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); - return; - } - - s.active = true; - s.stepMode = requestedStepMode; - s.verbose = verboseMode; - s.cmdCtx = ctx; - s.basePath = base; - s.unitDispatchCount.clear(); - s.unitRecoveryCount.clear(); - s.unitConsecutiveSkips.clear(); - s.lastBudgetAlertLevel = 0; - s.unitLifetimeDispatches.clear(); - s.completedKeySet.clear(); - loadPersistedKeys(base, s.completedKeySet); - resetHookState(); - restoreHookState(base); - resetProactiveHealing(); - s.autoStartTime = Date.now(); - s.resourceVersionOnStart = readResourceVersion(); - s.completedUnits = []; - s.pendingQuickTasks = []; - s.currentUnit = null; - s.currentMilestoneId = state.activeMilestone?.id ?? null; - s.originalModelId = ctx.model?.id ?? null; - s.originalModelProvider = ctx.model?.provider ?? null; - - // Register a SIGTERM handler so `kill ` cleans up the lock and exits. - registerSigtermHandler(base); - - // Capture the integration branch — records the branch the user was on when - // auto-mode started. Slice branches will merge back to this branch instead - // of the repo's default (main/master). Idempotent when the branch is the - // same; updates the record when started from a different branch (#300). - if (s.currentMilestoneId) { - if (getIsolationMode() !== "none") { - captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs }); - } - setActiveMilestoneId(base, s.currentMilestoneId); - } - - // ── Auto-worktree: create or enter worktree for the active milestone ── - // Store the original project root before any chdir so we can restore on stop. - // Skip if already inside a worktree (manual /worktree or another auto-worktree) - // to prevent nested worktree creation. - s.originalBasePath = base; - - const isUnderGsdWorktrees = (p: string): boolean => { - // Prevent creating nested auto-worktrees when running from within any - // `.gsd/worktrees/...` directory (including manual worktrees). - const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`; - if (p.includes(marker)) { - return true; - } - const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`; - return p.endsWith(worktreesSuffix); + // ── Fresh start path — delegated to auto-start.ts ── + const bootstrapDeps: BootstrapDeps = { + shouldUseWorktreeIsolation, + registerSigtermHandler, + lockBase, }; - if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) { - try { - const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId); - if (existingWtPath) { - // Worktree already exists (e.g., previous session created it) — enter it. - const wtPath = enterAutoWorktree(base, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); - ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info"); - } else { - // Fresh start — create worktree and enter it. - const wtPath = createAutoWorktree(base, s.currentMilestoneId); - s.basePath = wtPath; - s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); - ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info"); - } - // Re-register SIGTERM handler with the original s.basePath (lock lives there) - registerSigtermHandler(s.originalBasePath); - - // After worktree entry, load completed keys from BOTH locations (project root - // + worktree) so the in-memory set is the union. Prevents re-dispatch of units - // completed in either location after crash/restart (#769). - if (s.basePath !== s.originalBasePath) { - loadPersistedKeys(s.basePath, s.completedKeySet); - } - } catch (err) { - // Worktree creation is non-fatal — continue in the project root. - ctx.ui.notify( - `Auto-worktree setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`, - "warning", - ); - } - } - - // ── DB lifecycle: auto-migrate or open existing database ── - const gsdDbPath = join(s.basePath, ".gsd", "gsd.db"); - const gsdDirPath = join(s.basePath, ".gsd"); - if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) { - const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md")); - const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md")); - const hasMilestones = existsSync(join(gsdDirPath, "milestones")); - if (hasDecisions || hasRequirements || hasMilestones) { - try { - const { openDatabase: openDb } = await import("./gsd-db.js"); - const { migrateFromMarkdown } = await import("./md-importer.js"); - openDb(gsdDbPath); - migrateFromMarkdown(s.basePath); - } catch (err) { - process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`); - } - } - } - if (existsSync(gsdDbPath) && !isDbAvailable()) { - try { - const { openDatabase: openDb } = await import("./gsd-db.js"); - openDb(gsdDbPath); - } catch (err) { - process.stderr.write(`gsd-db: failed to open existing database: ${(err as Error).message}\n`); - } - } - - // Initialize metrics — loads existing ledger from disk. - // Use s.basePath (not base) so worktree-mode reads the worktree ledger (#769). - initMetrics(s.basePath); - - // Initialize routing history for adaptive learning - initRoutingHistory(s.basePath); - - // Capture the session's current model at auto-mode start (#650). - // This prevents model bleed when multiple GSD instances share the - // same global settings.json — each instance remembers its own model. - const currentModel = ctx.model; - if (currentModel) { - s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id }; - } - - // Snapshot installed skills so we can detect new ones after research - if (resolveSkillDiscoveryMode() !== "off") { - snapshotSkills(); - } - - ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); - ctx.ui.setFooter(hideFooter); - const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode"; - const pendingCount = state.registry.filter(m => m.status !== 'complete').length; - const scopeMsg = pendingCount > 1 - ? `Will loop through ${pendingCount} milestones.` - : "Will loop until milestone complete."; - ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); - - // Write initial lock file immediately so cross-process status detection - // works even before the first unit is dispatched (#723). - // The lock is updated with unit-specific info on each dispatch and cleared on stop. - writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); - - // Secrets collection gate — collect pending secrets before first dispatch - const mid = state.activeMilestone!.id; - try { - const manifestStatus = await getManifestStatus(base, mid); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await collectSecretsFromManifest(base, 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", - ); - } - - // Self-heal: clear stale runtime records where artifacts already exist. - // Use s.basePath (not base) — in worktree mode, s.basePath points to the worktree - // where runtime records and artifacts actually live (#769). - await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet); - - // Self-heal: remove stale .git/index.lock from prior crash. - // A stale lock file blocks all git operations (commit, merge, checkout). - // Only remove if older than 60 seconds (not from a concurrent process). - try { - const gitLockFile = join(base, ".git", "index.lock"); - if (existsSync(gitLockFile)) { - const lockAge = Date.now() - statSync(gitLockFile).mtimeMs; - if (lockAge > 60_000) { - unlinkSync(gitLockFile); - ctx.ui.notify("Removed stale .git/index.lock from prior crash.", "info"); - } - } - } catch (e) { debugLog("git-lock-cleanup-failed", { error: e instanceof Error ? e.message : String(e) }); } - - // Pre-flight: validate milestone queue for multi-milestone runs. - // Warn about issues that will cause auto-mode to pause or block. - try { - const msDir = join(base, ".gsd", "milestones"); - if (existsSync(msDir)) { - const milestoneIds = readdirSync(msDir, { withFileTypes: true }) - .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name)) - .map(d => d.name.match(/^(M\d{3})/)?.[1] ?? d.name); - if (milestoneIds.length > 1) { - const issues: string[] = []; - for (const id of milestoneIds) { - const draft = resolveMilestoneFile(base, id, "CONTEXT-DRAFT"); - if (draft) issues.push(`${id}: has CONTEXT-DRAFT.md (will pause for discussion)`); - } - if (issues.length > 0) { - ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued.\n${issues.map(i => ` ⚠ ${i}`).join("\n")}`, "warning"); - } else { - ctx.ui.notify(`Pre-flight: ${milestoneIds.length} milestones queued. All have full context.`, "info"); - } - } - } - } catch { /* non-fatal — pre-flight should never block auto-mode */ } + const ready = await bootstrapAutoSession(s, ctx, pi, base, verboseMode, requestedStepMode, bootstrapDeps); + if (!ready) return; // Dispatch the first unit await dispatchNextUnit(ctx, pi); @@ -1162,12 +689,7 @@ export async function startAuto( // ─── Agent End Handler ──────────────────────────────────────────────────────── -/** Guard against concurrent handleAgentEnd execution. Background job - * notifications and other system messages can trigger multiple agent_end - * events before the first handler finishes (the handler yields at every - * await). Without this guard, concurrent dispatchNextUnit calls race on - * newSession(), causing one to cancel the other and silently stopping - * auto-mode. */ +/** Guard against concurrent handleAgentEnd execution. */ export async function handleAgentEnd( ctx: ExtensionContext, @@ -1190,696 +712,34 @@ export async function handleAgentEnd( // Unit completed — clear its timeout clearUnitTimeout(); - // ── Parallel worker signal check ───────────────────────────────────── - // When running as a parallel worker (GSD_MILESTONE_LOCK set), check for - // coordinator signals before dispatching the next unit. - const milestoneLock = process.env.GSD_MILESTONE_LOCK; - if (milestoneLock) { - const signal = consumeSignal(s.basePath, milestoneLock); - if (signal) { - if (signal.signal === "stop") { - s.handlingAgentEnd = false; - await stopAuto(ctx, pi); - return; - } - if (signal.signal === "pause") { - s.handlingAgentEnd = false; - await pauseAuto(ctx, pi); - return; - } - // "resume" and "rebase" signals are handled elsewhere or no-op here - } - } + // ── Pre-verification processing (commit, doctor, state rebuild, etc.) ── + const postUnitCtx: PostUnitContext = { + s, + ctx, + pi, + buildSnapshotOpts, + lockBase, + stopAuto, + pauseAuto, + updateProgressWidget, + }; - // Invalidate all caches — the unit just completed and may have - // written planning files (task summaries, roadmap checkboxes, etc.) - invalidateAllCaches(); - - // Small delay to let files settle (git commits, file writes) - await new Promise(r => setTimeout(r, 500)); - - // Commit any dirty files the LLM left behind on the current branch. - // For execute-task units, build a meaningful commit message from the - // task summary (one-liner, key_files, inferred type). For other unit - // types, fall back to the generic chore() message. - if (s.currentUnit) { - try { - let taskContext: TaskCommitContext | undefined; - - if (s.currentUnit.type === "execute-task") { - const parts = s.currentUnit.id.split("/"); - const [mid, sid, tid] = parts; - if (mid && sid && tid) { - const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); - if (summaryPath) { - try { - const summaryContent = await loadFile(summaryPath); - if (summaryContent) { - const summary = parseSummary(summaryContent); - taskContext = { - taskId: `${sid}/${tid}`, - taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, - oneLiner: summary.oneLiner || undefined, - keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, - }; - } - } catch { - // Non-fatal — fall back to generic message - } - } - } - } - - const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext); - if (commitMsg) { - ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); - } - } catch { - // Non-fatal - } - - // Post-hook: fix mechanical bookkeeping the LLM may have skipped. - // 1. Doctor handles: checkbox marking (task-level bookkeeping). - // 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed). - // fixLevel:"task" ensures doctor only fixes task-level issues (e.g. marking - // checkboxes). Slice/milestone completion transitions (summary stubs, - // roadmap [x] marking) are left for the complete-slice dispatch unit. - // Exception: after complete-slice and run-uat, use fixLevel:"all" so roadmap - // checkboxes get fixed. run-uat is the terminal unit for a slice — if the - // roadmap checkbox wasn't marked done by complete-slice (e.g. edit failure), - // fixing it here prevents the state machine from re-dispatching run-uat - // indefinitely (#839, #1063). - try { - const scopeParts = s.currentUnit.id.split("/").slice(0, 2); - const doctorScope = scopeParts.join("/"); - const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); - const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; - const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); - if (report.fixesApplied.length > 0) { - ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); - } - - // ── Proactive health tracking ────────────────────────────────────── - // Record health snapshot for trend analysis and escalation logic. - const summary = summarizeDoctorIssues(report.issues); - recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length); - - // Check if we should escalate to LLM-assisted heal - if (summary.errors > 0) { - const unresolvedErrors = report.issues - .filter(i => i.severity === "error" && !i.fixable) - .map(i => ({ code: i.code, message: i.message, unitId: i.unitId })); - const escalation = checkHealEscalation(summary.errors, unresolvedErrors); - if (escalation.shouldEscalate) { - ctx.ui.notify( - `Doctor heal escalation: ${escalation.reason}. Dispatching LLM-assisted heal.`, - "warning", - ); - try { - const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js"); - const { dispatchDoctorHeal } = await import("./commands.js"); - const actionable = report.issues.filter(i => i.severity === "error"); - const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true }); - const structuredIssues = formatDoctorIssuesForPrompt(actionable); - dispatchDoctorHeal(pi, doctorScope, reportText, structuredIssues); - } catch { - // Non-fatal — escalation dispatch failure - } - } - } - } catch { - // Non-fatal — doctor failure should never block dispatch - } - // Throttle STATE.md rebuilds to reduce I/O spikes on long sessions. - // STATE.md is a derived diagnostic artifact — skipping a rebuild is safe; - // the next unit or stop/pause will rebuild it. - const now = Date.now(); - if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) { - try { - await rebuildState(s.basePath); - s.lastStateRebuildAt = now; - // State rebuild commit is bookkeeping — generic message is appropriate - autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id); - } catch { - // Non-fatal - } - } - - // ── Prune dead bg-shell processes ────────────────────────────────────── - // Dead processes retain ~500KB-1MB of output buffers each. Without pruning, - // they accumulate during long auto-mode sessions causing memory pressure. - try { - const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js"); - pruneDeadProcesses(); - } catch { - // Non-fatal — bg-shell may not be available - } - - // ── Sync worktree state back to project root ────────────────────────── - // Ensures that if auto-mode restarts, deriveState(projectRoot) reads - // current milestone progress instead of stale pre-worktree state (#654). - if (s.originalBasePath && s.originalBasePath !== s.basePath) { - try { - syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId); - } catch { - // Non-fatal — stale state is the existing behavior, sync is an improvement - } - } - - // ── Rewrite-docs completion: resolve overrides and reset circuit breaker ── - if (s.currentUnit.type === "rewrite-docs") { - try { - await resolveAllOverrides(s.basePath); - resetRewriteCircuitBreaker(); - ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); - } catch { - // Non-fatal — verifyExpectedArtifact will catch unresolved overrides - } - } - - // ── Post-triage: execute actionable resolutions (inject, replan, queue quick-tasks) ── - // After a triage-captures unit completes, the LLM has classified captures and - // updated CAPTURES.md. Now we execute those classifications: inject tasks into - // the plan, write replan triggers, and queue quick-tasks for dispatch. - if (s.currentUnit.type === "triage-captures") { - try { - const { executeTriageResolutions } = await import("./triage-resolution.js"); - const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; - - if (mid && sid) { - const triageResult = executeTriageResolutions(s.basePath, mid, sid); - - if (triageResult.injected > 0) { - ctx.ui.notify( - `Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`, - "info", - ); - } - if (triageResult.replanned > 0) { - ctx.ui.notify( - `Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`, - "info", - ); - } - if (triageResult.quickTasks.length > 0) { - // Queue quick-tasks for dispatch. They'll be picked up by the - // quick-task dispatch block below the triage check. - for (const qt of triageResult.quickTasks) { - s.pendingQuickTasks.push(qt); - } - ctx.ui.notify( - `Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`, - "info", - ); - } - for (const action of triageResult.actions) { - process.stderr.write(`gsd-triage: ${action}\n`); - } - } - } catch (err) { - // Non-fatal — triage resolution failure shouldn't block dispatch - process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`); - } - } - - // ── Path A fix: verify artifact and persist completion before re-entering dispatch ── - // After doctor + rebuildState, check whether the just-completed unit actually - // produced its expected artifact. If so, persist the completion key now so the - // idempotency check at the top of dispatchNextUnit() skips it — even if - // deriveState() still returns this unit as s.active (e.g. branch mismatch). - // - // IMPORTANT: For non-hook units, defer persistence until after the hook check. - // If a post-unit hook requests a retry, we need to remove the completion key - // so dispatchNextUnit re-dispatches the trigger unit. - let triggerArtifactVerified = false; - if (!s.currentUnit.type.startsWith("hook/")) { - try { - triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); - if (triggerArtifactVerified) { - const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; - if (!s.completedKeySet.has(completionKey)) { - persistCompletedKey(s.basePath, completionKey); - s.completedKeySet.add(completionKey); - } - invalidateAllCaches(); - } - } catch { - // Non-fatal — worst case we fall through to normal dispatch which has its own checks - } - } else { - // Hook unit completed — finalize its runtime record and clear it - try { - writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, { - phase: "finalized", - progressCount: 1, - lastProgressKind: "hook-completed", - }); - clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); - } catch { - // Non-fatal - } - } - } + const preResult = await postUnitPreVerification(postUnitCtx); + if (preResult === "dispatched") return; // ── Verification gate: run typecheck/lint/test after execute-task ── - if (s.currentUnit && s.currentUnit.type === "execute-task") { - try { - const effectivePrefs = loadEffectiveGSDPreferences(); - const prefs = effectivePrefs?.preferences; + const verificationResult = await runPostUnitVerification( + { s, ctx, pi }, + dispatchNextUnit, + startDispatchGapWatchdog, + pauseAuto, + ); + if (verificationResult === "retry" || verificationResult === "pause") return; - // Read task plan verify field from the current task's slice plan - // unitId format is "M001/S01/T03" — extract mid, sid, tid - const parts = s.currentUnit.id.split("/"); - let taskPlanVerify: string | undefined; - if (parts.length >= 3) { - const [mid, sid, tid] = parts; - const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); - if (planFile) { - const planContent = await loadFile(planFile); - if (planContent) { - const slicePlan = parsePlan(planContent); - const taskEntry = slicePlan?.tasks?.find(t => t.id === tid); - taskPlanVerify = taskEntry?.verify; - } - } - } - - const result = runVerificationGate({ basePath: s.basePath, - unitId: s.currentUnit.id, - cwd: s.basePath, - preferenceCommands: prefs?.verification_commands, - taskPlanVerify, - }); - - // Capture runtime errors from bg-shell and browser console - const runtimeErrors = await captureRuntimeErrors(); - if (runtimeErrors.length > 0) { - result.runtimeErrors = runtimeErrors; - // Blocking runtime errors override gate pass - if (runtimeErrors.some(e => e.blocking)) { - result.passed = false; - } - } - - // Conditional dependency audit (R008) - const auditWarnings = runDependencyAudit(s.basePath); - if (auditWarnings.length > 0) { - result.auditWarnings = auditWarnings; - process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`); - for (const w of auditWarnings) { - process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`); - } - } - - // Auto-fix retry preferences (R005 / D005) - const autoFixEnabled = prefs?.verification_auto_fix !== false; // default true - const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2; - const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`; - - if (result.checks.length > 0) { - const passCount = result.checks.filter(c => c.exitCode === 0).length; - const total = result.checks.length; - if (result.passed) { - ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`); - } else { - const failures = result.checks.filter(c => c.exitCode !== 0); - const failNames = failures.map(f => f.command).join(", "); - ctx.ui.notify(`Verification gate: FAILED — ${failNames}`); - process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`); - for (const f of failures) { - process.stderr.write(` ${f.command} exited ${f.exitCode}\n`); - if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`); - } - } - } - - // Log blocking runtime errors to stderr - if (result.runtimeErrors?.some(e => e.blocking)) { - const blockingErrors = result.runtimeErrors.filter(e => e.blocking); - process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`); - for (const err of blockingErrors) { - process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`); - } - } - - // Write verification evidence JSON artifact - const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; - if (parts.length >= 3) { - try { - const [mid, sid, tid] = parts; - const sDir = resolveSlicePath(s.basePath, mid, sid); - if (sDir) { - const tasksDir = join(sDir, "tasks"); - if (result.passed) { - writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id); - } else { - const nextAttempt = attempt + 1; - writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries); - } - } - } catch (evidenceErr) { - process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`); - } - } - - // ── Auto-fix retry logic ── - if (result.passed) { - // Gate passed — clear retry state and continue normal flow - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - } else if (autoFixEnabled && attempt + 1 <= maxRetries) { - // Gate failed, retries remaining — set up retry and return early - const nextAttempt = attempt + 1; - s.verificationRetryCount.set(s.currentUnit.id, nextAttempt); - s.pendingVerificationRetry = { - unitId: s.currentUnit.id, - failureContext: formatFailureContext(result), - attempt: nextAttempt, - }; - ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning"); - // Remove completion key so dispatchNextUnit re-dispatches this unit - s.completedKeySet.delete(completionKey); - removePersistedKey(s.basePath, completionKey); - // Dispatch retry immediately — without this, handleAgentEnd returns - // without calling dispatchNextUnit, leaving auto-mode stalled (#978). - try { - await dispatchNextUnit(ctx, pi); - } catch (retryDispatchErr) { - const msg = retryDispatchErr instanceof Error ? retryDispatchErr.message : String(retryDispatchErr); - ctx.ui.notify(`Verification retry dispatch error: ${msg}`, "error"); - startDispatchGapWatchdog(ctx, pi); - } - return; // ← Critical: exit before DB dual-write and post-unit hooks - } else { - // Gate failed, retries exhausted (or auto-fix disabled) — pause for human review - const exhaustedAttempt = attempt + 1; - s.verificationRetryCount.delete(s.currentUnit.id); - s.pendingVerificationRetry = null; - ctx.ui.notify( - `Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`, - "error", - ); - await pauseAuto(ctx, pi); - return; - } - } catch (err) { - // Gate errors are non-fatal — log and continue - process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`); - } - } - - // ── DB dual-write: re-import changed markdown files so next unit's prompts use fresh data ── - if (isDbAvailable()) { - try { - const { migrateFromMarkdown } = await import("./md-importer.js"); - migrateFromMarkdown(s.basePath); - } catch (err) { - process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`); - } - } - - // ── Post-unit hooks: check if a configured hook should run before normal dispatch ── - if (s.currentUnit && !s.stepMode) { - const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); - if (hookUnit) { - // Dispatch the hook unit instead of normal flow - const hookStartedAt = Date.now(); - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } - s.currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; - writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: hookStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); - - const state = await deriveState(s.basePath); - updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state); - const hookState = getActiveHook(); - ctx.ui.notify( - `Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`, - "info", - ); - - // Switch model if the hook specifies one - if (hookUnit.model) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = availableModels.find(m => - m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model, - ); - if (match) { - try { - await pi.setModel(match); - } catch { /* non-fatal — use current model */ } - } - } - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - resetHookState(); - await stopAuto(ctx, pi, "Hook session cancelled"); - return; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile); - // Persist hook state so cycle counts survive crashes - persistHookState(s.basePath); - - // Start supervision timers for hook units — hooks can get stuck just - // like normal units, and without a watchdog auto-mode would hang forever. - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - if (s.currentUnit) { - writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, { - phase: "timeout", - timeoutAt: Date.now(), - }); - } - ctx.ui.notify( - `Hook ${hookUnit.hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, - "warning", - ); - resetHookState(); - await pauseAuto(ctx, pi); - }, hookHardTimeoutMs); - - // Guard against race with timeout/pause before sending - if (!s.active) return; - pi.sendMessage( - { customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return; // handleAgentEnd will fire again when hook session completes - } - - // Check if a hook requested a retry of the trigger unit - if (isRetryPending()) { - const trigger = consumeRetryTrigger(); - if (trigger) { - // Remove the trigger unit's completion key so dispatchNextUnit - // will re-dispatch it instead of skipping it as already-complete. - const triggerKey = `${trigger.unitType}/${trigger.unitId}`; - s.completedKeySet.delete(triggerKey); - removePersistedKey(s.basePath, triggerKey); - ctx.ui.notify( - `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`, - "info", - ); - // Fall through to normal dispatchNextUnit — state derivation will - // re-select the same unit since it hasn't been marked complete - } - } - } - - // ── Triage check: dispatch triage unit if pending captures exist ────────── - // Fires after hooks complete, before normal dispatch. Follows the same - // early-dispatch-and-return pattern as hooks and fix-merge. - // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage), - // hook units (hooks run before triage conceptually). - if ( - !s.stepMode && - s.currentUnit && - !s.currentUnit.type.startsWith("hook/") && - s.currentUnit.type !== "triage-captures" && - s.currentUnit.type !== "quick-task" - ) { - try { - if (hasPendingCaptures(s.basePath)) { - const pending = loadPendingCaptures(s.basePath); - if (pending.length > 0) { - const state = await deriveState(s.basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; - - if (mid && sid) { - // Build triage prompt with current context - let currentPlan = ""; - let roadmapContext = ""; - const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); - if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; - const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP"); - if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; - - const capturesList = pending.map(c => - `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` - ).join("\n"); - - const prompt = loadPrompt("triage-captures", { - pendingCaptures: capturesList, - currentPlan: currentPlan || "(no active slice plan)", - roadmapContext: roadmapContext || "(no active roadmap)", - }); - - ctx.ui.notify( - `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, - "info", - ); - - // Close out previous unit metrics - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - // Dispatch triage as a new unit (early-dispatch-and-return) - const triageUnitType = "triage-captures"; - const triageUnitId = `${mid}/${sid}/triage`; - const triageStartedAt = Date.now(); - s.currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt }; - writeUnitRuntimeRecord(s.basePath, triageUnitType, triageUnitId, triageStartedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: triageStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); - updateProgressWidget(ctx, triageUnitType, triageUnitId, state); - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile); - - // Start unit timeout for triage (use same supervisor config as hooks) - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - ctx.ui.notify( - `Triage unit exceeded timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - }, triageTimeoutMs); - - if (!s.active) return; - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return; // handleAgentEnd will fire again when triage session completes - } - } - } - } catch { - // Triage check failure is non-fatal — proceed to normal dispatch - } - } - - // ── Quick-task dispatch: execute queued quick-tasks from triage resolution ── - // Quick-tasks are self-contained one-off tasks that don't modify the plan. - // They're queued during post-triage resolution and dispatched here one at a time. - if ( - !s.stepMode && - s.pendingQuickTasks.length > 0 && - s.currentUnit && - s.currentUnit.type !== "quick-task" - ) { - try { - const capture = s.pendingQuickTasks.shift()!; - const { buildQuickTaskPrompt } = await import("./triage-resolution.js"); - const { markCaptureExecuted } = await import("./captures.js"); - const prompt = buildQuickTaskPrompt(capture); - - ctx.ui.notify( - `Executing quick-task: ${capture.id} — "${capture.text}"`, - "info", - ); - - // Close out previous unit metrics - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt); - } - - // Dispatch quick-task as a new unit - const qtUnitType = "quick-task"; - const qtUnitId = `${ s.currentMilestoneId }/${capture.id}`; - const qtStartedAt = Date.now(); - s.currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt }; - writeUnitRuntimeRecord(s.basePath, qtUnitType, qtUnitId, qtStartedAt, { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: qtStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }); - const state = await deriveState(s.basePath); - updateProgressWidget(ctx, qtUnitType, qtUnitId, state); - - const result = await s.cmdCtx!.newSession(); - if (result.cancelled) { - await stopAuto(ctx, pi); - return; - } - const sessionFile = ctx.sessionManager.getSessionFile(); - writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile); - - // Mark capture as executed now that the unit is dispatched - markCaptureExecuted(s.basePath, capture.id); - - // Start unit timeout for quick-task - clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; - s.unitTimeoutHandle = setTimeout(async () => { - s.unitTimeoutHandle = null; - if (!s.active) return; - ctx.ui.notify( - `Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - }, qtTimeoutMs); - - if (!s.active) return; - pi.sendMessage( - { customType: "gsd-auto", content: prompt, display: s.verbose }, - { triggerTurn: true }, - ); - return; // handleAgentEnd will fire again when quick-task session completes - } catch { - // Non-fatal — proceed to normal dispatch - } - } - - // In step mode, pause and show a wizard instead of immediately dispatching - if (s.stepMode) { + // ── Post-verification processing (DB dual-write, hooks, triage, quick-tasks) ── + const postResult = await postUnitPostVerification(postUnitCtx); + if (postResult === "dispatched" || postResult === "stopped") return; + if (postResult === "step-wizard") { await showStepWizard(ctx, pi); return; } @@ -1906,26 +766,17 @@ export async function handleAgentEnd( try { await dispatchNextUnit(ctx, pi); } catch (dispatchErr) { - // dispatchNextUnit threw — without this catch the error would propagate - // to the pi event emitter which may silently swallow async rejections, - // leaving auto-mode s.active but permanently stalled (see #381). const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr); ctx.ui.notify( `Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`, "error", ); - - // Start the dispatch gap watchdog to retry after a delay. - // This gives transient issues (dirty working tree, branch state) time to settle. startDispatchGapWatchdog(ctx, pi); return; } finally { clearTimeout(dispatchHangGuard); } - // If dispatchNextUnit returned normally but auto-mode is still s.active and - // no new unit timeout was set (meaning sendMessage was never called), start - // the dispatch gap watchdog as a safety net. if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) { startDispatchGapWatchdog(ctx, pi); } @@ -1956,8 +807,6 @@ export async function handleAgentEnd( /** * Show the step-mode wizard after a unit completes. - * Derives the next unit from disk state and presents it to the user. - * If the user confirms, dispatches the next unit. If not, pauses. */ async function showStepWizard( ctx: ExtensionContext, @@ -1968,12 +817,10 @@ async function showStepWizard( const state = await deriveState(s.basePath); const mid = state.activeMilestone?.id; - // Build summary of what just completed const justFinished = s.currentUnit ? `${unitVerb(s.currentUnit.type)} ${s.currentUnit.id}` : "previous unit"; - // If no active milestone or everything is complete, stop if (!mid || state.phase === "complete") { const incomplete = state.registry.filter(m => m.status !== "complete"); if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") { @@ -1987,7 +834,6 @@ async function showStepWizard( return; } - // Peek at what's next by examining state const nextDesc = _describeNextUnit(state); const choice = await showNextAction(s.cmdCtx, { @@ -2025,12 +871,10 @@ async function showStepWizard( ctx.ui.notify("Switched to auto-mode.", "info"); await dispatchNextUnit(ctx, pi); } else if (choice === "status") { - // Show status then re-show the wizard const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx as ExtensionCommandContext); await showStepWizard(ctx, pi); } else { - // "not_yet" — pause await pauseAuto(ctx, pi); } } @@ -2062,15 +906,6 @@ const widgetStateAccessors: WidgetStateAccessors = { // ─── Core Loop ──────────────────────────────────────────────────────────────── -/** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */ - -/** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd). - * Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls - * that bypass the s.handlingAgentEnd guard. Recursive calls (from skip paths) are - * allowed via s.skipDepth > 0. */ - -/** Keys recently evicted by skip-loop breaker — prevents re-persistence in the fallback path (#912). */ - async function dispatchNextUnit( ctx: ExtensionContext, pi: ExtensionAPI, @@ -2083,44 +918,32 @@ async function dispatchNextUnit( return; } - // Reentrancy guard: allow recursive calls from skip paths (s.skipDepth > 0) - // but block concurrent external calls (watchdog, step wizard, etc.) + // Reentrancy guard if (s.dispatching && s.skipDepth === 0) { debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing"); - return; // Another dispatch is in progress — bail silently + return; } s.dispatching = true; try { - // Recursion depth guard: when many units are skipped in sequence (e.g., after - // crash recovery with 10+ completed units), recursive dispatchNextUnit calls - // can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH. + // Recursion depth guard if (s.skipDepth > MAX_SKIP_DEPTH) { s.skipDepth = 0; ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info"); await new Promise(r => setTimeout(r, 200)); } - // Resource version guard: detect mid-session resource updates. - // Templates are read from disk on each dispatch but extension code is loaded - // once at startup. If resources were re-synced (e.g. /gsd:update, npm update, - // or dev copy-resources), templates may expect variables the in-memory code - // doesn't provide. Stop gracefully instead of crashing. + // Resource version guard const staleMsg = checkResourcesStale(s.resourceVersionOnStart); if (staleMsg) { await stopAuto(ctx, pi, staleMsg); return; } - // Clear all caches so deriveState sees fresh disk state (#431). - // Parse cache is also cleared — doctor may have re-populated it with - // stale data between handleAgentEnd and this dispatch call (Path B fix). invalidateAllCaches(); s.lastPromptCharCount = undefined; s.lastBaselineCharCount = undefined; - // ── Pre-dispatch health gate ────────────────────────────────────────── - // Lightweight check for critical issues that would cause the next unit - // to fail or corrupt state. Auto-heals what it can, blocks on the rest. + // ── Pre-dispatch health gate ── try { const healthGate = await preDispatchHealthGate(s.basePath); if (healthGate.fixesApplied.length > 0) { @@ -2132,13 +955,10 @@ async function dispatchNextUnit( return; } } catch { - // Non-fatal — health gate failure should never block dispatch + // Non-fatal } - // ── Sync project root artifacts into worktree (#853) ───────────────── - // When the LLM writes artifacts to the main repo filesystem instead of - // the worktree, the worktree's gsd.db becomes stale. Sync before - // deriveState to ensure the worktree has the latest artifacts. + // ── Sync project root artifacts into worktree ── if (s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId) { syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId); } @@ -2161,12 +981,10 @@ async function dispatchNextUnit( "info", ); sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone"); - // Hint: visualizer available after milestone transition const vizPrefs = loadEffectiveGSDPreferences()?.preferences; if (vizPrefs?.auto_visualize) { ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); } - // Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false) if (vizPrefs?.auto_report !== false) { try { const { loadVisualizerData } = await import("./visualizer-data.js"); @@ -2219,7 +1037,6 @@ async function dispatchNextUnit( s.unitRecoveryCount.clear(); s.unitConsecutiveSkips.clear(); s.unitLifetimeDispatches.clear(); - // Clear completed-units.json for the finished milestone try { const file = completedKeysPath(s.basePath); if (existsSync(file)) { @@ -2228,13 +1045,7 @@ async function dispatchNextUnit( s.completedKeySet.clear(); } catch (e) { debugLog("completed-keys-reset-failed", { error: e instanceof Error ? e.message : String(e) }); } - // ── Worktree lifecycle on milestone transition (#616) ────────────── - // When transitioning from M_old to M_new inside a worktree, we must: - // 1. Merge the completed milestone's worktree back to main - // 2. Re-derive state from the project root - // 3. Create a new worktree for the incoming milestone - // Without this, M_new runs inside M_old's worktree on the wrong branch, - // and artifact paths resolve against the wrong .gsd/ directory. + // ── Worktree lifecycle on milestone transition (#616) ── if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) { try { const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); @@ -2246,7 +1057,6 @@ async function dispatchNextUnit( "info", ); } else { - // No roadmap found — teardown worktree without merge teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId); ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info"); } @@ -2255,23 +1065,19 @@ async function dispatchNextUnit( `Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`, "warning", ); - // Force cwd back to project root even if merge failed if (s.originalBasePath) { try { process.chdir(s.originalBasePath); } catch { /* best-effort */ } } } - // Update s.basePath to project root (mergeMilestoneToMain already chdir'd) s.basePath = s.originalBasePath; s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); invalidateAllCaches(); - // Re-derive state from project root before creating new worktree state = await deriveState(s.basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; - // Create new worktree for the incoming milestone if (mid) { captureIntegrationBranch(s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); try { @@ -2287,14 +1093,11 @@ async function dispatchNextUnit( } } } else { - // Not in worktree — capture integration branch for the new milestone (branch mode only). - // In none mode there's no milestone branch to merge back to, so skip. if (getIsolationMode() !== "none") { captureIntegrationBranch(s.originalBasePath || s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs }); } } - // Prune completed milestone from queue order file const pendingIds = state.registry .filter(m => m.status !== "complete") .map(m => m.id); @@ -2306,7 +1109,6 @@ async function dispatchNextUnit( } if (!mid) { - // Save final session before stopping if (s.currentUnit) { await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } @@ -2338,7 +1140,6 @@ async function dispatchNextUnit( } } } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") { - // Branch isolation mode: squash-merge milestone branch back before stopping try { const currentBranch = getCurrentBranch(s.basePath); const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId); @@ -2364,13 +1165,11 @@ async function dispatchNextUnit( sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone"); await stopAuto(ctx, pi, "All milestones complete"); } else if (state.phase === "blocked") { - // Milestones exist but are dependency-blocked const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; await stopAuto(ctx, pi, blockerMsg); ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning"); sendDesktopNotification("GSD", blockerMsg, "error", "attention"); } else { - // Milestones with remaining work exist but none became s.active — unexpected const ids = incomplete.map(m => m.id).join(", "); const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error"); @@ -2379,15 +1178,12 @@ async function dispatchNextUnit( return; } - // Guard: mid/midTitle must be defined strings from this point onward. - // The !mid check above returns early if mid is falsy; midTitle comes from - // the same object so it should always be present when mid is. if (!midTitle) { - midTitle = mid; // Defensive fallback: use milestone ID as title + midTitle = mid; ctx.ui.notify(`Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning"); } - // ── Mid-merge safety check: detect leftover merge state from a prior session ── + // ── Mid-merge safety check ── if (reconcileMergeState(s.basePath, ctx)) { invalidateAllCaches(); state = await deriveState(s.basePath); @@ -2395,7 +1191,6 @@ async function dispatchNextUnit( midTitle = state.activeMilestone?.title; } - // After merge guard removal (branchless architecture), mid/midTitle could be undefined if (!mid || !midTitle) { if (s.currentUnit) { await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); @@ -2416,7 +1211,6 @@ async function dispatchNextUnit( if (s.currentUnit) { await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); } - // Clear completed-units.json for the finished milestone so it doesn't grow unbounded. try { const file = completedKeysPath(s.basePath); if (existsSync(file)) { @@ -2424,7 +1218,7 @@ async function dispatchNextUnit( } s.completedKeySet.clear(); } catch (e) { debugLog("completed-keys-reset-failed", { error: e instanceof Error ? e.message : String(e) }); } - // ── Milestone merge: squash-merge milestone branch to main before stopping ── + // ── Milestone merge ── if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) { try { const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP"); @@ -2442,17 +1236,12 @@ async function dispatchNextUnit( `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`, "warning", ); - // Ensure cwd is restored even if merge failed partway through (#608). - // mergeMilestoneToMain may have chdir'd but then thrown, leaving us - // in an indeterminate location. if (s.originalBasePath) { s.basePath = s.originalBasePath; try { process.chdir(s.basePath); } catch { /* best-effort */ } } } } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") { - // Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch. - // Squash-merge back to the integration branch (or main) before stopping. try { const currentBranch = getCurrentBranch(s.basePath); const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId); @@ -2460,8 +1249,6 @@ async function dispatchNextUnit( const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP"); if (roadmapPath) { const roadmapContent = readFileSync(roadmapPath, "utf-8"); - // mergeMilestoneToMain handles: auto-commit, checkout integration branch, - // squash merge, commit, optional push, branch deletion. const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent); s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {}); ctx.ui.notify( @@ -2493,11 +1280,9 @@ async function dispatchNextUnit( return; } - // ── UAT Dispatch: run-uat fires after complete-slice merge, before reassessment ── - // Ensures the UAT file and slice summary are both on main when UAT runs. + // Budget ceiling guard, context window guard, secrets gate, dispatch table const prefs = loadEffectiveGSDPreferences()?.preferences; - // Budget ceiling guard — enforce budget with configurable action const budgetCeiling = prefs?.budget_ceiling; if (budgetCeiling !== undefined && budgetCeiling > 0) { const currentLedger = getLedger(); @@ -2544,8 +1329,7 @@ async function dispatchNextUnit( s.lastBudgetAlertLevel = 0; } - // Context window guard — pause if approaching context limits - const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default + 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) { @@ -2557,11 +1341,7 @@ async function dispatchNextUnit( } } - // ── Secrets re-check gate — runs before every dispatch, not just at startAuto ── - // plan-milestone writes the milestone SECRETS file (e.g., M001-SECRETS.md) during its unit. By the time we - // reach the next dispatchNextUnit call the manifest exists but hasn't been - // presented to the user yet. Without this re-check the model would proceed - // into plan-slice / execute-task with no real credentials and mock everything. + // Secrets re-check gate const runSecretsGate = async () => { try { const manifestStatus = await getManifestStatus(s.basePath, mid); @@ -2586,7 +1366,7 @@ async function dispatchNextUnit( await runSecretsGate(); - // ── Dispatch table: resolve phase → unit type + prompt ── + // ── Dispatch table ── const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs, }); @@ -2599,7 +1379,6 @@ async function dispatchNextUnit( } if (dispatchResult.action !== "dispatch") { - // skip action — yield and re-dispatch await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); return; @@ -2610,7 +1389,7 @@ async function dispatchNextUnit( prompt = dispatchResult.prompt; let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; - // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ── + // ── Pre-dispatch hooks ── const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath); if (preDispatchResult.firedHooks.length > 0) { ctx.ui.notify( @@ -2620,7 +1399,6 @@ async function dispatchNextUnit( } if (preDispatchResult.action === "skip") { ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info"); - // Yield then re-dispatch to advance to next unit await new Promise(r => setImmediate(r)); await dispatchNextUnit(ctx, pi); return; @@ -2640,378 +1418,76 @@ async function dispatchNextUnit( const observabilityIssues = await _collectObservabilityWarnings(ctx, s.basePath, unitType, unitId); - // Idempotency: skip units already completed in a prior session. - const idempotencyKey = `${unitType}/${unitId}`; - if (s.completedKeySet.has(idempotencyKey)) { - // Cross-validate: does the expected artifact actually exist? - const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath); - if (artifactExists) { - // Guard against infinite skip loops: if deriveState keeps returning the - // same completed unit, consecutive skips will trip this breaker. Evict the - // key so the next dispatch forces full reconciliation instead of looping. - const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - s.unitConsecutiveSkips.set(idempotencyKey, skipCount); - if (skipCount > MAX_CONSECUTIVE_SKIPS) { - // Cross-check: verify deriveState actually returns this unit (#790). - // If the unit's milestone is already complete, this is a phantom skip - // loop from stale crash recovery context — don't evict. - const skippedMid = unitId.split("/")[0]; - const skippedMilestoneComplete = skippedMid - ? !!resolveMilestoneFile(s.basePath, skippedMid, "SUMMARY") - : false; - if (skippedMilestoneComplete) { - // Milestone is complete — evicting this key would fight self-heal. - // Clear skip counter and re-dispatch from fresh state. - s.unitConsecutiveSkips.delete(idempotencyKey); - invalidateAllCaches(); - ctx.ui.notify( - `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, - "info", - ); - s.skipDepth++; - await new Promise(r => setTimeout(r, 50)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } - s.unitConsecutiveSkips.delete(idempotencyKey); - s.completedKeySet.delete(idempotencyKey); - s.recentlyEvictedKeys.add(idempotencyKey); - removePersistedKey(s.basePath, idempotencyKey); - invalidateAllCaches(); - ctx.ui.notify( - `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`, - "warning", - ); - if (!s.active) return; - s.skipDepth++; - await new Promise(r => setTimeout(r, 150)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } - // Count toward lifetime cap so hard-stop fires during skip loops (#792) - const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip); - if (lifeSkip > MAX_LIFETIME_DISPATCHES) { - await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`); - ctx.ui.notify( - `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip} iterations).`, - "error", - ); - return; - } - ctx.ui.notify( - `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`, - "info", - ); - if (!s.active) return; - s.skipDepth++; - await new Promise(r => setTimeout(r, 150)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } else { - // Stale completion record — artifact missing. Remove and re-run. - s.completedKeySet.delete(idempotencyKey); - removePersistedKey(s.basePath, idempotencyKey); - ctx.ui.notify( - `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`, - "warning", - ); - } - } - - // Fallback: if the idempotency key is missing but the expected artifact already - // exists on disk, the task completed in a prior session without persisting the key. - // Persist it now and skip re-dispatch. This prevents infinite loops where a task - // completes successfully but the completion key was never written. - // - // EXCEPTION: if the key was just evicted by the skip-loop breaker above, do NOT - // re-persist — that would recreate the exact loop the breaker was trying to break (#912). - if (verifyExpectedArtifact(unitType, unitId, s.basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) { - persistCompletedKey(s.basePath, idempotencyKey); - s.completedKeySet.add(idempotencyKey); - invalidateAllCaches(); - // Same consecutive-skip guard as the idempotency path above. - const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; - s.unitConsecutiveSkips.set(idempotencyKey, skipCount2); - if (skipCount2 > MAX_CONSECUTIVE_SKIPS) { - // Cross-check: verify the unit's milestone is still active (#790). - const skippedMid2 = unitId.split("/")[0]; - const skippedMilestoneComplete2 = skippedMid2 - ? !!resolveMilestoneFile(s.basePath, skippedMid2, "SUMMARY") - : false; - if (skippedMilestoneComplete2) { - s.unitConsecutiveSkips.delete(idempotencyKey); - invalidateAllCaches(); - ctx.ui.notify( - `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, - "info", - ); - s.skipDepth++; - await new Promise(r => setTimeout(r, 50)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } - s.unitConsecutiveSkips.delete(idempotencyKey); - s.completedKeySet.delete(idempotencyKey); - removePersistedKey(s.basePath, idempotencyKey); - invalidateAllCaches(); - ctx.ui.notify( - `Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`, - "warning", - ); - if (!s.active) return; - s.skipDepth++; - await new Promise(r => setTimeout(r, 150)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } - // Count toward lifetime cap so hard-stop fires during skip loops (#792) - const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2); - if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) { - await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`); - ctx.ui.notify( - `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle (${lifeSkip2} iterations).`, - "error", - ); - return; - } - ctx.ui.notify( - `Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`, - "info", - ); - if (!s.active) return; - s.skipDepth++; - await new Promise(r => setTimeout(r, 150)); - await dispatchNextUnit(ctx, pi); - s.skipDepth = Math.max(0, s.skipDepth - 1); - return; - } - - // Stuck detection — tracks total dispatches per unit (not just consecutive repeats). - // Pattern A→B→A→B would reset retryCount every time; this map catches it. - const dispatchKey = `${unitType}/${unitId}`; - const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0; - // Real dispatch reached — clear the consecutive-skip counter for this unit. - s.unitConsecutiveSkips.delete(dispatchKey); - - debugLog("dispatch-unit", { - type: unitType, - id: unitId, - cycle: prevCount + 1, - lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1, + // ── Idempotency check (delegated to auto-idempotency.ts) ── + const idempotencyResult = checkIdempotency({ + s, + unitType, + unitId, + basePath: s.basePath, + notify: (msg, level) => ctx.ui.notify(msg, level), }); - debugCount("dispatches"); - // Hard lifetime cap — survives counter resets from loop-recovery/self-repair. - // Catches the case where reconciliation "succeeds" (artifacts exist) but - // deriveState keeps returning the same unit, creating an infinite cycle. - const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1; - s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount); - if (lifetimeCount > MAX_LIFETIME_DISPATCHES) { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath); - await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`); - ctx.ui.notify( - `Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`, - "error", - ); - return; - } - if (prevCount >= MAX_UNIT_DISPATCHES) { - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - // Final reconciliation pass for execute-task: write any missing durable - // artifacts (summary placeholder + [x] checkbox) so the pipeline can - // advance instead of stopping. This is the last resort before halting. - if (unitType === "execute-task") { - const [mid, sid, tid] = unitId.split("/"); - if (mid && sid && tid) { - const status = await inspectExecuteTaskDurability(s.basePath, unitId); - if (status) { - const reconciled = skipExecuteTask(s.basePath, mid, sid, tid, status, "loop-recovery", prevCount); - // reconciled: skipExecuteTask attempted to write missing artifacts. - // verifyExpectedArtifact: confirms physical artifacts (summary + [x]) now exist on disk. - // Both must pass before we clear the dispatch counter and advance. - if (reconciled && verifyExpectedArtifact(unitType, unitId, s.basePath)) { - ctx.ui.notify( - `Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`, - "warning", - ); - // Persist completion so idempotency check prevents re-dispatch - // if deriveState keeps returning this unit (#462). - const reconciledKey = `${unitType}/${unitId}`; - persistCompletedKey(s.basePath, reconciledKey); - s.completedKeySet.add(reconciledKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - await new Promise(r => setImmediate(r)); - await dispatchNextUnit(ctx, pi); - return; - } - } - } - } - - // General reconciliation: if the last attempt DID produce the expected - // artifact on disk, clear the counter and advance instead of stopping. - // The execute-task path above handles its special case (writing placeholder - // summaries). This catch-all covers complete-slice, plan-slice, - // research-slice, and all other unit types where the Nth attempt at the - // dispatch limit succeeded but the counter check fires before anyone - // verifies disk state. Without this, a successful final attempt is - // indistinguishable from a failed one. - if (verifyExpectedArtifact(unitType, unitId, s.basePath)) { - ctx.ui.notify( - `Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`, - "info", - ); - // Persist completion so the idempotency check prevents re-dispatch - // if deriveState keeps returning this unit (see #462). - persistCompletedKey(s.basePath, dispatchKey); - s.completedKeySet.add(dispatchKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - await new Promise(r => setImmediate(r)); + if (idempotencyResult.action === "skip") { + if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") { + if (!s.active) return; + s.skipDepth++; + await new Promise(r => setTimeout(r, idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150)); await dispatchNextUnit(ctx, pi); + s.skipDepth = Math.max(0, s.skipDepth - 1); return; } - - // Last resort for complete-milestone: generate stub summary to unblock pipeline. - // All slices are done (otherwise we wouldn't be in completing-milestone phase), - // but the LLM failed to write the summary N times. A stub lets the pipeline advance. - if (unitType === "complete-milestone") { - try { - const mPath = resolveMilestonePath(s.basePath, unitId); - if (mPath) { - const stubPath = join(mPath, `${unitId}-SUMMARY.md`); - if (!existsSync(stubPath)) { - writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`); - ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning"); - persistCompletedKey(s.basePath, dispatchKey); - s.completedKeySet.add(dispatchKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - await new Promise(r => setImmediate(r)); - await dispatchNextUnit(ctx, pi); - return; - } - } - } catch { /* non-fatal — fall through to normal stop */ } - } - - const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath); - const remediation = buildLoopRemediationSteps(unitType, unitId, s.basePath); - await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`); - sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error"); + } else if (idempotencyResult.action === "stop") { + await stopAuto(ctx, pi, idempotencyResult.reason); ctx.ui.notify( - `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}${remediation ? `\n\n Remediation steps:\n${remediation}` : "\n Check branch state and .gsd/ artifacts."}`, + `Hard loop detected: ${unitType} ${unitId} hit lifetime cap during skip cycle.`, "error", ); return; } - s.unitDispatchCount.set(dispatchKey, prevCount + 1); - if (prevCount > 0) { - // Adaptive self-repair: each retry attempts a different remediation step. - if (unitType === "execute-task") { - const status = await inspectExecuteTaskDurability(s.basePath, unitId); - const [mid, sid, tid] = unitId.split("/"); - if (status && mid && sid && tid) { - if (status.summaryExists && !status.taskChecked) { - // Retry 1+: summary exists but checkbox not marked — mark [x] and advance. - const repaired = skipExecuteTask(s.basePath, mid, sid, tid, status, "self-repair", 0); - // repaired: skipExecuteTask updated metadata (returned early-true even if regex missed). - // verifyExpectedArtifact: confirms the physical artifact (summary + [x]) now exists. - if (repaired && verifyExpectedArtifact(unitType, unitId, s.basePath)) { - ctx.ui.notify( - `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`, - "warning", - ); - // Persist completion so idempotency check prevents re-dispatch (#462). - const repairedKey = `${unitType}/${unitId}`; - persistCompletedKey(s.basePath, repairedKey); - s.completedKeySet.add(repairedKey); - s.unitDispatchCount.delete(dispatchKey); - invalidateAllCaches(); - await new Promise(r => setImmediate(r)); - await dispatchNextUnit(ctx, pi); - return; - } - } else if (prevCount >= STUB_RECOVERY_THRESHOLD && !status.summaryExists) { - // Retry STUB_RECOVERY_THRESHOLD+: summary still missing after multiple attempts. - // Write a minimal stub summary so the next agent session has a recovery artifact - // to overwrite, rather than starting from scratch again. - const tasksDir = resolveTasksDir(s.basePath, mid, sid); - const sDir = resolveSlicePath(s.basePath, mid, sid); - const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null); - if (targetDir) { - if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true }); - const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY")); - if (!existsSync(summaryPath)) { - const stubContent = [ - `# PARTIAL RECOVERY — attempt ${prevCount + 1} of ${MAX_UNIT_DISPATCHES}`, - ``, - `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) has not yet produced a real summary.`, - `This placeholder was written by auto-mode after ${prevCount} dispatch attempts.`, - ``, - `The next agent session will retry this task. Replace this file with real work when done.`, - ].join("\n"); - writeFileSync(summaryPath, stubContent, "utf-8"); - ctx.ui.notify( - `Stub recovery (attempt ${prevCount + 1}/${MAX_UNIT_DISPATCHES}): ${unitId} stub summary placeholder written. Retrying with recovery context.`, - "warning", - ); - } - } - } - } + // "rerun" and "proceed" fall through to stuck detection + + // ── Stuck detection (delegated to auto-stuck-detection.ts) ── + const stuckResult = await checkStuckAndRecover({ + s, + ctx, + unitType, + unitId, + basePath: s.basePath, + buildSnapshotOpts: () => buildSnapshotOpts(unitType, unitId), + }); + + if (stuckResult.action === "stop") { + await stopAuto(ctx, pi, stuckResult.reason); + if (stuckResult.notifyMessage) { + ctx.ui.notify(stuckResult.notifyMessage, "error"); } - ctx.ui.notify( - `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`, - "warning", - ); + return; } + if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) { + await new Promise(r => setImmediate(r)); + await dispatchNextUnit(ctx, pi); + return; + } + // Snapshot metrics + activity log for the PREVIOUS unit before we reassign. - // The session still holds the previous unit's data (newSession hasn't fired yet). if (s.currentUnit) { await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - // Record routing outcome for adaptive learning if (s.currentUnitRouting) { const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId; recordOutcome( s.currentUnit.type, s.currentUnitRouting.tier as "light" | "standard" | "heavy", - !isRetry, // success = not being retried + !isRetry, ); } - // Only mark the previous unit as completed if: - // 1. We're not about to re-dispatch the same unit (retry scenario) - // 2. The expected artifact actually exists on disk - // For hook units, skip artifact verification — hooks don't produce standard - // artifacts and their runtime records were already finalized in handleAgentEnd. const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`; const incomingKey = `${unitType}/${unitId}`; const isHookUnit = s.currentUnit.type.startsWith("hook/"); const artifactVerified = isHookUnit || verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); if (closeoutKey !== incomingKey && artifactVerified) { if (!isHookUnit) { - // Only persist completion keys for real units — hook keys are - // ephemeral and should not pollute the idempotency set. persistCompletedKey(s.basePath, closeoutKey); s.completedKeySet.add(closeoutKey); } @@ -3022,7 +1498,6 @@ async function dispatchNextUnit( startedAt: s.currentUnit.startedAt, finishedAt: Date.now(), }); - // Cap to last 200 entries to prevent unbounded growth (#611) if (s.completedUnits.length > 200) { s.completedUnits = s.completedUnits.slice(-200); } @@ -3032,7 +1507,7 @@ async function dispatchNextUnit( } } s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; - captureAvailableSkills(); // Capture skill telemetry at dispatch time (#599) + captureAvailableSkills(); writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { phase: "dispatched", wrapupWarningSent: false, @@ -3047,8 +1522,6 @@ async function dispatchNextUnit( if (mid) updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); updateProgressWidget(ctx, unitType, unitId, state); - // Ensure preconditions — create directories, branches, etc. - // so the LLM doesn't have to get these right ensurePreconditions(unitType, unitId, s.basePath, state); // Fresh session — with timeout to prevent permanent hangs (#1073). @@ -3077,23 +1550,13 @@ async function dispatchNextUnit( return; } - // Branchless architecture: all work commits sequentially on the milestone - // branch — no per-slice branches or slice-level merges. Milestone merge - // happens when phase === "complete" (see mergeMilestoneToMain above). - - // Write lock AFTER newSession so we capture the session file path. - // Pi appends entries incrementally via appendFileSync, so on crash the - // session file survives with every tool call up to the crash point. const sessionFile = ctx.sessionManager.getSessionFile(); writeLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile); - // On crash recovery, prepend the full recovery briefing - // On retry (stuck detection), prepend deep diagnostic from last attempt - // Cap injected content to prevent unbounded prompt growth → OOM + // Prompt injection const MAX_RECOVERY_CHARS = 50_000; let finalPrompt = prompt; - // Verification retry — inject failure context so the agent can auto-fix if (s.pendingVerificationRetry) { const retryCtx = s.pendingVerificationRetry; s.pendingVerificationRetry = null; @@ -3119,14 +1582,12 @@ async function dispatchNextUnit( } } - // Inject observability repair instructions so the agent fixes gaps before - // proceeding with the unit (see #174). const repairBlock = buildObservabilityRepairBlock(observabilityIssues); if (repairBlock) { finalPrompt = `${finalPrompt}${repairBlock}`; } - // ── Prompt char measurement (R051) ── + // ── Prompt char measurement ── s.lastPromptCharCount = finalPrompt.length; s.lastBaselineCharCount = undefined; if (isDbAvailable()) { @@ -3142,221 +1603,35 @@ async function dispatchNextUnit( (requirementsContent?.length ?? 0) + (projectContent?.length ?? 0); } catch { - // Non-fatal — baseline measurement is best-effort + // Non-fatal } } - // Select and apply model for this unit (dynamic routing, fallback chains, etc.) + // Select and apply model const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel); s.currentUnitRouting = modelResult.routing; - // Start progress-aware supervision: a soft warning, an idle watchdog, and - // a larger hard ceiling. Productive long-running tasks may continue past the - // soft timeout; only idle/stalled tasks pause early. + // ── Start unit supervision (delegated to auto-timers.ts) ── clearUnitTimeout(); - const supervisor = resolveAutoSupervisorConfig(); - const softTimeoutMs = (supervisor.soft_timeout_minutes ?? 0) * 60 * 1000; - const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000; - const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000; + startUnitSupervision({ + s, + ctx, + pi, + unitType, + unitId, + prefs, + buildSnapshotOpts: () => buildSnapshotOpts(unitType, unitId), + buildRecoveryContext: () => buildRecoveryContext(), + pauseAuto, + }); - s.wrapupWarningHandle = setTimeout(() => { - s.wrapupWarningHandle = null; - if (!s.active || !s.currentUnit) return; - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "wrapup-warning-sent", - wrapupWarningSent: true, - }); - pi.sendMessage( - { - customType: "gsd-auto-wrapup", - display: s.verbose, - content: [ - "**TIME BUDGET WARNING — keep going only if progress is real.**", - "This unit crossed the soft time budget.", - "If you are making progress, continue. If not, switch to wrap-up mode now:", - "1. rerun the minimal required verification", - "2. write or update the required durable artifacts", - "3. mark task or slice state on disk correctly", - "4. leave precise resume notes if anything remains unfinished", - ].join("\n"), - }, - { triggerTurn: true }, - ); - }, softTimeoutMs); - - s.idleWatchdogHandle = setInterval(async () => { - try { - if (!s.active || !s.currentUnit) return; - const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); - if (!runtime) return; - if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; - - // Agent has tool calls currently executing (await_job, long bash, etc.) — - // not idle, just waiting for tool completion. But only suppress recovery - // if the tool started recently. A tool in-flight for longer than the idle - // timeout is likely stuck — e.g., `python -m http.server 8080 &` keeps the - // shell's stdout/stderr open, causing the Bash tool to hang indefinitely. - if (getInFlightToolCount() > 0) { - const oldestStart = getOldestInFlightToolStart()!; - const toolAgeMs = Date.now() - oldestStart; - if (toolAgeMs < idleTimeoutMs) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - lastProgressAt: Date.now(), - lastProgressKind: "tool-in-flight", - }); - return; - } - // Oldest tool has been running >= idleTimeoutMs — treat as a stuck/hung - // tool (e.g., background process holding stdout open). Fall through to - // idle recovery without resetting the progress clock. - ctx.ui.notify( - `Stalled tool detected: a tool has been in-flight for ${Math.round(toolAgeMs / 60000)}min. Treating as hung — attempting idle recovery.`, - "warning", - ); - } - - // Before triggering recovery, check if the agent is actually producing - // work on disk. `git status --porcelain` is cheap and catches any - // staged/unstaged/untracked changes the agent made since lastProgressAt. - if (detectWorkingTreeActivity(s.basePath)) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - lastProgressAt: Date.now(), - lastProgressKind: "filesystem-activity", - }); - return; - } - - if (s.currentUnit) { - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext()); - if (recovery === "recovered") return; - - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "paused", - }); - ctx.ui.notify( - `Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - } catch (err) { - // Guard against unhandled rejections in the async interval callback. - // Without this, a thrown error leaves the interval running forever - // while the auto-mode state becomes inconsistent. - const message = err instanceof Error ? err.message : String(err); - console.error(`[idle-watchdog] Unhandled error: ${message}`); - try { - ctx.ui.notify(`Idle watchdog error: ${message}`, "warning"); - } catch { /* best effort */ } - } - }, 15000); - - s.unitTimeoutHandle = setTimeout(async () => { - try { - s.unitTimeoutHandle = null; - if (!s.active) return; - if (s.currentUnit) { - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, { - phase: "timeout", - timeoutAt: Date.now(), - }); - await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id)); - } else { - saveActivityLog(ctx, s.basePath, unitType, unitId); - } - - const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext()); - if (recovery === "recovered") return; - - ctx.ui.notify( - `Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`, - "warning", - ); - await pauseAuto(ctx, pi); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`[hard-timeout] Unhandled error: ${message}`); - try { - ctx.ui.notify(`Hard timeout error: ${message}`, "warning"); - } catch { /* best effort */ } - } - }, hardTimeoutMs); - - // ── Continue-here context-pressure monitor ──────────────────────────── - // Polls context usage every 15s. When usage hits the continue-here - // threshold (70%), sends a one-shot wrap-up signal so the agent finishes - // gracefully and the next unit gets a fresh session. This is softer than - // context_pause_threshold which hard-pauses auto-mode entirely. - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - const executorContextWindow = resolveExecutorContextWindow( - ctx.modelRegistry as Parameters[0], - prefs as Parameters[1], - ctx.model?.contextWindow, - ); - const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent; - s.continueHereHandle = setInterval(() => { - if (!s.active || !s.currentUnit || !s.cmdCtx) return; - // One-shot guard: skip if already fired for this unit - const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId); - if (runtime?.continueHereFired) return; - - const contextUsage = s.cmdCtx.getContextUsage(); - if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return; - - // Fire once — mark runtime record and send wrap-up message - writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, { - continueHereFired: true, - }); - - if (s.verbose) { - ctx.ui.notify( - `Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, - "info", - ); - } - - pi.sendMessage( - { - customType: "gsd-auto-wrapup", - display: s.verbose, - content: [ - "**CONTEXT BUDGET WARNING — wrap up this unit now.**", - `Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`, - "The next unit needs a fresh context to work effectively. Wrap up now:", - "1. Finish any in-progress file writes", - "2. Write or update the required durable artifacts (summary, checkboxes)", - "3. Mark task state on disk correctly", - "4. Leave precise resume notes if anything remains unfinished", - "Do NOT start new sub-tasks or investigations.", - ].join("\n"), - }, - { triggerTurn: true }, - ); - - // Clear the interval after firing — no need to keep polling - if (s.continueHereHandle) { - clearInterval(s.continueHereHandle); - s.continueHereHandle = null; - } - }, 15_000); - - // Inject prompt — verify auto-mode still s.active (guards against race with timeout/pause) + // Inject prompt if (!s.active) return; pi.sendMessage( { customType: "gsd-auto", content: finalPrompt, display: s.verbose }, { triggerTurn: true }, ); - // For non-artifact-driven UAT types, pause auto-mode after sending the prompt. - // The agent will write the UAT result file surfacing it for human review, - // then on resume the result file exists and run-uat is skipped automatically. if (pauseAfterUatDispatch) { ctx.ui.notify( "UAT requires human execution. Auto-mode will pause after this unit writes the result file.", @@ -3381,29 +1656,22 @@ function ensurePreconditions( const parts = unitId.split("/"); const mid = parts[0]!; - // Always ensure milestone dir exists const mDir = resolveMilestonePath(base, mid); if (!mDir) { const newDir = join(milestonesDir(base), mid); mkdirSync(join(newDir, "slices"), { recursive: true }); } - // For slice-level units, ensure slice dir exists if (parts.length >= 2) { const sid = parts[1]!; - // Re-resolve milestone path after potential creation const mDirResolved = resolveMilestonePath(base, mid); if (mDirResolved) { const slicesDir = join(mDirResolved, "slices"); const sDir = resolveDir(slicesDir, sid); if (!sDir) { - // Create slice dir with bare ID (tasks/ included) mkdirSync(join(slicesDir, sid, "tasks"), { recursive: true }); } - // Always ensure tasks/ subdir exists — even when slice dir was already - // present. Handles the case where a slice was created manually or by a - // previous run that didn't create tasks/. (#900) const resolvedSliceDir = resolveDir(slicesDir, sid) ?? sid; const tasksDir = join(slicesDir, resolvedSliceDir, "tasks"); if (!existsSync(tasksDir)) { @@ -3416,10 +1684,6 @@ function ensurePreconditions( // ─── Diagnostics ────────────────────────────────────────────────────────────── -// collectObservabilityWarnings + buildObservabilityRepairBlock → auto-observability.ts - -// recoverTimedOutUnit → auto-timeout-recovery.ts - /** Build recovery context from module state for recoverTimedOutUnit */ function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext { return { basePath: s.basePath, verbose: s.verbose, @@ -3443,7 +1707,6 @@ export { */ export function _getUnitConsecutiveSkips(): Map { return s.unitConsecutiveSkips; } export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); } -// MAX_CONSECUTIVE_SKIPS re-exported from auto/session.ts at top of file /** * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks. @@ -3459,9 +1722,7 @@ export async function dispatchHookUnit( hookModel: string | undefined, targetBasePath: string, ): Promise { - // Ensure auto-mode is s.active if (!s.active) { - // Initialize auto-mode state minimally s.active = true; s.stepMode = true; s.cmdCtx = ctx as ExtensionCommandContext; @@ -3474,21 +1735,17 @@ export async function dispatchHookUnit( const hookUnitType = `hook/${hookName}`; const hookStartedAt = Date.now(); - - // Set up the trigger unit as the "current" unit so post-unit hooks can reference it + s.currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt }; - - // Create a new session for the hook + const result = await s.cmdCtx!.newSession(); if (result.cancelled) { await stopAuto(ctx, pi); return false; } - // Update current unit to the hook unit s.currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt }; - - // Write runtime record + writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, { phase: "dispatched", wrapupWarningSent: false, @@ -3498,7 +1755,6 @@ export async function dispatchHookUnit( lastProgressKind: "dispatch", }); - // Switch model if specified if (hookModel) { const availableModels = ctx.modelRegistry.getAvailable(); const match = availableModels.find(m => @@ -3507,15 +1763,13 @@ export async function dispatchHookUnit( if (match) { try { await pi.setModel(match); - } catch { /* non-fatal — use current model */ } + } catch { /* non-fatal */ } } } - // Write lock const sessionFile = ctx.sessionManager.getSessionFile(); writeLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile); - // Set up timeout clearUnitTimeout(); const supervisor = resolveAutoSupervisorConfig(); const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; @@ -3536,18 +1790,16 @@ export async function dispatchHookUnit( await pauseAuto(ctx, pi); }, hookHardTimeoutMs); - // Update status ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto"); ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info"); - // Send the hook prompt console.log(`[dispatchHookUnit] Sending prompt of length ${hookPrompt.length}`); console.log(`[dispatchHookUnit] Prompt preview: ${hookPrompt.substring(0, 200)}...`); pi.sendMessage( { customType: "gsd-auto", content: hookPrompt, display: true }, { triggerTurn: true }, ); - + return true; }