refactor: encapsulate auto.ts state into AutoSession class (#898) (#948)

* refactor: encapsulate auto.ts state into AutoSession class (#898)

Follow-up to PR #906 (7 module extractions). All ~40 mutable module-level
variables in auto.ts are replaced with properties on a single AutoSession
class instance (s).

Changes:
- auto/session.ts: 200-line AutoSession class with typed properties,
  clearTimers(), resetDispatchCounters(), completeCurrentUnit(), reset(),
  and toJSON() for diagnostics.
- auto.ts: ~700 variable references renamed from bare names to s.xxx.
  All module-level let/const state declarations removed. Constants
  (MAX_UNIT_DISPATCHES, etc.) re-exported from session.ts.
- Tests updated: milestone-transition-worktree.test.ts and
  triage-dispatch.test.ts source-grep patterns updated for s.xxx names.

Benefits:
- 40 scattered declarations → 1 class with typed properties
- Manual reset of 25+ variables in stopAuto → s.reset()
- s.toJSON() for state snapshots and diagnostics
- grep 's.' shows every state access

No behavioral changes. 1224 tests pass.

* fix: import constants locally for tsconfig.extensions.json compatibility

The extensions tsconfig couldn't resolve re-exported constants from
auto/session.js. Fix: import them explicitly in addition to re-exporting.
Also remove leftover DISPATCH_GAP_TIMEOUT_MS local declaration.
This commit is contained in:
Tom Boucher 2026-03-17 16:59:42 -04:00 committed by GitHub
parent 1e979ff626
commit 1b1df58749
4 changed files with 857 additions and 690 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
/**
* AutoSession encapsulates all mutable auto-mode state into a single instance.
*
* Replaces ~40 module-level variables scattered across auto.ts with typed
* properties on a class instance. Benefits:
*
* - reset() clears everything in one call (was 25+ manual resets in stopAuto)
* - toJSON() provides diagnostic snapshots
* - grep `s.` shows every state access
* - Constructable for testing
*/
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import type { GitServiceImpl } from "../git-service.js";
import type { CaptureEntry } from "../captures.js";
import type { BudgetAlertLevel } from "../auto-budget.js";
// ─── Exported Types ──────────────────────────────────────────────────────────
export interface CompletedUnit {
type: string;
id: string;
startedAt: number;
finishedAt: number;
}
export interface CurrentUnit {
type: string;
id: string;
startedAt: number;
}
export interface UnitRouting {
tier: string;
modelDowngraded: boolean;
}
export interface StartModel {
provider: string;
id: string;
}
export interface PendingVerificationRetry {
unitId: string;
failureContext: string;
attempt: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
export const MAX_UNIT_DISPATCHES = 3;
export const STUB_RECOVERY_THRESHOLD = 2;
export const MAX_LIFETIME_DISPATCHES = 6;
export const MAX_CONSECUTIVE_SKIPS = 3;
export const DISPATCH_GAP_TIMEOUT_MS = 5_000;
export const MAX_SKIP_DEPTH = 20;
// ─── AutoSession ─────────────────────────────────────────────────────────────
export class AutoSession {
// ── Lifecycle ────────────────────────────────────────────────────────────
active = false;
paused = false;
stepMode = false;
verbose = false;
cmdCtx: ExtensionCommandContext | null = null;
// ── Paths ────────────────────────────────────────────────────────────────
basePath = "";
originalBasePath = "";
gitService: GitServiceImpl | null = null;
// ── Dispatch counters ────────────────────────────────────────────────────
readonly unitDispatchCount = new Map<string, number>();
readonly unitLifetimeDispatches = new Map<string, number>();
readonly unitRecoveryCount = new Map<string, number>();
readonly unitConsecutiveSkips = new Map<string, number>();
readonly completedKeySet = new Set<string>();
// ── Timers ───────────────────────────────────────────────────────────────
unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
continueHereHandle: ReturnType<typeof setInterval> | null = null;
dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
// ── Current unit ─────────────────────────────────────────────────────────
currentUnit: CurrentUnit | null = null;
currentUnitRouting: UnitRouting | null = null;
completedUnits: CompletedUnit[] = [];
currentMilestoneId: string | null = null;
// ── Model state ──────────────────────────────────────────────────────────
autoModeStartModel: StartModel | null = null;
originalModelId: string | null = null;
originalModelProvider: string | null = null;
lastBudgetAlertLevel: BudgetAlertLevel = 0;
// ── Recovery ─────────────────────────────────────────────────────────────
pendingCrashRecovery: string | null = null;
pendingVerificationRetry: PendingVerificationRetry | null = null;
readonly verificationRetryCount = new Map<string, number>();
pausedSessionFile: string | null = null;
resourceVersionOnStart: string | null = null;
lastStateRebuildAt = 0;
// ── Guards ───────────────────────────────────────────────────────────────
handlingAgentEnd = false;
dispatching = false;
skipDepth = 0;
readonly recentlyEvictedKeys = new Set<string>();
// ── Metrics ──────────────────────────────────────────────────────────────
autoStartTime = 0;
lastPromptCharCount: number | undefined;
lastBaselineCharCount: number | undefined;
pendingQuickTasks: CaptureEntry[] = [];
// ── Signal handler ───────────────────────────────────────────────────────
sigtermHandler: (() => void) | null = null;
// ── Methods ──────────────────────────────────────────────────────────────
clearTimers(): void {
if (this.unitTimeoutHandle) { clearTimeout(this.unitTimeoutHandle); this.unitTimeoutHandle = null; }
if (this.wrapupWarningHandle) { clearTimeout(this.wrapupWarningHandle); this.wrapupWarningHandle = null; }
if (this.idleWatchdogHandle) { clearInterval(this.idleWatchdogHandle); this.idleWatchdogHandle = null; }
if (this.continueHereHandle) { clearInterval(this.continueHereHandle); this.continueHereHandle = null; }
if (this.dispatchGapHandle) { clearTimeout(this.dispatchGapHandle); this.dispatchGapHandle = null; }
}
resetDispatchCounters(): void {
this.unitDispatchCount.clear();
this.unitLifetimeDispatches.clear();
this.unitConsecutiveSkips.clear();
}
get lockBasePath(): string {
return this.originalBasePath || this.basePath;
}
completeCurrentUnit(): CompletedUnit | null {
if (!this.currentUnit) return null;
const done: CompletedUnit = { ...this.currentUnit, finishedAt: Date.now() };
this.completedUnits.push(done);
this.currentUnit = null;
return done;
}
reset(): void {
this.clearTimers();
// Lifecycle
this.active = false;
this.paused = false;
this.stepMode = false;
this.verbose = false;
this.cmdCtx = null;
// Paths
this.basePath = "";
this.originalBasePath = "";
this.gitService = null;
// Dispatch
this.unitDispatchCount.clear();
this.unitLifetimeDispatches.clear();
this.unitRecoveryCount.clear();
this.unitConsecutiveSkips.clear();
// Note: completedKeySet is intentionally NOT cleared — it persists
// across restarts to prevent re-dispatching completed units.
// Unit
this.currentUnit = null;
this.currentUnitRouting = null;
this.completedUnits = [];
this.currentMilestoneId = null;
// Model
this.autoModeStartModel = null;
this.originalModelId = null;
this.originalModelProvider = null;
this.lastBudgetAlertLevel = 0;
// Recovery
this.pendingCrashRecovery = null;
this.pendingVerificationRetry = null;
this.verificationRetryCount.clear();
this.pausedSessionFile = null;
this.resourceVersionOnStart = null;
this.lastStateRebuildAt = 0;
// Guards
this.handlingAgentEnd = false;
this.dispatching = false;
this.skipDepth = 0;
this.recentlyEvictedKeys.clear();
// Metrics
this.autoStartTime = 0;
this.lastPromptCharCount = undefined;
this.lastBaselineCharCount = undefined;
this.pendingQuickTasks = [];
// Signal handler
this.sigtermHandler = null;
}
toJSON(): Record<string, unknown> {
return {
active: this.active,
paused: this.paused,
stepMode: this.stepMode,
basePath: this.basePath,
currentMilestoneId: this.currentMilestoneId,
currentUnit: this.currentUnit,
completedUnits: this.completedUnits.length,
completedKeySet: this.completedKeySet.size,
unitDispatchCount: Object.fromEntries(this.unitDispatchCount),
dispatching: this.dispatching,
skipDepth: this.skipDepth,
};
}
}

View file

@ -134,7 +134,7 @@ test("auto.ts milestone transition block contains worktree lifecycle", () => {
"auto.ts should contain the worktree lifecycle comment marker",
);
assert.ok(
autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== currentMilestoneId"),
autoSrc.includes("mergeMilestoneToMain") && autoSrc.includes("mid !== s.currentMilestoneId"),
"auto.ts should call mergeMilestoneToMain during milestone transition",
);
assert.ok(

View file

@ -66,7 +66,7 @@ test("dispatch: triage check guards against step mode", () => {
autoSrc.indexOf("In step mode, pause and show a wizard"),
);
assert.ok(
triageBlock.includes("!stepMode"),
triageBlock.includes("!s.stepMode"),
"triage block should guard against step mode",
);
});
@ -77,7 +77,7 @@ test("dispatch: triage check guards against hook unit types", () => {
autoSrc.indexOf("In step mode, pause and show a wizard"),
);
assert.ok(
triageBlock.includes('!currentUnit.type.startsWith("hook/")'),
triageBlock.includes('!s.currentUnit.type.startsWith("hook/")'),
"triage block should not fire for hook units",
);
});
@ -88,7 +88,7 @@ test("dispatch: triage check guards against triage-on-triage", () => {
autoSrc.indexOf("In step mode, pause and show a wizard"),
);
assert.ok(
triageBlock.includes('currentUnit.type !== "triage-captures"'),
triageBlock.includes('s.currentUnit.type !== "triage-captures"'),
"triage block should not fire for triage units",
);
});
@ -99,7 +99,7 @@ test("dispatch: triage check guards against quick-task triggering triage", () =>
autoSrc.indexOf("In step mode, pause and show a wizard"),
);
assert.ok(
triageBlock.includes('currentUnit.type !== "quick-task"'),
triageBlock.includes('s.currentUnit.type !== "quick-task"'),
"triage block should not fire for quick-task units",
);
});
@ -231,7 +231,7 @@ test("dispatch: post-triage resolution executor fires after triage-captures unit
autoSrc.indexOf("Path A fix: verify artifact"),
);
assert.ok(
triageCompletionBlock.includes('currentUnit.type === "triage-captures"'),
triageCompletionBlock.includes('s.currentUnit.type === "triage-captures"'),
"should check for triage-captures unit completion",
);
assert.ok(
@ -268,8 +268,8 @@ test("dispatch: post-triage executor queues quick-tasks", () => {
autoSrc.indexOf("Path A fix: verify artifact"),
);
assert.ok(
triageCompletionBlock.includes("pendingQuickTasks"),
"should push quick-tasks to pendingQuickTasks queue",
triageCompletionBlock.includes("s.pendingQuickTasks"),
"should push quick-tasks to s.pendingQuickTasks queue",
);
});
@ -336,9 +336,9 @@ test("dispatch: quick-task excluded from post-unit hook triggering", () => {
// ─── pendingQuickTasks queue lifecycle ────────────────────────────────────────
test("dispatch: pendingQuickTasks queue is reset on auto-mode start/stop", () => {
const resetMatches = autoSrc.match(/pendingQuickTasks = \[\]/g);
const resetMatches = autoSrc.match(/s\.pendingQuickTasks = \[\]/g);
assert.ok(
resetMatches && resetMatches.length >= 3,
"pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)",
"s.pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)",
);
});