@@ -37,9 +37,9 @@ use serde_json::{Map, Value};
3737use crate :: agents:: hermes:: read_config_pinned_project_root;
3838use crate :: global_db:: { GlobalDb , ParseOffset , TranscriptBatch } ;
3939use crate :: sessions:: shared:: {
40- NewRows , ProjectRootMatcher , StoredCursor , TranscriptIngestStats , TranscriptLocation ,
41- TranscriptLocationMetadataKeys , append_location_metadata , content_storage_text_and_tools ,
42- path_belongs_to_project, preview_title, title_from_messages,
40+ NewRows , ProjectMembership , ProjectRootMatcher , StoredCursor , TranscriptIngestStats ,
41+ TranscriptLocation , TranscriptLocationMetadataKeys , append_location_metadata ,
42+ content_storage_text_and_tools , path_belongs_to_project, preview_title, title_from_messages,
4343} ;
4444use crate :: sessions:: { SessionMessageRecord , SessionRecord } ;
4545
@@ -539,7 +539,13 @@ async fn try_ingest_state_db_for_projects(
539539 & destination_matchers,
540540 source,
541541 & mut destination_routes,
542- ) ;
542+ )
543+ . map_err ( |_| {
544+ format ! (
545+ "could not classify Hermes rows from '{}' because project membership is unknown" ,
546+ state_db. display( )
547+ )
548+ } ) ?;
543549 for ( state_index, state) in states
544550 . iter_mut ( )
545551 . enumerate ( )
@@ -980,12 +986,17 @@ struct DestinationTurnLocations {
980986 row_indices : Vec < usize > ,
981987}
982988
989+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
990+ enum DestinationRoutingError {
991+ UnknownMembership ,
992+ }
993+
983994fn turn_project_locations_for_destinations (
984995 rows : & [ HermesRow ] ,
985996 destination_matchers : & [ ProjectRootMatcher ] ,
986997 source : & HermesProfileSource ,
987998 destination_routes : & mut HashMap < PathBuf , Vec < usize > > ,
988- ) -> Vec < DestinationTurnLocations > {
999+ ) -> Result < Vec < DestinationTurnLocations > , DestinationRoutingError > {
9891000 let mut by_session: HashMap < & str , Vec < & HermesRow > > = HashMap :: new ( ) ;
9901001 let row_indices = rows
9911002 . iter ( )
@@ -1017,7 +1028,7 @@ fn turn_project_locations_for_destinations(
10171028 let mut fallbacks = vec ! [ None ; destination_matchers. len( ) ] ;
10181029 for ( cwd, provenance) in fallback_candidates {
10191030 for destination_index in
1020- matching_destinations ( & cwd, destination_matchers, destination_routes)
1031+ matching_destinations ( & cwd, destination_matchers, destination_routes) ?
10211032 {
10221033 fallbacks[ destination_index] . get_or_insert_with ( || HermesSessionLocation {
10231034 cwd : cwd. clone ( ) ,
@@ -1035,7 +1046,7 @@ fn turn_project_locations_for_destinations(
10351046 & row_indices,
10361047 & mut locations,
10371048 destination_routes,
1038- ) ;
1049+ ) ? ;
10391050 turn. clear ( ) ;
10401051 }
10411052 turn. push ( row) ;
@@ -1047,12 +1058,12 @@ fn turn_project_locations_for_destinations(
10471058 & row_indices,
10481059 & mut locations,
10491060 destination_routes,
1050- ) ;
1061+ ) ? ;
10511062 }
10521063 for destination in & mut locations {
10531064 destination. row_indices . sort_unstable ( ) ;
10541065 }
1055- locations
1066+ Ok ( locations)
10561067}
10571068
10581069fn assign_turn_locations_for_destinations (
@@ -1062,7 +1073,7 @@ fn assign_turn_locations_for_destinations(
10621073 row_indices : & HashMap < i64 , usize > ,
10631074 locations : & mut [ DestinationTurnLocations ] ,
10641075 destination_routes : & mut HashMap < PathBuf , Vec < usize > > ,
1065- ) {
1076+ ) -> Result < ( ) , DestinationRoutingError > {
10661077 let explicit_paths = rows
10671078 . iter ( )
10681079 . rev ( )
@@ -1074,7 +1085,7 @@ fn assign_turn_locations_for_destinations(
10741085 } else {
10751086 for path in explicit_paths {
10761087 for destination_index in
1077- matching_destinations ( & path, destination_matchers, destination_routes)
1088+ matching_destinations ( & path, destination_matchers, destination_routes) ?
10781089 {
10791090 selected[ destination_index] . get_or_insert_with ( || HermesSessionLocation {
10801091 cwd : path. clone ( ) ,
@@ -1094,23 +1105,29 @@ fn assign_turn_locations_for_destinations(
10941105 }
10951106 }
10961107 }
1108+ Ok ( ( ) )
10971109}
10981110
10991111fn matching_destinations (
11001112 path : & Path ,
11011113 destination_matchers : & [ ProjectRootMatcher ] ,
11021114 destination_routes : & mut HashMap < PathBuf , Vec < usize > > ,
1103- ) -> Vec < usize > {
1115+ ) -> Result < Vec < usize > , DestinationRoutingError > {
11041116 if let Some ( indices) = destination_routes. get ( path) {
1105- return indices. clone ( ) ;
1117+ return Ok ( indices. clone ( ) ) ;
1118+ }
1119+ let mut indices = Vec :: new ( ) ;
1120+ for ( index, matcher) in destination_matchers. iter ( ) . enumerate ( ) {
1121+ match matcher. contains_status ( path) {
1122+ ProjectMembership :: Match => indices. push ( index) ,
1123+ ProjectMembership :: NoMatch => { }
1124+ ProjectMembership :: Unknown => {
1125+ return Err ( DestinationRoutingError :: UnknownMembership ) ;
1126+ }
1127+ }
11061128 }
1107- let indices = destination_matchers
1108- . iter ( )
1109- . enumerate ( )
1110- . filter_map ( |( index, matcher) | matcher. contains ( path) . then_some ( index) )
1111- . collect :: < Vec < _ > > ( ) ;
11121129 destination_routes. insert ( path. to_path_buf ( ) , indices. clone ( ) ) ;
1113- indices
1130+ Ok ( indices)
11141131}
11151132
11161133fn assign_turn_location (
@@ -1422,3 +1439,92 @@ fn file_mtime_secs(path: &Path) -> u64 {
14221439 . and_then ( |time| time. duration_since ( std:: time:: UNIX_EPOCH ) . ok ( ) )
14231440 . map_or ( 0 , |duration| duration. as_secs ( ) )
14241441}
1442+
1443+ #[ cfg( test) ]
1444+ mod tests {
1445+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
1446+
1447+ use super :: * ;
1448+
1449+ static IDENTITY_ATTEMPTS : AtomicUsize = AtomicUsize :: new ( 0 ) ;
1450+
1451+ fn retrying_identity ( path : & Path ) -> crate :: worktree:: GitRepoIdentityOutcome {
1452+ let root = path
1453+ . ancestors ( )
1454+ . find ( |ancestor| ancestor. file_name ( ) . is_some_and ( |name| name == "repo" ) )
1455+ . unwrap_or ( path) ;
1456+ if IDENTITY_ATTEMPTS . fetch_add ( 1 , Ordering :: SeqCst ) == 0 {
1457+ return crate :: worktree:: GitRepoIdentityOutcome :: Unknown ;
1458+ }
1459+ crate :: worktree:: GitRepoIdentityOutcome :: Resolved ( crate :: worktree:: GitRepoIdentity {
1460+ worktree_root : root. to_path_buf ( ) ,
1461+ common_dir : root. join ( ".git" ) ,
1462+ } )
1463+ }
1464+
1465+ fn row_with_cwd ( cwd : & Path ) -> HermesRow {
1466+ HermesRow {
1467+ id : 1 ,
1468+ session_id : "session" . to_string ( ) ,
1469+ role : "user" . to_string ( ) ,
1470+ content : Some ( "retry me" . to_string ( ) ) ,
1471+ tool_name : None ,
1472+ tool_calls : None ,
1473+ timestamp : None ,
1474+ session_title : None ,
1475+ session_model : None ,
1476+ parent_session_id : None ,
1477+ session_started_at : None ,
1478+ session_ended_at : None ,
1479+ session_source : None ,
1480+ session_cwd : Some ( cwd. to_string_lossy ( ) . to_string ( ) ) ,
1481+ session_input_tokens : None ,
1482+ session_output_tokens : None ,
1483+ session_cache_read_tokens : None ,
1484+ session_cache_write_tokens : None ,
1485+ session_reasoning_tokens : None ,
1486+ active : 1 ,
1487+ }
1488+ }
1489+
1490+ #[ test]
1491+ fn unknown_destination_route_retries_without_advancing_cursor ( ) {
1492+ IDENTITY_ATTEMPTS . store ( 0 , Ordering :: SeqCst ) ;
1493+ let temp = tempfile:: TempDir :: new ( ) . expect ( "temp dir" ) ;
1494+ let project_root = temp. path ( ) . join ( "repo" ) ;
1495+ let cwd = project_root. join ( "packages/app" ) ;
1496+ std:: fs:: create_dir_all ( & cwd) . expect ( "cwd" ) ;
1497+ let rows = vec ! [ row_with_cwd( & cwd) ] ;
1498+ let source = HermesProfileSource {
1499+ state_db : temp. path ( ) . join ( "state.db" ) ,
1500+ profile : None ,
1501+ legacy_project_pin : None ,
1502+ } ;
1503+ let previous = StoredCursor :: default ( ) ;
1504+ let mut persisted = previous;
1505+ let mut routes = HashMap :: new ( ) ;
1506+
1507+ let first_matchers = vec ! [ ProjectRootMatcher :: new_with_identity_resolver(
1508+ & project_root,
1509+ retrying_identity,
1510+ ) ] ;
1511+ assert ! (
1512+ turn_project_locations_for_destinations( & rows, & first_matchers, & source, & mut routes, )
1513+ . is_err( )
1514+ ) ;
1515+ assert_eq ! ( persisted, previous) ;
1516+ assert ! ( routes. is_empty( ) , "unknown routes must not be cached" ) ;
1517+
1518+ let retry_matchers = vec ! [ ProjectRootMatcher :: new_with_identity_resolver(
1519+ & project_root,
1520+ retrying_identity,
1521+ ) ] ;
1522+ let locations =
1523+ turn_project_locations_for_destinations ( & rows, & retry_matchers, & source, & mut routes)
1524+ . expect ( "the same source rows should route after identity recovers" ) ;
1525+ assert_eq ! ( locations[ 0 ] . row_indices, vec![ 0 ] ) ;
1526+ persisted. position = rows[ 0 ] . id as u64 ;
1527+ assert ! ( persisted. position > previous. position) ;
1528+ assert_eq ! ( IDENTITY_ATTEMPTS . load( Ordering :: SeqCst ) , 3 ) ;
1529+ }
1530+ }
0 commit comments