refactor: split auto-loop.ts monolith into auto/ directory modules (#1682)

Fixes #1684
This commit is contained in:
Iouri Goussev 2026-03-21 10:40:38 -04:00 committed by GitHub
parent 6277440581
commit 5d14a9cde2
13 changed files with 2068 additions and 1930 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
/**
* auto/detect-stuck.ts Sliding-window stuck detection for the auto-loop.
*
* Leaf node in the import DAG.
*/
import type { WindowEntry } from "./types.js";
/**
* Analyze a sliding window of recent unit dispatches for stuck patterns.
* Returns a signal with reason if stuck, null otherwise.
*
* Rule 1: Same error string twice in a row stuck immediately.
* Rule 2: Same unit key 3+ consecutive times stuck (preserves prior behavior).
* Rule 3: Oscillation ABAB in last 4 entries stuck.
*/
export function detectStuck(
window: readonly WindowEntry[],
): { stuck: true; reason: string } | null {
if (window.length < 2) return null;
const last = window[window.length - 1];
const prev = window[window.length - 2];
// Rule 1: Same error repeated consecutively
if (last.error && prev.error && last.error === prev.error) {
return {
stuck: true,
reason: `Same error repeated: ${last.error.slice(0, 200)}`,
};
}
// Rule 2: Same unit 3+ consecutive times
if (window.length >= 3) {
const lastThree = window.slice(-3);
if (lastThree.every((u) => u.key === last.key)) {
return {
stuck: true,
reason: `${last.key} derived 3 consecutive times without progress`,
};
}
}
// Rule 3: Oscillation (A→B→A→B in last 4)
if (window.length >= 4) {
const w = window.slice(-4);
if (
w[0].key === w[2].key &&
w[1].key === w[3].key &&
w[0].key !== w[1].key
) {
return {
stuck: true,
reason: `Oscillation detected: ${w[0].key}${w[1].key}`,
};
}
}
return null;
}

View file

@ -0,0 +1,281 @@
/**
* auto/loop-deps.ts LoopDeps interface for dependency injection into autoLoop.
*
* Leaf node in the import DAG (type-only).
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import type { GSDPreferences } from "../preferences.js";
import type { GSDState } from "../types.js";
import type { SessionLockStatus } from "../session-lock.js";
import type { CloseoutOptions } from "../auto-unit-closeout.js";
import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js";
import type {
VerificationContext,
VerificationResult,
} from "../auto-verification.js";
import type { DispatchAction } from "../auto-dispatch.js";
import type { WorktreeResolver } from "../worktree-resolver.js";
import type { CmuxLogLevel } from "../../cmux/index.js";
/**
* Dependencies injected by the caller (auto.ts startAuto) so autoLoop
* can access private functions from auto.ts without exporting them.
*/
export interface LoopDeps {
lockBase: () => string;
buildSnapshotOpts: (
unitType: string,
unitId: string,
) => CloseoutOptions & Record<string, unknown>;
stopAuto: (
ctx?: ExtensionContext,
pi?: ExtensionAPI,
reason?: string,
) => Promise<void>;
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>;
clearUnitTimeout: () => void;
updateProgressWidget: (
ctx: ExtensionContext,
unitType: string,
unitId: string,
state: GSDState,
) => void;
syncCmuxSidebar: (preferences: GSDPreferences | undefined, state: GSDState) => void;
logCmuxEvent: (
preferences: GSDPreferences | undefined,
message: string,
level?: CmuxLogLevel,
) => void;
// State and cache functions
invalidateAllCaches: () => void;
deriveState: (basePath: string) => Promise<GSDState>;
loadEffectiveGSDPreferences: () =>
| { preferences?: GSDPreferences }
| undefined;
// Pre-dispatch health gate
preDispatchHealthGate: (
basePath: string,
) => Promise<{ proceed: boolean; reason?: string; fixesApplied: string[] }>;
// Worktree sync
syncProjectRootToWorktree: (
originalBase: string,
basePath: string,
milestoneId: string | null,
) => void;
// Resource version guard
checkResourcesStale: (version: string | null) => string | null;
// Session lock
validateSessionLock: (basePath: string) => SessionLockStatus;
updateSessionLock: (
basePath: string,
unitType: string,
unitId: string,
completedUnits: number,
sessionFile?: string,
) => void;
handleLostSessionLock: (
ctx?: ExtensionContext,
lockStatus?: SessionLockStatus,
) => void;
// Milestone transition functions
sendDesktopNotification: (
title: string,
body: string,
kind: string,
category: string,
) => void;
setActiveMilestoneId: (basePath: string, mid: string) => void;
pruneQueueOrder: (basePath: string, pendingIds: string[]) => void;
isInAutoWorktree: (basePath: string) => boolean;
shouldUseWorktreeIsolation: () => boolean;
mergeMilestoneToMain: (
basePath: string,
milestoneId: string,
roadmapContent: string,
) => { pushed: boolean };
teardownAutoWorktree: (basePath: string, milestoneId: string) => void;
createAutoWorktree: (basePath: string, milestoneId: string) => string;
captureIntegrationBranch: (
basePath: string,
mid: string,
opts?: { commitDocs?: boolean },
) => void;
getIsolationMode: () => string;
getCurrentBranch: (basePath: string) => string;
autoWorktreeBranch: (milestoneId: string) => string;
resolveMilestoneFile: (
basePath: string,
milestoneId: string,
fileType: string,
) => string | null;
reconcileMergeState: (basePath: string, ctx: ExtensionContext) => boolean;
// Budget/context/secrets
getLedger: () => unknown;
getProjectTotals: (units: unknown) => { cost: number };
formatCost: (cost: number) => string;
getBudgetAlertLevel: (pct: number) => number;
getNewBudgetAlertLevel: (lastLevel: number, pct: number) => number;
getBudgetEnforcementAction: (enforcement: string, pct: number) => string;
getManifestStatus: (
basePath: string,
mid: string | undefined,
projectRoot?: string,
) => Promise<{ pending: unknown[] } | null>;
collectSecretsFromManifest: (
basePath: string,
mid: string | undefined,
ctx: ExtensionContext,
) => Promise<{
applied: unknown[];
skipped: unknown[];
existingSkipped: unknown[];
} | null>;
// Dispatch
resolveDispatch: (dctx: {
basePath: string;
mid: string;
midTitle: string;
state: GSDState;
prefs: GSDPreferences | undefined;
session?: AutoSession;
}) => Promise<DispatchAction>;
runPreDispatchHooks: (
unitType: string,
unitId: string,
prompt: string,
basePath: string,
) => {
firedHooks: string[];
action: string;
prompt?: string;
unitType?: string;
};
getPriorSliceCompletionBlocker: (
basePath: string,
mainBranch: string,
unitType: string,
unitId: string,
) => string | null;
getMainBranch: (basePath: string) => string;
collectObservabilityWarnings: (
ctx: ExtensionContext,
basePath: string,
unitType: string,
unitId: string,
) => Promise<unknown[]>;
buildObservabilityRepairBlock: (issues: unknown[]) => string | null;
// Unit closeout + runtime records
closeoutUnit: (
ctx: ExtensionContext,
basePath: string,
unitType: string,
unitId: string,
startedAt: number,
opts?: CloseoutOptions & Record<string, unknown>,
) => Promise<void>;
verifyExpectedArtifact: (
unitType: string,
unitId: string,
basePath: string,
) => boolean;
clearUnitRuntimeRecord: (
basePath: string,
unitType: string,
unitId: string,
) => void;
writeUnitRuntimeRecord: (
basePath: string,
unitType: string,
unitId: string,
startedAt: number,
record: Record<string, unknown>,
) => void;
recordOutcome: (unitType: string, tier: string, success: boolean) => void;
writeLock: (
lockBase: string,
unitType: string,
unitId: string,
completedCount: number,
sessionFile?: string,
) => void;
captureAvailableSkills: () => void;
ensurePreconditions: (
unitType: string,
unitId: string,
basePath: string,
state: GSDState,
) => void;
updateSliceProgressCache: (
basePath: string,
mid: string,
sliceId?: string,
) => void;
// Model selection + supervision
selectAndApplyModel: (
ctx: ExtensionContext,
pi: ExtensionAPI,
unitType: string,
unitId: string,
basePath: string,
prefs: GSDPreferences | undefined,
verbose: boolean,
startModel: { provider: string; id: string } | null,
retryContext?: { isRetry: boolean; previousTier?: string },
) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>;
startUnitSupervision: (sctx: {
s: AutoSession;
ctx: ExtensionContext;
pi: ExtensionAPI;
unitType: string;
unitId: string;
prefs: GSDPreferences | undefined;
buildSnapshotOpts: () => CloseoutOptions & Record<string, unknown>;
buildRecoveryContext: () => unknown;
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>;
}) => void;
// Prompt helpers
getDeepDiagnostic: (basePath: string) => string | null;
isDbAvailable: () => boolean;
reorderForCaching: (prompt: string) => string;
// Filesystem
existsSync: (path: string) => boolean;
readFileSync: (path: string, encoding: string) => string;
atomicWriteSync: (path: string, content: string) => void;
// Git
GitServiceImpl: new (basePath: string, gitConfig: unknown) => unknown;
// WorktreeResolver
resolver: WorktreeResolver;
// Post-unit processing
postUnitPreVerification: (
pctx: PostUnitContext,
opts?: PreVerificationOpts,
) => Promise<"dispatched" | "continue">;
runPostUnitVerification: (
vctx: VerificationContext,
pauseAuto: (ctx?: ExtensionContext, pi?: ExtensionAPI) => Promise<void>,
) => Promise<VerificationResult>;
postUnitPostVerification: (
pctx: PostUnitContext,
) => Promise<"continue" | "step-wizard" | "stopped">;
// Session manager
getSessionFile: (ctx: ExtensionContext) => string;
}

View file

@ -0,0 +1,195 @@
/**
* auto/loop.ts Main auto-mode execution loop.
*
* Iterates: derive dispatch guards runUnit finalize repeat.
* Exits when s.active becomes false or a terminal condition is reached.
*
* Imports from: auto/types, auto/resolve, auto/phases
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession, SidecarItem } from "./session.js";
import type { LoopDeps } from "./loop-deps.js";
import {
MAX_LOOP_ITERATIONS,
type LoopState,
type IterationContext,
type IterationData,
} from "./types.js";
import { _clearCurrentResolve } from "./resolve.js";
import {
runPreDispatch,
runDispatch,
runGuards,
runUnitPhase,
runFinalize,
} from "./phases.js";
import { debugLog } from "../debug-logger.js";
/**
* Main auto-mode execution loop. Iterates: derive dispatch guards
* runUnit finalize repeat. Exits when s.active becomes false or a
* terminal condition is reached.
*
* This is the linear replacement for the recursive
* dispatchNextUnit handleAgentEnd dispatchNextUnit chain.
*/
export async function autoLoop(
ctx: ExtensionContext,
pi: ExtensionAPI,
s: AutoSession,
deps: LoopDeps,
): Promise<void> {
debugLog("autoLoop", { phase: "enter" });
let iteration = 0;
const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
let consecutiveErrors = 0;
while (s.active) {
iteration++;
debugLog("autoLoop", { phase: "loop-top", iteration });
if (iteration > MAX_LOOP_ITERATIONS) {
debugLog("autoLoop", {
phase: "exit",
reason: "max-iterations",
iteration,
});
await deps.stopAuto(
ctx,
pi,
`Safety: loop exceeded ${MAX_LOOP_ITERATIONS} iterations — possible runaway`,
);
break;
}
if (!s.cmdCtx) {
debugLog("autoLoop", { phase: "exit", reason: "no-cmdCtx" });
break;
}
try {
// ── Blanket try/catch: one bad iteration must not kill the session
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
// ── Check sidecar queue before deriveState ──
let sidecarItem: SidecarItem | undefined;
if (s.sidecarQueue.length > 0) {
sidecarItem = s.sidecarQueue.shift()!;
debugLog("autoLoop", {
phase: "sidecar-dequeue",
kind: sidecarItem.kind,
unitType: sidecarItem.unitType,
unitId: sidecarItem.unitId,
});
}
const sessionLockBase = deps.lockBase();
if (sessionLockBase) {
const lockStatus = deps.validateSessionLock(sessionLockBase);
if (!lockStatus.valid) {
debugLog("autoLoop", {
phase: "session-lock-invalid",
reason: lockStatus.failureReason ?? "unknown",
existingPid: lockStatus.existingPid,
expectedPid: lockStatus.expectedPid,
});
deps.handleLostSessionLock(ctx, lockStatus);
debugLog("autoLoop", {
phase: "exit",
reason: "session-lock-lost",
detail: lockStatus.failureReason ?? "unknown",
});
break;
}
}
const ic: IterationContext = { ctx, pi, s, deps, prefs, iteration };
let iterData: IterationData;
if (!sidecarItem) {
// ── Phase 1: Pre-dispatch ─────────────────────────────────────────
const preDispatchResult = await runPreDispatch(ic, loopState);
if (preDispatchResult.action === "break") break;
if (preDispatchResult.action === "continue") continue;
const preData = preDispatchResult.data;
// ── Phase 2: Guards ───────────────────────────────────────────────
const guardsResult = await runGuards(ic, preData.mid);
if (guardsResult.action === "break") break;
// ── Phase 3: Dispatch ─────────────────────────────────────────────
const dispatchResult = await runDispatch(ic, preData, loopState);
if (dispatchResult.action === "break") break;
if (dispatchResult.action === "continue") continue;
iterData = dispatchResult.data;
} else {
// ── Sidecar path: use values from the sidecar item directly ──
const sidecarState = await deps.deriveState(s.basePath);
iterData = {
unitType: sidecarItem.unitType,
unitId: sidecarItem.unitId,
prompt: sidecarItem.prompt,
finalPrompt: sidecarItem.prompt,
pauseAfterUatDispatch: false,
observabilityIssues: [],
state: sidecarState,
mid: sidecarState.activeMilestone?.id,
midTitle: sidecarState.activeMilestone?.title,
isRetry: false, previousTier: undefined,
};
}
const unitPhaseResult = await runUnitPhase(ic, iterData, loopState, sidecarItem);
if (unitPhaseResult.action === "break") break;
// ── Phase 5: Finalize ───────────────────────────────────────────────
const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
if (finalizeResult.action === "break") break;
if (finalizeResult.action === "continue") continue;
consecutiveErrors = 0; // Iteration completed successfully
debugLog("autoLoop", { phase: "iteration-complete", iteration });
} catch (loopErr) {
// ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ──
consecutiveErrors++;
const msg = loopErr instanceof Error ? loopErr.message : String(loopErr);
debugLog("autoLoop", {
phase: "iteration-error",
iteration,
consecutiveErrors,
error: msg,
});
if (consecutiveErrors >= 3) {
// 3+ consecutive: hard stop — something is fundamentally broken
ctx.ui.notify(
`Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`,
"error",
);
await deps.stopAuto(
ctx,
pi,
`${consecutiveErrors} consecutive iteration failures`,
);
break;
} else if (consecutiveErrors === 2) {
// 2nd consecutive: try invalidating caches + re-deriving state
ctx.ui.notify(
`Iteration error (attempt ${consecutiveErrors}): ${msg}. Invalidating caches and retrying.`,
"warning",
);
deps.invalidateAllCaches();
} else {
// 1st error: log and retry — transient failures happen
ctx.ui.notify(`Iteration error: ${msg}. Retrying.`, "warning");
}
}
}
_clearCurrentResolve();
debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
/**
* auto/resolve.ts Per-unit one-shot promise state and resolution.
*
* Module-level mutable state: `_currentResolve` and `_sessionSwitchInFlight`.
* Setter functions are exported because ES modules can't mutate `let` vars
* across module boundaries.
*
* Imports from: auto/types
*/
import type { UnitResult, AgentEndEvent } from "./types.js";
import type { AutoSession } from "./session.js";
import { debugLog } from "../debug-logger.js";
// ─── Per-unit one-shot promise state ────────────────────────────────────────
//
// A single module-level resolve function scoped to the current unit execution.
// No queue — if an agent_end arrives with no pending resolver, it is dropped
// (logged as warning). This is simpler and safer than the previous session-
// scoped pendingResolve + pendingAgentEndQueue pattern.
let _currentResolve: ((result: UnitResult) => void) | null = null;
let _sessionSwitchInFlight = false;
// ─── Setters (needed for cross-module mutation) ─────────────────────────────
export function _setCurrentResolve(fn: ((result: UnitResult) => void) | null): void {
_currentResolve = fn;
}
export function _setSessionSwitchInFlight(v: boolean): void {
_sessionSwitchInFlight = v;
}
export function _clearCurrentResolve(): void {
_currentResolve = null;
}
// ─── resolveAgentEnd ─────────────────────────────────────────────────────────
/**
* Called from the agent_end event handler in index.ts to resolve the
* in-flight unit promise. One-shot: the resolver is nulled before calling
* to prevent double-resolution from model fallback retries.
*
* If no resolver exists (event arrived between loop iterations or during
* session switch), the event is dropped with a debug warning.
*/
export function resolveAgentEnd(event: AgentEndEvent): void {
if (_sessionSwitchInFlight) {
debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
return;
}
if (_currentResolve) {
debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
const r = _currentResolve;
_currentResolve = null;
r({ status: "completed", event });
} else {
debugLog("resolveAgentEnd", {
status: "no-pending-resolve",
warning: "agent_end with no pending unit",
});
}
}
export function isSessionSwitchInFlight(): boolean {
return _sessionSwitchInFlight;
}
// ─── resetPendingResolve (test helper) ───────────────────────────────────────
/**
* Reset module-level promise state. Only exported for test cleanup
* production code should never call this.
*/
export function _resetPendingResolve(): void {
_currentResolve = null;
_sessionSwitchInFlight = false;
}
/**
* No-op for backward compatibility with tests that previously set the
* active session. The module no longer holds a session reference.
*/
export function _setActiveSession(_session: AutoSession | null): void {
// No-op — kept for test backward compatibility
}

View file

@ -0,0 +1,123 @@
/**
* auto/run-unit.ts Single unit execution: session create prompt await agent_end.
*
* Imports from: auto/types, auto/resolve
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
import type { UnitResult } from "./types.js";
import { _setCurrentResolve, _setSessionSwitchInFlight } from "./resolve.js";
import { debugLog } from "../debug-logger.js";
/**
* Execute a single unit: create a new session, send the prompt, and await
* the agent_end promise. Returns a UnitResult describing what happened.
*
* The promise is one-shot: resolveAgentEnd() is the only way to resolve it.
* On session creation failure or timeout, returns { status: 'cancelled' }
* without awaiting the promise.
*/
export async function runUnit(
ctx: ExtensionContext,
pi: ExtensionAPI,
s: AutoSession,
unitType: string,
unitId: string,
prompt: string,
): Promise<UnitResult> {
debugLog("runUnit", { phase: "start", unitType, unitId });
// ── Session creation with timeout ──
debugLog("runUnit", { phase: "session-create", unitType, unitId });
let sessionResult: { cancelled: boolean };
let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
_setSessionSwitchInFlight(true);
try {
const sessionPromise = s.cmdCtx!.newSession().finally(() => {
_setSessionSwitchInFlight(false);
});
const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
sessionTimeoutHandle = setTimeout(
() => resolve({ cancelled: true }),
NEW_SESSION_TIMEOUT_MS,
);
});
sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
} catch (sessionErr) {
if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
const msg =
sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
debugLog("runUnit", {
phase: "session-error",
unitType,
unitId,
error: msg,
});
return { status: "cancelled" };
}
if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
if (sessionResult.cancelled) {
debugLog("runUnit-session-timeout", { unitType, unitId });
return { status: "cancelled" };
}
if (!s.active) {
return { status: "cancelled" };
}
// ── Create the agent_end promise (per-unit one-shot) ──
// This happens after newSession completes so session-switch agent_end events
// from the previous session cannot resolve the new unit.
_setSessionSwitchInFlight(false);
const unitPromise = new Promise<UnitResult>((resolve) => {
_setCurrentResolve(resolve);
});
// Ensure cwd matches basePath before dispatch (#1389).
// async_bash and background jobs can drift cwd away from the worktree.
// Realigning here prevents commits from landing on the wrong branch.
try {
if (process.cwd() !== s.basePath) {
process.chdir(s.basePath);
}
} catch { /* non-fatal — chdir may fail if dir was removed */ }
// ── Send the prompt ──
debugLog("runUnit", { phase: "send-message", unitType, unitId });
pi.sendMessage(
{ customType: "gsd-auto", content: prompt, display: s.verbose },
{ triggerTurn: true },
);
// ── Await agent_end ──
debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId });
const result = await unitPromise;
debugLog("runUnit", {
phase: "agent-end-received",
unitType,
unitId,
status: result.status,
});
// Discard trailing follow-up messages (e.g. async_job_result notifications)
// from the completed unit. Without this, queued follow-ups trigger wasteful
// LLM turns before the next session can start (#1642).
// clearQueue() lives on AgentSession but isn't part of the typed
// ExtensionCommandContext interface — call it via runtime check.
try {
const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
if (typeof cmdCtxAny?.clearQueue === "function") {
(cmdCtxAny.clearQueue as () => unknown)();
}
} catch {
// Non-fatal — clearQueue may not be available in all contexts
}
return result;
}

View file

@ -0,0 +1,99 @@
/**
* auto/types.ts Constants and types shared across auto-loop modules.
*
* Leaf node in the import DAG no imports from auto/.
*/
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import type { AutoSession } from "./session.js";
import type { GSDPreferences } from "../preferences.js";
import type { GSDState } from "../types.js";
import type { CmuxLogLevel } from "../../cmux/index.js";
import type { LoopDeps } from "./loop-deps.js";
/**
* Maximum total loop iterations before forced stop. Prevents runaway loops
* when units alternate IDs (bypassing the same-unit stuck detector).
* A milestone with 20 slices × 5 tasks × 3 phases 300 units. 500 gives
* generous headroom including retries and sidecar work.
*/
export const MAX_LOOP_ITERATIONS = 500;
/** Maximum characters of failure/crash context included in recovery prompts. */
export const MAX_RECOVERY_CHARS = 50_000;
/** Data-driven budget threshold notifications (descending). The 100% entry
* triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
* a simple notification. */
export const BUDGET_THRESHOLDS: Array<{
pct: number;
label: string;
notifyLevel: "info" | "warning" | "error";
cmuxLevel: "progress" | "warning" | "error";
}> = [
{ pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
{ pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
{ pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
{ pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
];
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* Minimal shape of the event parameter from pi.on("agent_end", ...).
* The full event has more fields, but the loop only needs messages.
*/
export interface AgentEndEvent {
messages: unknown[];
}
/**
* Result of a single unit execution (one iteration of the loop).
*/
export interface UnitResult {
status: "completed" | "cancelled" | "error";
event?: AgentEndEvent;
}
// ─── Phase pipeline types ────────────────────────────────────────────────────
export type PhaseResult<T = void> =
| { action: "continue" }
| { action: "break"; reason: string }
| { action: "next"; data: T }
export interface IterationContext {
ctx: ExtensionContext;
pi: ExtensionAPI;
s: AutoSession;
deps: LoopDeps;
prefs: GSDPreferences | undefined;
iteration: number;
}
export interface LoopState {
recentUnits: Array<{ key: string; error?: string }>;
stuckRecoveryAttempts: number;
}
export interface PreDispatchData {
state: GSDState;
mid: string;
midTitle: string;
}
export interface IterationData {
unitType: string;
unitId: string;
prompt: string;
finalPrompt: string;
pauseAfterUatDispatch: boolean;
observabilityIssues: unknown[];
state: GSDState;
mid: string | undefined;
midTitle: string | undefined;
isRetry: boolean;
previousTier: string | undefined;
}
export type WindowEntry = { key: string; error?: string };

View file

@ -14,30 +14,30 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const AUTO_TS_PATH = join(__dirname, "..", "auto.ts");
const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts");
const AUTO_RESOLVE_TS_PATH = join(__dirname, "..", "auto", "resolve.ts");
const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts");
function getAutoTsSource(): string {
return readFileSync(AUTO_TS_PATH, "utf-8");
}
function getAutoLoopTsSource(): string {
return readFileSync(AUTO_LOOP_TS_PATH, "utf-8");
function getAutoResolveTsSource(): string {
return readFileSync(AUTO_RESOLVE_TS_PATH, "utf-8");
}
function getSessionTsSource(): string {
return readFileSync(SESSION_TS_PATH, "utf-8");
}
test("auto-loop.ts declares _currentResolve for per-unit one-shot promises", () => {
const source = getAutoLoopTsSource();
test("auto/resolve.ts declares _currentResolve for per-unit one-shot promises", () => {
const source = getAutoResolveTsSource();
assert.ok(
source.includes("_currentResolve"),
"auto-loop.ts must declare _currentResolve for the per-unit resolve function",
"auto/resolve.ts must declare _currentResolve for the per-unit resolve function",
);
assert.ok(
source.includes("_sessionSwitchInFlight"),
"auto-loop.ts must declare _sessionSwitchInFlight guard",
"auto/resolve.ts must declare _sessionSwitchInFlight guard",
);
});

View file

@ -78,7 +78,7 @@ function createMilestoneArtifacts(dir: string, mid: string): void {
// ─── Source-level: verify the merge code exists in the "all complete" path ────
test("auto-loop 'all milestones complete' path merges before stopping (#962)", () => {
const loopSrc = readFileSync(join(__dirname, "..", "auto-loop.ts"), "utf-8");
const loopSrc = readFileSync(join(__dirname, "..", "auto", "phases.ts"), "utf-8");
const resolverSrc = readFileSync(
join(__dirname, "..", "worktree-resolver.ts"),
"utf-8",
@ -88,7 +88,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", (
const incompleteIdx = loopSrc.indexOf("incomplete.length === 0");
assert.ok(
incompleteIdx > -1,
"auto-loop.ts should have 'incomplete.length === 0' check",
"auto/phases.ts should have 'incomplete.length === 0' check",
);
// The merge call must appear BETWEEN the incomplete check and the stopAuto call.
@ -99,7 +99,7 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", (
assert.ok(
blockAfterIncomplete.includes("deps.resolver.mergeAndExit"),
"auto-loop.ts should call resolver.mergeAndExit in the 'all milestones complete' path",
"auto/phases.ts should call resolver.mergeAndExit in the 'all milestones complete' path",
);
// The merge should come before stopAuto in this block

View file

@ -247,20 +247,20 @@ test("auto-loop.ts exports autoLoop, runUnit, resolveAgentEnd", async () => {
);
});
test("auto-loop.ts contains a while keyword", () => {
test("auto/loop.ts contains a while keyword", () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "loop.ts"),
"utf-8",
);
assert.ok(
src.includes("while"),
"auto-loop.ts should contain a while keyword (loop or placeholder)",
"auto/loop.ts should contain a while keyword (loop or placeholder)",
);
});
test("auto-loop.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => {
test("auto/resolve.ts one-shot pattern: _currentResolve is nulled before calling resolver", () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "resolve.ts"),
"utf-8",
);
// The one-shot pattern requires: save ref, null the variable, then call
@ -893,18 +893,18 @@ test("autoLoop exits when no active milestone found", async (t) => {
test("autoLoop exports LoopDeps type", async () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "loop-deps.ts"),
"utf-8",
);
assert.ok(
src.includes("export interface LoopDeps"),
"auto-loop.ts should export LoopDeps interface",
"auto/loop-deps.ts should export LoopDeps interface",
);
});
test("autoLoop signature accepts deps parameter", async () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "loop.ts"),
"utf-8",
);
assert.ok(
@ -915,7 +915,7 @@ test("autoLoop signature accepts deps parameter", async () => {
test("autoLoop contains while (s.active) loop", () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "loop.ts"),
"utf-8",
);
assert.ok(
@ -926,22 +926,47 @@ test("autoLoop contains while (s.active) loop", () => {
// ── T03: End-to-end wiring structural assertions ─────────────────────────────
test("auto-loop.ts exports autoLoop, runUnit, and resolveAgentEnd", () => {
const src = readFileSync(
test("auto-loop.ts barrel re-exports autoLoop, runUnit, and resolveAgentEnd", () => {
const barrel = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
"utf-8",
);
assert.ok(
src.includes("export async function autoLoop"),
"must export autoLoop",
barrel.includes("autoLoop"),
"barrel must re-export autoLoop",
);
assert.ok(
src.includes("export async function runUnit"),
"must export runUnit",
barrel.includes("runUnit"),
"barrel must re-export runUnit",
);
assert.ok(
src.includes("export function resolveAgentEnd"),
"must export resolveAgentEnd",
barrel.includes("resolveAgentEnd"),
"barrel must re-export resolveAgentEnd",
);
// Verify the actual function declarations exist in the submodules
const loopSrc = readFileSync(
resolve(import.meta.dirname, "..", "auto", "loop.ts"),
"utf-8",
);
assert.ok(
loopSrc.includes("export async function autoLoop"),
"auto/loop.ts must define autoLoop",
);
const runUnitSrc = readFileSync(
resolve(import.meta.dirname, "..", "auto", "run-unit.ts"),
"utf-8",
);
assert.ok(
runUnitSrc.includes("export async function runUnit"),
"auto/run-unit.ts must define runUnit",
);
const resolveSrc = readFileSync(
resolve(import.meta.dirname, "..", "auto", "resolve.ts"),
"utf-8",
);
assert.ok(
resolveSrc.includes("export function resolveAgentEnd"),
"auto/resolve.ts must define resolveAgentEnd",
);
});
@ -1341,23 +1366,23 @@ test("detectStuck: truncates long error strings", () => {
});
test("stuck detection: logs debug output with stuck-detected phase", () => {
// Structural test: verify the auto-loop.ts source contains
// Structural test: verify auto/phases.ts contains
// stuck-detected and stuck-counter-reset debug log phases, plus detectStuck
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto-loop.ts"),
resolve(import.meta.dirname, "..", "auto", "phases.ts"),
"utf-8",
);
assert.ok(
src.includes('"stuck-detected"'),
"auto-loop.ts must log phase: 'stuck-detected' when stuck detection fires",
"auto/phases.ts must log phase: 'stuck-detected' when stuck detection fires",
);
assert.ok(
src.includes('"stuck-counter-reset"'),
"auto-loop.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit",
"auto/phases.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit",
);
assert.ok(
src.includes("detectStuck"),
"auto-loop.ts must use detectStuck for sliding window analysis",
"auto/phases.ts must use detectStuck for sliding window analysis",
);
});

View file

@ -122,23 +122,23 @@ test("worktree swap on milestone transition: merge old, create new", () => {
// ─── Verify the transition code path exists in auto.ts ──────────────────────
test("auto-loop.ts milestone transition block contains worktree lifecycle", () => {
const autoSrc = readFileSync(
join(__dirname, "..", "auto-loop.ts"),
test("auto/phases.ts milestone transition block contains worktree lifecycle", () => {
const phasesSrc = readFileSync(
join(__dirname, "..", "auto", "phases.ts"),
"utf-8",
);
// The resolver handles worktree merge + enter inside the milestone transition block
assert.ok(
autoSrc.includes("Worktree lifecycle on milestone transition"),
"auto-loop.ts should contain the worktree lifecycle comment marker",
phasesSrc.includes("Worktree lifecycle on milestone transition"),
"auto/phases.ts should contain the worktree lifecycle comment marker",
);
assert.ok(
autoSrc.includes("resolver.mergeAndExit") && autoSrc.includes("mid !== s.currentMilestoneId"),
"auto-loop.ts should call resolver.mergeAndExit during milestone transition",
phasesSrc.includes("resolver.mergeAndExit") && phasesSrc.includes("mid !== s.currentMilestoneId"),
"auto/phases.ts should call resolver.mergeAndExit during milestone transition",
);
assert.ok(
autoSrc.includes("resolver.enterMilestone"),
"auto-loop.ts should call resolver.enterMilestone for incoming milestone",
phasesSrc.includes("resolver.enterMilestone"),
"auto/phases.ts should call resolver.enterMilestone for incoming milestone",
);
});

View file

@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SESSION_TS_PATH = join(__dirname, "..", "auto", "session.ts");
const POST_UNIT_TS_PATH = join(__dirname, "..", "auto-post-unit.ts");
const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto-loop.ts");
const AUTO_LOOP_TS_PATH = join(__dirname, "..", "auto", "loop.ts");
function getSessionTsSource(): string {
return readFileSync(SESSION_TS_PATH, "utf-8");