Skip to content

Commit fc75e2f

Browse files
anish749Gary Basin
andauthored
fix: broadcast newly-discovered orphan sessions to UI (v0.2.50) (#136)
Fixes #135. ## Problem New orphan rows were inserted into `agent_sessions` but never broadcast — the UI's history list stayed empty until some unrelated event happened to trigger `updateDormantAgentSessions()`. ## Fix Add an `onSessionsDiscovered` callback fired at the end of `pollOnce` when `orphans > 0`, wired to `updateDormantAgentSessions()` next to the existing `onSessionActivated` / `onSessionOrphaned` callbacks. ## Tests Two new `logPoller.test.ts` cases: - callback fires for new-orphan discovery - callback does not fire when every new log matches a window 22/22 logPoller tests pass; lint and typecheck clean. --------- Co-authored-by: Gary Basin <gary@pineapple.mortgage>
1 parent 1a6fd7f commit fc75e2f

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@gbasin/agentboard",
3-
"version": "0.2.49",
3+
"version": "0.2.50",
44
"type": "module",
55
"description": "Web GUI for tmux optimized for AI agent TUIs",
66
"author": "gbasin",

src/server/__tests__/logPoller.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,143 @@ describe('LogPoller', () => {
874874
db.close()
875875
})
876876

877+
test('fires onOrphanSessionsDiscovered when a new session is discovered as orphan', async () => {
878+
const db = initDatabase({ path: ':memory:' })
879+
const registry = new SessionRegistry()
880+
// Empty registry: no tmux windows to match against, so any new log
881+
// becomes an orphan on first discovery.
882+
registry.replaceSessions([])
883+
884+
const orphanProjectPath = path.join(tempRoot, 'orphan-project')
885+
const encoded = encodeProjectPath(orphanProjectPath)
886+
const logDir = path.join(
887+
process.env.CLAUDE_CONFIG_DIR ?? '',
888+
'projects',
889+
encoded
890+
)
891+
await fs.mkdir(logDir, { recursive: true })
892+
893+
const tokens = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ')
894+
const logPath = path.join(logDir, 'orphan-session.jsonl')
895+
const userLine = buildUserLogEntry(tokens, {
896+
sessionId: 'claude-orphan-session',
897+
cwd: orphanProjectPath,
898+
})
899+
const assistantLine = JSON.stringify({
900+
type: 'assistant',
901+
message: { content: [{ type: 'text', text: tokens }] },
902+
})
903+
await fs.writeFile(logPath, `${userLine}\n${assistantLine}\n`)
904+
905+
const discovered: Array<{ newOrphans: number }> = []
906+
const poller = new LogPoller(db, registry, {
907+
matchWorkerClient: new InlineMatchWorkerClient(),
908+
onOrphanSessionsDiscovered: (stats) => discovered.push(stats),
909+
})
910+
911+
const stats = await poller.pollOnce()
912+
expect(stats.newSessions).toBe(1)
913+
expect(stats.orphans).toBe(1)
914+
expect(stats.matches).toBe(0)
915+
916+
// The DB row should exist as an orphan.
917+
const record = db.getSessionById('claude-orphan-session')
918+
expect(record).toBeDefined()
919+
expect(record?.currentWindow).toBeNull()
920+
921+
// Callback must fire exactly once with the orphan count, so the server
922+
// can broadcast the new history entries over the WebSocket.
923+
expect(discovered).toEqual([{ newOrphans: 1 }])
924+
925+
db.close()
926+
})
927+
928+
test('fires onOrphanSessionsDiscovered from pollChanged (watch-mode path)', async () => {
929+
// Watch mode is the default deployment, and new log files reach the
930+
// poller through pollChanged() rather than pollOnce(). The broadcast
931+
// must fire on both code paths.
932+
const db = initDatabase({ path: ':memory:' })
933+
const registry = new SessionRegistry()
934+
registry.replaceSessions([])
935+
936+
const orphanProjectPath = path.join(tempRoot, 'orphan-project-watch')
937+
const encoded = encodeProjectPath(orphanProjectPath)
938+
const logDir = path.join(
939+
process.env.CLAUDE_CONFIG_DIR ?? '',
940+
'projects',
941+
encoded
942+
)
943+
await fs.mkdir(logDir, { recursive: true })
944+
945+
const tokens = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ')
946+
const logPath = path.join(logDir, 'orphan-watch.jsonl')
947+
const userLine = buildUserLogEntry(tokens, {
948+
sessionId: 'claude-orphan-watch',
949+
cwd: orphanProjectPath,
950+
})
951+
const assistantLine = JSON.stringify({
952+
type: 'assistant',
953+
message: { content: [{ type: 'text', text: tokens }] },
954+
})
955+
await fs.writeFile(logPath, `${userLine}\n${assistantLine}\n`)
956+
957+
const discovered: Array<{ newOrphans: number }> = []
958+
const poller = new LogPoller(db, registry, {
959+
matchWorkerClient: new InlineMatchWorkerClient(),
960+
onOrphanSessionsDiscovered: (stats) => discovered.push(stats),
961+
})
962+
963+
await poller.pollChanged([logPath])
964+
965+
const record = db.getSessionById('claude-orphan-watch')
966+
expect(record).toBeDefined()
967+
expect(record?.currentWindow).toBeNull()
968+
expect(discovered).toEqual([{ newOrphans: 1 }])
969+
970+
db.close()
971+
})
972+
973+
test('does not fire onOrphanSessionsDiscovered when no new orphans are created', async () => {
974+
const db = initDatabase({ path: ':memory:' })
975+
const registry = new SessionRegistry()
976+
registry.replaceSessions([baseSession])
977+
978+
const tokens = Array.from({ length: 60 }, (_, i) => `token${i}`).join(' ')
979+
setTmuxOutput(baseSession.tmuxWindow, buildLastExchangeOutput(tokens))
980+
981+
const projectPath = baseSession.projectPath
982+
const encoded = encodeProjectPath(projectPath)
983+
const logDir = path.join(
984+
process.env.CLAUDE_CONFIG_DIR ?? '',
985+
'projects',
986+
encoded
987+
)
988+
await fs.mkdir(logDir, { recursive: true })
989+
const logPath = path.join(logDir, 'matched-session.jsonl')
990+
const userLine = buildUserLogEntry(tokens, {
991+
sessionId: 'claude-matched-session',
992+
cwd: projectPath,
993+
})
994+
const assistantLine = JSON.stringify({
995+
type: 'assistant',
996+
message: { content: [{ type: 'text', text: tokens }] },
997+
})
998+
await fs.writeFile(logPath, `${userLine}\n${assistantLine}\n`)
999+
1000+
const discovered: Array<{ newOrphans: number }> = []
1001+
const poller = new LogPoller(db, registry, {
1002+
matchWorkerClient: new InlineMatchWorkerClient(),
1003+
onOrphanSessionsDiscovered: (stats) => discovered.push(stats),
1004+
})
1005+
1006+
const stats = await poller.pollOnce()
1007+
expect(stats.newSessions).toBe(1)
1008+
expect(stats.orphans).toBe(0)
1009+
expect(discovered).toEqual([])
1010+
1011+
db.close()
1012+
})
1013+
8771014
test('fires onSessionOrphaned callback when superseding via slug', async () => {
8781015
const db = initDatabase({ path: ':memory:' })
8791016
const registry = new SessionRegistry()

src/server/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,9 @@ const logPoller = new LogPoller(db, registry, {
461461
})
462462
}
463463
},
464+
onOrphanSessionsDiscovered: () => {
465+
updateDormantAgentSessions()
466+
},
464467
isLastUserMessageLocked: (tmuxWindow) =>
465468
(lastUserMessageLocks.get(tmuxWindow) ?? 0) > Date.now(),
466469
maxLogsPerPoll: config.logPollMax,

src/server/logPoller.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export class LogPoller {
158158
private registry: SessionRegistry
159159
private onSessionOrphaned?: (sessionId: string, supersededBy?: string) => void
160160
private onSessionActivated?: (sessionId: string, window: string) => void
161+
private onOrphanSessionsDiscovered?: (stats: { newOrphans: number }) => void
161162
private isLastUserMessageLocked?: (tmuxWindow: string) => boolean
162163
private maxLogsPerPoll: number
163164
private matchProfile: boolean
@@ -180,6 +181,7 @@ export class LogPoller {
180181
{
181182
onSessionOrphaned,
182183
onSessionActivated,
184+
onOrphanSessionsDiscovered,
183185
isLastUserMessageLocked,
184186
maxLogsPerPoll,
185187
matchProfile,
@@ -189,6 +191,7 @@ export class LogPoller {
189191
}: {
190192
onSessionOrphaned?: (sessionId: string, supersededBy?: string) => void
191193
onSessionActivated?: (sessionId: string, window: string) => void
194+
onOrphanSessionsDiscovered?: (stats: { newOrphans: number }) => void
192195
isLastUserMessageLocked?: (tmuxWindow: string) => boolean
193196
maxLogsPerPoll?: number
194197
matchProfile?: boolean
@@ -201,6 +204,7 @@ export class LogPoller {
201204
this.registry = registry
202205
this.onSessionOrphaned = onSessionOrphaned
203206
this.onSessionActivated = onSessionActivated
207+
this.onOrphanSessionsDiscovered = onOrphanSessionsDiscovered
204208
this.isLastUserMessageLocked = isLastUserMessageLocked
205209
const limit = maxLogsPerPoll ?? DEFAULT_MAX_LOGS
206210
this.maxLogsPerPoll = Math.max(1, limit)
@@ -493,6 +497,17 @@ export class LogPoller {
493497
this.matchWorker = null
494498
}
495499

500+
private notifyOrphanSessionsDiscovered(newOrphans: number): void {
501+
if (newOrphans <= 0) return
502+
try {
503+
this.onOrphanSessionsDiscovered?.({ newOrphans })
504+
} catch (error) {
505+
logger.warn('log_poll_discovered_callback_error', {
506+
message: error instanceof Error ? error.message : String(error),
507+
})
508+
}
509+
}
510+
496511
async pollChanged(changedPaths: string[]): Promise<void> {
497512
if (this.pollInFlight) return
498513
this.pollInFlight = true
@@ -544,7 +559,8 @@ export class LogPoller {
544559
},
545560
})
546561

547-
this.processMatchResponse(response, windows, sessionRecords)
562+
const stats = this.processMatchResponse(response, windows, sessionRecords)
563+
this.notifyOrphanSessionsDiscovered(stats.orphans)
548564
} catch (error) {
549565
logger.warn('log_poll_changed_error', {
550566
message: error instanceof Error ? error.message : String(error),
@@ -1057,6 +1073,7 @@ export class LogPoller {
10571073
}
10581074

10591075
logger.info('log_poll', { ...stats })
1076+
this.notifyOrphanSessionsDiscovered(stats.orphans)
10601077
return stats
10611078
} finally {
10621079
this.pollInFlight = false

0 commit comments

Comments
 (0)