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