Unify TUI runtime commands with gateway and harden gateway restart

This commit is contained in:
William Valentin
2026-02-24 13:14:53 -08:00
parent db2f697741
commit 37be391a40
24 changed files with 1253 additions and 120 deletions
+139
View File
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { configSchema } from '../config/schema.js';
import { Lifecycle } from './lifecycle.js';
import { startServices } from './services.js';
vi.mock('../automation/index.js', () => {
return {
HeartbeatMonitor: class {
start(): void {}
stop(): void {}
},
MinioSyncScheduler: class {
start(): void {}
stop(): void {}
},
};
});
function makeConfig(overrides: Record<string, unknown> = {}) {
return configSchema.parse({
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
...overrides,
});
}
describe('startServices startup ordering', () => {
afterEach(async () => {
vi.restoreAllMocks();
});
it('fails after bounded retries on persistent gateway bind collision before starting channels', async () => {
const lifecycle = new Lifecycle();
const config = makeConfig({ server: { localhost: true, port: 18800 } });
const startError = new Error('listen EADDRINUSE: address already in use 127.0.0.1:18800') as Error & { code?: string };
startError.code = 'EADDRINUSE';
const channelRegistry = {
startAll: vi.fn(async () => {}),
stopAll: vi.fn(async () => {}),
};
const gateway = {
start: vi.fn(async () => { throw startError; }),
stop: vi.fn(async () => {}),
getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })),
};
await expect(startServices({
config,
lifecycle,
channelRegistry: channelRegistry as never,
gateway: gateway as never,
modelRouter: {} as never,
memoryDir: '/tmp',
dataDir: '/tmp',
gatewayStartRetry: {
maxAttempts: 3,
retryDelayMs: 0,
sleep: async () => {},
},
})).rejects.toThrow('Gateway bind failed');
expect(channelRegistry.startAll).not.toHaveBeenCalled();
expect(channelRegistry.stopAll).not.toHaveBeenCalled();
expect(gateway.start).toHaveBeenCalledTimes(3);
expect(gateway.stop).toHaveBeenCalledTimes(3);
});
it('retries gateway bind collisions and then starts channels on success', async () => {
const lifecycle = new Lifecycle();
const config = makeConfig({ server: { localhost: true, port: 18800 } });
const startError = new Error('listen EADDRINUSE: address already in use 127.0.0.1:18800') as Error & { code?: string };
startError.code = 'EADDRINUSE';
const order: string[] = [];
const channelRegistry = {
startAll: vi.fn(async () => { order.push('channels.start'); }),
stopAll: vi.fn(async () => {}),
};
const gateway = {
start: vi.fn(async () => {
order.push('gateway.start');
if (gateway.start.mock.calls.length === 1) {
throw startError;
}
}),
stop: vi.fn(async () => { order.push('gateway.stop'); }),
getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })),
};
await startServices({
config,
lifecycle,
channelRegistry: channelRegistry as never,
gateway: gateway as never,
modelRouter: {} as never,
memoryDir: '/tmp',
dataDir: '/tmp',
gatewayStartRetry: {
maxAttempts: 3,
retryDelayMs: 0,
sleep: async () => {},
},
});
expect(order).toEqual(['gateway.start', 'gateway.stop', 'gateway.start', 'channels.start']);
expect(channelRegistry.startAll).toHaveBeenCalledOnce();
await lifecycle.shutdown();
});
it('starts gateway before channels when startup succeeds', async () => {
const lifecycle = new Lifecycle();
const config = makeConfig({ server: { localhost: true, port: 18800 } });
const order: string[] = [];
const channelRegistry = {
startAll: vi.fn(async () => { order.push('channels.start'); }),
stopAll: vi.fn(async () => {}),
};
const gateway = {
start: vi.fn(async () => { order.push('gateway.start'); }),
stop: vi.fn(async () => {}),
getMetrics: vi.fn(() => ({ getModelMetrics: () => [] })),
};
await startServices({
config,
lifecycle,
channelRegistry: channelRegistry as never,
gateway: gateway as never,
modelRouter: {} as never,
memoryDir: '/tmp',
dataDir: '/tmp',
});
expect(order).toEqual(['gateway.start', 'channels.start']);
await lifecycle.shutdown();
});
});