From 59c7020100520caa9719bbdf8972897bf75a21fc Mon Sep 17 00:00:00 2001 From: Anish C Date: Sat, 16 May 2026 19:39:53 +0200 Subject: [PATCH] fix: broadcast newly-discovered orphan sessions to UI (#135) LogPoller#pollOnce inserted new orphan agent_sessions rows but only fired onSessionActivated / onSessionOrphaned, which are transition-only. First-time discoveries with no matching tmux window were silent, so the registry's history bucket never got populated until some unrelated event triggered updateDormantAgentSessions(). Add an onSessionsDiscovered callback fired at the end of pollOnce when orphans > 0, and wire it to updateDormantAgentSessions() alongside the existing callbacks. --- src/server/__tests__/logPoller.test.ts | 92 ++++++++++++++++++++++++++ src/server/index.ts | 3 + src/server/logPoller.ts | 14 ++++ 3 files changed, 109 insertions(+) diff --git a/src/server/__tests__/logPoller.test.ts b/src/server/__tests__/logPoller.test.ts index 86670ada..d7fea26c 100644 --- a/src/server/__tests__/logPoller.test.ts +++ b/src/server/__tests__/logPoller.test.ts @@ -874,6 +874,98 @@ describe('LogPoller', () => { db.close() }) + test('fires onSessionsDiscovered when a new session is discovered as orphan', async () => { + const db = initDatabase({ path: ':memory:' }) + const registry = new SessionRegistry() + // Empty registry: no tmux windows to match against, so any new log + // becomes an orphan on first discovery. + registry.replaceSessions([]) + + const orphanProjectPath = path.join(tempRoot, 'orphan-project') + const encoded = encodeProjectPath(orphanProjectPath) + const logDir = path.join( + process.env.CLAUDE_CONFIG_DIR ?? '', + 'projects', + encoded + ) + await fs.mkdir(logDir, { recursive: true }) + + const tokens = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ') + const logPath = path.join(logDir, 'orphan-session.jsonl') + const userLine = buildUserLogEntry(tokens, { + sessionId: 'claude-orphan-session', + cwd: orphanProjectPath, + }) + const assistantLine = JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: tokens }] }, + }) + await fs.writeFile(logPath, `${userLine}\n${assistantLine}\n`) + + const discovered: Array<{ newOrphans: number; newActive: number }> = [] + const poller = new LogPoller(db, registry, { + matchWorkerClient: new InlineMatchWorkerClient(), + onSessionsDiscovered: (stats) => discovered.push(stats), + }) + + const stats = await poller.pollOnce() + expect(stats.newSessions).toBe(1) + expect(stats.orphans).toBe(1) + expect(stats.matches).toBe(0) + + // The DB row should exist as an orphan. + const record = db.getSessionById('claude-orphan-session') + expect(record).toBeDefined() + expect(record?.currentWindow).toBeNull() + + // Callback must fire exactly once with the orphan count, so the server + // can broadcast the new history entries over the WebSocket. + expect(discovered).toEqual([{ newOrphans: 1, newActive: 0 }]) + + db.close() + }) + + test('does not fire onSessionsDiscovered when no new orphans are created', async () => { + const db = initDatabase({ path: ':memory:' }) + const registry = new SessionRegistry() + registry.replaceSessions([baseSession]) + + const tokens = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ') + setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokens)) + + const projectPath = baseSession.projectPath + const encoded = encodeProjectPath(projectPath) + const logDir = path.join( + process.env.CLAUDE_CONFIG_DIR ?? '', + 'projects', + encoded + ) + await fs.mkdir(logDir, { recursive: true }) + const logPath = path.join(logDir, 'matched-session.jsonl') + const userLine = buildUserLogEntry(tokens, { + sessionId: 'claude-matched-session', + cwd: projectPath, + }) + const assistantLine = JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: tokens }] }, + }) + await fs.writeFile(logPath, `${userLine}\n${assistantLine}\n`) + + const discovered: Array<{ newOrphans: number; newActive: number }> = [] + const poller = new LogPoller(db, registry, { + matchWorkerClient: new InlineMatchWorkerClient(), + onSessionsDiscovered: (stats) => discovered.push(stats), + }) + + const stats = await poller.pollOnce() + expect(stats.newSessions).toBe(1) + expect(stats.orphans).toBe(0) + expect(discovered).toEqual([]) + + db.close() + }) + test('fires onSessionOrphaned callback when superseding via slug', async () => { const db = initDatabase({ path: ':memory:' }) const registry = new SessionRegistry() diff --git a/src/server/index.ts b/src/server/index.ts index 34ed9c60..e939b0bf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -461,6 +461,9 @@ const logPoller = new LogPoller(db, registry, { }) } }, + onSessionsDiscovered: () => { + updateDormantAgentSessions() + }, isLastUserMessageLocked: (tmuxWindow) => (lastUserMessageLocks.get(tmuxWindow) ?? 0) > Date.now(), maxLogsPerPoll: config.logPollMax, diff --git a/src/server/logPoller.ts b/src/server/logPoller.ts index 09935137..66ec4358 100644 --- a/src/server/logPoller.ts +++ b/src/server/logPoller.ts @@ -158,6 +158,7 @@ export class LogPoller { private registry: SessionRegistry private onSessionOrphaned?: (sessionId: string, supersededBy?: string) => void private onSessionActivated?: (sessionId: string, window: string) => void + private onSessionsDiscovered?: (stats: { newOrphans: number; newActive: number }) => void private isLastUserMessageLocked?: (tmuxWindow: string) => boolean private maxLogsPerPoll: number private matchProfile: boolean @@ -180,6 +181,7 @@ export class LogPoller { { onSessionOrphaned, onSessionActivated, + onSessionsDiscovered, isLastUserMessageLocked, maxLogsPerPoll, matchProfile, @@ -189,6 +191,7 @@ export class LogPoller { }: { onSessionOrphaned?: (sessionId: string, supersededBy?: string) => void onSessionActivated?: (sessionId: string, window: string) => void + onSessionsDiscovered?: (stats: { newOrphans: number; newActive: number }) => void isLastUserMessageLocked?: (tmuxWindow: string) => boolean maxLogsPerPoll?: number matchProfile?: boolean @@ -201,6 +204,7 @@ export class LogPoller { this.registry = registry this.onSessionOrphaned = onSessionOrphaned this.onSessionActivated = onSessionActivated + this.onSessionsDiscovered = onSessionsDiscovered this.isLastUserMessageLocked = isLastUserMessageLocked const limit = maxLogsPerPoll ?? DEFAULT_MAX_LOGS this.maxLogsPerPoll = Math.max(1, limit) @@ -1057,6 +1061,16 @@ export class LogPoller { } logger.info('log_poll', { ...stats }) + if (stats.orphans > 0) { + const newActive = Math.max(0, stats.newSessions - stats.orphans) + try { + this.onSessionsDiscovered?.({ newOrphans: stats.orphans, newActive }) + } catch (error) { + logger.warn('log_poll_discovered_callback_error', { + message: error instanceof Error ? error.message : String(error), + }) + } + } return stats } finally { this.pollInFlight = false