Skip to content

Commit de85e86

Browse files
authored
Merge pull request #2942 from protoLabsAI/dev
Promote dev to staging (pre-v0.78.0)
2 parents 0792184 + 3d9756b commit de85e86

File tree

7 files changed

+165
-14
lines changed

7 files changed

+165
-14
lines changed

apps/server/src/routes/chat/ava-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface AvaConfig {
6262
/**
6363
* Default configuration used when no per-project config file exists.
6464
*
65-
* Enabled by default (~20 tools): briefing, health, notes, discord,
65+
* Enabled by default (8 tool groups): briefing, health, notes, discord,
6666
* metrics, settings, delegation, boardRead.
6767
*
6868
* Disabled by default (project-tactical): boardWrite, agentControl, autoMode,

apps/server/src/routes/webhooks/routes/github.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,10 @@ async function handleGlobalCheckSuiteEvent(
236236
* Mirrors handleCheckRunEvent from the per-project route. Uses PRWatcherService
237237
* to trigger fast-path checks for watched PRs.
238238
*/
239-
async function handleGlobalCheckRunEvent(payload: GitHubCheckRunWebhookPayload): Promise<void> {
239+
async function handleGlobalCheckRunEvent(
240+
payload: GitHubCheckRunWebhookPayload,
241+
events: EventEmitter
242+
): Promise<void> {
240243
const { action, check_run } = payload;
241244

242245
// Only react to completed check runs
@@ -245,7 +248,7 @@ async function handleGlobalCheckRunEvent(payload: GitHubCheckRunWebhookPayload):
245248
const prs = check_run.pull_requests ?? [];
246249
if (prs.length === 0) return;
247250

248-
const watcher = getPRWatcherService();
251+
const watcher = getPRWatcherService(events);
249252
if (!watcher) return;
250253

251254
for (const pr of prs) {
@@ -321,7 +324,7 @@ export function createGitHubWebhookHandler(events: EventEmitter, settingsService
321324

322325
// Handle check_run events — fast-path PRWatcher trigger
323326
if (eventType === 'check_run') {
324-
await handleGlobalCheckRunEvent(req.body as GitHubCheckRunWebhookPayload);
327+
await handleGlobalCheckRunEvent(req.body as GitHubCheckRunWebhookPayload, events);
325328
res.json({ success: true, message: 'check_run event processed' });
326329
return;
327330
}

apps/server/src/services/auto-mode-service.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,7 @@ export class AutoModeService {
893893
if (running.projectPath === projectPath) {
894894
running.abortController.abort();
895895
this.runningFeatures.delete(featureId);
896+
this.pendingCIInjections.delete(featureId);
896897
logger.info(`Aborted running feature ${featureId} during auto-loop stop`);
897898
}
898899
}
@@ -1321,6 +1322,7 @@ export class AutoModeService {
13211322
// The abort signal will still propagate to stop any ongoing execution
13221323
this.concurrencyManager.release(featureId);
13231324
this.runningFeatures.delete(featureId);
1325+
this.pendingCIInjections.delete(featureId);
13241326
this.typedEventBus.clearFeature(featureId);
13251327
activeAgentsCount.set(this.runningFeatures.size);
13261328

@@ -1383,6 +1385,7 @@ export class AutoModeService {
13831385
logger.info(`[Shutdown] Aborted feature: ${featureId}`);
13841386
}
13851387
this.runningFeatures.clear();
1388+
this.pendingCIInjections.clear();
13861389

13871390
// Unsubscribe all event listeners to prevent leaks on restart
13881391
for (const sub of this.eventSubscriptions) {
@@ -1400,10 +1403,11 @@ export class AutoModeService {
14001403

14011404
/**
14021405
* Check if an agent is currently running for a feature.
1403-
* Alias for isFeatureRunning — used by PRFeedbackService for CI injection logic.
1406+
* Matches on both featureId and projectPath to prevent cross-project collisions.
14041407
*/
1405-
isAgentRunning(featureId: string): boolean {
1406-
return this.runningFeatures.has(featureId);
1408+
isAgentRunning(featureId: string, projectPath: string): boolean {
1409+
const running = this.runningFeatures.get(featureId);
1410+
return running !== undefined && running.projectPath === projectPath;
14071411
}
14081412

14091413
/**
@@ -1412,10 +1416,16 @@ export class AutoModeService {
14121416
* continuation prompt after the current agent turn completes, so the agent
14131417
* can fix the CI failure without a full restart.
14141418
*
1419+
* Matches on both featureId and projectPath to prevent cross-project injection.
14151420
* Returns true if a running session was found and the injection was queued.
14161421
*/
1417-
async sendCIFailureToAgent(featureId: string, message: string): Promise<boolean> {
1418-
if (!this.runningFeatures.has(featureId)) {
1422+
async sendCIFailureToAgent(
1423+
featureId: string,
1424+
projectPath: string,
1425+
message: string
1426+
): Promise<boolean> {
1427+
const running = this.runningFeatures.get(featureId);
1428+
if (!running || running.projectPath !== projectPath) {
14191429
return false;
14201430
}
14211431
this.pendingCIInjections.set(featureId, message);
@@ -2081,6 +2091,7 @@ Address the follow-up instructions above. Review the previous work and make the
20812091
if (current === followUpRunningFeature) {
20822092
this.concurrencyManager.release(featureId);
20832093
this.runningFeatures.delete(featureId);
2094+
this.pendingCIInjections.delete(featureId);
20842095
this.typedEventBus.clearFeature(featureId);
20852096
activeAgentsCount.set(this.runningFeatures.size);
20862097
}

apps/server/src/services/pr-feedback-service.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,6 @@ const POLL_INTERVAL_MS = PR_FEEDBACK_POLL_INTERVAL_MS;
8181
/** Max iterations before escalating to CTO - prevents infinite feedback loops */
8282
const MAX_PR_ITERATIONS = 2;
8383

84-
/** Max total remediation cycles (feedback + CI combined) before blocking */
85-
const MAX_TOTAL_REMEDIATION_CYCLES = 4;
86-
8784
/** How often to poll for CI check status (60s) */
8885
const CI_POLL_INTERVAL_MS = PR_FEEDBACK_CI_POLL_INTERVAL_MS;
8986

@@ -1361,9 +1358,10 @@ export class PRFeedbackService {
13611358

13621359
// If the agent is already running, inject the CI failure as a message
13631360
// instead of restarting — saves $2-5 and 3-5 min per CI failure.
1364-
if (this.autoModeService.isAgentRunning(featureId)) {
1361+
if (this.autoModeService.isAgentRunning(featureId, pr.projectPath)) {
13651362
const injected = await this.autoModeService.sendCIFailureToAgent(
13661363
featureId,
1364+
pr.projectPath,
13671365
continuationPrompt
13681366
);
13691367
if (injected) {

apps/server/tests/unit/routes/global-webhook-ci-events.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,24 @@ describe('Global webhook route — CI events', () => {
315315
expect(mockTriggerCheck).not.toHaveBeenCalled();
316316
});
317317

318+
it('passes events emitter to getPRWatcherService so it can instantiate on a fresh process (Fix 1)', async () => {
319+
// Simulate a fresh process where the singleton has not been created yet —
320+
// getPRWatcherService returns null when called without events.
321+
// After the fix, the handler passes `events`, allowing the singleton to be created.
322+
mockGetPRWatcherService.mockReturnValueOnce(null);
323+
324+
const handler = createGitHubWebhookHandler(events as any, settingsService as any);
325+
const { req, res } = createMockExpressContext();
326+
327+
req.headers = { 'x-github-event': 'check_run' };
328+
req.body = makeCheckRunPayload();
329+
330+
await handler(req as Request, res as Response);
331+
332+
// Verify getPRWatcherService was called with the events argument
333+
expect(mockGetPRWatcherService).toHaveBeenCalledWith(events);
334+
});
335+
318336
it('returns 200 with success for check_run events', async () => {
319337
const handler = createGitHubWebhookHandler(events as any, settingsService as any);
320338
const { req, res } = createMockExpressContext();

apps/server/tests/unit/services/auto-mode-service.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,4 +400,125 @@ describe('auto-mode-service.ts', () => {
400400
expect(duration).toBeLessThan(40);
401401
});
402402
});
403+
404+
describe('isAgentRunning - cross-project isolation (Fix 2)', () => {
405+
// Helper to inject a running feature entry directly
406+
const addRunningFeature = (
407+
svc: AutoModeService,
408+
featureId: string,
409+
projectPath: string
410+
): void => {
411+
const runningFeaturesMap = (svc as any).runningFeatures as Map<
412+
string,
413+
{ featureId: string; projectPath: string; abortController: AbortController }
414+
>;
415+
runningFeaturesMap.set(featureId, {
416+
featureId,
417+
projectPath,
418+
abortController: new AbortController(),
419+
});
420+
};
421+
422+
it('should return true when featureId and projectPath both match', () => {
423+
addRunningFeature(service, 'feat-1', '/project/alpha');
424+
425+
expect(service.isAgentRunning('feat-1', '/project/alpha')).toBe(true);
426+
});
427+
428+
it('should return false when featureId matches but projectPath differs (cross-project collision)', () => {
429+
addRunningFeature(service, 'feat-1', '/project/alpha');
430+
431+
expect(service.isAgentRunning('feat-1', '/project/beta')).toBe(false);
432+
});
433+
434+
it('should return false when featureId does not exist', () => {
435+
expect(service.isAgentRunning('nonexistent', '/project/alpha')).toBe(false);
436+
});
437+
});
438+
439+
describe('sendCIFailureToAgent - cross-project isolation (Fix 2)', () => {
440+
const addRunningFeature = (
441+
svc: AutoModeService,
442+
featureId: string,
443+
projectPath: string
444+
): void => {
445+
const runningFeaturesMap = (svc as any).runningFeatures as Map<
446+
string,
447+
{ featureId: string; projectPath: string; abortController: AbortController }
448+
>;
449+
runningFeaturesMap.set(featureId, {
450+
featureId,
451+
projectPath,
452+
abortController: new AbortController(),
453+
});
454+
};
455+
456+
const getPendingInjections = (svc: AutoModeService): Map<string, string> =>
457+
(svc as any).pendingCIInjections as Map<string, string>;
458+
459+
it('should queue injection when featureId and projectPath both match', async () => {
460+
addRunningFeature(service, 'feat-1', '/project/alpha');
461+
462+
const result = await service.sendCIFailureToAgent('feat-1', '/project/alpha', 'CI failed');
463+
464+
expect(result).toBe(true);
465+
expect(getPendingInjections(service).get('feat-1')).toBe('CI failed');
466+
});
467+
468+
it('should reject injection when projectPath differs (cross-project protection)', async () => {
469+
addRunningFeature(service, 'feat-1', '/project/alpha');
470+
471+
const result = await service.sendCIFailureToAgent('feat-1', '/project/beta', 'CI failed');
472+
473+
expect(result).toBe(false);
474+
expect(getPendingInjections(service).has('feat-1')).toBe(false);
475+
});
476+
477+
it('should return false when feature is not running', async () => {
478+
const result = await service.sendCIFailureToAgent('nonexistent', '/project/alpha', 'msg');
479+
480+
expect(result).toBe(false);
481+
});
482+
});
483+
484+
describe('pendingCIInjections teardown (Fix 3)', () => {
485+
const addRunningFeature = (
486+
svc: AutoModeService,
487+
featureId: string,
488+
projectPath: string
489+
): void => {
490+
const runningFeaturesMap = (svc as any).runningFeatures as Map<
491+
string,
492+
{ featureId: string; projectPath: string; abortController: AbortController }
493+
>;
494+
runningFeaturesMap.set(featureId, {
495+
featureId,
496+
projectPath,
497+
abortController: new AbortController(),
498+
});
499+
};
500+
501+
const getPendingInjections = (svc: AutoModeService): Map<string, string> =>
502+
(svc as any).pendingCIInjections as Map<string, string>;
503+
504+
it('should clear pendingCIInjections when stopFeature is called', async () => {
505+
addRunningFeature(service, 'feat-stop', '/project/alpha');
506+
getPendingInjections(service).set('feat-stop', 'stale CI message');
507+
508+
await service.stopFeature('feat-stop');
509+
510+
expect(getPendingInjections(service).has('feat-stop')).toBe(false);
511+
});
512+
513+
it('should clear all pendingCIInjections on shutdown', async () => {
514+
addRunningFeature(service, 'feat-a', '/project/alpha');
515+
addRunningFeature(service, 'feat-b', '/project/beta');
516+
getPendingInjections(service).set('feat-a', 'stale msg a');
517+
getPendingInjections(service).set('feat-b', 'stale msg b');
518+
519+
await service.shutdown();
520+
521+
expect(getPendingInjections(service).size).toBe(0);
522+
});
523+
});
403524
});

libs/types/src/git-settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const DEFAULT_GIT_WORKFLOW_SETTINGS: Required<GitWorkflowSettings> = {
7777
prBaseBranch: 'main',
7878
maxPRLinesChanged: 500,
7979
maxPRFilesTouched: 20,
80-
excludeFromStaging: ['.automaker/', '.claude/worktrees/', '.worktrees/'],
80+
excludeFromStaging: [],
8181
softChecks: [],
8282
};
8383

0 commit comments

Comments
 (0)