Skip to content

Commit f9797dd

Browse files
authored
Merge pull request #8 from Multiplier-Labs/fix/remove-local-scripts
Remove local deploy/ops scripts, fix workflow loader and tests
2 parents ec17b06 + 3be9226 commit f9797dd

File tree

7 files changed

+1802
-0
lines changed

7 files changed

+1802
-0
lines changed

server/session-manager.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2212,4 +2212,266 @@ describe('SessionManager', () => {
22122212
expect((sm as any)._exitListeners).toHaveLength(2)
22132213
})
22142214
})
2215+
2216+
describe('broadcast() back-pressure', () => {
2217+
it('drops message when client buffer exceeds 1MB', () => {
2218+
const s = sm.create('bp-test', '/tmp')
2219+
const overloaded = fakeWs()
2220+
overloaded.bufferedAmount = 2_000_000
2221+
const normal = fakeWs()
2222+
sm.join(s.id, overloaded)
2223+
sm.join(s.id, normal)
2224+
sm.broadcast(sm.get(s.id)!, { type: 'result' } as any)
2225+
expect(overloaded.send).not.toHaveBeenCalled()
2226+
expect(normal.send).toHaveBeenCalled()
2227+
})
2228+
2229+
it('skips clients that are not OPEN', () => {
2230+
const s = sm.create('closed-test', '/tmp')
2231+
const closed = fakeWs(false)
2232+
sm.join(s.id, closed)
2233+
sm.broadcast(sm.get(s.id)!, { type: 'result' } as any)
2234+
expect(closed.send).not.toHaveBeenCalled()
2235+
})
2236+
})
2237+
2238+
describe('restoreRepoApprovalsFromDisk() error handling', () => {
2239+
it('handles corrupted JSON gracefully', () => {
2240+
const mockedExistsSync = vi.mocked(existsSync)
2241+
const mockedReadFileSync = vi.mocked(readFileSync)
2242+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
2243+
2244+
mockedExistsSync.mockImplementation((p) => String(p).includes('repo-approvals.json') ? true : false)
2245+
mockedReadFileSync.mockImplementation((p) => {
2246+
if (String(p).includes('repo-approvals.json')) return '{invalid json'
2247+
return '[]'
2248+
})
2249+
2250+
// Should not throw - constructor parses the corrupted JSON
2251+
expect(() => new SessionManager()).not.toThrow()
2252+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to restore repo approvals'), expect.any(Error))
2253+
2254+
consoleSpy.mockRestore()
2255+
mockedExistsSync.mockImplementation((p) => String(p).includes('sessions.json') ? false : true)
2256+
mockedReadFileSync.mockReturnValue('[]')
2257+
})
2258+
})
2259+
2260+
describe('restoreActiveSessions() continuation message', () => {
2261+
it('sends continuation message after system_init event', () => {
2262+
vi.useFakeTimers()
2263+
const mockedExistsSync = vi.mocked(existsSync)
2264+
const mockedReadFileSync = vi.mocked(readFileSync)
2265+
2266+
const sessionData = [{
2267+
id: 'continue-test',
2268+
name: 'Continue Test',
2269+
workingDir: '/tmp',
2270+
created: '2025-01-01T00:00:00.000Z',
2271+
claudeSessionId: 'claude-continue',
2272+
wasActive: true,
2273+
outputHistory: [],
2274+
}]
2275+
2276+
mockedExistsSync.mockImplementation((p) => String(p).includes('sessions.json') ? true : false)
2277+
mockedReadFileSync.mockReturnValue(JSON.stringify(sessionData))
2278+
2279+
const sm2 = new SessionManager()
2280+
2281+
// Create a fake claude process that captures the `once` callback
2282+
const onceCallbacks: Record<string, (...args: unknown[]) => void> = {}
2283+
const fakeCp = {
2284+
...fakeClaudeProcess(),
2285+
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => { onceCallbacks[event] = cb }),
2286+
}
2287+
2288+
vi.spyOn(sm2, 'startClaude').mockImplementation((id) => {
2289+
const session = sm2.get(id)
2290+
if (session) (session as any).claudeProcess = fakeCp
2291+
return true
2292+
})
2293+
2294+
sm2.restoreActiveSessions()
2295+
vi.advanceTimersByTime(0)
2296+
2297+
// Verify once was registered for system_init
2298+
expect(fakeCp.once).toHaveBeenCalledWith('system_init', expect.any(Function))
2299+
2300+
// Fire the system_init callback
2301+
onceCallbacks['system_init']()
2302+
expect(fakeCp.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Session restored'))
2303+
2304+
mockedExistsSync.mockImplementation((p) => String(p).includes('sessions.json') ? false : true)
2305+
mockedReadFileSync.mockReturnValue('[]')
2306+
vi.useRealTimers()
2307+
})
2308+
2309+
it('skips already-running sessions during restore', () => {
2310+
vi.useFakeTimers()
2311+
const mockedExistsSync = vi.mocked(existsSync)
2312+
const mockedReadFileSync = vi.mocked(readFileSync)
2313+
2314+
const sessionData = [{
2315+
id: 'already-running',
2316+
name: 'Already Running',
2317+
workingDir: '/tmp',
2318+
created: '2025-01-01T00:00:00.000Z',
2319+
claudeSessionId: 'claude-running',
2320+
wasActive: true,
2321+
outputHistory: [],
2322+
}]
2323+
2324+
mockedExistsSync.mockImplementation((p) => String(p).includes('sessions.json') ? true : false)
2325+
mockedReadFileSync.mockReturnValue(JSON.stringify(sessionData))
2326+
2327+
const sm2 = new SessionManager()
2328+
2329+
// Pre-assign a running ClaudeProcess
2330+
const session = sm2.get('already-running')!
2331+
;(session as any).claudeProcess = fakeClaudeProcess(true)
2332+
2333+
const startClaudeSpy = vi.spyOn(sm2, 'startClaude').mockReturnValue(true)
2334+
2335+
sm2.restoreActiveSessions()
2336+
vi.advanceTimersByTime(0)
2337+
2338+
// Should not start Claude since it's already alive
2339+
expect(startClaudeSpy).not.toHaveBeenCalled()
2340+
2341+
startClaudeSpy.mockRestore()
2342+
mockedExistsSync.mockImplementation((p) => String(p).includes('sessions.json') ? false : true)
2343+
mockedReadFileSync.mockReturnValue('[]')
2344+
vi.useRealTimers()
2345+
})
2346+
})
2347+
2348+
describe('handleClaudeExit behavior', () => {
2349+
it('stops auto-restart when _stoppedByUser is set', () => {
2350+
vi.useFakeTimers()
2351+
const s = sm.create('stopped-user', '/tmp')
2352+
const session = sm.get(s.id)!
2353+
2354+
// Simulate that Claude was running and user stopped it
2355+
;(session as any)._stoppedByUser = true
2356+
;(session as any).claudeProcess = fakeClaudeProcess()
2357+
2358+
const ws = fakeWs()
2359+
sm.join(s.id, ws)
2360+
2361+
// Trigger exit via the private method by calling it directly
2362+
;(sm as any).handleClaudeExit(session, s.id, 1, null)
2363+
2364+
// Should broadcast exit, not restart
2365+
const messages = ws.send.mock.calls.map((c: any) => JSON.parse(c[0]))
2366+
const exitMsg = messages.find((m: any) => m.type === 'system_message' && m.subtype === 'exit')
2367+
expect(exitMsg).toBeDefined()
2368+
expect(exitMsg.text).toContain('code=1')
2369+
2370+
// Should NOT broadcast restart message
2371+
const restartMsg = messages.find((m: any) => m.subtype === 'restart')
2372+
expect(restartMsg).toBeUndefined()
2373+
vi.useRealTimers()
2374+
})
2375+
2376+
it('clears stale claudeSessionId on code=1 first restart', () => {
2377+
vi.useFakeTimers()
2378+
const s = sm.create('stale-id', '/tmp')
2379+
const session = sm.get(s.id)!
2380+
;(session as any).claudeSessionId = 'stale-session'
2381+
;(session as any).restartCount = 0
2382+
;(session as any).lastRestartAt = null
2383+
2384+
;(sm as any).handleClaudeExit(session, s.id, 1, null)
2385+
2386+
expect(session.claudeSessionId).toBeNull()
2387+
vi.useRealTimers()
2388+
})
2389+
2390+
it('resets restart count after cooldown period', () => {
2391+
vi.useFakeTimers()
2392+
const s = sm.create('cooldown-test', '/tmp')
2393+
const session = sm.get(s.id)!
2394+
;(session as any).restartCount = 2
2395+
;(session as any).lastRestartAt = Date.now() - 600_000 // 10 minutes ago (> 5 min cooldown)
2396+
2397+
;(sm as any).handleClaudeExit(session, s.id, 0, null)
2398+
2399+
// restartCount should have been reset to 0 before incrementing to 1
2400+
expect((session as any).restartCount).toBe(1)
2401+
vi.useRealTimers()
2402+
})
2403+
2404+
it('broadcasts error when all restart attempts exhausted', () => {
2405+
vi.useFakeTimers()
2406+
const s = sm.create('exhausted-test', '/tmp')
2407+
const session = sm.get(s.id)!
2408+
;(session as any).restartCount = 5 // MAX_RESTARTS is 5
2409+
;(session as any).lastRestartAt = Date.now()
2410+
2411+
const ws = fakeWs()
2412+
sm.join(s.id, ws)
2413+
2414+
;(sm as any).handleClaudeExit(session, s.id, 1, null)
2415+
2416+
const messages = ws.send.mock.calls.map((c: any) => JSON.parse(c[0]))
2417+
const errorMsg = messages.find((m: any) => m.subtype === 'error')
2418+
expect(errorMsg).toBeDefined()
2419+
expect(errorMsg.text).toContain('Auto-restart disabled')
2420+
vi.useRealTimers()
2421+
})
2422+
2423+
it('notifies exit listeners with willRestart=true on auto-restart', () => {
2424+
vi.useFakeTimers()
2425+
const listener = vi.fn()
2426+
sm.onSessionExit(listener)
2427+
2428+
const s = sm.create('listener-test', '/tmp')
2429+
const session = sm.get(s.id)!
2430+
;(session as any).restartCount = 0
2431+
2432+
;(sm as any).handleClaudeExit(session, s.id, 1, 'SIGTERM')
2433+
2434+
expect(listener).toHaveBeenCalledWith(s.id, 1, 'SIGTERM', true)
2435+
vi.useRealTimers()
2436+
})
2437+
2438+
it('notifies exit listeners with willRestart=false when stopped by user', () => {
2439+
const listener = vi.fn()
2440+
sm.onSessionExit(listener)
2441+
2442+
const s = sm.create('listener-stopped', '/tmp')
2443+
const session = sm.get(s.id)!
2444+
;(session as any)._stoppedByUser = true
2445+
2446+
;(sm as any).handleClaudeExit(session, s.id, 0, null)
2447+
2448+
expect(listener).toHaveBeenCalledWith(s.id, 0, null, false)
2449+
})
2450+
})
2451+
2452+
describe('handleClaudeResult API retry', () => {
2453+
it('broadcasts exhaustion message after MAX_API_RETRIES', () => {
2454+
vi.useFakeTimers()
2455+
const s = sm.create('retry-exhaust', '/tmp')
2456+
const session = sm.get(s.id)!
2457+
;(session as any)._apiRetryCount = 3 // MAX_API_RETRIES is 3
2458+
;(session as any)._lastUserInput = 'test input'
2459+
;(session as any).claudeProcess = fakeClaudeProcess()
2460+
2461+
const ws = fakeWs()
2462+
sm.join(s.id, ws)
2463+
2464+
// Simulate a retryable error result with retries exhausted
2465+
;(sm as any).handleClaudeResult(session, s.id, 'API error: overloaded', true)
2466+
2467+
const messages = ws.send.mock.calls.map((c: any) => JSON.parse(c[0]))
2468+
const errorMsg = messages.find((m: any) => m.subtype === 'error' && m.text?.includes('retries'))
2469+
expect(errorMsg).toBeDefined()
2470+
expect(errorMsg.text).toContain('3 retries')
2471+
2472+
// Retry counter should be reset
2473+
expect((session as any)._apiRetryCount).toBe(0)
2474+
vi.useRealTimers()
2475+
})
2476+
})
22152477
})

0 commit comments

Comments
 (0)