Skip to content

Commit f6c57ed

Browse files
committed
fix: retry uncertain session discovery
1 parent 520ddcf commit f6c57ed

5 files changed

Lines changed: 299 additions & 55 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tracedecay": patch
3+
---
4+
5+
Bound session worktree discovery so Git timeouts remain retryable without re-entering expensive repository scans or advancing transcript cursors.

src/daemon/git_watch.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -466,13 +466,52 @@ async fn supervise_project(inner: Arc<GitWatcherInner>, state: Arc<WatchState>)
466466
/// One project's event loop: build the notify watcher over git metadata, then
467467
/// debounce raw events into coalesced syncs. On watcher construction/death,
468468
/// fall back to a 5-minute mtime poll for THIS project only.
469+
enum IdentityDiscoveryDisposition {
470+
Watch(crate::worktree::GitRepoIdentity),
471+
Degraded,
472+
Retry,
473+
}
474+
475+
fn identity_discovery_disposition(
476+
outcome: crate::worktree::GitRepoIdentityOutcome,
477+
) -> IdentityDiscoveryDisposition {
478+
match outcome {
479+
crate::worktree::GitRepoIdentityOutcome::Resolved(identity) => {
480+
IdentityDiscoveryDisposition::Watch(identity)
481+
}
482+
crate::worktree::GitRepoIdentityOutcome::NotFound => IdentityDiscoveryDisposition::Degraded,
483+
crate::worktree::GitRepoIdentityOutcome::Unknown => IdentityDiscoveryDisposition::Retry,
484+
}
485+
}
486+
469487
async fn project_task(inner: Arc<GitWatcherInner>, state: Arc<WatchState>) {
470-
let Some(identity) = crate::worktree::git_repo_identity(&state.project_root) else {
471-
// Not a resolvable git repo (yet). Degrade to polling so a later `git
472-
// init` / clone is still eventually covered.
473-
state.health.set_degraded(true);
474-
degraded_poll_loop(&inner, &state, None).await;
475-
return;
488+
let mut discovery_backoff = Duration::from_millis(500);
489+
let identity = loop {
490+
match identity_discovery_disposition(crate::worktree::git_repo_identity_outcome(
491+
&state.project_root,
492+
)) {
493+
IdentityDiscoveryDisposition::Watch(identity) => break identity,
494+
IdentityDiscoveryDisposition::Degraded => {
495+
// Definitively not a git repo (yet). Degrade to polling so a
496+
// later `git init` / clone is still eventually covered.
497+
state.health.set_degraded(true);
498+
degraded_poll_loop(&inner, &state, None).await;
499+
return;
500+
}
501+
IdentityDiscoveryDisposition::Retry => {
502+
state.health.set_degraded(true);
503+
state.health.beat();
504+
log_daemon_event(
505+
"git_watch_discovery_retry",
506+
&[
507+
("project", state.project_root.display().to_string()),
508+
("backoff_ms", discovery_backoff.as_millis().to_string()),
509+
],
510+
);
511+
tokio::time::sleep(discovery_backoff).await;
512+
discovery_backoff = (discovery_backoff * 2).min(RESTART_BACKOFF_MAX);
513+
}
514+
}
476515
};
477516
let common_dir = identity.common_dir;
478517

src/daemon/git_watch/tests.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ fn heartbeat_staleness() {
102102
assert!(old.heartbeat_stale());
103103
}
104104

105+
#[test]
106+
fn timed_out_identity_discovery_retries_instead_of_degrading_forever() {
107+
assert!(matches!(
108+
identity_discovery_disposition(crate::worktree::GitRepoIdentityOutcome::Unknown),
109+
IdentityDiscoveryDisposition::Retry
110+
));
111+
assert!(matches!(
112+
identity_discovery_disposition(crate::worktree::GitRepoIdentityOutcome::NotFound),
113+
IdentityDiscoveryDisposition::Degraded
114+
));
115+
}
116+
105117
/// The shared coordinator must not start a second store-writing lifetime while
106118
/// the first one is held. Paused Tokio time plus Notify/oneshot handshakes make
107119
/// this a scheduling-state assertion rather than a wall-clock sleep.

src/sessions/hermes.rs

Lines changed: 125 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ use serde_json::{Map, Value};
3737
use crate::agents::hermes::read_config_pinned_project_root;
3838
use crate::global_db::{GlobalDb, ParseOffset, TranscriptBatch};
3939
use 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
};
4444
use 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+
983994
fn 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

10581069
fn 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

10991111
fn 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

11161133
fn 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

Comments
 (0)