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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/cli-adapters/claude.ts +13 -1
- package/src/cli-adapters/gemini.ts +17 -2
- package/src/commands/check.ts +108 -12
- package/src/commands/ci/list-jobs.ts +3 -2
- package/src/commands/clean.ts +29 -0
- package/src/commands/help.ts +1 -1
- package/src/commands/index.ts +2 -1
- package/src/commands/init.ts +4 -4
- package/src/commands/review.ts +108 -12
- package/src/commands/run.ts +109 -12
- package/src/commands/shared.ts +56 -10
- package/src/commands/validate.ts +20 -0
- package/src/config/schema.ts +5 -0
- package/src/config/validator.ts +6 -13
- package/src/core/change-detector.ts +1 -0
- package/src/core/entry-point.ts +48 -7
- package/src/core/runner.ts +90 -56
- package/src/gates/result.ts +32 -0
- package/src/gates/review.ts +428 -162
- package/src/index.ts +4 -2
- package/src/output/console-log.ts +146 -0
- package/src/output/console.ts +103 -9
- package/src/output/logger.ts +52 -8
- package/src/templates/run_gauntlet.template.md +20 -13
- package/src/utils/log-parser.ts +498 -162
- package/src/utils/session-ref.ts +82 -0
- package/src/commands/check.test.ts +0 -29
- package/src/commands/detect.test.ts +0 -43
- package/src/commands/health.test.ts +0 -93
- package/src/commands/help.test.ts +0 -44
- package/src/commands/init.test.ts +0 -130
- package/src/commands/list.test.ts +0 -121
- package/src/commands/rerun.ts +0 -160
- package/src/commands/review.test.ts +0 -31
- package/src/commands/run.test.ts +0 -27
- package/src/config/loader.test.ts +0 -151
- package/src/core/entry-point.test.ts +0 -61
- package/src/gates/review.test.ts +0 -291
package/src/commands/run.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
});
|
package/src/commands/shared.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
+
const LOCK_FILENAME = ".gauntlet-run.lock";
|
|
6
|
+
|
|
7
|
+
export async function exists(filePath: string): Promise<boolean> {
|
|
5
8
|
try {
|
|
6
|
-
await fs.stat(
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
});
|
package/src/config/validator.ts
CHANGED
|
@@ -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(
|
|
116
|
-
checks[
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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 {
|
package/src/core/entry-point.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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[],
|
package/src/core/runner.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
+
const allPassed = this.results.every((r) => r.status === "pass");
|
|
68
80
|
|
|
69
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
|
201
|
+
if (isAvailable) {
|
|
202
|
+
hasHealthy = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
172
205
|
}
|
|
173
206
|
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
}
|