Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/docs/src/content/docs/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ A matched trigger becomes a skill task. Each task contains:
- model, runtime, max turns, chunking, and verification options
- output thresholds for failure and reporting

Warden launches matched skills in parallel. A shared semaphore gates file-level
Warden launches matched skills in parallel. A shared queue dispatches hunk
analysis so multiple skills can be active while total model concurrency stays
bounded.

Expand All @@ -88,8 +88,9 @@ Before a model sees code, Warden prepares the diff:
4. Expand each hunk with surrounding file context.
5. Group hunks by file.

The unit of main analysis is a hunk with context. Files run in parallel when
allowed by the runner, while hunks inside a file run in order.
The unit of main analysis is a hunk with context. Hunks are independent queue
items, so workers may analyze multiple hunks from the same file concurrently.
Reports preserve source order even when hunks finish out of order.

See [Chunking](/config/chunking/) for file pattern modes and coalescing
settings.
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/cli/run.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The bare `warden` command is an alias for this command.
| `--report-on <severity>` | Only show findings at or above this severity. |
| `--min-confidence <level>` | Only show findings at or above this confidence. |
| `--fix` | Automatically apply all suggested fixes. |
| `--parallel <n>` | Max concurrent file analyses across running skills. |
| `--parallel <n>` | Max concurrent hunk analyses across running skills. |
| `-x, --fail-fast` | Stop after the first finding. |
| `--staged` | Analyze only staged changes. |
| `--git` | Force ambiguous targets to be treated as git refs. |
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/config/runner.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ concurrency = 1
<code>concurrency</code>
<span class="sentry-property-meta">number</span>
</dt>
<dd>Maximum concurrent file analyses across running CLI skills. In GitHub Actions, also limits matched trigger dispatch.</dd>
<dd>Maximum concurrent hunk analyses across running CLI skills. In GitHub Actions, it also limits matched trigger dispatch.</dd>
</div>
</dl>

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/github/workflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,6 @@ that do not actually run for the current event complete as neutral.
<code>parallel</code>
<span class="sentry-property-meta">number</span>
</dt>
<dd>Maximum concurrent matched trigger executions and file analyses unless <code>runner.concurrency</code> is set. Default: <code>5</code>.</dd>
<dd>Maximum concurrent matched trigger executions and hunk analyses unless <code>runner.concurrency</code> is set. Default: <code>5</code>.</dd>
</div>
</dl>
4 changes: 1 addition & 3 deletions packages/warden/src/action/triggers/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ describe('executeTrigger', () => {
auxiliaryMaxRetries: 9,
}),
}),
expect.any(Number),
expect.anything(),
undefined
);
Expand All @@ -252,7 +251,6 @@ describe('executeTrigger', () => {
expect.objectContaining({
runnerOptions: expect.objectContaining({ historicalEvidence }),
}),
expect.any(Number),
expect.anything(),
undefined
);
Expand Down Expand Up @@ -611,7 +609,7 @@ describe('executeTrigger', () => {
reason: 'not real',
};

vi.mocked(runSkillTask).mockImplementation(async (_taskOptions, _fileConcurrency, callbacks) => {
vi.mocked(runSkillTask).mockImplementation(async (_taskOptions, callbacks) => {
callbacks.onFindingProcessing?.('test-trigger', event);
return { name: 'test-trigger', report: mockReport };
});
Expand Down
11 changes: 5 additions & 6 deletions packages/warden/src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import { runSkillTask, createDefaultCallbacks } from '../../cli/output/tasks.js'
import type { SkillTaskOptions } from '../../cli/output/tasks.js';
import { renderSkillReport } from '../../output/renderer.js';
import { logGroup, logGroupEnd } from '../workflow/base.js';
import { DEFAULT_FILE_CONCURRENCY, type AnalysisChunkingConfig } from '../../sdk/types.js';
import type { AnalysisChunkingConfig } from '../../sdk/types.js';
import type { FindingProcessingEvent } from '../../sdk/types.js';
import { SkillRunnerError } from '../../sdk/errors.js';
import type { Semaphore } from '../../utils/index.js';
import type { AsyncWorkQueue } from '../../utils/index.js';
import { Verbosity } from '../../cli/output/verbosity.js';
import type { ProviderFailureCircuitBreaker } from '../../sdk/circuit-breaker.js';
import { assertValidPiModelSelectors } from '../../sdk/runtimes/model-selectors.js';
Expand Down Expand Up @@ -100,8 +100,8 @@ export interface TriggerExecutorDeps {
globalRequestChanges?: boolean;
/** Global fail-check from action inputs (trigger-specific takes precedence) */
globalFailCheck?: boolean;
/** Global semaphore for limiting concurrent file analyses across triggers */
semaphore?: Semaphore;
/** Global queue for limiting concurrent hunk analyses across triggers. */
analysisQueue?: AsyncWorkQueue;
/** Shared controller for stopping the whole action run */
abortController?: AbortController;
/** Shared circuit breaker for auth/provider failures */
Expand Down Expand Up @@ -237,8 +237,7 @@ export async function executeTrigger(
defaultCallbacks.onFindingProcessing?.(skillName, event);
},
};
const fileConcurrency = deps.semaphore ? Number.MAX_SAFE_INTEGER : DEFAULT_FILE_CONCURRENCY;
const result = await runSkillTask(taskOptions, fileConcurrency, callbacks, deps.semaphore);
const result = await runSkillTask(taskOptions, callbacks, deps.analysisQueue);
const report = result.report;

if (!report) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
version = 1

[runner]
concurrency = 2

[defaults]
auxiliaryMaxRetries = 3

Expand Down
20 changes: 11 additions & 9 deletions packages/warden/src/action/workflow/pr-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ import {
} from './base.js';
import { runPRWorkflow } from './pr-workflow.js';
import { clearSkillsCache } from '../../skills/loader.js';
import { Semaphore } from '../../utils/index.js';
import { AsyncWorkQueue } from '../../utils/index.js';
import { buildFindingsOutput } from '../../reporting/output.js';

// Type the mocks
Expand Down Expand Up @@ -1621,14 +1621,12 @@ describe('runPRWorkflow', () => {
await runPRWorkflow(mockOctokit, createDefaultInputs(), 'pull_request', EVENT_PAYLOAD_PATH, FIXTURES_DIR);

expect(mockRunSkillTask).toHaveBeenCalledTimes(1);
const [taskOptions, fileConcurrency, _callbacks, semaphore] = mockRunSkillTask.mock.calls[0]!;
const [taskOptions, _callbacks, analysisQueue] = mockRunSkillTask.mock.calls[0]!;
expect(taskOptions).toEqual(expect.objectContaining({
name: 'test-skill',
displayName: 'test-skill',
}));
// When a semaphore is provided, fileConcurrency is unlimited (semaphore is the gate)
expect(fileConcurrency).toBe(Number.MAX_SAFE_INTEGER);
expect(semaphore).toBeInstanceOf(Semaphore);
expect(analysisQueue).toBeInstanceOf(AsyncWorkQueue);
});

it('writes a live snapshot after the trigger completes, carrying skillExecutionId and skippedTriggers', async () => {
Expand Down Expand Up @@ -1693,6 +1691,7 @@ describe('runPRWorkflow', () => {
let activeRuns = 0;
let maxActiveRuns = 0;
let invocationCount = 0;
const analysisQueues: (AsyncWorkQueue | undefined)[] = [];
let resolveFirstRun!: () => void;
let resolveFirstRunStarted!: () => void;
const firstRun = new Promise<void>((resolve) => {
Expand All @@ -1702,7 +1701,8 @@ describe('runPRWorkflow', () => {
resolveFirstRunStarted = resolve;
});

mockRunSkillTask.mockImplementation(async (taskOptions) => {
mockRunSkillTask.mockImplementation(async (taskOptions, _callbacks, analysisQueue) => {
analysisQueues.push(analysisQueue);
invocationCount++;
activeRuns++;
maxActiveRuns = Math.max(maxActiveRuns, activeRuns);
Expand Down Expand Up @@ -1742,6 +1742,10 @@ describe('runPRWorkflow', () => {
expect(mockRunSkillTask).toHaveBeenCalledTimes(2);
expect(callsBeforeFirstRunFinished).toBe(1);
expect(maxActiveRuns).toBe(1);
expect(analysisQueues).toHaveLength(2);
expect(analysisQueues[0]).toBeInstanceOf(AsyncWorkQueue);
expect(analysisQueues[1]).toBe(analysisQueues[0]);
expect(analysisQueues[0]?.concurrency).toBe(1);
});

it('accounts for a trigger the circuit breaker aborted before dispatch, and fails the run since nothing succeeded', async () => {
Expand Down Expand Up @@ -2225,7 +2229,7 @@ describe('runPRWorkflow', () => {
await runPRWorkflow(mockOctokit, createDefaultInputs(), 'pull_request', EVENT_PAYLOAD_PATH, FIXTURES_DIR);

// runSkillTask receives options with context containing the custom files
const [taskOptions, fileConcurrency, _callbacks, semaphore] = mockRunSkillTask.mock.calls[0]!;
const [taskOptions] = mockRunSkillTask.mock.calls[0]!;
expect(taskOptions.context.pullRequest?.files).toEqual(
expect.arrayContaining([
expect.objectContaining({
Expand All @@ -2234,8 +2238,6 @@ describe('runPRWorkflow', () => {
}),
])
);
expect(fileConcurrency).toBe(Number.MAX_SAFE_INTEGER);
expect(semaphore).toBeInstanceOf(Semaphore);
});
});

Expand Down
8 changes: 4 additions & 4 deletions packages/warden/src/action/workflow/pr-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { ExistingComment } from '../../output/dedup.js';
import { buildAnalyzedScope, findStaleComments, resolveStaleComments } from '../../output/stale.js';
import { filterFindings } from '../../types/index.js';
import type { EventContext, SkillReport, Finding } from '../../types/index.js';
import { runPool, Semaphore } from '../../utils/index.js';
import { AsyncWorkQueue, runPool } from '../../utils/index.js';
import { evaluateFixAttempts, postThreadReply } from '../fix-evaluation/index.js';
import type { EvaluateFixAttemptsResult, FixEvaluation } from '../fix-evaluation/index.js';
import { aggregateUsage } from '../../sdk/usage.js';
Expand Down Expand Up @@ -618,12 +618,12 @@ async function executeAllTriggers(
const concurrency = runnerConcurrency ?? inputs.parallel;
const runtimeEnv = await prepareRuntimeEnvironment(matchedTriggers, inputs);

const semaphore = new Semaphore(concurrency);
const analysisQueue = new AsyncWorkQueue(concurrency);
const abortController = new AbortController();
const circuitBreaker = new ProviderFailureCircuitBreaker({ abortController });
const completedSoFar: TriggerResult[] = [];

// Limit trigger dispatch too; the semaphore only gates work after a trigger starts.
// Limit trigger dispatch too; the analysis queue only gates work after a trigger starts.
const results = await runPool(
matchedTriggers,
concurrency,
Expand All @@ -637,7 +637,7 @@ async function executeAllTriggers(
globalMaxFindings: inputs.maxFindings,
globalRequestChanges: inputs.requestChanges,
globalFailCheck: inputs.failCheck,
semaphore,
analysisQueue,
abortController,
circuitBreaker,
checks: options.checks,
Expand Down
6 changes: 3 additions & 3 deletions packages/warden/src/action/workflow/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@
]);
});

it('passes auxiliaryMaxRetries through resolved schedule triggers', async () => {
it('passes runner and auxiliary settings through resolved schedule triggers', async () => {
mockRunSkill.mockResolvedValue(createSkillReport());
mockBuildContext.mockResolvedValue(createScheduleContext());

Expand All @@ -354,12 +354,12 @@
expect(mockRunSkill).toHaveBeenNthCalledWith(1,
expect.anything(),
expect.anything(),
expect.objectContaining({ auxiliaryMaxRetries: 7 })
expect.objectContaining({ auxiliaryMaxRetries: 7, concurrency: 2 })

Check warning on line 357 in packages/warden/src/action/workflow/schedule.test.ts

View check run for this annotation

@sentry/warden / warden: code-review

Base config runner concurrency incorrectly overrides repo config

`schedule.ts` computes `runnerConcurrency` with `??`, which picks the base config value before the merged config. When a base config defines `[runner].concurrency`, repo-level overrides are ignored even though `mergeRunnerConfig` already resolves them correctly into `layered.config`. Bug is at `schedule.ts:137-140`; nearest changed line in the hunk is the `concurrency: 2` assertion that exercises the setting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base config runner concurrency incorrectly overrides repo config

schedule.ts computes runnerConcurrency with ??, which picks the base config value before the merged config. When a base config defines [runner].concurrency, repo-level overrides are ignored even though mergeRunnerConfig already resolves them correctly into layered.config. Bug is at schedule.ts:137-140; nearest changed line in the hunk is the concurrency: 2 assertion that exercises the setting.

Evidence
  • mergeRunnerConfig in config/loader.ts merges runner configs with overlay (repo) priority via { ...base, ...overlay }.
  • loadLayeredWardenConfig returns the merged result in layered.config, so layered.config.runner?.concurrency already holds the correct effective value.
  • schedule.ts:137-140 computes runnerConcurrency = baseConfig?.runner?.concurrency ?? repoConfig?.runner?.concurrency ?? config.runner?.concurrency.
  • If the base config sets runner.concurrency = 5 and the repo config sets runner.concurrency = 2, the merged config correctly yields 2, but runnerConcurrency evaluates to 5 because ?? stops at the first non-nullish value.
  • This causes the schedule workflow to use the base concurrency limit instead of the repo override, violating the documented layering semantics.

Identified by Warden · code-review · 33R-MJS

);
expect(mockRunSkill).toHaveBeenNthCalledWith(2,
expect.anything(),
expect.anything(),
expect.objectContaining({ auxiliaryMaxRetries: 3 })
expect.objectContaining({ auxiliaryMaxRetries: 3, concurrency: 2 })
);
});

Expand Down
6 changes: 6 additions & 0 deletions packages/warden/src/action/workflow/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async function runScheduleWorkflowInner(
logGroupEnd();

let scheduleTriggers: ResolvedTrigger[];
let runnerConcurrency: number | undefined;
let skillRootsByName: LayeredSkillRootsByName | undefined;
let service = resolveActionServiceOptions(inputs);
try {
Expand All @@ -133,6 +134,10 @@ async function runScheduleWorkflowInner(
|| layered.repoConfig?.defaults?.offline === true
|| layered.config.defaults?.offline === true,
);
runnerConcurrency =
layered.baseConfig?.runner?.concurrency ??
layered.repoConfig?.runner?.concurrency ??
layered.config.runner?.concurrency;
skillRootsByName = buildSkillRootsByName(repoPath, layered, inputs.baseSkillRoot);
service = resolveActionServiceOptions(inputs, layered.config.service);
scheduleTriggers = resolveLayeredSkillConfigs(layered, undefined, skillRootsByName)
Expand Down Expand Up @@ -301,6 +306,7 @@ async function runScheduleWorkflowInner(
auxiliaryEffort: resolved.auxiliaryEffort,
synthesisModel: resolved.synthesisModel,
maxTurns: resolved.maxTurns,
concurrency: runnerConcurrency ?? inputs.parallel,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
batchDelayMs: resolved.batchDelayMs,
maxContextFiles: resolved.maxContextFiles,
ignore: resolved.ignore,
Expand Down
2 changes: 1 addition & 1 deletion packages/warden/src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const CLIOptionsSchema = z.object({
/** Only show findings at or above this confidence in output */
minConfidence: ConfidenceThresholdSchema.optional(),
help: z.boolean().default(false),
/** Max concurrent file analyses across running skills (default depends on command) */
/** Max concurrent hunk analyses across running skills (default depends on command) */
parallel: z.number().int().positive().optional(),
/** Model to use for analysis (fallback when not set in config) */
model: z.string().optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/warden/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const HELP_OPTIONS: Record<HelpOptionId, HelpOptionSpec> = {
},
parallel: {
label: '--parallel <n>',
description: 'Max concurrent file analyses across running skills',
description: 'Max concurrent hunk analyses across running skills',
},
failFast: {
label: '-x, --fail-fast',
Expand Down
2 changes: 1 addition & 1 deletion packages/warden/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,7 +1424,7 @@ export async function runSkills(
return 1;
}
let tasks: SkillTaskOptions[];
const concurrency = options.parallel ?? DEFAULT_CONCURRENCY;
const concurrency = options.parallel ?? config?.runner?.concurrency ?? DEFAULT_CONCURRENCY;
try {
tasks = await createSkillTasks({
specs,
Expand Down
15 changes: 7 additions & 8 deletions packages/warden/src/cli/output/ink-runner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
type FileState,
} from './tasks.js';
import { formatDuration, formatCost, truncate, countBySeverity, formatSeverityDot, pluralize, totalAuxiliaryCost } from './formatters.js';
import { Semaphore } from '../../utils/index.js';
import { AsyncWorkQueue } from '../../utils/index.js';
import { Verbosity } from './verbosity.js';
import { ICON_CHECK, ICON_SKIPPED, ICON_PENDING, ICON_ERROR, SPINNER_FRAMES } from './icons.js';
import figures from 'figures';
Expand Down Expand Up @@ -276,8 +276,8 @@ export async function runSkillTasksWithInk(
: undefined;

if (tasks.length === 0 || verbosity === Verbosity.Quiet) {
// No tasks or quiet mode - run without UI using global semaphore.
const semaphore = new Semaphore(concurrency);
// No tasks or quiet mode - run without UI using the global analysis queue.
const analysisQueue = new AsyncWorkQueue(concurrency);
const circuitAbortController = new AbortController();
const circuitBreaker = new ProviderFailureCircuitBreaker({ abortController: circuitAbortController });
const composedTasks = composeTasksWithFailFast(
Expand Down Expand Up @@ -308,7 +308,7 @@ export async function runSkillTasksWithInk(
}
: {}),
};
return runComposedSkillTasks(composedTasks, callbacks, semaphore);
return runComposedSkillTasks(composedTasks, callbacks, analysisQueue);
}

// Track skill states
Expand Down Expand Up @@ -474,8 +474,7 @@ export async function runSkillTasksWithInk(
: undefined,
};

// Global semaphore gates file-level work across all skills.
const semaphore = new Semaphore(concurrency);
const analysisQueue = new AsyncWorkQueue(concurrency);

// Compose per-task abort controllers: fire on SIGINT, fail-fast, or provider circuit breaker.
const circuitAbortController = new AbortController();
Expand All @@ -487,8 +486,8 @@ export async function runSkillTasksWithInk(
circuitAbortController,
);

// Launch all skills in parallel; the semaphore is the sole concurrency gate.
const results = await runComposedSkillTasks(composedTasks, callbacks, semaphore);
// Launch all skills in parallel; the queue is the sole concurrency gate.
const results = await runComposedSkillTasks(composedTasks, callbacks, analysisQueue);

// Flush any pending setImmediate from updateUI so last-tick warnings are
// rendered before we tear down. setImmediate is FIFO, so our callback runs
Expand Down
Loading
Loading