feat: add VCS workflows and cycle extension
This commit is contained in:
+430
-60
@@ -5,6 +5,7 @@
|
||||
* review — generates a review bundle for the Codex host
|
||||
* retry-review — resumes a cleaned scope-gate review
|
||||
* decide — receives Codex HostDecisionV1, drives state transitions
|
||||
* extend — extends a budget_exhausted run with additional cycles
|
||||
* status — prints current run state
|
||||
* abort — terminates a run with a reason
|
||||
*
|
||||
@@ -19,7 +20,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan } from "./workflow-config.js";
|
||||
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan, WorkflowConfigError } from "./workflow-config.js";
|
||||
import { checkConvergence, decisionSignatures } from "./workflow-convergence.js";
|
||||
import type { FindingSignature } from "./workflow-convergence.js";
|
||||
import { createExecutorAdapter, probeExecutor } from "./workflow-executor.js";
|
||||
@@ -27,17 +28,12 @@ import {
|
||||
WorkflowLockError,
|
||||
acquireLock,
|
||||
appendEvent,
|
||||
checkFilesInScope,
|
||||
cycleDiffFile,
|
||||
cycleDecisionFile,
|
||||
cycleExecLogFile,
|
||||
cycleHostReviewFile,
|
||||
findRunDir,
|
||||
generateRunId,
|
||||
gitDiffFromHead,
|
||||
gitHead,
|
||||
gitIsClean,
|
||||
gitRepoRoot,
|
||||
initWorkflowState,
|
||||
nowIso,
|
||||
readState,
|
||||
@@ -46,12 +42,20 @@ import {
|
||||
runDir as makeRunDir,
|
||||
writeState,
|
||||
} from "./workflow-state.js";
|
||||
import {
|
||||
createVcsProvider,
|
||||
detectVcs,
|
||||
extractBaselineHead,
|
||||
VcsError,
|
||||
} from "./vcs-provider.js";
|
||||
import type {
|
||||
CycleRecord,
|
||||
ExecutorResult,
|
||||
HostDecisionV1,
|
||||
HostReviewBundleV1,
|
||||
ScopeViolation,
|
||||
VcsBaseline,
|
||||
VcsKind,
|
||||
WorkflowPlanV1,
|
||||
WorkflowState,
|
||||
WorkflowStatusOutput,
|
||||
@@ -237,22 +241,101 @@ function persistExecutorAttempt(
|
||||
export interface StartWorkflowOpts {
|
||||
planInput: string; // file path or "-" for stdin
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
maxCycles?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** Resolve VCS selection from candidates and configs - exported for testing */
|
||||
export async function resolveVcsSelection(
|
||||
cwd: string,
|
||||
explicitVcs?: VcsKind
|
||||
): Promise<{ vcsKind: VcsKind; repoRoot: string }> {
|
||||
// If explicit VCS provided, use it directly
|
||||
if (explicitVcs) {
|
||||
const provider = createVcsProvider(explicitVcs);
|
||||
try {
|
||||
const repoRoot = provider.repoRoot(cwd);
|
||||
return { vcsKind: explicitVcs, repoRoot };
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Discover candidates
|
||||
const { findVcsCandidates } = await import("./vcs-provider.js");
|
||||
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
|
||||
|
||||
if (!candidates.git && !candidates.svn) {
|
||||
throw new WorkflowEngineError(
|
||||
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
|
||||
);
|
||||
}
|
||||
|
||||
// Load config from every distinct candidate root
|
||||
const configs = new Map<string, { vcs?: VcsKind; root: string }>();
|
||||
|
||||
for (const [vcsType, root] of Object.entries(candidates) as Array<[string, string | undefined]>) {
|
||||
if (!root) continue;
|
||||
|
||||
// Skip if we already loaded config from this root
|
||||
if (configs.has(root)) continue;
|
||||
|
||||
try {
|
||||
const config = resolveWorkflowConfig(root, {});
|
||||
configs.set(root, { vcs: config.vcs, root });
|
||||
} catch (err) {
|
||||
// Re-throw validation errors, tolerate missing config
|
||||
if (err instanceof WorkflowConfigError) {
|
||||
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`);
|
||||
}
|
||||
// Config doesn't exist - that's ok
|
||||
configs.set(root, { root });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract project defaultVcs settings
|
||||
const projectDefaults = Array.from(configs.values())
|
||||
.map(c => c.vcs)
|
||||
.filter((v): v is VcsKind => v !== undefined);
|
||||
|
||||
// Check for conflicting project defaults
|
||||
const uniqueDefaults = new Set(projectDefaults);
|
||||
if (uniqueDefaults.size > 1) {
|
||||
throw new WorkflowEngineError(
|
||||
`Conflicting VCS defaults in project configs: ${Array.from(uniqueDefaults).join(" vs ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error
|
||||
const projectDefault = projectDefaults[0];
|
||||
|
||||
if (candidates.git && candidates.svn) {
|
||||
// Ambiguous: need explicit selection
|
||||
if (projectDefault === "git") {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
} else if (projectDefault === "svn") {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn };
|
||||
} else {
|
||||
throw new WorkflowEngineError(
|
||||
"Ambiguous VCS: both Git and SVN detected. " +
|
||||
"Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json."
|
||||
);
|
||||
}
|
||||
} else if (candidates.git) {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
} else {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn! };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: string }> {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
|
||||
// 1. Locate repo root
|
||||
let repoRoot: string;
|
||||
try {
|
||||
repoRoot = gitRepoRoot(cwd);
|
||||
} catch (err) {
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
// 2. Load and validate plan
|
||||
// 1. Load and validate plan first
|
||||
let planRaw: string;
|
||||
if (opts.planInput === "-") {
|
||||
planRaw = fs.readFileSync(0, "utf8");
|
||||
@@ -272,20 +355,29 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
validateWorkflowPlan(plan);
|
||||
const typedPlan = plan as WorkflowPlanV1;
|
||||
|
||||
// 3. Resolve config
|
||||
// 2. Resolve VCS selection using deterministic helper
|
||||
const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
|
||||
|
||||
// 3. Resolve full config from final repoRoot
|
||||
const config = resolveWorkflowConfig(repoRoot, {
|
||||
...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}),
|
||||
...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}),
|
||||
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
|
||||
});
|
||||
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
|
||||
// 4. Probe executor
|
||||
await probeExecutor(config.executor, config.executorConfig);
|
||||
|
||||
// 5. Require clean working tree
|
||||
let isClean: boolean;
|
||||
try {
|
||||
isClean = gitIsClean(repoRoot);
|
||||
isClean = vcsProvider.isClean(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
if (!isClean) {
|
||||
@@ -295,7 +387,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Acquire lock (one active workflow per repo)
|
||||
// 6. Capture baseline
|
||||
let vcsBaseline: VcsBaseline;
|
||||
try {
|
||||
vcsBaseline = vcsProvider.captureBaseline(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
// 7. Acquire lock (one active workflow per repo)
|
||||
const runId = generateRunId();
|
||||
try {
|
||||
acquireLock(repoRoot, runId);
|
||||
@@ -309,11 +412,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
const dir = makeRunDir(repoRoot, runId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const baselineHead = gitHead(repoRoot);
|
||||
const state = initWorkflowState({
|
||||
runId,
|
||||
repoRoot,
|
||||
baselineHead,
|
||||
vcsBaseline,
|
||||
vcsKind,
|
||||
executor: config.executor,
|
||||
plan: typedPlan,
|
||||
maxCycles: config.maxCycles,
|
||||
@@ -327,13 +430,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
kind: "workflow_started",
|
||||
runId,
|
||||
timestamp: state.createdAt,
|
||||
data: { executor: config.executor, maxCycles: config.maxCycles, baselineHead },
|
||||
data: { executor: config.executor, maxCycles: config.maxCycles, vcsKind, vcsBaseline },
|
||||
});
|
||||
|
||||
process.stdout.write(`workflow started: ${runId}\n`);
|
||||
process.stdout.write(` vcs: ${vcsKind}\n`);
|
||||
process.stdout.write(` executor: ${config.executor}\n`);
|
||||
process.stdout.write(` max-cycles: ${config.maxCycles}\n`);
|
||||
process.stdout.write(` baseline: ${baselineHead}\n`);
|
||||
if (vcsBaseline.kind === "git") {
|
||||
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
||||
} else if (vcsBaseline.kind === "svn") {
|
||||
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`);
|
||||
}
|
||||
process.stdout.write(` run dir: ${dir}\n\n`);
|
||||
|
||||
// 7. Start first execution cycle
|
||||
@@ -441,33 +549,56 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
state.enginePid = process.pid;
|
||||
writeStateSafety(dir, state);
|
||||
|
||||
// 1. Generate diff from baseline HEAD
|
||||
// Get VCS provider
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state (corrupt state file).");
|
||||
}
|
||||
|
||||
// 1. Generate diff from baseline
|
||||
let diff: string;
|
||||
try {
|
||||
diff = gitDiffFromHead(state.repoRoot, state.baselineHead);
|
||||
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${(err as Error).message}`);
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`);
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const diffFile = cycleDiffFile(dir, cycleIndex);
|
||||
fs.writeFileSync(diffFile, diff, "utf8");
|
||||
cycleRecord.diffFile = path.relative(dir, diffFile);
|
||||
|
||||
// 2. Check for HEAD drift (executor committed something)
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
// Check if intermediate commits happened
|
||||
transitionToTerminal(
|
||||
state,
|
||||
dir,
|
||||
"blocked",
|
||||
`HEAD changed from baseline (${state.baselineHead.slice(0, 8)}) to ${currentHead.slice(0, 8)} — executor may have committed. Manual review required.`,
|
||||
);
|
||||
throw new WorkflowEngineError("HEAD changed unexpectedly. Workflow blocked.");
|
||||
// 2. Check for baseline drift
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(
|
||||
state,
|
||||
dir,
|
||||
"blocked",
|
||||
`Baseline validation failed: ${err.message}`,
|
||||
);
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 3. Check out-of-scope files
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (outOfScope.length > 0) {
|
||||
const scopeViolations = makeScopeViolations(outOfScope);
|
||||
const previousCycle = state.cycles.find((cycle) => cycle.cycleIndex === cycleIndex - 1);
|
||||
@@ -521,7 +652,9 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
runId: state.runId,
|
||||
cycleIndex,
|
||||
repoRoot: state.repoRoot,
|
||||
baselineHead: state.baselineHead,
|
||||
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
|
||||
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
|
||||
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
|
||||
generatedAt,
|
||||
plan: state.plan,
|
||||
diffFile: diffFile,
|
||||
@@ -846,14 +979,33 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
|
||||
return "Codex review bundle is missing from disk.";
|
||||
}
|
||||
|
||||
// 2. HEAD must not have changed
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
return `HEAD changed (baseline: ${state.baselineHead.slice(0, 8)}, current: ${currentHead.slice(0, 8)}).`;
|
||||
// 2. Baseline must not have changed
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
return "No baseline found in state.";
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return err.message;
|
||||
}
|
||||
return `Baseline validation failed: ${(err as Error).message}`;
|
||||
}
|
||||
|
||||
// 3. All files must be in scope
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return `Scope check failed: ${err.message}`;
|
||||
}
|
||||
return `Scope check failed: ${(err as Error).message}`;
|
||||
}
|
||||
if (outOfScope.length > 0) {
|
||||
return `Out-of-scope files: ${outOfScope.join(", ")}`;
|
||||
}
|
||||
@@ -961,15 +1113,32 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
);
|
||||
}
|
||||
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry review: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
|
||||
4,
|
||||
);
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state.");
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
if (outOfScope.length > 0) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry review while out-of-scope files remain: ${outOfScope.sort().join(", ")}.`,
|
||||
@@ -1052,14 +1221,31 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
}
|
||||
const resolvingScope = !recoveringInitialCycle && (previousCycle?.scopeViolations?.length ?? 0) > 0;
|
||||
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry execution: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
|
||||
4,
|
||||
);
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state.", 4);
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
const approvedScopePaths = new Set((previousCycle?.scopeViolations ?? []).map((violation) => violation.path));
|
||||
const unexpectedOutOfScope = resolvingScope
|
||||
? outOfScope.filter((filePath) => !approvedScopePaths.has(filePath))
|
||||
@@ -1128,6 +1314,180 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
await resumeExecutorCycle(state, dir, cycle, acceptedFindings);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflow extend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtendWorkflowOpts {
|
||||
runId: string;
|
||||
additionalCycles: number;
|
||||
}
|
||||
|
||||
export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
|
||||
// 1. Validate positive integer
|
||||
if (!Number.isInteger(opts.additionalCycles) || opts.additionalCycles < 1) {
|
||||
throw new WorkflowEngineError(
|
||||
`--additional-cycles must be a positive integer, got: ${opts.additionalCycles}`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
const { state, dir } = requireState(opts.runId);
|
||||
|
||||
// 2. Must be in budget_exhausted terminal state
|
||||
if (state.status !== "budget_exhausted") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' is in status '${state.status}', not budget_exhausted. ` +
|
||||
`Only budget_exhausted runs can be extended.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Must have a valid session handle
|
||||
if (!state.sessionHandle) {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' has no session handle. Cannot extend without a resumable session.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. The last persisted decision must be 'fix'
|
||||
const lastCycle = state.cycles[state.cycles.length - 1];
|
||||
if (!lastCycle?.decisionFile || lastCycle.decisionOutcome !== "fix") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' cannot be extended: the last cycle decision must be 'fix'.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Reacquire the repository lock BEFORE validation
|
||||
try {
|
||||
acquireLock(state.repoRoot, state.runId);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowLockError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Helper to release lock and throw
|
||||
const failAndRelease = (message: string, exitCode = 4): never => {
|
||||
releaseLock(state.repoRoot, state.runId);
|
||||
throw new WorkflowEngineError(message, exitCode);
|
||||
};
|
||||
|
||||
// 6. Validate baseline and scope under the lock
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
failAndRelease("No baseline found in state.");
|
||||
return; // Never reached but helps TypeScript
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let outOfScope: string[] = [];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: scope check failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outOfScope.length > 0) {
|
||||
failAndRelease(`Cannot extend: out-of-scope files detected: ${outOfScope.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Check convergence using existing history
|
||||
const convergenceError = checkWorkflowConvergence(state, dir);
|
||||
if (convergenceError) {
|
||||
failAndRelease(`Cannot extend: ${convergenceError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Load and validate the last decision BEFORE mutating state
|
||||
const decisionPath = path.join(dir, lastCycle.decisionFile!);
|
||||
let decision: HostDecisionV1;
|
||||
let acceptedFindings: string = "";
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown;
|
||||
validateHostDecision(raw);
|
||||
decision = raw;
|
||||
acceptedFindings = buildAcceptedFindingsText(decision, state, dir, lastCycle.cycleIndex);
|
||||
} catch (err) {
|
||||
failAndRelease(`Cannot extend: failed to read last decision: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 9. Now atomically mutate state: budget, cycle, status, history
|
||||
const oldMaxCycles = state.maxCycles;
|
||||
const newMaxCycles = oldMaxCycles + opts.additionalCycles;
|
||||
const nextCycle = state.currentCycle + 1;
|
||||
const extendedAt = nowIso();
|
||||
|
||||
state.maxCycles = newMaxCycles;
|
||||
state.enginePid = process.pid;
|
||||
state.updatedAt = extendedAt;
|
||||
state.status = "executing";
|
||||
delete state.stopReason;
|
||||
delete state.stopDescription;
|
||||
|
||||
const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: extendedAt };
|
||||
state.cycles.push(nextCycleRecord);
|
||||
state.currentCycle = nextCycle;
|
||||
|
||||
// Record extension history in state
|
||||
if (!state.extensionHistory) {
|
||||
state.extensionHistory = [];
|
||||
}
|
||||
state.extensionHistory.push({
|
||||
extendedAt,
|
||||
oldMaxCycles,
|
||||
newMaxCycles,
|
||||
additionalCycles: opts.additionalCycles,
|
||||
resumedCycle: nextCycle,
|
||||
});
|
||||
|
||||
// 10. Persist state and events
|
||||
writeState(dir, state);
|
||||
appendEvent(dir, {
|
||||
kind: "budget_extended",
|
||||
runId: state.runId,
|
||||
timestamp: extendedAt,
|
||||
data: {
|
||||
additionalCycles: opts.additionalCycles,
|
||||
oldMaxCycles,
|
||||
newMaxCycles,
|
||||
nextCycle,
|
||||
},
|
||||
});
|
||||
|
||||
appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle });
|
||||
|
||||
process.stdout.write(`workflow extended: ${state.runId}\n`);
|
||||
process.stdout.write(` additional cycles: ${opts.additionalCycles}\n`);
|
||||
process.stdout.write(` old max cycles: ${oldMaxCycles}\n`);
|
||||
process.stdout.write(` new max cycles: ${newMaxCycles}\n`);
|
||||
process.stdout.write(` resuming cycle: ${nextCycle}\n\n`);
|
||||
|
||||
// 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
|
||||
try {
|
||||
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
|
||||
} catch (err) {
|
||||
// resumeExecutorCycle already handles errors and releases lock
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflow status
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1177,7 +1537,9 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
scopeViolations: state.status === "awaiting_scope_resolution" ? cycle?.scopeViolations : undefined,
|
||||
stopReason: state.stopReason,
|
||||
stopDescription: state.stopDescription,
|
||||
baselineHead: state.baselineHead,
|
||||
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
|
||||
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
|
||||
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
|
||||
repoRoot: state.repoRoot,
|
||||
createdAt: state.createdAt,
|
||||
updatedAt: state.updatedAt,
|
||||
@@ -1230,7 +1592,15 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
if (state.stopReason) lines.push(p("Stop reason", state.stopReason));
|
||||
if (state.stopDescription) lines.push(p("Stop detail", state.stopDescription.slice(0, 80)));
|
||||
lines.push(p("Repo root", state.repoRoot));
|
||||
lines.push(p("Baseline HEAD", state.baselineHead.slice(0, 8)));
|
||||
const baselineHead = extractBaselineHead(state);
|
||||
if (baselineHead) {
|
||||
lines.push(p("Baseline HEAD", baselineHead.slice(0, 8)));
|
||||
} else if (state.vcsBaseline?.kind === "svn") {
|
||||
lines.push(p("Baseline SVN", `r${state.vcsBaseline.revision}`));
|
||||
}
|
||||
if (state.vcsKind) {
|
||||
lines.push(p("VCS", state.vcsKind));
|
||||
}
|
||||
lines.push(p("Created", state.createdAt));
|
||||
lines.push(p("Updated", state.updatedAt));
|
||||
lines.push("─".repeat(46));
|
||||
|
||||
Reference in New Issue
Block a user