feat: add VCS workflows and cycle extension

This commit is contained in:
liujing
2026-07-17 09:07:28 +08:00
parent c9d76d8f58
commit d13f31d9d1
14 changed files with 2335 additions and 189 deletions
+430 -60
View File
@@ -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));