refactor: split auto-loop.ts monolith into auto/ directory modules (#1682)
Fixes #1684
This commit is contained in:
parent
6277440581
commit
5d14a9cde2
13 changed files with 2068 additions and 1930 deletions
File diff suppressed because it is too large
Load diff
60
src/resources/extensions/gsd/auto/detect-stuck.ts
Normal file
60
src/resources/extensions/gsd/auto/detect-stuck.ts
Normal 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 A→B→A→B in last 4 entries → stuck.
|
||||
*/
|
||||
export function detectStuck(
|
||||
window: readonly WindowEntry[],
|
||||
): { stuck: true; reason: string } | null {
|
||||
if (window.length < 2) return null;
|
||||
|
||||
const last = window[window.length - 1];
|
||||
const prev = window[window.length - 2];
|
||||
|
||||
// Rule 1: Same error repeated consecutively
|
||||
if (last.error && prev.error && last.error === prev.error) {
|
||||
return {
|
||||
stuck: true,
|
||||
reason: `Same error repeated: ${last.error.slice(0, 200)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 2: Same unit 3+ consecutive times
|
||||
if (window.length >= 3) {
|
||||
const lastThree = window.slice(-3);
|
||||
if (lastThree.every((u) => u.key === last.key)) {
|
||||
return {
|
||||
stuck: true,
|
||||
reason: `${last.key} derived 3 consecutive times without progress`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: Oscillation (A→B→A→B in last 4)
|
||||
if (window.length >= 4) {
|
||||
const w = window.slice(-4);
|
||||
if (
|
||||
w[0].key === w[2].key &&
|
||||
w[1].key === w[3].key &&
|
||||
w[0].key !== w[1].key
|
||||
) {
|
||||
return {
|
||||
stuck: true,
|
||||
reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
281
src/resources/extensions/gsd/auto/loop-deps.ts
Normal file
281
src/resources/extensions/gsd/auto/loop-deps.ts
Normal 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;
|
||||
}
|
||||
195
src/resources/extensions/gsd/auto/loop.ts
Normal file
195
src/resources/extensions/gsd/auto/loop.ts
Normal 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 });
|
||||
}
|
||||
1144
src/resources/extensions/gsd/auto/phases.ts
Normal file
1144
src/resources/extensions/gsd/auto/phases.ts
Normal file
File diff suppressed because it is too large
Load diff
88
src/resources/extensions/gsd/auto/resolve.ts
Normal file
88
src/resources/extensions/gsd/auto/resolve.ts
Normal 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
|
||||
}
|
||||
123
src/resources/extensions/gsd/auto/run-unit.ts
Normal file
123
src/resources/extensions/gsd/auto/run-unit.ts
Normal 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;
|
||||
}
|
||||
99
src/resources/extensions/gsd/auto/types.ts
Normal file
99
src/resources/extensions/gsd/auto/types.ts
Normal 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 };
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue