Skip to content

Commit 787ec7d

Browse files
author
Wintermute
committed
fix: autopilot-cycle handler forwards job.data.phases to runCycle
The autopilot-cycle handler always ran ALL_PHASES regardless of job data. This caused production stalls when the embed phase had a large backlog (17K+ stale chunks) that exceeded the 30-minute job timeout. Every 5-min cycle would start, hit the embed wall, stall, and get force-killed — creating an infinite stall loop that kept the queue perpetually unhealthy. The fix validates job.data.phases against ALL_PHASES (preventing injection) and forwards the selected phases to runCycle(). Callers can now submit fast cycles (lint+backlinks+sync+extract) on a 5-min cron and run embed separately with a longer timeout during off-peak hours. If phases is omitted, not an array, or filters to empty, behavior is unchanged (all phases run). Tests: 4 new cases covering phase restriction, invalid name filtering, empty array fallback, and non-array type safety.
1 parent 08746b0 commit 787ec7d

2 files changed

Lines changed: 111 additions & 0 deletions

File tree

src/commands/jobs.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,10 +948,19 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
948948
? job.data.repoPath
949949
: (await engine.getConfig('sync.repo_path')) ?? '.';
950950

951+
// Allow callers to select phases via job data (e.g. skip embed for
952+
// fast cycles). Validates against ALL_PHASES to prevent injection.
953+
const { ALL_PHASES } = await import('../core/cycle.ts');
954+
const validPhases = new Set(ALL_PHASES);
955+
const requestedPhases = Array.isArray(job.data.phases)
956+
? (job.data.phases as string[]).filter(p => validPhases.has(p as any))
957+
: undefined;
958+
951959
const report = await runCycle(engine, {
952960
brainDir: repoPath,
953961
pull: true, // autopilot daemon opts into git pull
954962
signal: job.signal, // propagate abort so cycle bails on timeout/cancel
963+
...(requestedPhases && requestedPhases.length > 0 ? { phases: requestedPhases as any } : {}),
955964
yieldBetweenPhases: async () => {
956965
// Yield to the event loop so worker lock-renewal can fire.
957966
await new Promise<void>(r => setImmediate(r));

test/handlers.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,105 @@ describe('autopilot-cycle handler — partial failure does NOT throw', () => {
120120
}
121121
}, 30_000);
122122
});
123+
124+
describe('autopilot-cycle handler — phase passthrough', () => {
125+
test('job.data.phases restricts which phases run', async () => {
126+
const fs = await import('fs');
127+
const { execSync } = await import('child_process');
128+
const { tmpdir } = await import('os');
129+
const { join } = await import('path');
130+
const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-pass-'));
131+
try {
132+
execSync('git init', { cwd: dir, stdio: 'pipe' });
133+
execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' });
134+
execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' });
135+
execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' });
136+
137+
const handler = (worker as any).handlers.get('autopilot-cycle');
138+
// Request only lint and sync — embed should NOT appear
139+
const result = await handler({
140+
data: { repoPath: dir, phases: ['lint', 'sync'] },
141+
signal: { aborted: false } as any,
142+
job: { id: 10, name: 'autopilot-cycle' } as any,
143+
});
144+
145+
expect(result).toBeDefined();
146+
const report = (result as any).report;
147+
expect(report).toBeDefined();
148+
const phaseNames = report.phases.map((p: any) => p.phase);
149+
expect(phaseNames).toContain('lint');
150+
expect(phaseNames).toContain('sync');
151+
// Phases NOT requested must be absent
152+
expect(phaseNames).not.toContain('embed');
153+
expect(phaseNames).not.toContain('extract');
154+
expect(phaseNames).not.toContain('backlinks');
155+
expect(phaseNames).not.toContain('orphans');
156+
} finally {
157+
fs.rmSync(dir, { recursive: true, force: true });
158+
}
159+
}, 30_000);
160+
161+
test('invalid phase names in job.data.phases are filtered out', async () => {
162+
const fs = await import('fs');
163+
const { execSync } = await import('child_process');
164+
const { tmpdir } = await import('os');
165+
const { join } = await import('path');
166+
const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-invalid-'));
167+
try {
168+
execSync('git init', { cwd: dir, stdio: 'pipe' });
169+
execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' });
170+
execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' });
171+
execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' });
172+
173+
const handler = (worker as any).handlers.get('autopilot-cycle');
174+
// Mix valid and bogus names — only 'lint' should survive filtering
175+
const result = await handler({
176+
data: { repoPath: dir, phases: ['lint', 'BOGUS', 'rm -rf /'] },
177+
signal: { aborted: false } as any,
178+
job: { id: 11, name: 'autopilot-cycle' } as any,
179+
});
180+
181+
const report = (result as any).report;
182+
const phaseNames = report.phases.map((p: any) => p.phase);
183+
expect(phaseNames).toContain('lint');
184+
expect(phaseNames).not.toContain('BOGUS');
185+
expect(phaseNames.length).toBe(1);
186+
} finally {
187+
fs.rmSync(dir, { recursive: true, force: true });
188+
}
189+
}, 30_000);
190+
191+
test('empty phases array falls back to all phases (same as no phases)', async () => {
192+
const handler = (worker as any).handlers.get('autopilot-cycle');
193+
// Empty array should fall through to ALL_PHASES (same as omitting phases)
194+
const result = await handler({
195+
data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: [] },
196+
signal: { aborted: false } as any,
197+
job: { id: 12, name: 'autopilot-cycle' } as any,
198+
});
199+
200+
const report = (result as any).report;
201+
// With all phases, filesystem phases fail on missing dir
202+
const phaseNames = report.phases.map((p: any) => p.phase);
203+
expect(phaseNames).toContain('lint');
204+
expect(phaseNames).toContain('backlinks');
205+
expect(phaseNames).toContain('sync');
206+
}, 30_000);
207+
208+
test('non-array phases value is ignored (falls back to all)', async () => {
209+
const handler = (worker as any).handlers.get('autopilot-cycle');
210+
// String instead of array — should be ignored
211+
const result = await handler({
212+
data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: 'lint' },
213+
signal: { aborted: false } as any,
214+
job: { id: 13, name: 'autopilot-cycle' } as any,
215+
});
216+
217+
const report = (result as any).report;
218+
const phaseNames = report.phases.map((p: any) => p.phase);
219+
// Should have all phases since the string was ignored
220+
expect(phaseNames).toContain('lint');
221+
expect(phaseNames).toContain('sync');
222+
expect(phaseNames).toContain('embed');
223+
}, 30_000);
224+
});

0 commit comments

Comments
 (0)