@@ -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 ( )
0 commit comments