From 5695cf0b158bb825262f4a2a48f58b3fde34fbd7 Mon Sep 17 00:00:00 2001 From: VantaNode <208094903+VantaNode@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:25:28 -0700 Subject: [PATCH 1/4] fix(claude-code): dedupe compacted continuation sessions --- .../src/sources/claude_code/history.rs | 35 +- .../src/sources/claude_code/history_tests.rs | 18 ++ .../src/sources/imported_history/cache.rs | 299 +++++++++++++++--- .../sources/imported_history/cache_tests.rs | 90 ++++++ .../src/sources/imported_history/mod.rs | 5 + src/api/tauri/rpc/schemas/sessionAggregate.ts | 3 + .../__tests__/continuationVisibility.test.ts | 61 ++++ .../continuationVisibility.ts | 29 ++ .../connectors/useSessionMenuItems/index.tsx | 19 ++ .../__tests__/sidebarLoaders.test.ts | 21 +- src/store/session/sessionAtom/loaders.ts | 1 + src/store/session/sessionAtom/types.ts | 2 + 12 files changed, 528 insertions(+), 55 deletions(-) create mode 100644 src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts create mode 100644 src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index e379362ed..a0dbefb57 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -4,7 +4,7 @@ //! converts them into ORGII's canonical `ActivityChunk` shape for read-only //! replay. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::fs; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -38,7 +38,10 @@ const CLAUDE_CODE_PROVIDER_SLUG: &str = "claudecode"; // v10: harness-injected user lines (isMeta, task-notification origin) no // longer open rounds or feed the first-prompt title; user image blocks // surface as data-URL attachments on the user bubble. -const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 10; +// v11: capture compact-boundary ancestry markers so continuation families +// survive Claude Code rewriting the first user message during compaction. +const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 11; +const MAX_COMPACT_BOUNDARY_MARKERS: usize = imported_cache::MAX_CONTINUATION_MARKERS - 1; pub type ClaudeCodeHistorySessionRow = ImportedHistorySessionRow; pub type ClaudeCodeHistorySessionPage = ImportedHistorySessionPage; @@ -74,6 +77,9 @@ struct ClaudeCodeHistoryMeta { /// field, but message uuids are preserved — so this is a stable group key /// uniting a conversation's continuation siblings for dedupe. first_user_uuid: Option, + /// Compact-boundary uuids retained by continuation rewrites. Together + /// with `first_user_uuid` these form a bounded ancestry marker set. + continuation_markers: Vec, } #[derive(Debug, Deserialize)] @@ -82,6 +88,8 @@ struct ClaudeJsonlLine { #[serde(default)] r#type: String, #[serde(default)] + subtype: String, + #[serde(default)] summary: String, /// `ai-title` records: the auto-generated title shown in the Claude Code app. #[serde(default)] @@ -878,6 +886,9 @@ struct ClaudeSessionMetaState { // same way Codex does, instead of listing it as a top-level session. parent_source_session_id: Option, first_user_uuid: Option, + /// Keep the newest compact boundaries; the first-user marker consumes the + /// remaining slot in the 64-marker cache metadata budget. + compact_boundary_uuids: VecDeque, } impl ClaudeSessionMetaState { @@ -955,6 +966,22 @@ impl ClaudeSessionMetaState { { self.first_user_uuid = Some(parsed.uuid.trim().to_string()); } + if parsed.r#type == "system" + && parsed.subtype == "compact_boundary" + && !parsed.uuid.trim().is_empty() + { + let marker = parsed.uuid.trim(); + if !self + .compact_boundary_uuids + .iter() + .any(|existing| existing == marker) + { + if self.compact_boundary_uuids.len() >= MAX_COMPACT_BOUNDARY_MARKERS { + self.compact_boundary_uuids.pop_front(); + } + self.compact_boundary_uuids.push_back(marker.to_string()); + } + } let harness_injected = is_harness_injected_user_line(&parsed); if let Some(message) = parsed.message { if self.first_prompt.is_empty() && parsed.r#type == "user" && !harness_injected { @@ -1096,6 +1123,7 @@ impl ClaudeSessionMetaState { .parent_source_session_id .map(|uuid| format!("{CLAUDE_CODE_SESSION_PREFIX}{uuid}")), first_user_uuid: self.first_user_uuid, + continuation_markers: self.compact_boundary_uuids.into_iter().collect(), }) } } @@ -1209,8 +1237,9 @@ fn session_meta_to_cache_input(meta: ClaudeCodeHistoryMeta) -> ImportedHistoryCa branch: meta.branch, impact: meta.impact, listable: true, - source_metadata_json: imported_cache::continuation_group_metadata_json( + source_metadata_json: imported_cache::continuation_metadata_json( meta.first_user_uuid.as_deref(), + &meta.continuation_markers, ), parent_session_id: meta.parent_session_id, } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs index 0f1143de2..bcda8b41b 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs @@ -857,6 +857,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { // contribute a key. let content = r#"{"type":"custom-title","customTitle":"My convo","sessionId":"d0641111-1111-1111-1111-111111111111"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:00:00.000Z","message":{"role":"user","content":"first message"}} +{"type":"system","subtype":"compact_boundary","uuid":"eeb66522-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000002","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:01:00.000Z","message":{"role":"user","content":"second message"}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -879,6 +880,10 @@ fn captures_first_user_uuid_as_continuation_group_key() { meta.first_user_uuid.as_deref(), Some("b7b5ae5f-0000-0000-0000-000000000001") ); + assert_eq!( + meta.continuation_markers, + vec!["eeb66522-0000-0000-0000-000000000001"] + ); let cache_input = session_meta_to_cache_input(meta); let metadata_json = cache_input.source_metadata_json.expect("metadata json"); @@ -889,6 +894,19 @@ fn captures_first_user_uuid_as_continuation_group_key() { .and_then(|value| value.as_str()), Some("b7b5ae5f-0000-0000-0000-000000000001") ); + assert_eq!( + parsed + .get(imported_cache::CONTINUATION_MARKERS_FIELD) + .and_then(Value::as_array) + .expect("continuation markers") + .iter() + .filter_map(Value::as_str) + .collect::>(), + vec![ + "b7b5ae5f-0000-0000-0000-000000000001", + "eeb66522-0000-0000-0000-000000000001" + ] + ); std::fs::remove_file(&path).expect("remove fixture"); std::fs::remove_dir(&temp_dir).expect("remove temp dir"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs index ae7548a78..32b2fde5c 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs @@ -406,7 +406,8 @@ pub fn query_imported_sidebar_page_from_conn( "SELECT session_id, name, created_at_ms, updated_at_ms, cache.repo_path, model, files_changed, lines_added, lines_removed, touched_files_json, input_tokens, output_tokens, source_path, - identity.repo_root_path, identity.remote_urls_json, cache.branch + identity.repo_root_path, identity.remote_urls_json, cache.branch, + cache.source_metadata_json FROM imported_history_session_cache cache LEFT JOIN imported_history_repo_identity identity ON identity.working_path = cache.repo_path @@ -443,6 +444,7 @@ pub fn query_imported_sidebar_page_from_conn( // Stored as "" for sources that report no branch (the upsert // coalesces `None`), so normalize back to absent. let branch: String = row.get(15)?; + let source_metadata_json: String = row.get(16)?; Ok(ImportedHistorySidebarRow { session_id: row.get(0)?, name: row.get(1)?, @@ -460,6 +462,9 @@ pub fn query_imported_sidebar_page_from_conn( branch: non_empty_string(branch), storage_path: non_empty_string(source_path), model: non_empty_string(model), + continuation_lineage_id: continuation_lineage_id_from_metadata_json( + &source_metadata_json, + ), total_tokens: input_tokens + output_tokens, files_changed: row.get(6)?, lines_added: row.get(7)?, @@ -867,19 +872,23 @@ fn has_newer_continuation_sibling( if session.parent_session_id.is_some() { return Ok(false); } - let Some(group_key) = session + let Some(metadata) = session .source_metadata_json .as_deref() - .and_then(|json| serde_json::from_str::(json).ok()) - .as_ref() - .and_then(|metadata| metadata.get(CONTINUATION_GROUP_KEY_FIELD)) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) + .and_then(parse_continuation_metadata) else { return Ok(false); }; + // Normal post-sync lookups use the elected lineage id. The group-key + // fallback preserves the pre-election/legacy behavior for rows written by + // older parsers that have not yet been reindexed. + let (field, family_key) = if let Some(lineage_id) = metadata.lineage_id { + (CONTINUATION_LINEAGE_ID_FIELD, lineage_id) + } else if let Some(group_key) = metadata.group_key { + (CONTINUATION_GROUP_KEY_FIELD, group_key) + } else { + return Ok(false); + }; conn.query_row( &format!( "SELECT EXISTS( @@ -888,7 +897,7 @@ fn has_newer_continuation_sibling( AND source_session_id != ?2 AND COALESCE(parent_session_id, '') = '' AND CASE WHEN json_valid(source_metadata_json) - THEN json_extract(source_metadata_json, '$.{CONTINUATION_GROUP_KEY_FIELD}') + THEN json_extract(source_metadata_json, '$.{field}') END = ?3 AND (updated_at_ms > ?4 OR (updated_at_ms = ?4 AND source_session_id > ?2)) @@ -897,7 +906,7 @@ fn has_newer_continuation_sibling( rusqlite::params![ source, session.source_session_id, - group_key, + family_key, session.updated_at_ms ], |row| Ok(row.get::<_, i64>(0)? != 0), @@ -1044,9 +1053,7 @@ pub fn set_imported_session_pinned_from_conn( } /// The set of imported session ids the user has pinned. -pub fn pinned_imported_session_ids_from_conn( - conn: &Connection, -) -> Result, String> { +pub fn pinned_imported_session_ids_from_conn(conn: &Connection) -> Result, String> { let mut statement = conn .prepare("SELECT session_id FROM imported_history_session_pin") .map_err(|err| format!("Failed to read imported session pins: {err}"))?; @@ -1089,6 +1096,72 @@ fn non_empty_string(value: String) -> Option { /// file with no link field, so readers derive a family key from content that /// the rewrite preserves (Claude: the first user message's uuid). pub const CONTINUATION_GROUP_KEY_FIELD: &str = "continuationGroupKey"; +/// Bounded ancestry markers preserved across Claude Code compact rewrites. +pub const CONTINUATION_MARKERS_FIELD: &str = "continuationMarkers"; +/// Stable component id elected after every source sync. +pub const CONTINUATION_LINEAGE_ID_FIELD: &str = "continuationLineageId"; +/// Hard cap for source-controlled marker arrays read from cache metadata. +pub const MAX_CONTINUATION_MARKERS: usize = 64; + +#[derive(Debug, Clone)] +struct ContinuationMetadata { + value: serde_json::Value, + group_key: Option, + markers: Vec, + lineage_id: Option, +} + +fn metadata_string(value: Option<&serde_json::Value>) -> Option { + value + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn parse_continuation_metadata(metadata_json: &str) -> Option { + let value = serde_json::from_str::(metadata_json).ok()?; + if !value.is_object() { + return None; + } + let group_key = metadata_string(value.get(CONTINUATION_GROUP_KEY_FIELD)); + let lineage_id = metadata_string(value.get(CONTINUATION_LINEAGE_ID_FIELD)); + let mut markers = Vec::with_capacity(MAX_CONTINUATION_MARKERS); + let mut seen = HashSet::new(); + if let Some(group_key) = group_key.as_ref() { + seen.insert(group_key.clone()); + markers.push(group_key.clone()); + } + if let Some(values) = value + .get(CONTINUATION_MARKERS_FIELD) + .and_then(serde_json::Value::as_array) + { + for marker in values { + if markers.len() >= MAX_CONTINUATION_MARKERS { + break; + } + let Some(marker) = metadata_string(Some(marker)) else { + continue; + }; + if seen.insert(marker.clone()) { + markers.push(marker); + } + } + } + if markers.is_empty() { + return None; + } + Some(ContinuationMetadata { + value, + group_key, + markers, + lineage_id, + }) +} + +pub fn continuation_lineage_id_from_metadata_json(metadata_json: &str) -> Option { + parse_continuation_metadata(metadata_json)?.lineage_id +} /// Serialize the continuation group key into `source_metadata_json` shape. pub fn continuation_group_metadata_json(group_key: Option<&str>) -> Option { @@ -1096,8 +1169,51 @@ pub fn continuation_group_metadata_json(group_key: Option<&str>) -> Option, + ancestry_markers: &[String], +) -> Option { + let group_key = group_key + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string); + let mut markers = Vec::with_capacity(MAX_CONTINUATION_MARKERS); + let mut seen = HashSet::new(); + if let Some(group_key) = group_key.as_ref() { + seen.insert(group_key.clone()); + markers.push(group_key.clone()); + } + for marker in ancestry_markers { + if markers.len() >= MAX_CONTINUATION_MARKERS { + break; + } + let marker = marker.trim(); + if !marker.is_empty() && seen.insert(marker.to_string()) { + markers.push(marker.to_string()); + } + } + if markers.is_empty() { + return None; + } + let mut value = serde_json::Map::new(); + if let Some(group_key) = group_key { + value.insert( + CONTINUATION_GROUP_KEY_FIELD.to_string(), + serde_json::Value::String(group_key), + ); + } + value.insert( + CONTINUATION_MARKERS_FIELD.to_string(), + serde_json::Value::Array(markers.into_iter().map(serde_json::Value::String).collect()), + ); + Some(serde_json::Value::Object(value).to_string()) +} + /// Demote continuation-superseded sessions: within each group of top-level -/// sessions sharing a continuation group key, only the newest sibling (by +/// sessions whose bounded ancestry markers form a connected component, only +/// the newest sibling (by /// `updated_at_ms`, then `source_session_id`) stays listable; every other /// currently-listable sibling flips to `listable = 0`. /// @@ -1111,7 +1227,7 @@ pub fn demote_superseded_continuations_from_conn( ) -> Result { let mut stmt = conn .prepare( - "SELECT source_session_id, source_metadata_json, updated_at_ms, listable + "SELECT source_session_id, source_metadata_json, created_at_ms, updated_at_ms, listable FROM imported_history_session_cache WHERE source = ?1 AND COALESCE(parent_session_id, '') = '' @@ -1124,54 +1240,137 @@ pub fn demote_superseded_continuations_from_conn( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, - row.get::<_, i64>(3)? != 0, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)? != 0, )) }) .map_err(|err| format!("Failed to query continuation election rows: {err}"))?; - // group key -> (winner-ordering key, listable losers seen so far) - struct Family { - winner: (i64, String), - listable_ids: Vec<(String, (i64, String))>, + struct ElectionRow { + source_session_id: String, + metadata: ContinuationMetadata, + created_at_ms: i64, + updated_at_ms: i64, + listable: bool, + } + + struct DisjointSet { + parent: Vec, } - let mut families: HashMap = HashMap::new(); + + impl DisjointSet { + fn new(len: usize) -> Self { + Self { + parent: (0..len).collect(), + } + } + + fn find(&mut self, index: usize) -> usize { + if self.parent[index] != index { + self.parent[index] = self.find(self.parent[index]); + } + self.parent[index] + } + + fn union(&mut self, left: usize, right: usize) { + let left_root = self.find(left); + let right_root = self.find(right); + if left_root != right_root { + self.parent[right_root] = left_root; + } + } + } + + let mut election_rows = Vec::new(); for row in rows { - let (source_session_id, metadata_json, updated_at_ms, listable) = + let (source_session_id, metadata_json, created_at_ms, updated_at_ms, listable) = row.map_err(|err| format!("Failed to read continuation election row: {err}"))?; - let Some(group_key) = serde_json::from_str::(&metadata_json) - .ok() - .as_ref() - .and_then(|metadata| metadata.get(CONTINUATION_GROUP_KEY_FIELD)) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) - else { + let Some(metadata) = parse_continuation_metadata(&metadata_json) else { continue; }; - let ordering = (updated_at_ms, source_session_id.clone()); - let family = families.entry(group_key).or_insert_with(|| Family { - winner: ordering.clone(), - listable_ids: Vec::new(), + election_rows.push(ElectionRow { + source_session_id, + metadata, + created_at_ms, + updated_at_ms, + listable, }); - if ordering > family.winner { - family.winner = ordering.clone(); + } + + let mut sets = DisjointSet::new(election_rows.len()); + let mut marker_owner: HashMap = HashMap::new(); + for (index, row) in election_rows.iter().enumerate() { + for marker in &row.metadata.markers { + if let Some(owner) = marker_owner.get(marker).copied() { + sets.union(index, owner); + } else { + marker_owner.insert(marker.clone(), index); + } } - if listable { - family.listable_ids.push((source_session_id, ordering)); + } + + let mut families: HashMap> = HashMap::new(); + for index in 0..election_rows.len() { + families.entry(sets.find(index)).or_default().push(index); + } + + let mut losers = Vec::new(); + let mut metadata_updates = Vec::new(); + for member_indices in families.values() { + let winner_index = *member_indices + .iter() + .max_by_key(|index| { + let row = &election_rows[**index]; + (row.updated_at_ms, row.source_session_id.as_str()) + }) + .expect("continuation family has at least one member"); + let canonical_index = *member_indices + .iter() + .min_by_key(|index| { + let row = &election_rows[**index]; + (row.created_at_ms, row.source_session_id.as_str()) + }) + .expect("continuation family has at least one member"); + // Preserve an already-elected id when a new continuation joins the + // component. That keeps a force-revealed row already held by the + // frontend comparable with the newly elected roster winner. A parser + // migration has no elected ids yet, so it falls back to one canonical + // member and stamps the whole component once. + let lineage_id = member_indices + .iter() + .filter_map(|index| election_rows[*index].metadata.lineage_id.as_ref()) + .min() + .cloned() + .or_else(|| election_rows[canonical_index].metadata.group_key.clone()) + .unwrap_or_else(|| election_rows[canonical_index].metadata.markers[0].clone()); + + for index in member_indices { + let row = &election_rows[*index]; + if row.listable && *index != winner_index { + losers.push(row.source_session_id.clone()); + } + if row.metadata.lineage_id.as_deref() != Some(lineage_id.as_str()) { + let mut metadata = row.metadata.value.clone(); + if let Some(object) = metadata.as_object_mut() { + object.insert( + CONTINUATION_LINEAGE_ID_FIELD.to_string(), + serde_json::Value::String(lineage_id.clone()), + ); + metadata_updates.push((row.source_session_id.clone(), metadata.to_string())); + } + } } } - let losers = families - .values() - .flat_map(|family| { - family - .listable_ids - .iter() - .filter(|(_, ordering)| *ordering != family.winner) - .map(|(id, _)| id.clone()) - }) - .collect::>(); + for (source_session_id, metadata_json) in metadata_updates { + conn.execute( + "UPDATE imported_history_session_cache + SET source_metadata_json = ?3 + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![source, source_session_id, metadata_json], + ) + .map_err(|err| format!("Failed to stamp continuation lineage: {err}"))?; + } for source_session_id in &losers { conn.execute( "UPDATE imported_history_session_cache diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 1108b8973..535402ba0 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -403,6 +403,74 @@ fn continuation_election_demotes_all_but_newest_sibling() { assert!(listable_of(&conn, SOURCE_CODEX_APP, "keyless")); } +#[test] +fn continuation_election_connects_compaction_epochs_transitively() { + let mut conn = fixture_conn(); + let mut root = input(SOURCE_CODEX_APP, "root", 100); + root.source_metadata_json = + continuation_metadata_json(Some("first-user-root"), &["compact-a".to_string()]); + let mut middle = input(SOURCE_CODEX_APP, "middle", 200); + middle.source_metadata_json = continuation_metadata_json( + Some("first-user-middle"), + &["compact-a".to_string(), "compact-b".to_string()], + ); + let mut newest = input(SOURCE_CODEX_APP, "newest", 300); + newest.source_metadata_json = + continuation_metadata_json(Some("first-user-newest"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[root, middle, newest]).expect("upsert"); + + let demoted = + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("election"); + + assert_eq!(demoted, 2); + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "root")); + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "middle")); + assert!(listable_of(&conn, SOURCE_CODEX_APP, "newest")); + for source_session_id in ["root", "middle", "newest"] { + let metadata_json: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![SOURCE_CODEX_APP, source_session_id], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&metadata_json).as_deref(), + Some("first-user-root") + ); + } + + let page = query_imported_sidebar_page_from_conn(&conn, SOURCE_CODEX_APP, None, None, 10, 0) + .expect("sidebar page"); + assert_eq!(page.sessions.len(), 1); + assert_eq!(page.sessions[0].session_id, "codex_app-newest"); + assert_eq!( + page.sessions[0].continuation_lineage_id.as_deref(), + Some("first-user-root") + ); + + // A later continuation preserves the elected id even when its own group + // key would sort before the original id. + let mut later = input(SOURCE_CODEX_APP, "later", 400); + later.source_metadata_json = + continuation_metadata_json(Some("000-new-first-user"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[later]).expect("upsert later"); + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("second election"); + let later_metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'later'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("later metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&later_metadata).as_deref(), + Some("first-user-root") + ); +} + #[test] fn continuation_election_never_promotes_and_skips_subagents() { let mut conn = fixture_conn(); @@ -630,6 +698,28 @@ fn continuation_group_metadata_json_shapes() { ); } +#[test] +fn continuation_metadata_bounds_and_deduplicates_markers() { + let markers = (0..100) + .map(|index| format!("marker-{index}")) + .collect::>(); + let json = continuation_metadata_json(Some("marker-0"), &markers).expect("metadata"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse"); + let stored = parsed + .get(CONTINUATION_MARKERS_FIELD) + .and_then(serde_json::Value::as_array) + .expect("markers"); + assert_eq!(stored.len(), MAX_CONTINUATION_MARKERS); + assert_eq!(stored[0].as_str(), Some("marker-0")); + assert_eq!( + stored + .iter() + .filter(|marker| marker.as_str() == Some("marker-0")) + .count(), + 1 + ); +} + fn table_has_column(conn: &Connection, table: &str, column: &str) -> bool { let mut stmt = conn .prepare(&format!("PRAGMA table_info({table})")) diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index b7c1214a9..ef84a3836 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -282,6 +282,11 @@ pub struct ImportedHistorySidebarRow { pub storage_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Continuation-family identity elected from source metadata. Sidebar + /// consumers use it only to avoid rendering both a force-revealed active + /// sibling and the family's canonical roster row. + #[serde(skip_serializing_if = "Option::is_none")] + pub continuation_lineage_id: Option, /// ORGII-owned pin state, read from `imported_history_session_pin`. /// A pin belongs to ORGII, not to the source app, so it is stored beside /// the rebuildable cache rather than on it. diff --git a/src/api/tauri/rpc/schemas/sessionAggregate.ts b/src/api/tauri/rpc/schemas/sessionAggregate.ts index ea281a620..fd8b38e2a 100644 --- a/src/api/tauri/rpc/schemas/sessionAggregate.ts +++ b/src/api/tauri/rpc/schemas/sessionAggregate.ts @@ -280,6 +280,9 @@ export const ExternalHistorySidebarRowSchema = z.object({ // copy, so this is their only storage path. storagePath: z.string().optional(), model: z.string().optional(), + // Stable continuation-family identity elected by the imported-history + // cache. Used only for sidebar de-duplication of force-revealed siblings. + continuationLineageId: z.string().optional(), /** ORGII-owned pin state; imported sessions carry no pin from their source. */ pinned: z.boolean().optional(), totalTokens: z.number().int().optional(), diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts new file mode 100644 index 000000000..230669df7 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { Session } from "@src/store/session"; + +import { + continuationLineagesForRevealedSessions, + isRosterSiblingOfRevealedContinuation, +} from "../continuationVisibility"; + +function session(sessionId: string, continuationLineageId?: string): Session { + return { + session_id: sessionId, + status: "completed", + created_at: "2026-08-05T00:00:00.000Z", + updated_at: "2026-08-05T00:00:00.000Z", + continuationLineageId, + }; +} + +describe("continuationLineagesForRevealedSessions", () => { + it("returns only lineages owned by explicitly revealed rows", () => { + expect( + continuationLineagesForRevealedSessions( + [ + session("active-old", "lineage-a"), + session("roster-new", "lineage-a"), + session("unrelated", "lineage-b"), + session("legacy-without-lineage"), + ], + new Set(["active-old", "legacy-without-lineage"]) + ) + ).toEqual(new Set(["lineage-a"])); + }); + + it("hides the roster winner but keeps the explicitly revealed sibling", () => { + const revealedIds = new Set(["active-old"]); + const revealedLineages = new Set(["lineage-a"]); + + expect( + isRosterSiblingOfRevealedContinuation( + session("roster-new", "lineage-a"), + revealedIds, + revealedLineages + ) + ).toBe(true); + expect( + isRosterSiblingOfRevealedContinuation( + session("active-old", "lineage-a"), + revealedIds, + revealedLineages + ) + ).toBe(false); + expect( + isRosterSiblingOfRevealedContinuation( + session("unrelated", "lineage-b"), + revealedIds, + revealedLineages + ) + ).toBe(false); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts new file mode 100644 index 000000000..7644d6167 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts @@ -0,0 +1,29 @@ +import type { Session } from "@src/store/session"; + +export function continuationLineagesForRevealedSessions( + sessions: readonly Session[], + revealedSessionIds: ReadonlySet +): ReadonlySet { + const lineages = new Set(); + for (const session of sessions) { + if ( + revealedSessionIds.has(session.session_id) && + session.continuationLineageId + ) { + lineages.add(session.continuationLineageId); + } + } + return lineages; +} + +export function isRosterSiblingOfRevealedContinuation( + session: Session, + revealedSessionIds: ReadonlySet, + revealedContinuationLineages: ReadonlySet +): boolean { + return Boolean( + !revealedSessionIds.has(session.session_id) && + session.continuationLineageId && + revealedContinuationLineages.has(session.continuationLineageId) + ); +} diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx index d5267d64d..2c4bffb32 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx @@ -21,6 +21,10 @@ import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import { getSessionSearchText } from "@src/util/session/sessionSearch"; import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; +import { + continuationLineagesForRevealedSessions, + isRosterSiblingOfRevealedContinuation, +} from "./continuationVisibility"; import { DEFAULT_GROUP_VISIBLE_COUNT, type DateGroupKey, @@ -243,12 +247,26 @@ export function useSessionMenuItems({ () => createSidebarRosterMatcher(pagination), [pagination] ); + const revealedContinuationLineages = useMemo( + () => + continuationLineagesForRevealedSessions( + sortedSessions, + revealedSessionIds + ), + [revealedSessionIds, sortedSessions] + ); const visibleSessions = useMemo( () => sortedSessions.filter((session) => { const explicitlyRevealed = revealedSessionIds.has(session.session_id); + const hiddenRosterSibling = isRosterSiblingOfRevealedContinuation( + session, + revealedSessionIds, + revealedContinuationLineages + ); return ( + !hiddenRosterSibling && isPrimarySessionListSession(session) && (explicitlyRevealed || (isInSidebarRoster(session) && @@ -269,6 +287,7 @@ export function useSessionMenuItems({ includeExternal, isInSidebarRoster, revealedSessionIds, + revealedContinuationLineages, selectedOrgIds, sortedSessions, ] diff --git a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts index dd588a077..d46fbd330 100644 --- a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts +++ b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts @@ -44,7 +44,11 @@ vi.mock("../persistence", () => ({ persistSessions: mocks.persistSessions, })); -function makeRow(sessionId: string, updatedAt: string) { +function makeRow( + sessionId: string, + updatedAt: string, + continuationLineageId?: string +) { return { sessionId, name: sessionId, @@ -52,6 +56,7 @@ function makeRow(sessionId: string, updatedAt: string) { updatedAt, repoPath: "/tmp/project", storagePath: `/tmp/store/${sessionId}.jsonl`, + continuationLineageId, }; } @@ -86,7 +91,13 @@ describe("loadSidebarSessions", () => { bucket: "older", sessions: source.sourceId === "codex_app" - ? [makeRow("codexapp-healthy", "2026-07-01T00:00:00Z")] + ? [ + makeRow( + "codexapp-healthy", + "2026-07-01T00:00:00Z", + "continuation-root" + ), + ] : [], hasMore: false, }, @@ -105,6 +116,12 @@ describe("loadSidebarSessions", () => { expect(pagination?.["external_history:codex_app"].sessionIds).toEqual([ "codexapp-healthy", ]); + expect( + mocks.store + ?.get(sessionsAtom) + .find((session) => session.session_id === "codexapp-healthy") + ?.continuationLineageId + ).toBe("continuation-root"); }); it("does not publish an authoritative empty page when the whole batch rejects", async () => { diff --git a/src/store/session/sessionAtom/loaders.ts b/src/store/session/sessionAtom/loaders.ts index 1ee779678..439b8c4dd 100644 --- a/src/store/session/sessionAtom/loaders.ts +++ b/src/store/session/sessionAtom/loaders.ts @@ -254,6 +254,7 @@ function importedHistoryPageResult( repoRemoteUrls: row.repoRemoteUrls, branch: row.branch, storagePath: row.storagePath, + continuationLineageId: row.continuationLineageId, agentIconId: source.iconId, agentDisplayName: source.displayName, model: row.model, diff --git a/src/store/session/sessionAtom/types.ts b/src/store/session/sessionAtom/types.ts index 4a0a9c660..b6079d493 100644 --- a/src/store/session/sessionAtom/types.ts +++ b/src/store/session/sessionAtom/types.ts @@ -167,6 +167,8 @@ export interface Session { repoRemoteUrls?: string[]; /** Path to the file or directory where this session's persisted data lives. */ storagePath?: string; + /** Imported-history continuation family used to suppress duplicate sidebar siblings. */ + continuationLineageId?: string; /** Worktree path for isolated parallel sessions */ worktreePath?: string; /** Branch name inside the worktree (e.g. `agent/abc123`) */ From f286d2c0a22c9760980d24e363272e9c070d9d95 Mon Sep 17 00:00:00 2001 From: VantaNode <208094903+VantaNode@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:55:25 -0700 Subject: [PATCH 2/4] feat(skill): add provider lifecycle coverage matrix --- .orgii/skills/org2-performance-guard/SKILL.md | 68 ++++++++++++++++--- .../org2-performance-guard/agents/openai.yaml | 4 +- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.orgii/skills/org2-performance-guard/SKILL.md b/.orgii/skills/org2-performance-guard/SKILL.md index 3cd881768..345c7736f 100644 --- a/.orgii/skills/org2-performance-guard/SKILL.md +++ b/.orgii/skills/org2-performance-guard/SKILL.md @@ -1,6 +1,6 @@ --- name: org2-performance-guard -description: Prevent CPU, RAM, I/O, and background-work regressions in ORG2. Use when adding or reviewing polling, timers, Realtime subscriptions, event listeners, workers, streaming paths, caches, pagination, external-history scans, cloud sync, source-control loading, per-session state, or multi-instance behavior; also use before delivering a performance refactor or any feature that stays alive while the UI is idle or hidden. +description: Prevent CPU, RAM, I/O, background-work, and false-green lifecycle regressions in ORG2. Use when adding or reviewing polling, timers, Realtime subscriptions, event listeners, workers, streaming paths, caches, pagination, provider-owned transcript ingestion or identity/dedupe, external-history scans, cloud sync, source-control loading, per-session state, true-machine verification, or multi-provider/multi-instance behavior; also use before delivering a performance refactor or any feature that stays alive while the UI is idle or hidden. --- # ORG2 Performance Guard @@ -25,6 +25,8 @@ Require all applicable invariants before delivery: - Keep blocking filesystem, database, git, and process work off async executor threads and render-critical paths. - Load large histories, request rounds, diffs, and replay segments on demand; do not eagerly materialize invisible data. - Isolate secondary Tauri identities completely: data home, external-history home, ports, cookies/auth, and app-lifetime caches. +- Treat provider ingestion, local identity/listability, UI hydration, cloud transport, and remote rendering as separate verification boundaries. Passing A-to-B sync does not prove the upstream local lifecycle. +- Test every provider and raw source transition claimed by the change. Do not infer Claude Code compaction coverage from Codex append coverage, or vice versa. - Keep rendered E2E strict. Missing UI must fail with diagnostics; never turn a regression into `console.warn`, catch-and-continue, or a debug-helper bypass. ## Required workflow @@ -63,10 +65,36 @@ For each resource, record the required behavior in these states: | Scope | personal org, cloud org, removed org, revoked share | | Session | unopened, active, inactive, deleted, forked | | Instance | primary, direct-launched secondary, launcher-created secondary | +| Source | discover, append, large append, compact/rewrite, rotate, delete | +| UI | clean load, old row active/open/pinned during refresh, restart | +| Transport | local ingest, upload, remote download, reconnect | Flag any resource whose owner or terminal state is ambiguous. -### 3. Choose the correct pattern +### 3. Separate provider lifecycle from machine topology + +For provider history, session identity, dedupe, or sync work, build a coverage matrix before testing: + +| Axis | Minimum relevant states | +| -------------- | -------------------------------------------------------------------------------- | +| Provider | every changed provider plus every provider explicitly claimed as working | +| Raw transition | create, append, large append, compact/rewrite, rotate, fork/subagent, delete | +| App timing | cold start, source changes while ORG2 is open, rescan, restart | +| UI state | clean roster, previous row active/open/pinned, search/filter/load-more as needed | +| Topology | local ingest, isolated secondary, A upload, B download/reconnect | + +Apply these validity rules: + +- Treat each matrix cell as independent evidence. Two machines exercise topology; they do not create provider compaction, rotation, or lineage transitions automatically. +- Exercise the raw provider artifact or a faithful before/after fixture. Do not seed only normalized cache/database rows when the parser, watermark, identity, lineage, or dedupe contract is under test. +- Derive identity markers from the raw artifact. Do not fabricate identical group keys that merely restate the implementation assumption. +- Include an assumption-breaking fixture for identity logic: for example, a rewritten transcript head with a changed first-message UUID but a preserved ancestry marker. +- Observe local ingest and listability before enabling or asserting cloud upload. Then verify upload cursor/payload and remote rendering separately. +- Keep the previous session active, open, or pinned while applying the source transition when exact-id hydration or force-reveal paths exist. +- Repeat rescan/restart once to prove idempotence and stable row/resource counts. +- Name every unexecuted provider or transition. Never summarize partial coverage as “multi-provider,” “dual-machine,” or “full lifecycle.” + +### 4. Choose the correct pattern Apply the smallest applicable pattern: @@ -80,7 +108,7 @@ Apply the smallest applicable pattern: - **Demand-driven loading:** paginate or fetch details only after expansion/selection; retain only the visible or recently used window. - **Generation guard:** discard late async results after stop, restart, account switch, endpoint switch, or a newer request. -### 4. Sweep equivalent paths +### 5. Sweep equivalent paths After finding one issue, search for every semantic peer. A fix is incomplete if another surface still owns a parallel implementation. @@ -96,7 +124,7 @@ Typical ORG2 sweeps: Unify duplicate resource ownership before tuning individual call sites. -### 5. Protect correctness and privacy +### 6. Protect correctness and privacy Performance changes must not weaken: @@ -110,7 +138,7 @@ Performance changes must not weaken: Capture identity and generation at request start. Before committing a result, confirm the current identity/generation still matches. Do not display a previous identity's cached rows while refreshing. -### 6. Verify proportionally +### 7. Verify proportionally Always run: @@ -121,11 +149,16 @@ Always run: For rendered/background changes, also run the real Tauri surface when available: -1. Observe primary and secondary instances separately. -2. Measure visible idle, hidden idle, active streaming, and post-close/post-delete behavior. -3. Exercise account switch, endpoint switch, and direct secondary launch when relevant. -4. Confirm request/subscription/timer counts stabilize rather than grow after repeated open/close cycles. -5. Confirm strict rendered E2E uses user-visible actions for the behavior under assertion. +1. Isolate primary and secondary data homes, provider roots, auth, ports, and processes. +2. Capture a baseline: raw files, cache rows/listability, active/open/pinned row, cursor/epoch, payload count, process count, CPU, and RSS as applicable. +3. Apply the raw source transition while ORG2 is already open. For compaction/rewrite/rotation, stage or produce the actual before/after artifact instead of pre-populating the final database state. +4. Assert local parsing, identity/lineage, exact row count, listability, timestamp, and sidebar behavior before cloud transport can hide the owning-boundary failure. +5. Keep an old row active/open/pinned, rescan, and assert that hydration does not resurrect a superseded sibling or hide the active row entirely. +6. Verify A upload and B download/reconnect separately, including cursor/epoch and exact appended payload counts when incremental behavior is claimed. +7. Rescan and restart once; confirm data and request/subscription/timer/process counts remain stable. +8. Measure visible idle, hidden idle, active work, and post-close/post-delete behavior. +9. Exercise account switch, endpoint switch, and direct secondary launch when relevant. +10. Confirm strict rendered E2E uses user-visible actions for the behavior under assertion. Do not claim a performance improvement from code shape alone. State the evidence actually collected and any environment blocker. @@ -142,6 +175,11 @@ Reject or revise a change when any applicable answer is unknown or false: - Does one session's update wake unrelated session views? - Does a growing transcript/history/diff require full eager materialization? - Does a direct secondary launch inherit primary external history or auth state? +- Which provider and raw source transition produced the evidence for each compatibility claim? +- Did the test inspect local ingest and identity before testing cloud transport? +- Did it keep the previous row active/open/pinned across rescan or only test a clean roster? +- Were family/identity keys parsed from raw artifacts, or fabricated to match the implementation? +- Did “dual-machine” testing merely replicate an already-normalized final state? - Can a missing rendered element be skipped while the E2E still passes? ## Required delivery output @@ -155,10 +193,18 @@ Report findings and evidence in this compact form: | Scope/isolation | fix / keep | cache/request key | identity/generation guard | switch/revocation test | | Rendering/hot path | fix / keep | subscription/allocation trace | narrowing/coalescing | render or unit evidence | +For provider ingestion, session identity, or sync work, also report: + +| Provider | Raw transition | App/UI state | Topology/boundary | Expected invariant | Observed evidence | +| -------------- | ----------------- | ---------------------------- | --------------------------- | ----------------------------------------- | ---------------------------- | +| exact provider | actual transition | cold/live/active-row/restart | local/A-to-cloud/cloud-to-B | exact rows, identity, cursor, payload, UI | measured result or `not run` | + +Use one row per materially distinct matrix cell. A shared implementation permits shared unit coverage only at the shared boundary; each provider adapter still needs representative raw input before claiming compatibility. + End with: - `Performance verdict: pass` only when every applicable invariant is evidenced. -- `Performance verdict: blocked` when required real measurement or compilation cannot run; name the blocker. +- `Performance verdict: blocked` when required real measurement, provider transition, or compilation cannot run; name the blocker and the uncovered matrix cells. - `Performance verdict: fail` when an unbounded, duplicate, hidden-active, stale-write, or cross-identity path remains. Never promise that a skill can make regressions impossible. Enforce the gates, expose unknowns, and refuse an unsupported green verdict. diff --git a/.orgii/skills/org2-performance-guard/agents/openai.yaml b/.orgii/skills/org2-performance-guard/agents/openai.yaml index c11fe8902..a60f49cd1 100644 --- a/.orgii/skills/org2-performance-guard/agents/openai.yaml +++ b/.orgii/skills/org2-performance-guard/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "ORG2 Performance Guard" - short_description: "Prevent CPU/RAM regressions in ORG2 changes" - default_prompt: "Use $org2-performance-guard to audit this ORG2 change for CPU, RAM, polling, cache, and lifecycle regressions." + short_description: "Guard ORG2 performance and lifecycle coverage" + default_prompt: "Use $org2-performance-guard to audit this ORG2 change across performance, provider lifecycle, and true-machine coverage." From 3f568bda7b3369b5167b7f1aa549a13cfca4053c Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:49:29 -0700 Subject: [PATCH 3/4] fix(claude-code): keep split continuation families deduplicated Review follow-ups for the lineage election: - Union stamped lineage ids alongside raw ancestry markers. Deleting an intermediate transcript can disconnect a family's marker graph after stamping; a later rescan then reinserted the old sibling as a listable row in its own component, resurrecting the duplicate this feature removes, while the exact-id lookup kept treating both halves as one family through the shared lineage. Lineage ids are member uuids, so they share the marker namespace without cross-conversation collisions. - Preserve a stamped continuationLineageId across rescan upserts. The parser never emits the elected id, so the plain column replace eroded the stamp on every rescan and the reveal/dedupe comparison only held until the next scan touched the row. A metadata rewrite that loses continuation identity still drops the stamp with it. - Make DisjointSet::find iterative; a pathological union order could chain O(component) parents and recurse that deep on the sync thread. Pre-commit hook ran. Total eslint: 18, total circular: 0 --- .../src/sources/imported_history/cache.rs | 49 +++++++-- .../sources/imported_history/cache_tests.rs | 103 ++++++++++++++++++ 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs index 32b2fde5c..177c48a79 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs @@ -127,7 +127,7 @@ pub fn upsert_imported_session_cache_from_conn( let updated_at = Utc::now().to_rfc3339(); { let mut stmt = tx - .prepare( + .prepare(&format!( "INSERT INTO imported_history_session_cache ( source, source_session_id, session_id, source_path, source_record_key, source_mtime_ms, source_size_bytes, source_fingerprint, parser_version, @@ -163,10 +163,22 @@ pub fn upsert_imported_session_cache_from_conn( lines_removed = excluded.lines_removed, touched_files_json = excluded.touched_files_json, listable = excluded.listable, - source_metadata_json = excluded.source_metadata_json, + source_metadata_json = CASE + WHEN json_valid(excluded.source_metadata_json) + AND json_valid(imported_history_session_cache.source_metadata_json) + AND json_extract(imported_history_session_cache.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}') IS NOT NULL + AND json_extract(excluded.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}') IS NULL + THEN json_set(excluded.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}', + json_extract(imported_history_session_cache.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}')) + ELSE excluded.source_metadata_json + END, parent_session_id = excluded.parent_session_id, updated_at = excluded.updated_at", - ) + )) .map_err(|err| format!("Failed to prepare imported history cache upsert: {err}"))?; for input in inputs { let touched_files_json = serde_json::to_string(&input.impact.touched_files) @@ -1266,10 +1278,20 @@ pub fn demote_superseded_continuations_from_conn( } fn find(&mut self, index: usize) -> usize { - if self.parent[index] != index { - self.parent[index] = self.find(self.parent[index]); + // Iterative with path compression: a pathological union order can + // chain O(component) parents, and recursing that deep on the sync + // thread is an avoidable stack risk. + let mut root = index; + while self.parent[root] != root { + root = self.parent[root]; } - self.parent[index] + let mut current = index; + while self.parent[current] != root { + let next = self.parent[current]; + self.parent[current] = root; + current = next; + } + root } fn union(&mut self, left: usize, right: usize) { @@ -1300,7 +1322,20 @@ pub fn demote_superseded_continuations_from_conn( let mut sets = DisjointSet::new(election_rows.len()); let mut marker_owner: HashMap = HashMap::new(); for (index, row) in election_rows.iter().enumerate() { - for marker in &row.metadata.markers { + // A stamped lineage id joins the connectivity keys alongside the raw + // ancestry markers. Deleting an intermediate transcript can split a + // family's marker graph into disconnected halves AFTER both halves + // were stamped; without this key the election would list both halves' + // winners (the duplicate row returns) while the exact-id lookup keeps + // treating them as one family via the shared lineage. Lineage ids are + // themselves member uuids (a canonical group key), so they share the + // marker namespace without colliding across conversations. + for marker in row + .metadata + .markers + .iter() + .chain(row.metadata.lineage_id.as_ref()) + { if let Some(owner) = marker_owner.get(marker).copied() { sets.union(index, owner); } else { diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 535402ba0..ee58d0914 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -471,6 +471,109 @@ fn continuation_election_connects_compaction_epochs_transitively() { ); } +#[test] +fn continuation_election_survives_a_family_split_after_stamping() { + // Deleting intermediate transcripts can disconnect a family's marker + // graph AFTER the lineage was stamped. A later rescan reinserts an old + // sibling as a fresh listable row with no stamp to inherit; only the + // stamped lineage on the surviving member (whose value is the canonical + // member's group key) reconnects the halves. Without lineage as a + // connectivity key the election would list both halves' winners and the + // duplicate row this feature removes would return. + let mut conn = fixture_conn(); + let mut root = input(SOURCE_CODEX_APP, "root", 100); + root.source_metadata_json = + continuation_metadata_json(Some("first-user-root"), &["compact-a".to_string()]); + let mut middle = input(SOURCE_CODEX_APP, "middle", 200); + middle.source_metadata_json = continuation_metadata_json( + Some("first-user-middle"), + &["compact-a".to_string(), "compact-b".to_string()], + ); + let mut newest = input(SOURCE_CODEX_APP, "newest", 300); + newest.source_metadata_json = + continuation_metadata_json(Some("first-user-newest"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[root.clone(), middle, newest]) + .expect("upsert"); + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("first election"); + + // The intermediate transcript ages out and the old sibling's row is + // dropped with it; a later rescan reinserts the old sibling from its + // still-present file as a brand-new listable row without any stamp. + for gone in ["middle", "root"] { + conn.execute( + "DELETE FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![SOURCE_CODEX_APP, gone], + ) + .expect("drop row"); + } + upsert_imported_session_cache_from_conn(&mut conn, &[root]).expect("reinsert root"); + + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("second election"); + + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "root")); + assert!(listable_of(&conn, SOURCE_CODEX_APP, "newest")); + let root_metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'root'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("root metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&root_metadata).as_deref(), + Some("first-user-root") + ); +} + +#[test] +fn rescan_upsert_preserves_a_stamped_lineage_id() { + // A rescan replaces `source_metadata_json` with freshly parsed metadata + // that never carries the elected lineage. The upsert must carry the stamp + // over, or every rescan erodes the id the reveal/dedupe paths compare. + let mut conn = fixture_conn(); + let mut row = input(SOURCE_CODEX_APP, "stamped", 100); + row.source_metadata_json = Some( + serde_json::json!({ + CONTINUATION_GROUP_KEY_FIELD: "first-user-a", + CONTINUATION_MARKERS_FIELD: ["first-user-a", "compact-a"], + CONTINUATION_LINEAGE_ID_FIELD: "elected-lineage", + }) + .to_string(), + ); + upsert_imported_session_cache_from_conn(&mut conn, &[row.clone()]).expect("initial upsert"); + + row.source_metadata_json = + continuation_metadata_json(Some("first-user-a"), &["compact-a".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[row.clone()]).expect("rescan upsert"); + let metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'stamped'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&metadata).as_deref(), + Some("elected-lineage") + ); + + // A rewrite that loses continuation identity drops the stamp with it. + row.source_metadata_json = None; + upsert_imported_session_cache_from_conn(&mut conn, &[row]).expect("keyless upsert"); + let metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'stamped'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!(metadata, ""); +} + #[test] fn continuation_election_never_promotes_and_skips_subagents() { let mut conn = fixture_conn(); From a4ccfb897b231112c4fe6d549a7fe45a8ce61660 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:30:19 -0700 Subject: [PATCH 4/4] feat(skill): require an upgrade cell in dual-instance verification Fresh-state runs sample only the post-change state space, so bugs living in the version TRANSITION stay invisible: PR #692's costliest defect (legacy flat cursors forced O(total) epoch rewrites) escaped every run that built its anchors with the new binary, and PR #693's lineage stamp was erased by the very next rescan the new build itself performed. The protocol now demands one cell where the OLD build writes the durable state and the NEW build must ride the ordinary incremental path over it, plus a second-order cycle proving state the new build stamps survives its own next scan. Fault-injection guidance also gains "inject the fault point the change ADDS", since the rotation list only encodes yesterday's failure modes. Pre-commit hook ran. Total eslint: 18, total circular: 0 --- .../dual-instance-verification/SKILL.md | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.orgii/skills/dual-instance-verification/SKILL.md b/.orgii/skills/dual-instance-verification/SKILL.md index 04eb1e491..c6cec9d5e 100644 --- a/.orgii/skills/dual-instance-verification/SKILL.md +++ b/.orgii/skills/dual-instance-verification/SKILL.md @@ -122,7 +122,24 @@ actions in the background, invisible to every existing cell. a local source DB mid-run (hollow read), block the identity endpoint (lookup failure), kill the app mid-transfer (partial persist). The guard under test must defer/refuse — any destructive act under injected fault is - a failure. + a failure. When the change ADDS a fault point (a new read, probe, or IPC + call), inject THAT fault — the rotation list only covers yesterday's + failure modes. +- **Upgrade cell: persisted state must cross the version boundary.** Any + change that reads durable state written by earlier builds (push cursors, + cache metadata, parser output, settings) gets one cell where the OLD build + writes the state and the NEW build operates on it: run a pre-change binary + (a dated `org2-main.exe` or a develop build) through the flow first, then + swap binaries over the SAME homes and continue. Assert the new build rides + the ordinary incremental path — no epoch rewrite, no refuse, no silent + re-derive — and that a second cycle (new build writes, new build reads) + is idempotent. A fresh-anchor test with only the new binary proves nothing + about migration: PR #692's costliest bug (every legacy flat cursor forced + an O(total) epoch rewrite) was invisible to every run that built its state + with the new code. Second-order cycles count too: state the new build + STAMPS must survive the new build's own next scan/rescan before the + invariant is real (PR #693's lineage stamp was erased by the very next + rescan's metadata rewrite). - **Unexplained delta becomes a cell.** The first ledger delta, log line, resource pattern, or store-vs-UI discrepancy without a mechanism-level explanation is promoted to a scenario in the CURRENT run — not noted for @@ -228,6 +245,12 @@ rollover or the window silently truncates. boot, and positional chunk ids turned the shuffle into a fresh hash chain each time. Within one app lifetime everything looked stable; only boot-vs-boot comparison of push decisions could see it. (#608 root cause.) +- **Fresh-state runs cannot see migration bugs**: every cell that builds its + own state with the binary under test samples only the post-change state + space. Bugs that live in the TRANSITION — legacy cursor meets new hash + mode, old parser rows meet new election, stamped metadata meets the next + rescan — need the upgrade cell above. The tell is a verification report + whose every artifact was created during the run itself. - **"Pre-existing" used as a verdict**: a symptom reproduces on baseline, is correctly cleared of THIS PR's authorship, and is then silently cleared of being a bug at all — because the run's attention is scoped to the PR, and