Skip to content
Open
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
92 changes: 92 additions & 0 deletions src/server/__tests__/logPoller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,9 @@ const logPoller = new LogPoller(db, registry, {
})
}
},
onSessionsDiscovered: () => {
updateDormantAgentSessions()
},
isLastUserMessageLocked: (tmuxWindow) =>
(lastUserMessageLocks.get(tmuxWindow) ?? 0) > Date.now(),
maxLogsPerPoll: config.logPollMax,
Expand Down
14 changes: 14 additions & 0 deletions src/server/logPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -180,6 +181,7 @@ export class LogPoller {
{
onSessionOrphaned,
onSessionActivated,
onSessionsDiscovered,
isLastUserMessageLocked,
maxLogsPerPoll,
matchProfile,
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down