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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ jobs:
kill "$AEGIS_PID" || true
fi
- name: Check bundle size
run: "THRESHOLD_KB=2508\nSERVER_SIZE=$(find dist/ -name \"*.js\" ! -path \"\
run: "THRESHOLD_KB=2516\nSERVER_SIZE=$(find dist/ -name \"*.js\" ! -path \"\
*/__tests__/*\" ! -path \"*/dashboard/*\" -exec du -ck {} + | tail -1 | awk '{print $1}')\nSERVER_SIZE_KB=$((SERVER_SIZE))\n\
echo \"## Bundle Size Report\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"\" >> \"\
$GITHUB_STEP_SUMMARY\"\necho \"| Scope | Size (KB) | Threshold (KB) | Status\
Expand Down Expand Up @@ -332,7 +332,7 @@ jobs:
}
- name: Check bundle size
if: runner.os != 'Windows'
run: "THRESHOLD_KB=2508\nSERVER_SIZE=$(find dist/ -name \"*.js\" ! -path \"\
run: "THRESHOLD_KB=2516\nSERVER_SIZE=$(find dist/ -name \"*.js\" ! -path \"\
*/__tests__/*\" ! -path \"*/dashboard/*\" -exec du -ck {} + | tail -1 | awk '{print $1}')\nSERVER_SIZE_KB=$((SERVER_SIZE))\n\
echo \"## Bundle Size Report\" >> \"$GITHUB_STEP_SUMMARY\"\necho \"\" >> \"\
$GITHUB_STEP_SUMMARY\"\necho \"| Scope | Size (KB) | Threshold (KB) | Status\
Expand Down
146 changes: 146 additions & 0 deletions src/__tests__/dead-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Tests for monitor/dead-detector.ts — Dead session detection and cleanup.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DeadDetector } from '../monitor/dead-detector.js';
import type { DeadDetectorDeps } from '../monitor/dead-detector.js';

function makeDeps(): DeadDetectorDeps {
return {
sessions: {
listSessions: vi.fn(() => []),
isWindowAlive: vi.fn(async () => true),
approve: vi.fn(),
killSession: vi.fn(async () => {}),
} as any,
makePayload: ((event: any, session: any, detail: any) => ({ event, session: { id: session.id }, detail })) as DeadDetectorDeps['makePayload'],
emitDead: vi.fn(),
alertFailure: vi.fn(),
statusChange: vi.fn(),
removeSession: vi.fn(),
};
}

function makeSession(id = 's1', overrides: Record<string, any> = {}) {
return {
id,
displayName: `Session ${id}`,
windowId: `w-${id}`,
claudeSessionId: `cs-${id}`,
ccPid: 1234,
createdAt: Date.now() - 60000,
lastActivity: new Date().toISOString(),
...overrides,
} as any;
}

describe('DeadDetector', () => {
let deps: DeadDetectorDeps;
let detector: DeadDetector;

beforeEach(() => {
vi.clearAllMocks();
deps = makeDeps();
detector = new DeadDetector(deps);
});

describe('getDeadNotified', () => {
it('starts with empty set', () => {
expect(detector.getDeadNotified()).toEqual(new Set());
});
});

describe('updateDeps', () => {
it('replaces dependency callbacks', () => {
const newEmitDead = vi.fn();
detector.updateDeps({ emitDead: newEmitDead });
// Verify by triggering dead detection
const session = makeSession();
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(false);

return detector.checkDeadSessions().then(() => {
expect(newEmitDead).toHaveBeenCalledWith('s1', expect.any(String));
});
});
});

describe('checkDeadSessions', () => {
it('does nothing when no sessions exist', async () => {
(deps.sessions.listSessions as any).mockReturnValue([]);
await detector.checkDeadSessions();
expect(deps.statusChange).not.toHaveBeenCalled();
});

it('skips sessions already in deadNotified', async () => {
const session = makeSession();
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(false);

// First detection
await detector.checkDeadSessions();
expect(deps.statusChange).toHaveBeenCalledTimes(1);

// Second run — should skip
vi.clearAllMocks();
(deps.sessions.listSessions as any).mockReturnValue([session]);
await detector.checkDeadSessions();
expect(deps.statusChange).not.toHaveBeenCalled();
});

it('detects dead session and notifies', async () => {
const session = makeSession('s1');
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(false);

await detector.checkDeadSessions();

expect(detector.getDeadNotified().has('s1')).toBe(true);
expect(deps.emitDead).toHaveBeenCalledWith('s1', expect.stringContaining('died'));
expect(deps.statusChange).toHaveBeenCalledWith(
expect.objectContaining({ event: 'status.dead' }),
);
expect(deps.alertFailure).toHaveBeenCalledWith('session_failure', expect.any(String));
expect(deps.removeSession).toHaveBeenCalledWith('s1');
expect(deps.sessions.killSession).toHaveBeenCalledWith('s1');
});

it('skips alive sessions', async () => {
const session = makeSession();
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(true);

await detector.checkDeadSessions();

expect(detector.getDeadNotified().has('s1')).toBe(false);
expect(deps.statusChange).not.toHaveBeenCalled();
});

it('works without optional callbacks (emitDead, alertFailure)', async () => {
delete deps.emitDead;
delete deps.alertFailure;
const session = makeSession();
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(false);

await detector.checkDeadSessions();

expect(deps.statusChange).toHaveBeenCalled();
expect(deps.removeSession).toHaveBeenCalledWith('s1');
});
});

describe('removeSession', () => {
it('removes session from deadNotified set', async () => {
const session = makeSession();
(deps.sessions.listSessions as any).mockReturnValue([session]);
(deps.sessions.isWindowAlive as any).mockResolvedValue(false);

await detector.checkDeadSessions();
expect(detector.getDeadNotified().has('s1')).toBe(true);

detector.removeSession('s1');
expect(detector.getDeadNotified().has('s1')).toBe(false);
});
});
});
154 changes: 154 additions & 0 deletions src/__tests__/rate-limit-retry-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Tests for monitor/rate-limit-retry.ts — Rate-limit detection and automatic retry.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RateLimitRetryHandler } from '../monitor/rate-limit-retry.js';
import type { RateLimitRetryDeps, RateLimitRetryConfig } from '../monitor/rate-limit-retry.js';

// Mock computeDelayMs for deterministic tests
vi.mock('../retry.js', () => ({
computeDelayMs: vi.fn((attempt, base) => base * Math.pow(2, attempt - 1)),
}));

function makeDeps(): RateLimitRetryDeps {
return {
makePayload: ((event: any, session: any, detail: any) => ({ event, session: { id: session.id }, detail })) as RateLimitRetryDeps['makePayload'],
statusChange: vi.fn(),
alertFailure: vi.fn(),
metricsFailed: vi.fn(),
markRateLimited: vi.fn(),
unmarkRateLimited: vi.fn(),
};
}

const defaultConfig: RateLimitRetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
};

function makeSession(id = 's1') {
return {
id,
displayName: `Session ${id}`,
workDir: '/tmp/test',
tenantId: 'default',
ownerKeyId: 'master',
status: 'working',
} as any;
}

describe('RateLimitRetryHandler', () => {
let deps: RateLimitRetryDeps;
let handler: RateLimitRetryHandler;

beforeEach(() => {
vi.clearAllMocks();
deps = makeDeps();
handler = new RateLimitRetryHandler(deps, defaultConfig);
});

describe('getRetryAttempts', () => {
it('starts with empty map', () => {
expect(handler.getRetryAttempts()).toEqual(new Map());
});
});

describe('updateDeps', () => {
it('replaces dependency callbacks', () => {
const newAlertFailure = vi.fn();
handler.updateDeps({ alertFailure: newAlertFailure });

// Verify by checking the handler exists and updated
expect(handler.getRetryAttempts()).toEqual(new Map());
});
});

describe('rateLimitCoordinator', () => {
it('exposes the coordinator', () => {
expect(handler.rateLimitCoordinator).toBeDefined();
});
});

describe('removeSession', () => {
it('removes retry tracking and dequeues from coordinator', () => {
handler.getRetryAttempts().set('s1', 2);
handler.removeSession('s1');
expect(handler.getRetryAttempts().has('s1')).toBe(false);
});
});

describe('handleRateLimitSignal (no ACP backend)', () => {
it('emits legacy notification when no ACP backend set', async () => {
const session = makeSession();
await handler.handleRateLimitSignal(session, 'rate_limit');

expect(deps.statusChange).toHaveBeenCalledWith(
expect.objectContaining({ event: 'status.rate_limited' }),
);
expect(deps.markRateLimited).toHaveBeenCalledWith('s1');
// No retry attempts tracked without backend
expect(handler.getRetryAttempts().has('s1')).toBe(false);
});
});

describe('handleRateLimitSignal (with ACP backend)', () => {
it('tracks retry attempts and schedules retry', async () => {
const mockBackend = {
restartSession: vi.fn(async () => ({ backoffDelayMs: 100 })),
};
handler.setAcpBackend(mockBackend as any);

const session = makeSession();
await handler.handleRateLimitSignal(session, 'rate_limit');

expect(deps.markRateLimited).toHaveBeenCalledWith('s1');
expect(handler.getRetryAttempts().get('s1')).toBe(1);
expect(deps.statusChange).toHaveBeenCalledWith(
expect.objectContaining({
event: 'status.rate_limited',
detail: expect.stringContaining('Retrying (1/3)'),
}),
);
});

it('increments retry count on repeated calls', async () => {
const mockBackend = {
restartSession: vi.fn(async () => ({ backoffDelayMs: 100 })),
};
handler.setAcpBackend(mockBackend as any);

const session = makeSession();
await handler.handleRateLimitSignal(session, 'rate_limit');
await handler.handleRateLimitSignal(session, 'rate_limit');

expect(handler.getRetryAttempts().get('s1')).toBe(2);
});
});

describe('handleRateLimitSignal (retries exhausted)', () => {
it('notifies error when max retries exceeded with backend', async () => {
const mockBackend = {
restartSession: vi.fn(async () => { throw new Error('restart failed'); }),
};
handler.setAcpBackend(mockBackend as any);

// Pre-fill retry count to max
handler.getRetryAttempts().set('s1', defaultConfig.maxRetries);

const session = makeSession();
// This call will try attempt maxRetries+1 which exceeds limit → no new attempt tracked
await handler.handleRateLimitSignal(session, 'rate_limit');

// Should emit exhausted status immediately since attempt > maxRetries
expect(deps.statusChange).toHaveBeenCalledWith(
expect.objectContaining({
event: 'status.error',
detail: expect.stringContaining('exhausted'),
}),
);
expect(deps.alertFailure).toHaveBeenCalled();
expect(deps.metricsFailed).toHaveBeenCalledWith('s1');
});
});
});
Loading
Loading