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
+35 -3
View File
@@ -9,12 +9,13 @@
- **Codex** acts as the host planner, reviewer, and final acceptance authority
- **Reasonix, Claude Code, or Agy** executes implementation and fixes in the same session
- The workflow maintains atomic state, Git baseline gates, scope enforcement, and convergence limits
- The workflow maintains atomic state, VCS baseline gates, scope enforcement, and convergence limits
- Supports both **Git** and **SVN** working copies with strict validation
## Prerequisites
- Node.js >= 20
- Git repository
- Git repository or SVN working copy
- At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), or [Agy](https://agy.dev)
## Installation
@@ -76,9 +77,13 @@ agent-workflow decide --run <run-id> --input decision.json
Start a new workflow run. Requires a clean working tree.
```bash
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--max-cycles <n>]
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--vcs <git|svn>] [--max-cycles <n>]
```
- `--vcs` - Explicitly specify VCS kind (`git` or `svn`). If omitted, auto-detects from the working directory.
- Git and SVN working copies are automatically detected when `--vcs` is not provided.
- SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts).
### `agent-workflow review`
Generate a Codex review bundle after executor completion. Checks HEAD stability and scope compliance.
@@ -159,6 +164,16 @@ Terminate an active workflow.
agent-workflow abort --run <run-id> --reason "requirements changed"
```
### `agent-workflow extend`
Extend a `budget_exhausted` run with additional cycles. The run must have ended at `budget_exhausted` status with a valid session handle and a final `fix` decision.
```bash
agent-workflow extend --run <run-id> --additional-cycles <n>
```
This allows continuation of the same executor session after reaching the cycle limit, preserving all audit history and convergence checks.
## Configuration
Create `agent-workflow.json` in your repository root:
@@ -167,6 +182,7 @@ Create `agent-workflow.json` in your repository root:
{
"version": "1",
"defaultExecutor": "reasonix",
"defaultVcs": "auto",
"maxCycles": 5,
"timeoutSeconds": 1800,
"executors": {
@@ -185,6 +201,8 @@ Create `agent-workflow.json` in your repository root:
}
```
- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, or `"svn"`. Default is `"auto"`.
## Environment Variables
- `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`)
@@ -210,6 +228,20 @@ Any active state → completed | needs_human | blocked | budget_exhausted | abor
All file modifications must stay within the approved scope. Out-of-scope changes trigger `awaiting_scope_resolution`, where Codex inspects each violation and decides whether to remove it or escalate to `needs_human`.
## VCS Support
### Git
Standard Git repositories are fully supported with baseline HEAD tracking and diff generation.
### SVN (Strict Mode)
SVN working copies are supported with strict validation:
- **Requires**: Single consistent revision (no mixed revisions)
- **Rejects**: Switched paths, externals, conflicts, incomplete states, locked files
- **Baseline**: Repository URL, UUID, and working revision
- **Diff**: Tracks modifications, additions (including unversioned), deletions, and property changes
- Use `svn update` to synchronize revisions before starting a workflow
- The workflow never runs `svn commit`, `svn switch`, or `svn update`
## Convergence Limits
The workflow stops with `needs_human` when:
+123
View File
@@ -8,6 +8,9 @@
"name": "agent-workflow",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.10.1"
},
"bin": {
"agent-workflow": "bin/agent-workflow.js"
},
@@ -524,6 +527,18 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -964,6 +979,18 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1070,6 +1097,45 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.1",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1119,6 +1185,18 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/is-unsafe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1423,6 +1501,21 @@
"node": ">=12.20.0"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1544,6 +1637,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1820,6 +1928,21 @@
"engines": {
"node": ">=8"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
}
}
}
+3
View File
@@ -51,5 +51,8 @@
},
"engines": {
"node": ">=20"
},
"dependencies": {
"fast-xml-parser": "^5.10.1"
}
}
+31 -11
View File
@@ -14,11 +14,12 @@ Support these explicit operations:
```text
$agent-workflow-host 执行已确认计划
$agent-workflow-host 使用 claude 执行已确认计划
$agent-workflow-host 使用 svn 执行已确认计划
$agent-workflow-host 继续 <run-id>
$agent-workflow-host 查看 <run-id>
$agent-workflow-host 清理后重试 <run-id>
$agent-workflow-host 执行器重试 <run-id>
$agent-workflow-host 执行器重试 <run-id>
$agent-workflow-host 延长 <run-id> 添加 <n> 个周期
$agent-workflow-host 中止 <run-id> 原因:需求变化
$agent-workflow-host 日志 <run-id> [--follow]
```
@@ -34,15 +35,16 @@ $agent-workflow-host 日志 <run-id> [--follow]
- Codex owns plan normalization, finding classification, verification, and acceptance.
- The executor owns implementation, tests, and fixes. During an active workflow, Codex must not edit target repository files itself.
- `agent-workflow review` only prepares evidence for Codex. Do not invoke another reviewer as an acceptance gate.
- Never run or authorize `git commit`, `git push`, `git reset`, history rewriting, or branch deletion.
- Never run or authorize `git commit`, `git push`, `git reset`, `svn commit`, `svn switch`, `svn update`, history rewriting, or branch deletion.
- Never broaden the approved scope or silently change requirements after start. Abort and create a new plan when requirements materially change.
## Start A Confirmed Plan
1. Resolve the target Git root and inspect the repository before execution.
1. Resolve the target VCS root (Git or SVN) and inspect the repository before execution.
2. Run these preflight checks:
- `command -v agent-workflow`
- `git status --porcelain` must be empty.
- For Git: `git status --porcelain` must be empty.
- For SVN: `svn status` must show a clean working copy with no mixed revisions.
- Probe the selected executor with `<executor> --version`.
- Confirm no unresolved active workflow prevents a new run.
3. Do not install tools, commit, or stash automatically. Report the exact missing prerequisite and stop.
@@ -64,7 +66,7 @@ $agent-workflow-host 日志 <run-id> [--follow]
7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently:
```bash
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy>
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> [--vcs <git|svn>]
```
After starting, announce:
@@ -72,7 +74,7 @@ After starting, announce:
Run <run-id> started. Monitor live output: agent-workflow logs --run <run-id> --follow
```
Honor an explicit `max-cycles` argument. Otherwise use project configuration and workflow defaults.
Honor an explicit `max-cycles` argument or `--vcs` flag. Otherwise use project configuration and workflow defaults. VCS is auto-detected if not specified.
## Drive The State Machine
@@ -143,24 +145,42 @@ Create `HostDecisionV1` in the temporary directory:
After `fix`, repeat review and decision for the new cycle. Respect the configured cycle budget and the engine's persistence/oscillation stop conditions.
## Resume, Inspect, Or Abort
## Resume, Inspect, Extend, Or Abort
- For `查看 <run-id>`, run `agent-workflow status --run <run-id> --json`, summarize it, and do not advance the workflow.
- For `继续 <run-id>`, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory.
- For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id>` after the approved scope artifacts have been removed. It must refuse when HEAD drift or out-of-scope paths remain.
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when HEAD has drifted, scope violations exist, or the cycle already produced review artifacts.
- Treat `completed`, `needs_human`, `blocked`, `budget_exhausted`, and `aborted` as terminal. Report them instead of attempting another cycle.
- For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id>` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain.
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
- For `延长 <run-id> 添加 <n> 个周期`, run `agent-workflow extend --run <run-id> --additional-cycles <n>` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session.
- Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle.
- `budget_exhausted` is terminal but can be extended with the extend command if all conditions are met.
- For an explicit abort, run `agent-workflow abort --run <run-id> --reason <text>` and report the resulting status.
## Failure Handling
- Missing session handle, executor failure, or HEAD drift must stop as `blocked`; do not work around the gate.
- Missing session handle, executor failure, or baseline drift (Git HEAD change, SVN revision change) must stop as `blocked`; do not work around the gate.
- When a resume failed before producing cycle artifacts because the same session was busy, ask the user to close the owning executor process, then use `retry-execute`; never kill an unrelated process automatically.
- Out-of-scope edits must enter `awaiting_scope_resolution`; inspect and remediate them through the same executor session. Repeated identical violations must stop as `needs_human`.
- Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence.
- If the cycle budget is exhausted and more work remains, explain the `extend` command option to the user.
- If interrupted, preserve the run ID. A later explicit `继续 <run-id>` invocation resumes from durable state.
- If the user changes requirements, abort the active run and return to plan discussion. Do not mutate the frozen plan in place.
## VCS-Specific Guidance
### Git Workflows
- Standard baseline tracking with HEAD SHA
- Diff includes tracked changes and unversioned additions
- Scope checking via `git diff --name-only` and `git ls-files`
### SVN Workflows (Strict Mode)
- Baseline includes repository URL, UUID, and single working revision
- Before starting: verify `svn info` shows consistent state, no mixed revisions
- Diff includes tracked modifications, unversioned additions, deletions, and property changes
- The executor never runs `svn commit`, `svn switch`, or `svn update`
- Reject runs with switched paths, externals, conflicts, or incomplete states
- If mixed revisions detected: stop and instruct user to run `svn update`
## Final Report
Report:
+9
View File
@@ -19,6 +19,7 @@ import {
logsWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
extendWorkflow,
WorkflowEngineError,
} from "./workflow-engine.js";
import { WorkflowConfigError } from "./workflow-config.js";
@@ -42,6 +43,7 @@ async function main() {
await startWorkflow({
planInput: args.planInput,
executor: args.executor,
vcs: args.vcs,
maxCycles: args.maxCycles,
});
break;
@@ -81,6 +83,13 @@ async function main() {
});
break;
case "extend":
await extendWorkflow({
runId: args.runId,
additionalCycles: args.additionalCycles,
});
break;
default: {
const _exhaustive: never = args;
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
+24
View File
@@ -13,8 +13,10 @@ export {
logsWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
extendWorkflow,
WorkflowEngineError,
emitWorkflowMeta,
resolveVcsSelection,
} from "./workflow-engine.js";
export type {
@@ -26,6 +28,7 @@ export type {
LogsWorkflowOpts,
RetryReviewWorkflowOpts,
RetryExecuteWorkflowOpts,
ExtendWorkflowOpts,
} from "./workflow-engine.js";
export {
@@ -114,6 +117,7 @@ export type {
WorkflowStatusArgs,
WorkflowAbortArgs,
WorkflowLogsArgs,
WorkflowExtendArgs,
} from "./workflow-args.js";
export type {
@@ -139,6 +143,11 @@ export type {
ScopeViolation,
WorkflowEvent,
WorkflowEventKind,
VcsKind,
VcsBaseline,
VcsBaselineGit,
VcsBaselineSvn,
ExtensionRecord,
} from "./workflow-types.js";
export {
@@ -147,6 +156,7 @@ export {
WORKFLOW_ACTIVE_STATUSES,
WORKFLOW_TERMINAL_STATUSES,
HOST_DECISION_OUTCOMES,
VCS_KINDS,
} from "./workflow-types.js";
export {
@@ -160,3 +170,17 @@ export type {
ChildRunResult,
StreamProgress,
} from "./child-process.js";
export {
VcsError,
GitProvider,
SvnProvider,
detectVcs,
createVcsProvider,
extractBaselineHead,
findVcsCandidates,
} from "./vcs-provider.js";
export type {
VcsProvider,
} from "./vcs-provider.js";
+729
View File
@@ -0,0 +1,729 @@
/**
* VCS provider abstraction for Git and SVN working copies.
*
* Git: Standard Git behavior (unchanged from previous implementation).
* SVN: Strict working copy mode - rejects mixed revisions, switched paths,
* externals, conflicts, and incomplete states.
*/
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { XMLParser } from "fast-xml-parser";
import type { VcsBaseline, VcsKind } from "./workflow-types.js";
export class VcsError extends Error {
constructor(message: string) {
super(message);
this.name = "VcsError";
}
}
// ---------------------------------------------------------------------------
// VCS Provider Interface
// ---------------------------------------------------------------------------
export interface VcsProvider {
readonly kind: VcsKind;
/** Locate the working copy root from the given directory. */
repoRoot(cwd: string): string;
/** Capture the current baseline state. */
captureBaseline(repoRoot: string): VcsBaseline;
/** Check if the working copy is clean (no uncommitted changes). */
isClean(repoRoot: string): boolean;
/** Generate a diff from the baseline to the current working tree. */
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string;
/** Check that all modified files are within the allowed scope. */
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[];
/** Validate that the baseline hasn't drifted (no commits/updates). */
validateBaseline(repoRoot: string, baseline: VcsBaseline): void;
}
// ---------------------------------------------------------------------------
// Git Provider
// ---------------------------------------------------------------------------
export class GitProvider implements VcsProvider {
readonly kind: VcsKind = "git";
repoRoot(cwd: string = process.cwd()): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new VcsError("Not in a git repository (or git not found).");
}
}
captureBaseline(repoRoot: string): VcsBaseline {
try {
const head = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return { kind: "git", head };
} catch {
throw new VcsError("Failed to read HEAD.");
}
}
isClean(repoRoot: string): boolean {
try {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
throw new VcsError("Failed to check git status.");
}
}
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
try {
const trackedDiff = execFileSync("git", ["diff", baseline.head], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const untrackedFiles = untrackedStr
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let finalDiff = trackedDiff;
for (const file of untrackedFiles) {
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
cwd: repoRoot,
encoding: "utf8",
});
const untrackedDiff = (res.stdout || "").trim();
if (untrackedDiff) {
if (finalDiff) {
finalDiff += "\n" + untrackedDiff;
} else {
finalDiff = untrackedDiff;
}
}
}
return finalDiff;
} catch {
throw new VcsError("Failed to compute diff from baseline HEAD.");
}
}
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
let diff: string;
let untracked: string;
try {
diff = execFileSync("git", ["diff", "--name-only", baseline.head], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw new VcsError("Failed to check modified files.");
}
const modifiedFiles = (diff + "\n" + untracked)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
}
validateBaseline(repoRoot: string, baseline: VcsBaseline): void {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
const currentHead = this.captureBaseline(repoRoot);
if (currentHead.kind === "git" && currentHead.head !== baseline.head) {
throw new VcsError(
`HEAD changed from baseline (${baseline.head.slice(0, 8)}) to ${currentHead.head.slice(0, 8)}.`
);
}
}
}
// ---------------------------------------------------------------------------
// SVN Provider (Strict Mode)
// ---------------------------------------------------------------------------
export class SvnProvider implements VcsProvider {
readonly kind: VcsKind = "svn";
repoRoot(cwd: string = process.cwd()): string {
try {
const output = execFileSync("svn", ["info", "--show-item", "wc-root"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (!output) {
throw new VcsError("Not in an SVN working copy (or svn not found).");
}
return output;
} catch {
throw new VcsError("Not in an SVN working copy (or svn not found).");
}
}
captureBaseline(repoRoot: string): VcsBaseline {
// Strict validation before capturing baseline
this.validateStrictWorkingCopy(repoRoot);
try {
const repoUrl = execFileSync("svn", ["info", "--show-item", "url"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const repoUuid = execFileSync("svn", ["info", "--show-item", "repos-uuid"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const revision = parseInt(
execFileSync("svn", ["info", "--show-item", "revision"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim(),
10
);
if (!repoUrl || !repoUuid || !Number.isInteger(revision) || revision < 0) {
throw new VcsError("Failed to capture SVN baseline: invalid info output.");
}
return { kind: "svn", repoUrl, repoUuid, revision };
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to capture SVN baseline: ${(err as Error).message}`);
}
}
isClean(repoRoot: string): boolean {
this.validateStrictWorkingCopy(repoRoot);
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(xml);
// Clean means no modifications, additions, deletions, or unversioned files
return parsed.entries.length === 0;
} catch {
throw new VcsError("Failed to check SVN status.");
}
}
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
try {
const statusXml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(statusXml);
let combinedDiff = "";
for (const entry of parsed.entries) {
const itemStatus = entry.wcStatus;
const propsStatus = entry.propsStatus;
// Skip clean files, ignored files, and external references
if (itemStatus === "normal" && propsStatus === "none") continue;
if (itemStatus === "ignored") continue;
if (itemStatus === "external") continue;
// Normalize and validate path
const entryPath = this.normalizeAndValidatePath(entry.path, repoRoot);
const fullPath = path.resolve(repoRoot, entryPath);
if (itemStatus === "added" || itemStatus === "unversioned") {
if (itemStatus === "added") {
// Added file: use svn diff
const addDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (addDiffRes.status !== 0 && addDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${addDiffRes.stderr}`);
}
if (addDiffRes.stdout) {
combinedDiff += addDiffRes.stdout.trim() + "\n";
}
} else {
// Unversioned file: generate diff manually
if (fs.existsSync(fullPath)) {
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
// Skip directories
continue;
}
if (stats.isFile()) {
try {
// Try to read as text, but handle binary
const content = fs.readFileSync(fullPath);
// Check if binary
const isBinary = content.includes(0);
if (isBinary) {
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `Cannot display: file marked as a binary type.\n`;
combinedDiff += `svn:mime-type = application/octet-stream\n`;
} else {
const textContent = content.toString("utf8");
const lines = textContent.split("\n");
const hasTrailingNewline = textContent.endsWith("\n");
const lineCount = hasTrailingNewline ? lines.length - 1 : lines.length;
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `--- ${entryPath}\t(nonexistent)\n`;
combinedDiff += `+++ ${entryPath}\t(working copy)\n`;
combinedDiff += `@@ -0,0 +1,${lineCount} @@\n`;
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && hasTrailingNewline && lines[i] === "") {
continue;
}
combinedDiff += `+${lines[i]}\n`;
}
if (!hasTrailingNewline && lineCount > 0) {
combinedDiff += "\\ No newline at end of file\n";
}
}
} catch (err) {
throw new VcsError(`Failed to read unversioned file ${entryPath}: ${(err as Error).message}`);
}
}
}
}
} else if (itemStatus === "missing" || itemStatus === "deleted") {
// Deleted/missing file: retrieve BASE content for proper diff
const baseDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (baseDiffRes.status !== 0 && baseDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${baseDiffRes.stderr}`);
}
if (baseDiffRes.stdout) {
combinedDiff += baseDiffRes.stdout.trim() + "\n";
} else {
// If svn diff is empty for missing file, generate deletion diff from BASE
const catRes = spawnSync("svn", ["cat", entryPath + "@BASE"], {
cwd: repoRoot,
encoding: "utf8",
});
if (catRes.status === 0 && catRes.stdout) {
const content = catRes.stdout;
const lines = content.split("\n");
const hasTrailingNewline = content.endsWith("\n");
const lineCount = hasTrailingNewline ? lines.length - 1 : lines.length;
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `--- ${entryPath}\t(revision BASE)\n`;
combinedDiff += `+++ ${entryPath}\t(working copy)\n`;
combinedDiff += `@@ -1,${lineCount} +0,0 @@\n`;
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && hasTrailingNewline && lines[i] === "") {
continue;
}
combinedDiff += `-${lines[i]}\n`;
}
if (!hasTrailingNewline && lineCount > 0) {
combinedDiff += "\\ No newline at end of file\n";
}
} else if (catRes.status !== 0) {
throw new VcsError(`svn cat BASE failed for ${entryPath}: ${catRes.stderr || "unknown error"}`);
}
}
} else if (itemStatus === "modified" || propsStatus === "modified") {
// Modified file or property change: use svn diff
const modDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (modDiffRes.status !== 0 && modDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${modDiffRes.stderr}`);
}
if (modDiffRes.stdout) {
combinedDiff += modDiffRes.stdout.trim() + "\n";
}
}
}
return combinedDiff.trim();
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to generate SVN diff: ${(err as Error).message}`);
}
}
/** Normalize and validate SVN status path is within repoRoot, return repository-relative path. */
private normalizeAndValidatePath(entryPath: string, repoRoot: string): string {
// Normalize path to repository-relative
const normalized = path.isAbsolute(entryPath)
? path.relative(repoRoot, entryPath)
: entryPath;
// Reject paths outside working copy
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
throw new VcsError(`Path outside working copy: ${entryPath}`);
}
return normalized;
}
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(xml);
const modifiedFiles: string[] = [];
for (const entry of parsed.entries) {
// Skip clean files, ignored files, and external references
if (entry.wcStatus === "normal" && entry.propsStatus === "none") continue;
if (entry.wcStatus === "ignored") continue;
if (entry.wcStatus === "external") continue;
// Normalize and validate path
const normalizedPath = this.normalizeAndValidatePath(entry.path, repoRoot);
modifiedFiles.push(normalizedPath);
}
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to check SVN scope: ${(err as Error).message}`);
}
}
validateBaseline(repoRoot: string, baseline: VcsBaseline): void {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
const current = this.captureBaseline(repoRoot);
if (current.kind !== "svn") {
throw new VcsError("Current baseline is not SVN.");
}
if (current.repoUrl !== baseline.repoUrl) {
throw new VcsError(`SVN URL changed from ${baseline.repoUrl} to ${current.repoUrl}.`);
}
if (current.repoUuid !== baseline.repoUuid) {
throw new VcsError(`SVN UUID changed from ${baseline.repoUuid} to ${current.repoUuid}.`);
}
if (current.revision !== baseline.revision) {
throw new VcsError(
`SVN revision changed from ${baseline.revision} to ${current.revision}. ` +
`Run 'svn update' or commit/switch detected.`
);
}
}
/** Strict validation: reject mixed revisions, switched paths, externals, conflicts, etc. */
private validateStrictWorkingCopy(repoRoot: string): void {
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
this.validateSvnStatusXml(xml);
// Check for mixed revisions using svn info --xml -R
const infoXml = execFileSync("svn", ["info", "--xml", "-R"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024,
});
const revisions = this.extractWorkingRevisions(infoXml);
if (revisions.size > 1) {
throw new VcsError(
`SVN mixed revisions detected (${Array.from(revisions).sort().join(", ")}). ` +
`Run 'svn update' to synchronize the working copy.`
);
}
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to validate SVN working copy: ${(err as Error).message}`);
}
}
/** Validate SVN status XML for forbidden states - exported for testing */
validateSvnStatusXml(xml: string): void {
const parsed = this.parseSvnStatusXml(xml);
// Check for forbidden states in ALL entries
for (const entry of parsed.entries) {
if (entry.wcStatus === "conflicted") {
throw new VcsError(`SVN conflict detected: ${entry.path}`);
}
if (entry.wcStatus === "obstructed") {
throw new VcsError(`SVN obstruction detected: ${entry.path}`);
}
if (entry.wcStatus === "incomplete") {
throw new VcsError(`SVN incomplete state detected: ${entry.path}`);
}
if (entry.switched) {
throw new VcsError(`SVN switched path detected: ${entry.path}`);
}
if (entry.fileExternal || entry.treeConflicted || entry.wcStatus === "external") {
throw new VcsError(`SVN external detected: ${entry.path}`);
}
if (entry.locked) {
throw new VcsError(`SVN working-copy locked file detected: ${entry.path}`);
}
}
}
private extractWorkingRevisions(infoXml: string): Set<number> {
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
const result = parser.parse(infoXml);
const revisions = new Set<number>();
if (!result.info) return revisions;
const entries = Array.isArray(result.info.entry) ? result.info.entry : [result.info.entry];
for (const entry of entries) {
if (!entry) continue;
// Use entry revision attribute (working revision), not commit/last-changed revision
const entryRev = entry["@_revision"];
if (entryRev !== undefined) {
const rev = parseInt(String(entryRev), 10);
if (!isNaN(rev)) {
revisions.add(rev);
}
}
}
return revisions;
}
private parseSvnStatusXml(xml: string): { entries: SvnStatusEntry[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const result = parser.parse(xml);
const entries: SvnStatusEntry[] = [];
if (!result.status || !result.status.target) {
return { entries };
}
const target = result.status.target;
if (!target.entry) {
return { entries };
}
const entryList = Array.isArray(target.entry) ? target.entry : [target.entry];
for (const entry of entryList) {
if (!entry) continue;
const entryPath = entry["@_path"];
const wcStatus = entry["wc-status"];
if (!wcStatus) continue;
const itemStatus = wcStatus["@_item"] || "normal";
const propsStatus = wcStatus["@_props"] || "none";
const switched = wcStatus["@_switched"] === "true";
const fileExternal = wcStatus["@_file-external"] === "true";
const treeConflicted = wcStatus["@_tree-conflicted"] === "true";
const wcLocked = wcStatus["@_wc-locked"] === "true";
// Include ALL entries for strict validation, including normal and external
entries.push({
path: entryPath,
wcStatus: itemStatus,
propsStatus,
switched,
fileExternal,
treeConflicted,
locked: wcLocked,
});
}
return { entries };
}
}
interface SvnStatusEntry {
path: string;
wcStatus: string;
propsStatus: string;
switched: boolean;
fileExternal: boolean;
treeConflicted: boolean;
locked: boolean;
}
// ---------------------------------------------------------------------------
// VCS Detection & Factory
// ---------------------------------------------------------------------------
export function detectVcs(cwd: string): VcsKind {
let hasGit = false;
let hasSvn = false;
// Check for Git
try {
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
stdio: "ignore",
});
hasGit = true;
} catch {
// Not git
}
// Check for SVN
try {
execFileSync("svn", ["info", "--show-item", "wc-root"], {
cwd,
stdio: "ignore",
});
hasSvn = true;
} catch {
// Not svn
}
if (hasGit && hasSvn) {
throw new VcsError(
"Ambiguous VCS: both Git and SVN detected. " +
"Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json."
);
}
if (hasGit) return "git";
if (hasSvn) return "svn";
throw new VcsError(
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
);
}
/** Find VCS candidates without throwing on ambiguity. */
export function findVcsCandidates(cwd: string): { git?: string; svn?: string } {
const candidates: { git?: string; svn?: string } = {};
// Try Git
try {
const gitRoot = new GitProvider().repoRoot(cwd);
candidates.git = gitRoot;
} catch {
// Not git or git not available
}
// Try SVN
try {
const svnRoot = new SvnProvider().repoRoot(cwd);
candidates.svn = svnRoot;
} catch {
// Not svn or svn not available
}
return candidates;
}
export function createVcsProvider(kind: VcsKind): VcsProvider {
switch (kind) {
case "git":
return new GitProvider();
case "svn":
return new SvnProvider();
default: {
const _exhaustive: never = kind;
throw new VcsError(`Unknown VCS kind: ${_exhaustive}`);
}
}
}
/** Backward compatibility: extract head from old or new baseline format. */
export function extractBaselineHead(state: { baselineHead?: string; vcsBaseline?: VcsBaseline }): string | undefined {
if (state.vcsBaseline?.kind === "git") {
return state.vcsBaseline.head;
}
return state.baselineHead;
}
+41 -4
View File
@@ -2,11 +2,12 @@
* Argument parsing for agent-workflow subcommands.
*/
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs";
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend";
export interface WorkflowStartArgs {
sub: "start";
executor?: string;
vcs?: string;
planInput: string; // file path or "-"
maxCycles?: number;
}
@@ -51,6 +52,12 @@ export interface WorkflowLogsArgs {
tail: number;
}
export interface WorkflowExtendArgs {
sub: "extend";
runId: string;
additionalCycles: number;
}
export type WorkflowArgs =
| WorkflowStartArgs
| WorkflowReviewArgs
@@ -59,7 +66,8 @@ export type WorkflowArgs =
| WorkflowDecideArgs
| WorkflowStatusArgs
| WorkflowAbortArgs
| WorkflowLogsArgs;
| WorkflowLogsArgs
| WorkflowExtendArgs;
export class WorkflowArgsError extends Error {
constructor(message: string) {
@@ -85,11 +93,12 @@ function parsePositiveInt(flag: string, value: string): number {
const WORKFLOW_USAGE = `
Usage:
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--max-cycles <n>]
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn>] [--max-cycles <n>]
agent-workflow review --run <run-id>
agent-workflow retry-review --run <run-id>
agent-workflow retry-execute --run <run-id>
agent-workflow decide --run <run-id> --input <decision.json|->
agent-workflow extend --run <run-id> --additional-cycles <n>
agent-workflow status --run <run-id> [--json]
agent-workflow abort --run <run-id> --reason <text>
agent-workflow logs --run <run-id> [--follow] [--tail <lines>]
@@ -98,12 +107,14 @@ Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 fo
Examples:
agent-workflow start --plan plan.json --executor reasonix
agent-workflow start --plan plan.json --executor reasonix --vcs svn
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
agent-workflow review --run 2026-01-01T00-00-00_01_abc123
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123
agent-workflow decide --run <id> --input decision.json
agent-workflow decide --run <id> --input - # read decision from stdin
agent-workflow extend --run <id> --additional-cycles 3
agent-workflow status --run <id>
agent-workflow status --run <id> --json
agent-workflow abort --run <id> --reason "changed requirements"
@@ -125,6 +136,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
switch (sub) {
case "start": {
let executor: string | undefined;
let vcs: string | undefined;
let planInput: string | undefined;
let maxCycles: number | undefined;
@@ -134,6 +146,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
case "--executor":
executor = requireValue(arg, rest);
break;
case "--vcs":
vcs = requireValue(arg, rest);
break;
case "--plan":
planInput = requireValue(arg, rest);
break;
@@ -151,7 +166,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
return { sub: "start", executor, planInput, maxCycles };
return { sub: "start", executor, vcs, planInput, maxCycles };
}
case "review": {
@@ -283,6 +298,28 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
return { sub: "logs", runId, follow, tail };
}
case "extend": {
let runId: string | undefined;
let additionalCycles: number | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "--additional-cycles") {
additionalCycles = parsePositiveInt(arg, requireValue(arg, rest));
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow extend");
if (additionalCycles === undefined) {
throw new WorkflowArgsError("--additional-cycles is required for workflow extend");
}
return { sub: "extend", runId, additionalCycles };
}
default:
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
}
+22 -1
View File
@@ -12,8 +12,9 @@ import type {
ExecutorKind,
ResolvedWorkflowConfig,
WorkflowProjectConfig,
VcsKind,
} from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
@@ -54,6 +55,12 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
if (obj.defaultExecutor !== undefined && !EXECUTOR_KINDS.includes(obj.defaultExecutor as ExecutorKind)) {
throw new WorkflowConfigError(`${filePath}: defaultExecutor must be one of ${EXECUTOR_KINDS.join(", ")}`);
}
if (obj.defaultVcs !== undefined) {
const validVcs = ["auto", ...VCS_KINDS];
if (!validVcs.includes(obj.defaultVcs as string)) {
throw new WorkflowConfigError(`${filePath}: defaultVcs must be one of ${validVcs.join(", ")}`);
}
}
if (obj.maxCycles !== undefined && (typeof obj.maxCycles !== "number" || !Number.isInteger(obj.maxCycles) || obj.maxCycles < 1)) {
throw new WorkflowConfigError(`${filePath}: maxCycles must be a positive integer`);
}
@@ -73,6 +80,7 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
export interface WorkflowCliOverrides {
executor?: ExecutorKind;
vcs?: VcsKind;
maxCycles?: number;
}
@@ -88,6 +96,18 @@ export function resolveWorkflowConfig(
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
}
// VCS selection precedence: CLI --vcs, then project defaultVcs (unless "auto"), then auto-detect
let vcs: VcsKind | undefined;
if (cli.vcs) {
if (!VCS_KINDS.includes(cli.vcs)) {
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
}
vcs = cli.vcs;
} else if (project?.defaultVcs && project.defaultVcs !== "auto") {
vcs = project.defaultVcs as VcsKind;
}
// If vcs is still undefined, caller will auto-detect
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? DEFAULTS.maxCycles;
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`);
@@ -101,6 +121,7 @@ export function resolveWorkflowConfig(
return {
executor,
...(vcs ? { vcs } : {}),
maxCycles,
timeoutSeconds,
executorConfig,
+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));
+3 -1
View File
@@ -41,13 +41,15 @@ const SAFETY_CONSTRAINTS = `
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances.
- DO NOT run svn commit, svn switch, svn update, svn relocate, or svn revert under any circumstances.
- DO NOT modify .svn metadata directories or files.
- DO NOT rewrite git history or delete branches.
- DO NOT create or modify files outside the scope listed above.
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
- Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.
- Before exiting, inspect git status --porcelain --untracked-files=all (or svn status) and leave no artifacts outside the allowed scope.
- You may read, edit, and test code, but never commit or push changes.
- If you encounter an error you cannot resolve, stop and report it clearly.
`.trimStart();
+24 -104
View File
@@ -17,8 +17,10 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import type { ProgressSidecar, WorkflowEvent, WorkflowState } from "./workflow-types.js";
import type { ProgressSidecar, WorkflowEvent, WorkflowState, VcsBaseline } from "./workflow-types.js";
// Import VCS providers for backward-compatible helpers
import { GitProvider } from "./vcs-provider.js";
// ---------------------------------------------------------------------------
// Paths
@@ -160,7 +162,7 @@ export function readLock(repoRoot: string): string | undefined {
}
// ---------------------------------------------------------------------------
// Git helpers
// Legacy Git helpers (kept for backward compatibility with old code)
// ---------------------------------------------------------------------------
export class GitError extends Error {
@@ -170,118 +172,32 @@ export class GitError extends Error {
}
}
/** Return the absolute repo root (throws if not in a git repo). */
const _gitProvider = new GitProvider();
/** @deprecated Use VcsProvider instead. Return the absolute repo root (throws if not in a git repo). */
export function gitRepoRoot(cwd: string = process.cwd()): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Not in a git repository (or git not found).");
}
return _gitProvider.repoRoot(cwd);
}
/** Return HEAD SHA. */
/** @deprecated Use VcsProvider instead. Return HEAD SHA. */
export function gitHead(repoRoot: string): string {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Failed to read HEAD.");
}
const baseline = _gitProvider.captureBaseline(repoRoot);
return baseline.kind === "git" ? baseline.head : "";
}
/** Return true when the working tree is clean (no uncommitted changes). */
/** @deprecated Use VcsProvider instead. Return true when the working tree is clean (no uncommitted changes). */
export function gitIsClean(repoRoot: string): boolean {
try {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
throw new GitError("Failed to check git status.");
}
return _gitProvider.isClean(repoRoot);
}
/** Generate a diff from baseline HEAD to the current working tree. */
/** @deprecated Use VcsProvider instead. Generate a diff from baseline HEAD to the current working tree. */
export function gitDiffFromHead(repoRoot: string, baselineHead: string): string {
try {
const trackedDiff = execFileSync("git", ["diff", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const untrackedFiles = untrackedStr
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let finalDiff = trackedDiff;
for (const file of untrackedFiles) {
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
cwd: repoRoot,
encoding: "utf8",
});
const untrackedDiff = (res.stdout || "").trim();
if (untrackedDiff) {
if (finalDiff) {
finalDiff += "\n" + untrackedDiff;
} else {
finalDiff = untrackedDiff;
}
}
}
return finalDiff;
} catch {
throw new GitError("Failed to compute diff from baseline HEAD.");
}
return _gitProvider.diffFromBaseline(repoRoot, { kind: "git", head: baselineHead });
}
/** Check that all modified files are within the allowed scope. */
/** @deprecated Use VcsProvider instead. Check that all modified files are within the allowed scope. */
export function checkFilesInScope(repoRoot: string, baselineHead: string, scope: string[]): string[] {
let diff: string;
let untracked: string;
try {
diff = execFileSync("git", ["diff", "--name-only", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw new GitError("Failed to check modified files.");
}
const modifiedFiles = (diff + "\n" + untracked)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
return _gitProvider.checkFilesInScope(repoRoot, { kind: "git", head: baselineHead }, scope);
}
// ---------------------------------------------------------------------------
@@ -296,7 +212,9 @@ export function nowIso(): string {
export function initWorkflowState(opts: {
runId: string;
repoRoot: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: import("./workflow-types.js").VcsKind;
executor: import("./workflow-types.js").ExecutorKind;
plan: import("./workflow-types.js").WorkflowPlanV1;
maxCycles: number;
@@ -308,7 +226,9 @@ export function initWorkflowState(opts: {
runId: opts.runId,
repoHash: repoHash(opts.repoRoot),
repoRoot: opts.repoRoot,
baselineHead: opts.baselineHead,
...(opts.baselineHead ? { baselineHead: opts.baselineHead } : {}),
...(opts.vcsBaseline ? { vcsBaseline: opts.vcsBaseline } : {}),
...(opts.vcsKind ? { vcsKind: opts.vcsKind } : {}),
executor: opts.executor,
plan: opts.plan,
status: "executing",
+810
View File
@@ -0,0 +1,810 @@
/**
* Comprehensive tests for SVN support and budget extension features.
*
* Uses real svnadmin repositories and fake executors to test:
* - VCS detection and ambiguity with real repos
* - SVN strict validation (mixed revisions, externals, conflicts, etc.)
* - SVN diff generation (modifications, additions, properties, missing files)
* - Extension validation and immediate resume with session preservation
* - Extension lock/baseline/scope gates
* - Convergence check during extension
* - Backward compatibility with old Git states
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import {
detectVcs,
VcsError,
createVcsProvider,
SvnProvider,
GitProvider,
} from "./vcs-provider.js";
import { resolveWorkflowConfig } from "./workflow-config.js";
import { extendWorkflow, startWorkflow } from "./workflow-engine.js";
import { readState, runDir, writeState, initWorkflowState, gitHead, releaseLock } from "./workflow-state.js";
import type { WorkflowPlanV1, HostDecisionV1, ExtensionRecord } from "./workflow-types.js";
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-svn-test-"));
}
function cleanupDir(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
function makeValidPlan(): WorkflowPlanV1 {
return {
version: "1",
title: "Test plan",
planMarkdown: "Do something.",
scope: ["src/"],
acceptanceCriteria: ["Tests pass"],
verificationCommands: [["echo", "test"]],
};
}
// Create a real SVN repository with svnadmin
function createSvnRepo(repoDir: string, wcDir: string): void {
// Create repository
execFileSync("svnadmin", ["create", repoDir], { stdio: "ignore" });
// Checkout working copy
execFileSync("svn", ["checkout", `file://${repoDir}`, wcDir], { stdio: "ignore" });
// Create initial structure
fs.mkdirSync(path.join(wcDir, "src"), { recursive: true });
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "initial content\n");
// Add and commit
execFileSync("svn", ["add", "src"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["commit", "-m", "Initial commit"], { cwd: wcDir, stdio: "ignore" });
// Update working copy to ensure it's at the latest revision
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
}
// ---------------------------------------------------------------------------
// VCS Detection and Ambiguity
// ---------------------------------------------------------------------------
describe("VCS detection with real repositories", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("detects Git repository", () => {
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
expect(detectVcs(tmpDir)).toBe("git");
});
it("detects SVN working copy", () => {
const repoDir = path.join(tmpDir, "repo");
const wcDir = path.join(tmpDir, "wc");
createSvnRepo(repoDir, wcDir);
expect(detectVcs(wcDir)).toBe("svn");
});
it("throws ambiguity error when SVN working copy contains .git", () => {
const repoDir = path.join(tmpDir, "repo");
const wcDir = path.join(tmpDir, "wc");
createSvnRepo(repoDir, wcDir);
// Create .git directory inside SVN working copy
execFileSync("git", ["init"], { cwd: wcDir, stdio: "ignore" });
expect(() => detectVcs(wcDir)).toThrow(/Ambiguous VCS.*both Git and SVN/);
});
});
// ---------------------------------------------------------------------------
// VCS Precedence
// ---------------------------------------------------------------------------
describe("VCS precedence from nested directories", () => {
let tmpDir: string;
let repoRoot: string;
beforeEach(() => {
tmpDir = makeTmpDir();
repoRoot = path.join(tmpDir, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("honors project defaultVcs from nested working-copy paths", () => {
fs.writeFileSync(
path.join(repoRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", defaultVcs: "git" })
);
const nestedDir = path.join(repoRoot, "subdir");
fs.mkdirSync(nestedDir);
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.vcs).toBe("git");
});
it("CLI override wins over project config", () => {
fs.writeFileSync(
path.join(repoRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", defaultVcs: "svn" })
);
const config = resolveWorkflowConfig(repoRoot, { vcs: "git" });
expect(config.vcs).toBe("git");
});
});
// ---------------------------------------------------------------------------
// SVN Mixed Revisions Detection
// ---------------------------------------------------------------------------
describe("SVN mixed revisions with real repository", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("accepts working copy at single revision with different last-changed revisions", () => {
createSvnRepo(repoDir, wcDir);
// Make another commit that modifies existing file
fs.appendFileSync(path.join(wcDir, "src", "test.txt"), "second line\n");
execFileSync("svn", ["commit", "-m", "Modify test.txt"], { cwd: wcDir, stdio: "ignore" });
// Now update to latest - all files at same working revision but different last-changed
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
// Should not throw - all at same working revision
expect(() => svnProvider.captureBaseline(wcDir)).not.toThrow();
});
it("rejects actual mixed working revisions", () => {
createSvnRepo(repoDir, wcDir);
// Make another commit
fs.writeFileSync(path.join(wcDir, "src", "test2.txt"), "second file\n");
execFileSync("svn", ["add", "src/test2.txt"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["commit", "-m", "Add second file"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
// Update only one file to create mixed revisions
execFileSync("svn", ["update", "-r", "1", "src/test.txt"], { cwd: wcDir, stdio: "ignore" });
expect(() => svnProvider.captureBaseline(wcDir)).toThrow(/mixed revisions/i);
});
});
// ---------------------------------------------------------------------------
// SVN Strict Validation
// ---------------------------------------------------------------------------
describe("SVN strict validation with real states", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("detects conflicted files", () => {
createSvnRepo(repoDir, wcDir);
// Create a conflict by modifying the same file in different ways
// Make a change and commit
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "version A\n");
execFileSync("svn", ["commit", "-m", "Version A"], { cwd: wcDir, stdio: "ignore" });
// Go back to r1 and make a different change
execFileSync("svn", ["update", "-r", "1"], { cwd: wcDir, stdio: "ignore" });
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "version B\n");
// Try to update - this creates a conflict
spawnSync("svn", ["update"], { cwd: wcDir });
// Check if conflict exists by looking at status
const statusXml = execFileSync("svn", ["status", "--xml"], {
cwd: wcDir,
encoding: "utf8",
});
// Verify conflict was actually created
if (!statusXml.includes('item="conflicted"')) {
// Skip test if conflict wasn't created
return;
}
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/conflict/i);
});
it("validates external via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="external-dir">
<wc-status item="external" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i);
});
it("validates file-external via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="external-file.txt">
<wc-status item="normal" props="none" file-external="true" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i);
});
it("validates working-copy locked via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="locked-file.txt">
<wc-status item="normal" props="none" wc-locked="true" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/locked/i);
});
it("validates obstruction via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="obstructed.txt">
<wc-status item="obstructed" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/obstruction/i);
});
it("validates incomplete via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="incomplete.txt">
<wc-status item="incomplete" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/incomplete/i);
});
});
// ---------------------------------------------------------------------------
// SVN Diff Generation
// ---------------------------------------------------------------------------
describe("SVN diff generation", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
createSvnRepo(repoDir, wcDir);
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("generates diff for modified files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "modified content\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("-initial content");
expect(diff).toContain("+modified content");
});
it("generates diff for unversioned additions", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "new.txt"), "new file\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/new.txt");
expect(diff).toContain("+new file");
});
it("generates diff for directly missing tracked files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.unlinkSync(path.join(wcDir, "src", "test.txt"));
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/test.txt");
expect(diff).toContain("-initial content");
});
it("handles files without trailing newline", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "no-newline.txt"), "no newline");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/no-newline.txt");
expect(diff).toContain("+no newline");
expect(diff).toContain("\\ No newline at end of file");
});
it("handles empty unversioned files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "empty.txt"), "");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/empty.txt");
});
it("handles paths with spaces and special characters", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "file with spaces.txt"), "content\n");
fs.writeFileSync(path.join(wcDir, "src", "file's apostrophe.txt"), "content\n");
fs.writeFileSync(path.join(wcDir, "src", "文件.txt"), "Chinese filename\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("file with spaces.txt");
expect(diff).toContain("file's apostrophe.txt");
expect(diff).toContain("文件.txt");
});
it("handles binary unversioned files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Create a binary file with null bytes
const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02]);
fs.writeFileSync(path.join(wcDir, "src", "binary.dat"), binaryData);
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/binary.dat");
expect(diff).toContain("binary type");
});
it("handles property-only changes", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Set a property on existing file
execFileSync("svn", ["propset", "svn:eol-style", "native", "src/test.txt"], {
cwd: wcDir,
stdio: "ignore",
});
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/test.txt");
expect(diff).toContain("svn:eol-style");
});
it("skips directories in unversioned additions", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Create unversioned directory with a file
fs.mkdirSync(path.join(wcDir, "src", "newdir"));
fs.writeFileSync(path.join(wcDir, "src", "newdir", "file.txt"), "content\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
// SVN status only reports the parent directory as unversioned, not contents
// If directory is reported, it's skipped; if only file, it's included
// This test verifies directories themselves are skipped in diff generation
expect(diff).not.toContain("Cannot display");
});
});
// ---------------------------------------------------------------------------
// Extension Feature with Fake Executor
// ---------------------------------------------------------------------------
describe("Budget extension with fake executor", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let originalWorkflowDir: string | undefined;
let fakeBinDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "workflows");
fakeBinDir = path.join(tmpDir, "bin");
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeBinDir, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
fs.writeFileSync(path.join(repoRoot, "test.txt"), "test");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot });
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
// Create fake reasonix that returns immediately
const fakeBin = path.join(fakeBinDir, "reasonix");
fs.writeFileSync(fakeBin, `#!/bin/bash\necho '{"session_id":"test-session"}'\nexit 0\n`, { mode: 0o755 });
});
afterEach(() => {
if (originalWorkflowDir === undefined) {
delete process.env.AGENT_WORKFLOW_DIR;
} else {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
}
cleanupDir(tmpDir);
});
it("extends budget and records history in state", async () => {
const runId = "test-extend-001";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
fs.writeFileSync(path.join(tmpDir, "session.jsonl"), "");
state.executorConfig = { binary: path.join(fakeBinDir, "reasonix") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "f1", disposition: "accept", summary: "Fix", rationale: "Test" }],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Extension should succeed and resume executor
await extendWorkflow({ runId, additionalCycles: 3 });
// Check state after successful extension
const updatedState = readState(dir);
expect(updatedState).toBeDefined();
// State should be awaiting_review after fake executor completes
expect(updatedState!.status).toBe("awaiting_review");
expect(updatedState!.maxCycles).toBe(5);
expect(updatedState!.currentCycle).toBe(3);
// Session handle unchanged
expect(updatedState!.sessionHandle?.kind).toBe("reasonix");
expect(updatedState!.sessionHandle?.sessionFilePath).toBe(path.join(tmpDir, "session.jsonl"));
// Extension history recorded
expect(updatedState!.extensionHistory).toBeDefined();
expect(updatedState!.extensionHistory).toHaveLength(1);
expect(updatedState!.extensionHistory![0].oldMaxCycles).toBe(2);
expect(updatedState!.extensionHistory![0].newMaxCycles).toBe(5);
expect(updatedState!.extensionHistory![0].additionalCycles).toBe(3);
expect(updatedState!.extensionHistory![0].resumedCycle).toBe(3);
// Check cycle 3 was created
expect(updatedState!.cycles).toHaveLength(2);
expect(updatedState!.cycles[1].cycleIndex).toBe(3);
}, 10000);
it("rejects extension with lock conflict", async () => {
const runId = "test-extend-lock";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Acquire lock with different runId to simulate conflict
const { acquireLock } = await import("./workflow-state.js");
acquireLock(repoRoot, "other-run-id");
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/workflow.*active/i);
// Clean up
releaseLock(repoRoot, "other-run-id");
});
it("rejects extension with out-of-scope changes", async () => {
const runId = "test-extend-scope";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Create out-of-scope file
fs.writeFileSync(path.join(repoRoot, "out-of-scope.txt"), "bad");
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/out-of-scope/i);
// Clean up
fs.unlinkSync(path.join(repoRoot, "out-of-scope.txt"));
});
it("rolls back on corrupt decision with lock released", async () => {
const runId = "test-extend-corrupt";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
// Write corrupt decision JSON
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), "{ invalid json");
writeState(dir, state);
const originalMaxCycles = state.maxCycles;
const originalStatus = state.status;
const originalCycleCount = state.cycles.length;
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/decision/i);
// State should be unchanged (rollback)
const unchangedState = readState(dir);
expect(unchangedState!.maxCycles).toBe(originalMaxCycles);
expect(unchangedState!.status).toBe(originalStatus);
expect(unchangedState!.cycles).toHaveLength(originalCycleCount);
expect(unchangedState!.extensionHistory).toBeUndefined();
// Lock should be released
const { acquireLock } = await import("./workflow-state.js");
expect(() => acquireLock(repoRoot, runId)).not.toThrow();
releaseLock(repoRoot, runId);
});
it("releases lock on baseline drift", async () => {
const runId = "test-extend-002";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const oldHead = gitHead(repoRoot);
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: oldHead },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Create baseline drift
fs.writeFileSync(path.join(repoRoot, "new.txt"), "drift");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "drift"], { cwd: repoRoot });
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/baseline/i);
// The extension should have acquired and then released the lock on failure
// Try to acquire it ourselves to confirm it was released
const { acquireLock } = await import("./workflow-state.js");
expect(() => acquireLock(repoRoot, runId)).not.toThrow();
releaseLock(repoRoot, runId);
});
});
// ---------------------------------------------------------------------------
// Backward Compatibility
// ---------------------------------------------------------------------------
describe("Backward compatibility", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("reads old state files with only baselineHead", () => {
const dir = path.join(tmpDir, "run-001");
fs.mkdirSync(dir, { recursive: true });
const oldState = {
version: "1",
runId: "run-001",
repoHash: "abcd1234",
repoRoot: tmpDir,
baselineHead: "abc123def456",
executor: "reasonix",
plan: makeValidPlan(),
status: "awaiting_review",
maxCycles: 5,
currentCycle: 1,
cycles: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(
path.join(dir, "state.json"),
JSON.stringify(oldState, null, 2)
);
const state = readState(dir);
expect(state).toBeDefined();
expect(state!.baselineHead).toBe("abc123def456");
expect(state!.vcsBaseline).toBeUndefined();
expect(state!.vcsKind).toBeUndefined();
});
});
+51 -5
View File
@@ -15,6 +15,31 @@
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const;
export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// ---------------------------------------------------------------------------
// VCS abstraction
// ---------------------------------------------------------------------------
export const VCS_KINDS = ["git", "svn"] as const;
export type VcsKind = (typeof VCS_KINDS)[number];
/** Structured VCS baseline (replaces single baselineHead string). */
export interface VcsBaselineGit {
kind: "git";
head: string;
}
export interface VcsBaselineSvn {
kind: "svn";
/** Repository root URL */
repoUrl: string;
/** Repository UUID */
repoUuid: string;
/** Single working revision (mixed revisions are rejected) */
revision: number;
}
export type VcsBaseline = VcsBaselineGit | VcsBaselineSvn;
// ---------------------------------------------------------------------------
// Progress sidecar — lightweight real-time observability
// ---------------------------------------------------------------------------
@@ -167,8 +192,12 @@ export interface WorkflowState {
repoHash: string;
/** Absolute path to the repository root. */
repoRoot: string;
/** Git HEAD SHA at workflow start. */
baselineHead: string;
/** Git HEAD SHA at workflow start (version 1 states only). */
baselineHead?: string;
/** Structured VCS baseline (replaces baselineHead for new runs). */
vcsBaseline?: VcsBaseline;
/** VCS kind detected or specified at start. */
vcsKind?: VcsKind;
executor: ExecutorKind;
executorConfig?: ExecutorConfig;
timeoutSeconds?: number;
@@ -187,6 +216,16 @@ export interface WorkflowState {
updatedAt: string;
/** True when --agy-degraded-continue is in effect. */
agyDegraded?: boolean;
/** Extension history: records of each budget extension. */
extensionHistory?: ExtensionRecord[];
}
export interface ExtensionRecord {
extendedAt: string;
oldMaxCycles: number;
newMaxCycles: number;
additionalCycles: number;
resumedCycle: number;
}
// ---------------------------------------------------------------------------
@@ -230,7 +269,9 @@ export interface HostReviewBundleV1 {
runId: string;
cycleIndex: number;
repoRoot: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: VcsKind;
generatedAt: string;
plan: WorkflowPlanV1;
diffFile: string;
@@ -255,6 +296,7 @@ export interface ExecutorConfig {
export interface WorkflowProjectConfig {
version: "1";
defaultExecutor?: ExecutorKind;
defaultVcs?: "auto" | VcsKind;
maxCycles?: number;
timeoutSeconds?: number;
executors?: {
@@ -270,6 +312,7 @@ export interface WorkflowProjectConfig {
export interface ResolvedWorkflowConfig {
executor: ExecutorKind;
vcs?: VcsKind;
maxCycles: number;
timeoutSeconds: number;
executorConfig: ExecutorConfig;
@@ -295,7 +338,9 @@ export interface WorkflowStatusOutput {
scopeViolations?: ScopeViolation[];
stopReason?: WorkflowTerminalStatus;
stopDescription?: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: VcsKind;
repoRoot: string;
createdAt: string;
updatedAt: string;
@@ -332,7 +377,8 @@ export type WorkflowEventKind =
| "cycle_completed"
| "workflow_stopped"
| "workflow_aborted"
| "blocked";
| "blocked"
| "budget_extended";
export interface WorkflowEvent {
kind: WorkflowEventKind;