agent-gauntlet 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/cli-adapters/claude.ts +13 -1
  4. package/src/cli-adapters/gemini.ts +17 -2
  5. package/src/commands/check.ts +108 -12
  6. package/src/commands/ci/list-jobs.ts +3 -2
  7. package/src/commands/clean.ts +29 -0
  8. package/src/commands/help.ts +1 -1
  9. package/src/commands/index.ts +2 -1
  10. package/src/commands/init.ts +4 -4
  11. package/src/commands/review.ts +108 -12
  12. package/src/commands/run.ts +109 -12
  13. package/src/commands/shared.ts +56 -10
  14. package/src/commands/validate.ts +20 -0
  15. package/src/config/schema.ts +5 -0
  16. package/src/config/validator.ts +6 -13
  17. package/src/core/change-detector.ts +1 -0
  18. package/src/core/entry-point.ts +48 -7
  19. package/src/core/runner.ts +90 -56
  20. package/src/gates/result.ts +32 -0
  21. package/src/gates/review.ts +428 -162
  22. package/src/index.ts +4 -2
  23. package/src/output/console-log.ts +146 -0
  24. package/src/output/console.ts +103 -9
  25. package/src/output/logger.ts +52 -8
  26. package/src/templates/run_gauntlet.template.md +20 -13
  27. package/src/utils/log-parser.ts +498 -162
  28. package/src/utils/session-ref.ts +82 -0
  29. package/src/commands/check.test.ts +0 -29
  30. package/src/commands/detect.test.ts +0 -43
  31. package/src/commands/health.test.ts +0 -93
  32. package/src/commands/help.test.ts +0 -44
  33. package/src/commands/init.test.ts +0 -130
  34. package/src/commands/list.test.ts +0 -121
  35. package/src/commands/rerun.ts +0 -160
  36. package/src/commands/review.test.ts +0 -31
  37. package/src/commands/run.test.ts +0 -27
  38. package/src/config/loader.test.ts +0 -151
  39. package/src/core/entry-point.test.ts +0 -61
  40. package/src/gates/review.test.ts +0 -291
@@ -6,8 +6,19 @@ import { EntryPointExpander } from "../core/entry-point.js";
6
6
  import { JobGenerator } from "../core/job.js";
7
7
  import { Runner } from "../core/runner.js";
8
8
  import { ConsoleReporter } from "../output/console.js";
9
+ import { startConsoleLog } from "../output/console-log.js";
9
10
  import { Logger } from "../output/logger.js";
10
- import { rotateLogs } from "./shared.js";
11
+ import {
12
+ findPreviousFailures,
13
+ type PreviousViolation,
14
+ } from "../utils/log-parser.js";
15
+ import { readSessionRef, writeSessionRef } from "../utils/session-ref.js";
16
+ import {
17
+ acquireLock,
18
+ cleanLogs,
19
+ hasExistingLogs,
20
+ releaseLock,
21
+ } from "./shared.js";
11
22
 
12
23
  export function registerRunCommand(program: Command): void {
13
24
  program
@@ -24,14 +35,16 @@ export function registerRunCommand(program: Command): void {
24
35
  "Use diff for current uncommitted changes (staged and unstaged)",
25
36
  )
26
37
  .action(async (options) => {
38
+ let config: Awaited<ReturnType<typeof loadConfig>> | undefined;
39
+ let lockAcquired = false;
40
+ let restoreConsole: (() => void) | undefined;
27
41
  try {
28
- const config = await loadConfig();
29
-
30
- // Rotate logs before starting
31
- await rotateLogs(config.project.log_dir);
42
+ config = await loadConfig();
43
+ restoreConsole = await startConsoleLog(config.project.log_dir);
44
+ await acquireLock(config.project.log_dir);
45
+ lockAcquired = true;
32
46
 
33
47
  // Determine effective base branch
34
- // Priority: CLI override > CI env var > config
35
48
  const effectiveBaseBranch =
36
49
  options.baseBranch ||
37
50
  (process.env.GITHUB_BASE_REF &&
@@ -40,10 +53,77 @@ export function registerRunCommand(program: Command): void {
40
53
  : null) ||
41
54
  config.project.base_branch;
42
55
 
43
- const changeDetector = new ChangeDetector(effectiveBaseBranch, {
44
- commit: options.commit,
45
- uncommitted: options.uncommitted,
46
- });
56
+ // Detect rerun mode: if logs exist and not targeting a specific commit, enter verification mode
57
+ const logsExist = await hasExistingLogs(config.project.log_dir);
58
+ const isRerun = logsExist && !options.commit;
59
+
60
+ let failuresMap:
61
+ | Map<string, Map<string, PreviousViolation[]>>
62
+ | undefined;
63
+ let changeOptions:
64
+ | { commit?: string; uncommitted?: boolean; fixBase?: string }
65
+ | undefined;
66
+
67
+ if (isRerun) {
68
+ console.log(
69
+ chalk.dim(
70
+ "Existing logs detected — running in verification mode...",
71
+ ),
72
+ );
73
+ const previousFailures = await findPreviousFailures(
74
+ config.project.log_dir,
75
+ options.gate,
76
+ );
77
+
78
+ failuresMap = new Map();
79
+ for (const gateFailure of previousFailures) {
80
+ const adapterMap = new Map<string, PreviousViolation[]>();
81
+ for (const af of gateFailure.adapterFailures) {
82
+ // Use review index as key if available (new @index pattern)
83
+ const key = af.reviewIndex
84
+ ? String(af.reviewIndex)
85
+ : af.adapterName;
86
+ adapterMap.set(key, af.violations);
87
+ }
88
+ failuresMap.set(gateFailure.jobId, adapterMap);
89
+ }
90
+
91
+ if (previousFailures.length > 0) {
92
+ const totalViolations = previousFailures.reduce(
93
+ (sum, gf) =>
94
+ sum +
95
+ gf.adapterFailures.reduce(
96
+ (s, af) => s + af.violations.length,
97
+ 0,
98
+ ),
99
+ 0,
100
+ );
101
+ console.log(
102
+ chalk.yellow(
103
+ `Found ${previousFailures.length} gate(s) with ${totalViolations} previous violation(s)`,
104
+ ),
105
+ );
106
+ }
107
+
108
+ changeOptions = { uncommitted: true };
109
+ const fixBase = await readSessionRef(config.project.log_dir);
110
+ if (fixBase) {
111
+ changeOptions.fixBase = fixBase;
112
+ }
113
+ } else if (options.commit || options.uncommitted) {
114
+ changeOptions = {
115
+ commit: options.commit,
116
+ uncommitted: options.uncommitted,
117
+ };
118
+ }
119
+
120
+ const changeDetector = new ChangeDetector(
121
+ effectiveBaseBranch,
122
+ changeOptions || {
123
+ commit: options.commit,
124
+ uncommitted: options.uncommitted,
125
+ },
126
+ );
47
127
  const expander = new EntryPointExpander();
48
128
  const jobGen = new JobGenerator(config);
49
129
 
@@ -52,6 +132,8 @@ export function registerRunCommand(program: Command): void {
52
132
 
53
133
  if (changes.length === 0) {
54
134
  console.log(chalk.green("No changes detected."));
135
+ await releaseLock(config.project.log_dir);
136
+ restoreConsole?.();
55
137
  process.exit(0);
56
138
  }
57
139
 
@@ -69,6 +151,8 @@ export function registerRunCommand(program: Command): void {
69
151
 
70
152
  if (jobs.length === 0) {
71
153
  console.log(chalk.yellow("No applicable gates for these changes."));
154
+ await releaseLock(config.project.log_dir);
155
+ restoreConsole?.();
72
156
  process.exit(0);
73
157
  }
74
158
 
@@ -80,16 +164,29 @@ export function registerRunCommand(program: Command): void {
80
164
  config,
81
165
  logger,
82
166
  reporter,
83
- undefined,
84
- undefined,
167
+ failuresMap,
168
+ changeOptions,
85
169
  effectiveBaseBranch,
86
170
  );
87
171
 
88
172
  const success = await runner.run(jobs);
173
+
174
+ if (success) {
175
+ await cleanLogs(config.project.log_dir);
176
+ } else {
177
+ await writeSessionRef(config.project.log_dir);
178
+ }
179
+
180
+ await releaseLock(config.project.log_dir);
181
+ restoreConsole?.();
89
182
  process.exit(success ? 0 : 1);
90
183
  } catch (error: unknown) {
184
+ if (config && lockAcquired) {
185
+ await releaseLock(config.project.log_dir);
186
+ }
91
187
  const err = error as { message?: string };
92
188
  console.error(chalk.red("Error:"), err.message);
189
+ restoreConsole?.();
93
190
  process.exit(1);
94
191
  }
95
192
  });
@@ -1,26 +1,71 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { clearSessionRef } from "../utils/session-ref";
3
4
 
4
- export async function exists(path: string): Promise<boolean> {
5
+ const LOCK_FILENAME = ".gauntlet-run.lock";
6
+
7
+ export async function exists(filePath: string): Promise<boolean> {
5
8
  try {
6
- await fs.stat(path);
9
+ await fs.stat(filePath);
7
10
  return true;
8
11
  } catch {
9
12
  return false;
10
13
  }
11
14
  }
12
15
 
13
- export async function rotateLogs(logDir: string): Promise<void> {
16
+ export async function acquireLock(logDir: string): Promise<void> {
17
+ await fs.mkdir(logDir, { recursive: true });
18
+ const lockPath = path.resolve(logDir, LOCK_FILENAME);
19
+ try {
20
+ await fs.writeFile(lockPath, String(process.pid), { flag: "wx" });
21
+ } catch (err: unknown) {
22
+ if (
23
+ typeof err === "object" &&
24
+ err !== null &&
25
+ "code" in err &&
26
+ (err as { code: string }).code === "EEXIST"
27
+ ) {
28
+ console.error(
29
+ `Error: A gauntlet run is already in progress (lock file: ${lockPath}).`,
30
+ );
31
+ console.error(
32
+ "If no run is actually in progress, delete the lock file manually.",
33
+ );
34
+ process.exit(1);
35
+ }
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ export async function releaseLock(logDir: string): Promise<void> {
41
+ const lockPath = path.resolve(logDir, LOCK_FILENAME);
42
+ try {
43
+ await fs.rm(lockPath, { force: true });
44
+ } catch {
45
+ // no-op if missing
46
+ }
47
+ }
48
+
49
+ export async function hasExistingLogs(logDir: string): Promise<boolean> {
50
+ try {
51
+ const entries = await fs.readdir(logDir);
52
+ return entries.some(
53
+ (f) => (f.endsWith(".log") || f.endsWith(".json")) && f !== "previous",
54
+ );
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export async function cleanLogs(logDir: string): Promise<void> {
14
61
  const previousDir = path.join(logDir, "previous");
15
62
 
16
63
  try {
17
- // 1. Ensure logDir exists (if not, nothing to rotate, but we should create it for future use if needed,
18
- // though usually the logger creates it. If it doesn't exist, we can just return).
19
64
  if (!(await exists(logDir))) {
20
65
  return;
21
66
  }
22
67
 
23
- // 2. Clear gauntlet_logs/previous if it exists
68
+ // 1. Delete all files in previous/
24
69
  if (await exists(previousDir)) {
25
70
  const previousFiles = await fs.readdir(previousDir);
26
71
  await Promise.all(
@@ -32,19 +77,20 @@ export async function rotateLogs(logDir: string): Promise<void> {
32
77
  await fs.mkdir(previousDir, { recursive: true });
33
78
  }
34
79
 
35
- // 3. Move all existing files in gauntlet_logs/ to gauntlet_logs/previous
80
+ // 2. Move all .log and .json files from logDir root into previous/
36
81
  const files = await fs.readdir(logDir);
37
82
  await Promise.all(
38
83
  files
39
- .filter((file) => file !== "previous")
84
+ .filter((file) => file.endsWith(".log") || file.endsWith(".json"))
40
85
  .map((file) =>
41
86
  fs.rename(path.join(logDir, file), path.join(previousDir, file)),
42
87
  ),
43
88
  );
89
+
90
+ await clearSessionRef(logDir);
44
91
  } catch (error) {
45
- // Log warning but don't crash the run as log rotation failure isn't critical
46
92
  console.warn(
47
- "Failed to rotate logs in",
93
+ "Failed to clean logs in",
48
94
  logDir,
49
95
  ":",
50
96
  error instanceof Error ? error.message : error,
@@ -0,0 +1,20 @@
1
+ import chalk from "chalk";
2
+ import type { Command } from "commander";
3
+ import { loadConfig } from "../config/loader.js";
4
+
5
+ export function registerValidateCommand(program: Command): void {
6
+ program
7
+ .command("validate")
8
+ .description("Validate .gauntlet/ config files against schemas")
9
+ .action(async () => {
10
+ try {
11
+ await loadConfig();
12
+ console.log(chalk.green("All config files are valid."));
13
+ process.exitCode = 0;
14
+ } catch (error: unknown) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ console.error(chalk.red("Validation failed:"), message);
17
+ process.exitCode = 1;
18
+ }
19
+ });
20
+ }
@@ -51,6 +51,7 @@ export const reviewPromptFrontmatterSchema = z.object({
51
51
 
52
52
  export const entryPointSchema = z.object({
53
53
  path: z.string().min(1),
54
+ exclude: z.array(z.string().min(1)).optional(),
54
55
  checks: z.array(z.string().min(1)).optional(),
55
56
  reviews: z.array(z.string().min(1)).optional(),
56
57
  });
@@ -59,6 +60,10 @@ export const gauntletConfigSchema = z.object({
59
60
  base_branch: z.string().min(1).default("origin/main"),
60
61
  log_dir: z.string().min(1).default("gauntlet_logs"),
61
62
  allow_parallel: z.boolean().default(true),
63
+ max_retries: z.number().default(3),
64
+ rerun_new_issue_threshold: z
65
+ .enum(["critical", "high", "medium", "low"])
66
+ .default("high"),
62
67
  cli: cliConfigSchema,
63
68
  entry_points: z.array(entryPointSchema).min(1),
64
69
  });
@@ -108,12 +108,13 @@ export async function validateConfig(
108
108
  if (file.endsWith(".yml") || file.endsWith(".yaml")) {
109
109
  const filePath = path.join(checksPath, file);
110
110
  filesChecked.push(filePath);
111
+ const name = path.basename(file, path.extname(file));
111
112
  try {
112
113
  const content = await fs.readFile(filePath, "utf-8");
113
114
  const raw = YAML.parse(content);
114
115
  const parsed = checkGateSchema.parse(raw);
115
- existingCheckNames.add(parsed.name); // Track that this check exists
116
- checks[parsed.name] = parsed;
116
+ existingCheckNames.add(name); // Track that this check exists
117
+ checks[name] = parsed;
117
118
 
118
119
  // Semantic validation
119
120
  if (!parsed.command || parsed.command.trim() === "") {
@@ -125,17 +126,9 @@ export async function validateConfig(
125
126
  });
126
127
  }
127
128
  } catch (error: unknown) {
128
- // Try to extract check name from raw YAML even if parsing failed
129
- try {
130
- const content = await fs.readFile(filePath, "utf-8");
131
- const raw = YAML.parse(content);
132
- if (raw.name && typeof raw.name === "string") {
133
- existingCheckNames.add(raw.name); // Track that this check file exists
134
- }
135
- } catch {
136
- // If we can't even parse the name, that's okay - we'll just skip tracking it
137
- }
138
-
129
+ // Track that this check file exists even if parsing failed
130
+ // Use filename-based name since name is no longer in YAML
131
+ existingCheckNames.add(name);
139
132
  if (error instanceof ZodError) {
140
133
  error.errors.forEach((err) => {
141
134
  issues.push({
@@ -6,6 +6,7 @@ const execAsync = promisify(exec);
6
6
  export interface ChangeDetectorOptions {
7
7
  commit?: string; // If provided, get diff for this commit vs its parent
8
8
  uncommitted?: boolean; // If true, only get uncommitted changes (staged + unstaged)
9
+ fixBase?: string; // If provided, get diff from this ref to current working tree
9
10
  }
10
11
 
11
12
  export class ChangeDetector {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { Glob } from "bun";
3
4
  import type { EntryPointConfig } from "../config/types.js";
4
5
 
5
6
  export interface ExpandedEntryPoint {
@@ -16,24 +17,37 @@ export class EntryPointExpander {
16
17
  const rootEntryPoint = entryPoints.find((ep) => ep.path === ".");
17
18
 
18
19
  // Always include root entry point if configured and there are ANY changes
19
- // Or should it only run if files match root patterns?
20
- // Spec says: "A root entry point always exists and applies to repository-wide gates."
21
- // Usually root gates run on any change or specific files in root.
22
- // For simplicity, if root is configured, we'll include it if there are any changed files.
23
20
  if (changedFiles.length > 0) {
24
21
  const rootConfig = rootEntryPoint ?? { path: "." };
25
- results.push({ path: ".", config: rootConfig });
22
+ // Apply exclusion filtering for root if configured
23
+ const filteredRootChanges = this.filterExcludedFiles(
24
+ changedFiles,
25
+ rootConfig.exclude,
26
+ );
27
+
28
+ if (filteredRootChanges.length > 0) {
29
+ results.push({ path: ".", config: rootConfig });
30
+ }
26
31
  }
27
32
 
28
33
  for (const ep of entryPoints) {
29
34
  if (ep.path === ".") continue; // Handled above
30
35
 
36
+ // Apply exclusion filtering first!
37
+ const filteredChanges = this.filterExcludedFiles(
38
+ changedFiles,
39
+ ep.exclude,
40
+ );
41
+
42
+ // If no relevant files remain, skip this entry point
43
+ if (filteredChanges.length === 0) continue;
44
+
31
45
  if (ep.path.endsWith("*")) {
32
46
  // Wildcard directory (e.g., "engines/*")
33
47
  const parentDir = ep.path.slice(0, -2); // "engines"
34
48
  const expandedPaths = await this.expandWildcard(
35
49
  parentDir,
36
- changedFiles,
50
+ filteredChanges,
37
51
  );
38
52
 
39
53
  for (const subDir of expandedPaths) {
@@ -44,7 +58,7 @@ export class EntryPointExpander {
44
58
  }
45
59
  } else {
46
60
  // Fixed directory (e.g., "apps/api")
47
- if (this.hasChangesInDir(ep.path, changedFiles)) {
61
+ if (this.hasChangesInDir(ep.path, filteredChanges)) {
48
62
  results.push({
49
63
  path: ep.path,
50
64
  config: ep,
@@ -81,6 +95,33 @@ export class EntryPointExpander {
81
95
  return results;
82
96
  }
83
97
 
98
+ private filterExcludedFiles(files: string[], patterns?: string[]): string[] {
99
+ if (!patterns || patterns.length === 0) {
100
+ return files;
101
+ }
102
+
103
+ // Pre-compile globs
104
+ const globs: Glob[] = [];
105
+ const prefixes: string[] = [];
106
+
107
+ for (const pattern of patterns) {
108
+ if (pattern.match(/[*?[{]/)) {
109
+ globs.push(new Glob(pattern));
110
+ } else {
111
+ prefixes.push(pattern);
112
+ }
113
+ }
114
+
115
+ return files.filter((file) => {
116
+ // If matches ANY pattern, exclude it
117
+ const isExcluded =
118
+ prefixes.some((p) => file === p || file.startsWith(`${p}/`)) ||
119
+ globs.some((g) => g.match(file));
120
+
121
+ return !isExcluded;
122
+ });
123
+ }
124
+
84
125
  private async expandWildcard(
85
126
  parentDir: string,
86
127
  changedFiles: string[],
@@ -39,6 +39,19 @@ export class Runner {
39
39
  async run(jobs: Job[]): Promise<boolean> {
40
40
  await this.logger.init();
41
41
 
42
+ // Enforce retry limit before executing gates
43
+ const maxRetries = this.config.project.max_retries ?? 3;
44
+ const currentRunNumber = this.logger.getRunNumber();
45
+ const maxAllowedRuns = maxRetries + 1;
46
+
47
+ if (currentRunNumber > maxAllowedRuns) {
48
+ console.error(
49
+ `Retry limit exceeded: run ${currentRunNumber} exceeds max allowed ${maxAllowedRuns} (max_retries: ${maxRetries}). Run \`agent-gauntlet clean\` to reset.`,
50
+ );
51
+ process.exitCode = 1;
52
+ return false;
53
+ }
54
+
42
55
  const { runnableJobs, preflightResults } = await this.preflight(jobs);
43
56
  this.results.push(...preflightResults);
44
57
 
@@ -54,7 +67,6 @@ export class Runner {
54
67
  const parallelPromises = parallelJobs.map((job) => this.executeJob(job));
55
68
 
56
69
  // Start sequential jobs
57
- // We run them one by one, but concurrently with the parallel batch
58
70
  const sequentialPromise = (async () => {
59
71
  for (const job of sequentialJobs) {
60
72
  if (this.shouldStop) break;
@@ -64,9 +76,21 @@ export class Runner {
64
76
 
65
77
  await Promise.all([...parallelPromises, sequentialPromise]);
66
78
 
67
- await this.reporter.printSummary(this.results);
79
+ const allPassed = this.results.every((r) => r.status === "pass");
68
80
 
69
- return this.results.every((r) => r.status === "pass");
81
+ // If on the final allowed run and gates failed, report "Retry limit exceeded"
82
+ if (!allPassed && currentRunNumber === maxAllowedRuns) {
83
+ await this.reporter.printSummary(
84
+ this.results,
85
+ this.config.project.log_dir,
86
+ "Retry limit exceeded",
87
+ );
88
+ return false;
89
+ }
90
+
91
+ await this.reporter.printSummary(this.results, this.config.project.log_dir);
92
+
93
+ return allPassed;
70
94
  }
71
95
 
72
96
  private async executeJob(job: Job): Promise<void> {
@@ -76,43 +100,53 @@ export class Runner {
76
100
 
77
101
  let result: GateResult;
78
102
 
79
- if (job.type === "check") {
80
- const logPath = this.logger.getLogPath(job.id);
81
- const jobLogger = await this.logger.createJobLogger(job.id);
82
- const effectiveBaseBranch =
83
- this.baseBranchOverride || this.config.project.base_branch;
84
- result = await this.checkExecutor.execute(
85
- job.id,
86
- job.gateConfig as LoadedCheckGateConfig,
87
- job.workingDirectory,
88
- jobLogger,
89
- effectiveBaseBranch,
90
- );
91
- result.logPath = logPath;
92
- } else {
93
- // Use sanitized Job ID for lookup because that's what log-parser uses (based on filenames)
94
- const safeJobId = sanitizeJobId(job.id);
95
- const previousFailures = this.previousFailuresMap?.get(safeJobId);
96
- const loggerFactory = this.logger.createLoggerFactory(job.id);
97
- const effectiveBaseBranch =
98
- this.baseBranchOverride || this.config.project.base_branch;
99
- result = await this.reviewExecutor.execute(
100
- job.id,
101
- job.gateConfig as ReviewGateConfig & ReviewPromptFrontmatter,
102
- job.entryPoint,
103
- loggerFactory,
104
- effectiveBaseBranch,
105
- previousFailures,
106
- this.changeOptions,
107
- this.config.project.cli.check_usage_limit,
108
- );
103
+ try {
104
+ if (job.type === "check") {
105
+ const logPath = await this.logger.getLogPath(job.id);
106
+ const jobLogger = await this.logger.createJobLogger(job.id);
107
+ const effectiveBaseBranch =
108
+ this.baseBranchOverride || this.config.project.base_branch;
109
+ result = await this.checkExecutor.execute(
110
+ job.id,
111
+ job.gateConfig as LoadedCheckGateConfig,
112
+ job.workingDirectory,
113
+ jobLogger,
114
+ effectiveBaseBranch,
115
+ );
116
+ result.logPath = logPath;
117
+ } else {
118
+ // Use sanitized Job ID for lookup because that's what log-parser uses (based on filenames)
119
+ const safeJobId = sanitizeJobId(job.id);
120
+ const previousFailures = this.previousFailuresMap?.get(safeJobId);
121
+ const loggerFactory = this.logger.createLoggerFactory(job.id);
122
+ const effectiveBaseBranch =
123
+ this.baseBranchOverride || this.config.project.base_branch;
124
+ result = await this.reviewExecutor.execute(
125
+ job.id,
126
+ job.gateConfig as ReviewGateConfig & ReviewPromptFrontmatter,
127
+ job.entryPoint,
128
+ loggerFactory,
129
+ effectiveBaseBranch,
130
+ previousFailures,
131
+ this.changeOptions,
132
+ this.config.project.cli.check_usage_limit,
133
+ this.config.project.rerun_new_issue_threshold,
134
+ );
135
+ }
136
+ } catch (err) {
137
+ console.error("[ERROR] Execution failed for", job.id, ":", err);
138
+ result = {
139
+ jobId: job.id,
140
+ status: "error",
141
+ duration: 0,
142
+ message: err instanceof Error ? err.message : String(err),
143
+ };
109
144
  }
110
145
 
111
146
  this.results.push(result);
112
147
  this.reporter.onJobComplete(job, result);
113
148
 
114
149
  // Handle Fail Fast (only for checks, and only when parallel is false)
115
- // fail_fast can only be set on checks when parallel is false (enforced by schema)
116
150
  if (
117
151
  result.status !== "pass" &&
118
152
  job.type === "check" &&
@@ -136,9 +170,9 @@ export class Runner {
136
170
  (job.gateConfig as LoadedCheckGateConfig).command,
137
171
  );
138
172
  if (!commandName) {
139
- preflightResults.push(
140
- await this.recordPreflightFailure(job, "Unable to parse command"),
141
- );
173
+ const msg = "Unable to parse command";
174
+ console.error(`[PREFLIGHT] ${job.id}: ${msg}`);
175
+ preflightResults.push(await this.recordPreflightFailure(job, msg));
142
176
  if (this.shouldFailFast(job)) this.shouldStop = true;
143
177
  continue;
144
178
  }
@@ -148,36 +182,32 @@ export class Runner {
148
182
  job.workingDirectory,
149
183
  );
150
184
  if (!available) {
151
- preflightResults.push(
152
- await this.recordPreflightFailure(
153
- job,
154
- `Missing command: ${commandName}`,
155
- ),
156
- );
185
+ const msg = `Missing command: ${commandName}`;
186
+ console.error(`[PREFLIGHT] ${job.id}: ${msg}`);
187
+ preflightResults.push(await this.recordPreflightFailure(job, msg));
157
188
  if (this.shouldFailFast(job)) this.shouldStop = true;
158
189
  continue;
159
190
  }
160
191
  } else {
161
192
  const reviewConfig = job.gateConfig as ReviewGateConfig &
162
193
  ReviewPromptFrontmatter;
163
- const required = reviewConfig.num_reviews ?? 1;
164
- const availableTools: string[] = [];
165
194
 
195
+ // Only need at least 1 healthy adapter (round-robin handles the rest)
196
+ let hasHealthy = false;
166
197
  for (const toolName of reviewConfig.cli_preference || []) {
167
- if (availableTools.length >= required) break;
168
198
  const cached = cliCache.get(toolName);
169
199
  const isAvailable = cached ?? (await this.checkAdapter(toolName));
170
200
  cliCache.set(toolName, isAvailable);
171
- if (isAvailable) availableTools.push(toolName);
201
+ if (isAvailable) {
202
+ hasHealthy = true;
203
+ break;
204
+ }
172
205
  }
173
206
 
174
- if (availableTools.length < required) {
175
- preflightResults.push(
176
- await this.recordPreflightFailure(
177
- job,
178
- `Missing CLI tools: need ${required}, found ${availableTools.length}`,
179
- ),
180
- );
207
+ if (!hasHealthy) {
208
+ const msg = "Preflight failed: no healthy adapters available";
209
+ console.error(`[PREFLIGHT] ${job.id}: ${msg}`);
210
+ preflightResults.push(await this.recordPreflightFailure(job, msg));
181
211
  if (this.shouldFailFast(job)) this.shouldStop = true;
182
212
  continue;
183
213
  }
@@ -194,7 +224,7 @@ export class Runner {
194
224
  message: string,
195
225
  ): Promise<GateResult> {
196
226
  if (job.type === "check") {
197
- const logPath = this.logger.getLogPath(job.id);
227
+ const logPath = await this.logger.getLogPath(job.id);
198
228
  const jobLogger = await this.logger.createJobLogger(job.id);
199
229
  await jobLogger(
200
230
  `[${new Date().toISOString()}] Health check failed\n${message}\n`,
@@ -222,6 +252,11 @@ export class Runner {
222
252
  const health = await adapter.checkHealth({
223
253
  checkUsageLimit: this.config.project.cli.check_usage_limit,
224
254
  });
255
+ if (health.status !== "healthy") {
256
+ console.log(
257
+ `[DEBUG] Adapter ${name} check failed: ${health.status} - ${health.message}`,
258
+ );
259
+ }
225
260
  return health.status === "healthy";
226
261
  }
227
262
 
@@ -267,7 +302,6 @@ export class Runner {
267
302
  }
268
303
 
269
304
  private shouldFailFast(job: Job): boolean {
270
- // Only checks can have fail_fast, and only when parallel is false
271
305
  return Boolean(job.type === "check" && job.gateConfig.fail_fast);
272
306
  }
273
307
  }