fix(console): reconcile history around lineage anchors

This commit is contained in:
2026-09-30 11:16:23 +09:00
parent 4976b6fd00
commit 709f3c5530
9 changed files with 288 additions and 16 deletions
+14
View File
@@ -842,6 +842,16 @@ pub struct SessionConversationTurn {
pub entries: Vec<SessionSnapshotEntry>,
}
/// The immediate parent lineage and the stable real-user boundary adopted from it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SessionHistoryLineageBoundary {
pub lineage_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adopted_through_turn_id: Option<String>,
}
/// Bounded backward page over the adopted conversation lineage.
///
/// Turns remain chronological within a page. `next_cursor` is an opaque,
@@ -859,6 +869,10 @@ pub struct SessionHistoryPage {
/// cursor with this page's boundary.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compact_ancestor_lineage_ids: Vec<String>,
/// Immediate parent relationship used to retain only the adopted prefix
/// across fork/rewind refreshes that have no newest-page overlap.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_lineage: Option<SessionHistoryLineageBoundary>,
pub turns: Vec<SessionConversationTurn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
+5 -4
View File
@@ -12,10 +12,10 @@ use crate::{
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
@@ -106,6 +106,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<SessionConversationTurn>(&cfg, &mut output);
push_decl::<SessionHistoryLineageBoundary>(&cfg, &mut output);
push_decl::<SessionHistoryPage>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
+40 -2
View File
@@ -4,8 +4,8 @@ use base64::{
};
use protocol::{
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -261,6 +261,28 @@ pub fn read_retained_session_history_page(
} else {
None
};
let parent_lineage = if lineage.len() > 1 {
let parent_turns = read_history_turns_backward(
&store,
active.session_id,
&lineage[1..],
None,
1,
RetainedHistoryReadLimits {
max_scan_bytes: limits.max_scan_bytes.saturating_sub(metadata_bytes),
max_entries: limits.max_entries.saturating_sub(metadata_entries),
..limits
},
)?;
Some(SessionHistoryLineageBoundary {
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
adopted_through_turn_id: parent_turns
.first()
.map(|positioned| positioned.turn.turn_id.clone()),
})
} else {
None
};
let mut turns = read_history_turns_backward(
&store,
active.session_id,
@@ -302,6 +324,7 @@ pub fn read_retained_session_history_page(
session_id: active.session_id.to_string(),
lineage_id,
compact_ancestor_lineage_ids,
parent_lineage,
turns: turns
.into_iter()
.map(|positioned| positioned.turn)
@@ -1735,6 +1758,21 @@ mod tests {
.all(|entry| entry.entry_id != "assistant-1-unadopted")
}));
assert!(page.turns.iter().all(|turn| turn.turn_id != "user-2"));
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session")).unwrap();
let (_, source_lineage_id, _, _) = load_adopted_lineage(
&store,
session_id,
source_segment,
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(
page.parent_lineage,
Some(SessionHistoryLineageBoundary {
lineage_id: source_lineage_id,
adopted_through_turn_id: Some("user-1".to_string()),
})
);
}
#[test]