Skip to content

Commit 520ddcf

Browse files
committed
fix(sessions): preserve unknown worktree membership
1 parent 9ade11a commit 520ddcf

4 files changed

Lines changed: 374 additions & 81 deletions

File tree

src/sessions/claude.rs

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ use serde_json::{Map, Value};
2626
use crate::accounting::parser::parse_timestamp;
2727
use crate::sessions::SessionMessageRecord;
2828
use crate::sessions::shared::{
29-
ProjectRootMatcherCache, StoredCursor, TranscriptLocation, TranscriptLocationMetadataKeys,
30-
append_location_metadata_cached, append_tool_calls_metadata, append_tool_event_metadata,
31-
append_usage_metadata, content_storage_text_and_tools, preview_truncated, title_from_messages,
29+
ProjectMembership, ProjectRootMatcherCache, StoredCursor, TranscriptLocation,
30+
TranscriptLocationMetadataKeys, append_location_metadata_cached, append_tool_calls_metadata,
31+
append_tool_event_metadata, append_usage_metadata, content_storage_text_and_tools,
32+
preview_truncated, title_from_messages,
3233
};
3334
use crate::sessions::source::{
3435
ParsedTranscript, SessionDraft, TranscriptSource, collect_files_with_ext, stream_new_jsonl,
@@ -210,22 +211,26 @@ impl TranscriptSource for ClaudeSource {
210211
for line in &new.lines {
211212
let record = &line.value;
212213
let line_cwd = record_cwd(record).or_else(|| session_cwd.clone());
213-
let include = self.user_scope.as_ref().map_or_else(
214-
|| {
215-
line_cwd.as_deref().is_some_and(|cwd| {
216-
project_matcher
217-
.as_ref()
218-
.is_some_and(|matcher| matcher.contains(cwd))
219-
})
220-
},
221-
|_scope| {
222-
line_cwd.as_deref().is_none_or(|cwd| {
223-
!registered_root_matchers
224-
.iter()
225-
.any(|matcher| matcher.contains(cwd))
226-
})
227-
},
228-
);
214+
let include = if self.user_scope.is_none() {
215+
line_cwd.as_deref().map_or(Some(false), |cwd| {
216+
project_matcher
217+
.as_ref()
218+
.map(|matcher| matcher.contains_status(cwd).definitive())
219+
.unwrap_or(Some(false))
220+
})
221+
} else {
222+
line_cwd.as_deref().map_or(Some(true), |cwd| {
223+
let mut unknown = false;
224+
for matcher in &registered_root_matchers {
225+
match matcher.contains_status(cwd) {
226+
ProjectMembership::Match => return Some(false),
227+
ProjectMembership::NoMatch => {}
228+
ProjectMembership::Unknown => unknown = true,
229+
}
230+
}
231+
(!unknown).then_some(true)
232+
})
233+
}?;
229234
if !include {
230235
continue;
231236
}
@@ -1362,9 +1367,27 @@ fn record_cwd(record: &Value) -> Option<PathBuf> {
13621367

13631368
#[cfg(test)]
13641369
mod tests {
1370+
use std::sync::atomic::{AtomicUsize, Ordering};
1371+
13651372
use super::*;
13661373
use serde_json::json;
13671374

1375+
static UNKNOWN_PATH_ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
1376+
1377+
fn retrying_identity(path: &Path) -> crate::worktree::GitRepoIdentityOutcome {
1378+
let root = path
1379+
.ancestors()
1380+
.find(|ancestor| ancestor.file_name().is_some_and(|name| name == "repo"))
1381+
.unwrap_or(path);
1382+
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
1383+
return crate::worktree::GitRepoIdentityOutcome::Unknown;
1384+
}
1385+
crate::worktree::GitRepoIdentityOutcome::Resolved(crate::worktree::GitRepoIdentity {
1386+
worktree_root: root.to_path_buf(),
1387+
common_dir: root.join(".git"),
1388+
})
1389+
}
1390+
13681391
#[test]
13691392
fn structured_git_operation_becomes_host_commit_evidence() {
13701393
let mut metadata = Map::new();
@@ -1508,6 +1531,47 @@ mod tests {
15081531
assert_eq!(second_metadata["claude_message_worktree"], first_worktree);
15091532
}
15101533

1534+
#[test]
1535+
fn claude_unknown_membership_retries_without_advancing_cursor() {
1536+
UNKNOWN_PATH_ATTEMPTS.store(0, Ordering::SeqCst);
1537+
let temp = tempfile::TempDir::new().expect("temp dir");
1538+
let project_root = temp.path().join("repo");
1539+
let nested_cwd = project_root.join("packages/app");
1540+
std::fs::create_dir_all(&nested_cwd).expect("nested cwd");
1541+
let transcript = temp.path().join("retry.jsonl");
1542+
std::fs::write(
1543+
&transcript,
1544+
format!(
1545+
"{}\n",
1546+
json!({
1547+
"type": "user",
1548+
"sessionId": "retry",
1549+
"cwd": nested_cwd,
1550+
"message": {"role": "user", "content": "retry me"}
1551+
})
1552+
),
1553+
)
1554+
.expect("write transcript");
1555+
let mut source = ClaudeSource::with_home(temp.path());
1556+
source.project_matchers =
1557+
ProjectRootMatcherCache::with_identity_resolver(retrying_identity);
1558+
1559+
let previous = StoredCursor::default();
1560+
assert!(
1561+
source
1562+
.parse_new(&transcript, previous, &project_root, None)
1563+
.is_none(),
1564+
"unknown membership must abort before a new cursor can be persisted"
1565+
);
1566+
1567+
let retried = source
1568+
.parse_new(&transcript, previous, &project_root, None)
1569+
.expect("unknown membership must be resolved again on retry");
1570+
assert_eq!(retried.messages.len(), 1);
1571+
assert!(retried.new_cursor.position > previous.position);
1572+
assert_eq!(UNKNOWN_PATH_ATTEMPTS.load(Ordering::SeqCst), 3);
1573+
}
1574+
15111575
#[test]
15121576
fn redacted_only_thinking_records_no_reasoning_row() {
15131577
// Matches Codex's encrypted-reasoning convention: no plaintext, no row.

src/sessions/codex.rs

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ use serde_json::Value;
5656
use crate::accounting::parser::parse_timestamp;
5757
use crate::sessions::SessionMessageRecord;
5858
use crate::sessions::shared::{
59-
ProjectRootMatcherCache, StoredCursor, append_tool_calls_metadata,
59+
ProjectMembership, ProjectRootMatcherCache, StoredCursor, append_tool_calls_metadata,
6060
content_storage_text_and_tools, title_from_messages,
6161
};
6262
use crate::sessions::source::{
@@ -206,22 +206,26 @@ impl TranscriptSource for CodexSource {
206206
let mut last_in_scope_git = None;
207207
for line in &new.lines {
208208
let is_context_record = context_state.observe_context_record(&line.value, path, &meta);
209-
let in_scope = self.user_scope.as_ref().map_or_else(
210-
|| {
211-
context_state.cwd.as_deref().is_some_and(|cwd| {
212-
project_matcher
213-
.as_ref()
214-
.is_some_and(|matcher| matcher.contains(cwd))
215-
})
216-
},
217-
|_scope| {
218-
context_state.cwd.as_deref().is_none_or(|cwd| {
219-
!registered_root_matchers
220-
.iter()
221-
.any(|matcher| matcher.contains(cwd))
222-
})
223-
},
224-
);
209+
let in_scope = if self.user_scope.is_none() {
210+
context_state.cwd.as_deref().map_or(Some(false), |cwd| {
211+
project_matcher
212+
.as_ref()
213+
.map(|matcher| matcher.contains_status(cwd).definitive())
214+
.unwrap_or(Some(false))
215+
})
216+
} else {
217+
context_state.cwd.as_deref().map_or(Some(true), |cwd| {
218+
let mut unknown = false;
219+
for matcher in &registered_root_matchers {
220+
match matcher.contains_status(cwd) {
221+
ProjectMembership::Match => return Some(false),
222+
ProjectMembership::NoMatch => {}
223+
ProjectMembership::Unknown => unknown = true,
224+
}
225+
}
226+
(!unknown).then_some(true)
227+
})
228+
}?;
225229
if !in_scope {
226230
if compacted_summary_from_line(
227231
&line.value,
@@ -1606,10 +1610,28 @@ mod goal_event_tests {
16061610
#[cfg(test)]
16071611
#[allow(clippy::unwrap_used)]
16081612
mod source_matcher_cache_tests {
1613+
use std::sync::atomic::{AtomicUsize, Ordering};
1614+
16091615
use super::*;
16101616
use serde_json::json;
16111617
use tempfile::TempDir;
16121618

1619+
static UNKNOWN_PATH_ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
1620+
1621+
fn retrying_identity(path: &Path) -> crate::worktree::GitRepoIdentityOutcome {
1622+
let root = path
1623+
.ancestors()
1624+
.find(|ancestor| ancestor.file_name().is_some_and(|name| name == "repo"))
1625+
.unwrap_or(path);
1626+
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
1627+
return crate::worktree::GitRepoIdentityOutcome::Unknown;
1628+
}
1629+
crate::worktree::GitRepoIdentityOutcome::Resolved(crate::worktree::GitRepoIdentity {
1630+
worktree_root: root.to_path_buf(),
1631+
common_dir: root.join(".git"),
1632+
})
1633+
}
1634+
16131635
fn write_rollout(path: &Path, session_id: &str, cwd: &Path) {
16141636
let lines = [
16151637
json!({
@@ -1679,4 +1701,33 @@ mod source_matcher_cache_tests {
16791701
serde_json::from_str(second.messages[0].metadata_json.as_deref().unwrap()).unwrap();
16801702
assert_eq!(second_metadata["codex_turn_worktree"], first_worktree);
16811703
}
1704+
1705+
#[test]
1706+
fn codex_unknown_membership_retries_without_advancing_cursor() {
1707+
UNKNOWN_PATH_ATTEMPTS.store(0, Ordering::SeqCst);
1708+
let temp = TempDir::new().unwrap();
1709+
let project_root = temp.path().join("repo");
1710+
let nested_cwd = project_root.join("packages/app");
1711+
std::fs::create_dir_all(&nested_cwd).unwrap();
1712+
let transcript = temp.path().join("retry.jsonl");
1713+
write_rollout(&transcript, "retry-session", &nested_cwd);
1714+
let mut source = CodexSource::with_home(temp.path());
1715+
source.project_matchers =
1716+
ProjectRootMatcherCache::with_identity_resolver(retrying_identity);
1717+
1718+
let previous = StoredCursor::default();
1719+
assert!(
1720+
source
1721+
.parse_new(&transcript, previous, &project_root, None)
1722+
.is_none(),
1723+
"unknown membership must abort before a new cursor can be persisted"
1724+
);
1725+
1726+
let retried = source
1727+
.parse_new(&transcript, previous, &project_root, None)
1728+
.expect("unknown membership must be resolved again on retry");
1729+
assert_eq!(retried.messages.len(), 1);
1730+
assert!(retried.new_cursor.position > previous.position);
1731+
assert_eq!(UNKNOWN_PATH_ATTEMPTS.load(Ordering::SeqCst), 3);
1732+
}
16821733
}

0 commit comments

Comments
 (0)