Skip to content

Commit 818c020

Browse files
authored
Add adaptive worker fan-out and validation caps (#227)
* Improve worker validation and project fan-out caps * fix(worker): harden validation-evidence gate + stop it hard-failing tasks The pre-review worker validation gate (INT-2446 fan-out follow-up) had several false-positive/false-negative edges and could kill an approvable task: - commandLooksLikeValidation short-circuited on a leading inspection verb, so a chained command like `git diff && npm test` was judged as "no validation". Now each shell segment is evaluated independently. - .mts/.cts modules slipped both the gate and the tester-skip check; added them to the relevant/code file patterns. - Pure data trees (locale/fixtures/snapshots/mocks) forced a build/test. They are now exempt — but ONLY for non-code assets, so a real source module under those dirs (src/__mocks__/api.ts) still gets gated. - README/LICENSE/CHANGELOG/NOTICE were treated as docs regardless of extension, so a source module named readme.ts skipped the gate while isTesterCodeFile still saw it as code. The doc-only match now requires a doc extension. - In a tester-less pipeline a worker that reported commands=[] (e.g. git-detected changes promoted to success with no JSON block) was bounced twice then hard-failed, never reaching the reviewer. The gate now nudges once, then DEFERS to the reviewer instead of failing — via a pure predicate, since shouldAbortSelfRepair marks the session failed as a side effect. Adds workerValidationEvidence.test.ts and a defer-to-reviewer pipeline test. * fix(fanout): actually execute fan-out on dirty worktrees + surface it The fan-out gate recommended fan-out 53x in the live daemon but it ran 0x: the gate scores past its threshold almost only on self-repair retry signals (iteration > 1), yet runWorkerFanout hard-required a clean worktree and bailed before launching. On a retry the worktree always holds the previous iteration's uncommitted edits, so the two mechanisms were mutually exclusive — the headline feature never ran in production. - Drop the clean-worktree precondition. Instead snapshot the project's uncommitted state (tracked + untracked) into a throwaway temp index without mutating the real index, seed+commit it into each sandbox, and promote only the incremental winner diff so it layers onto the already-dirty project. - The fan-out onLog only reached broadcastEvent, so stdout showed the gate's "recommend fan-out" line but no execution evidence — the exact reason this was invisible. Log executing/promoted/fallback to console.log too. Adds workerFanout.test.ts covering fan-out over a dirty worktree.
1 parent c9390d8 commit 818c020

19 files changed

Lines changed: 873 additions & 167 deletions

config.example.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ autonomous:
4747
schedule: "*/15 * * * *" # Every 15 minutes
4848
maxAttempts: 3
4949
maxConcurrentTasks: 4 # Number of concurrent tasks
50+
worktreeMode: true # Required for same-project parallel tasks
51+
allowSameProjectConcurrent: true
52+
maxConcurrentPerProject: 2 # Optional cap for same-project worktree parallelism
5053

5154
allowedProjects:
5255
- ~/dev/your-project

src/agents/pairPipeline.test.ts

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('PairPipeline model selection', () => {
5858
success: true,
5959
summary: 'done',
6060
filesChanged: ['src/example.ts'],
61-
commands: [],
61+
commands: ['npm test -- src/example.test.ts'],
6262
output: '',
6363
confidencePercent: 100,
6464
});
@@ -276,6 +276,220 @@ describe('PairPipeline model selection', () => {
276276
expect(getDefaultModel).not.toHaveBeenCalled();
277277
});
278278

279+
it('retries code-changing workers before review when validation commands are missing', async () => {
280+
runWorker
281+
.mockResolvedValueOnce({
282+
success: true,
283+
summary: 'changed code without checking it',
284+
filesChanged: ['src/example.ts'],
285+
commands: [],
286+
output: '',
287+
confidencePercent: 95,
288+
})
289+
.mockResolvedValueOnce({
290+
success: true,
291+
summary: 'changed code and ran a focused test',
292+
filesChanged: ['src/example.ts'],
293+
commands: ['npm test -- src/example.test.ts'],
294+
output: 'PASS src/example.test.ts',
295+
confidencePercent: 95,
296+
});
297+
298+
const { PairPipeline } = await import('./pairPipeline.js');
299+
const pipeline = new PairPipeline({
300+
stages: ['worker', 'reviewer'],
301+
maxIterations: 2,
302+
roles: {
303+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
304+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
305+
},
306+
});
307+
308+
const result = await pipeline.run(task(), process.cwd());
309+
310+
expect(result.success).toBe(true);
311+
expect(runWorker).toHaveBeenCalledTimes(2);
312+
expect(runReviewer).toHaveBeenCalledTimes(1);
313+
expect(runWorker.mock.calls[1][0]).toEqual(expect.objectContaining({
314+
previousFeedback: expect.stringContaining('validation evidence missing'),
315+
}));
316+
});
317+
318+
it('defers to the reviewer instead of hard-failing when validation evidence stays missing', async () => {
319+
// A worker whose git changes were promoted to success with commands=[]
320+
// (no JSON block) must not be killed after a couple of retries — the gate is
321+
// a nudge, so once self-repair stagnates the reviewer gets the final say.
322+
runWorker.mockResolvedValue({
323+
success: true,
324+
summary: 'changed code, never self-reported commands',
325+
filesChanged: ['src/example.ts'],
326+
commands: [],
327+
output: '',
328+
confidencePercent: 95,
329+
});
330+
331+
const { PairPipeline } = await import('./pairPipeline.js');
332+
const pipeline = new PairPipeline({
333+
stages: ['worker', 'reviewer'],
334+
maxIterations: 4,
335+
roles: {
336+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
337+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
338+
},
339+
});
340+
341+
const result = await pipeline.run(task(), process.cwd());
342+
343+
// Reviewer approves → task succeeds despite never getting validation evidence.
344+
expect(result.success).toBe(true);
345+
expect(runReviewer).toHaveBeenCalledTimes(1);
346+
});
347+
348+
it('does not treat inspection-only commands as validation evidence', async () => {
349+
runWorker
350+
.mockResolvedValueOnce({
351+
success: true,
352+
summary: 'changed code after searching',
353+
filesChanged: ['src/example.ts'],
354+
commands: ['rg "npm test" package.json', 'git grep "cargo test"'],
355+
output: '',
356+
confidencePercent: 95,
357+
})
358+
.mockResolvedValueOnce({
359+
success: true,
360+
summary: 'changed code and ran a script smoke check',
361+
filesChanged: ['src/example.ts'],
362+
commands: ['python scripts/smoke_example.py --dry-run'],
363+
output: 'ok',
364+
confidencePercent: 95,
365+
});
366+
367+
const { PairPipeline } = await import('./pairPipeline.js');
368+
const pipeline = new PairPipeline({
369+
stages: ['worker', 'reviewer'],
370+
maxIterations: 2,
371+
roles: {
372+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
373+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
374+
},
375+
});
376+
377+
const result = await pipeline.run(task(), process.cwd());
378+
379+
expect(result.success).toBe(true);
380+
expect(runWorker).toHaveBeenCalledTimes(2);
381+
expect(runReviewer).toHaveBeenCalledTimes(1);
382+
expect(runWorker.mock.calls[1][0]).toEqual(expect.objectContaining({
383+
previousFeedback: expect.stringContaining('non-validation commands'),
384+
}));
385+
});
386+
387+
it('requires validation for build and dependency manifests without extensions', async () => {
388+
runWorker
389+
.mockResolvedValueOnce({
390+
success: true,
391+
summary: 'changed runtime manifests without checking them',
392+
filesChanged: ['Dockerfile', 'Makefile', 'requirements.txt', 'go.mod', 'Cargo.lock'],
393+
commands: [],
394+
output: '',
395+
confidencePercent: 95,
396+
})
397+
.mockResolvedValueOnce({
398+
success: true,
399+
summary: 'changed manifests and ran a smoke build',
400+
filesChanged: ['Dockerfile', 'Makefile', 'requirements.txt', 'go.mod', 'Cargo.lock'],
401+
commands: ['npm run ci'],
402+
output: 'ok',
403+
confidencePercent: 95,
404+
});
405+
406+
const { PairPipeline } = await import('./pairPipeline.js');
407+
const pipeline = new PairPipeline({
408+
stages: ['worker', 'reviewer'],
409+
maxIterations: 2,
410+
roles: {
411+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
412+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
413+
},
414+
});
415+
416+
const result = await pipeline.run(task(), process.cwd());
417+
418+
expect(result.success).toBe(true);
419+
expect(runWorker).toHaveBeenCalledTimes(2);
420+
expect(runReviewer).toHaveBeenCalledTimes(1);
421+
expect(runWorker.mock.calls[1][0]).toEqual(expect.objectContaining({
422+
previousFeedback: expect.stringContaining('requirements.txt'),
423+
}));
424+
});
425+
426+
it('still requires validation when tester is enabled but would skip manifest-only changes', async () => {
427+
runWorker
428+
.mockResolvedValueOnce({
429+
success: true,
430+
summary: 'changed package metadata without checking it',
431+
filesChanged: ['package.json'],
432+
commands: [],
433+
output: '',
434+
confidencePercent: 95,
435+
})
436+
.mockResolvedValueOnce({
437+
success: true,
438+
summary: 'changed package metadata and ran ci',
439+
filesChanged: ['package.json'],
440+
commands: ['npm run ci'],
441+
output: 'ok',
442+
confidencePercent: 95,
443+
});
444+
445+
const { PairPipeline } = await import('./pairPipeline.js');
446+
const pipeline = new PairPipeline({
447+
stages: ['worker', 'tester', 'reviewer'],
448+
maxIterations: 2,
449+
roles: {
450+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
451+
tester: { enabled: true, model: 'tester', timeoutMs: 0 },
452+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
453+
},
454+
});
455+
456+
const result = await pipeline.run(task(), process.cwd());
457+
458+
expect(result.success).toBe(true);
459+
expect(runWorker).toHaveBeenCalledTimes(2);
460+
expect(runReviewer).toHaveBeenCalledTimes(1);
461+
expect(runWorker.mock.calls[1][0]).toEqual(expect.objectContaining({
462+
previousFeedback: expect.stringContaining('validation evidence missing'),
463+
}));
464+
});
465+
466+
it('allows docs-only workers to reach review without validation commands', async () => {
467+
runWorker.mockResolvedValueOnce({
468+
success: true,
469+
summary: 'updated docs',
470+
filesChanged: ['docs/usage.md'],
471+
commands: [],
472+
output: '',
473+
confidencePercent: 95,
474+
});
475+
476+
const { PairPipeline } = await import('./pairPipeline.js');
477+
const pipeline = new PairPipeline({
478+
stages: ['worker', 'reviewer'],
479+
maxIterations: 1,
480+
roles: {
481+
worker: { enabled: true, model: 'worker', timeoutMs: 0 },
482+
reviewer: { enabled: true, model: 'reviewer', timeoutMs: 0 },
483+
},
484+
});
485+
486+
const result = await pipeline.run(task({ title: 'Update docs' }), process.cwd());
487+
488+
expect(result.success).toBe(true);
489+
expect(runWorker).toHaveBeenCalledTimes(1);
490+
expect(runReviewer).toHaveBeenCalledTimes(1);
491+
});
492+
279493
it('degrades to an undefined model when getDefaultModel fails', async () => {
280494
getDefaultModel.mockRejectedValue(new Error('no auth'));
281495
const { PairPipeline } = await import('./pairPipeline.js');

0 commit comments

Comments
 (0)