fix: migrate legacy session history and retained archives

This commit is contained in:
2026-10-19 13:52:43 +09:00
parent b3189d8e85
commit cea0225a6e
19 changed files with 960 additions and 109 deletions
+19
View File
@@ -68,6 +68,24 @@ impl FsStore {
) -> Result<Self, StoreError> { ) -> Result<Self, StoreError> {
let root = root.into(); let root = root.into();
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
for entry in fs::read_dir(&root)? {
let entry = entry?;
if entry.file_type()?.is_dir()
&& let Some(session_id) = entry
.file_name()
.to_str()
.and_then(|name| name.parse().ok())
{
let _guard = match retention.lock_live(session_id) {
Ok(guard) => guard,
// Interrupted erasure may leave bytes behind. Never read or
// upgrade them, and do not block unrelated live Sessions.
Err(StoreError::SessionErased(_)) => continue,
Err(error) => return Err(error),
};
crate::worker_session_store::migrate_fs_session(&entry.path(), session_id)?;
}
}
Ok(Self { Ok(Self {
root, root,
append_lock: Arc::new(Mutex::new(())), append_lock: Arc::new(Mutex::new(())),
@@ -434,6 +452,7 @@ impl Store for FsStore {
let path = self.log_path(session_id, segment_id); let path = self.log_path(session_id, segment_id);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
crate::worker_session_store::migrate_fs_session(parent, session_id)?;
} }
let mut content = String::new(); let mut content = String::new();
for entry in entries { for entry in entries {
@@ -8,6 +8,9 @@ use agen::llm_client::types::RequestConfig;
use protocol::Segment; use protocol::Segment;
use serde::Deserialize; use serde::Deserialize;
#[cfg(test)]
mod tests;
use crate::{ use crate::{
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
@@ -83,6 +86,7 @@ pub(crate) fn decode_entry(
LegacySessionLogEntryV2::History(entry) => Entry::History(entry), LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry), LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
}, },
3 => Entry::Current(serde_json::from_str(line)?),
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"), _ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
}; };
Ok(match entry { Ok(match entry {
@@ -93,6 +97,61 @@ pub(crate) fn decode_entry(
}) })
} }
/// Schema 3 used DerivedSummary for Host compaction seeds. Only the trusted
/// creation boundary may identify one; arbitrary text and later seed items do not.
/// Include already-upgraded entries so an interrupted multi-file migration resumes.
pub(crate) fn compaction_summary_ids<'a>(
entries: impl Iterator<Item = &'a LogEntry>,
) -> std::collections::HashSet<LoggedSessionHistoryEntryId> {
entries
.filter_map(|entry| {
let LogEntry::AnnotatedSegmentStart {
compacted_from: Some(_),
forked_from: None,
history,
..
} = entry
else {
return None;
};
let first = history.first()?;
(matches!(
first.metadata.origin,
LoggedSessionHistoryOrigin::DerivedSummary
| LoggedSessionHistoryOrigin::CompactionSummary
) && matches!(
first.item,
LoggedItem::Message {
role: crate::LoggedRole::System,
..
}
) && agen::Item::from(first.item.clone())
.as_text()
.is_some_and(|text| text.starts_with("[Compacted context summary]\n\n")))
.then(|| first.metadata.entry_id.clone())
})
.collect()
}
pub(crate) fn migrate_compaction_origins(
entries: &mut [LogEntry],
summaries: &std::collections::HashSet<LoggedSessionHistoryEntryId>,
) {
// Only inherited seed copies can share these identities. Appended input is
// not a construction boundary and must never be reclassified from its text.
for entry in entries {
if let LogEntry::AnnotatedSegmentStart { history, .. } = entry {
for entry in history {
if summaries.contains(&entry.metadata.entry_id)
&& entry.metadata.origin == LoggedSessionHistoryOrigin::DerivedSummary
{
entry.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary;
}
}
}
}
}
enum Entry { enum Entry {
History(LegacyHistoryLogEntry), History(LegacyHistoryLogEntry),
Current(LogEntry), Current(LogEntry),
@@ -0,0 +1,102 @@
use super::*;
fn seed(
origin: LoggedSessionHistoryOrigin,
compacted: bool,
role: crate::LoggedRole,
text: &str,
) -> LogEntry {
LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: SessionId::now_v7(),
system_prompt: None,
config: Default::default(),
forked_from: None,
compacted_from: compacted.then(|| SegmentOrigin {
segment_id: SegmentId::now_v7(),
at_turn_index: 0,
}),
history: vec![LoggedHistoryEntry {
item: LoggedItem::Message {
role,
content: vec![crate::LoggedContentPart::Text { text: text.into() }],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId("summary".into()),
origin,
derivation: Some(crate::LoggedHistoryDerivation {
sources: vec![LoggedSessionHistoryEntryId("source".into())],
}),
},
}],
}
}
#[test]
fn only_trusted_compaction_seed_is_migrated_and_retries_update_inherited_copies() {
let text = "[Compacted context summary]\n\nsummary";
for (compacted, role, text) in [
(false, crate::LoggedRole::System, text),
(true, crate::LoggedRole::User, text),
(
true,
crate::LoggedRole::System,
"[Session TaskStore snapshot] tasks",
),
] {
let entry = seed(
LoggedSessionHistoryOrigin::DerivedSummary,
compacted,
role,
text,
);
assert!(compaction_summary_ids([&entry].into_iter()).is_empty());
}
for origin in [
LoggedSessionHistoryOrigin::DerivedSummary,
LoggedSessionHistoryOrigin::CompactionSummary,
] {
let creation = seed(origin, true, crate::LoggedRole::System, text);
let inherited = seed(
LoggedSessionHistoryOrigin::DerivedSummary,
false,
crate::LoggedRole::System,
text,
);
let LogEntry::AnnotatedSegmentStart { history, .. } = &inherited else {
panic!()
};
let mut pasted = history[0].clone();
pasted.metadata.entry_id = LoggedSessionHistoryEntryId("pasted".into());
let appended = LogEntry::AnnotatedUserInput {
ts: 2,
history: vec![pasted.clone()],
segments: vec![],
extensions: vec![],
};
let mut entries = vec![creation, inherited, appended];
let ids = compaction_summary_ids(entries.iter());
migrate_compaction_origins(&mut entries, &ids);
for entry in &entries[..2] {
let LogEntry::AnnotatedSegmentStart { history, .. } = entry else {
panic!()
};
assert_eq!(
history[0].metadata.origin,
LoggedSessionHistoryOrigin::CompactionSummary
);
assert_eq!(history[0].metadata.entry_id.0, "summary");
assert_eq!(
history[0].metadata.derivation.as_ref().unwrap().sources[0].0,
"source"
);
}
let LogEntry::AnnotatedUserInput { history, .. } = &entries[2] else {
panic!()
};
assert_eq!(history, &[pasted]);
let once = serde_json::to_vec(&entries).unwrap();
migrate_compaction_origins(&mut entries, &ids);
assert_eq!(serde_json::to_vec(&entries).unwrap(), once);
}
}
+5 -1
View File
@@ -1602,6 +1602,7 @@ mod tests {
segment_id(20), segment_id(20),
"completed", "completed",
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 4, ts: 4,
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
@@ -1640,7 +1641,10 @@ mod tests {
( (
segment_id(24), segment_id(24),
"abandoned", "abandoned",
LogEntry::PausedTurnAbandoned { ts: 4 }, LogEntry::PausedTurnAbandoned {
entry_id: None,
ts: 4,
},
), ),
]; ];
for (segment_id, text, outcome) in outcomes { for (segment_id, text, outcome) in outcomes {
+3 -1
View File
@@ -814,7 +814,9 @@ fn boundary_reference(
offset: u64, offset: u64,
) -> String { ) -> String {
let id = match record { let id = match record {
LogEntry::RunYielded { entry_id, .. } LogEntry::RunCompleted { entry_id, .. }
| LogEntry::PausedTurnAbandoned { entry_id, .. }
| LogEntry::RunYielded { entry_id, .. }
| LogEntry::RunResumed { entry_id, .. } | LogEntry::RunResumed { entry_id, .. }
| LogEntry::RunCancelled { entry_id, .. } | LogEntry::RunCancelled { entry_id, .. }
| LogEntry::RunErrored { entry_id, .. } => entry_id.as_ref(), | LogEntry::RunErrored { entry_id, .. } => entry_id.as_ref(),
@@ -244,6 +244,7 @@ fn late_run_boundary_is_new_material_without_changing_previously_supplied_bodies
), ),
( (
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 5, ts: 5,
interrupted: false, interrupted: false,
result: agen::EngineResult::Finished, result: agen::EngineResult::Finished,
@@ -256,6 +257,7 @@ fn late_run_boundary_is_new_material_without_changing_previously_supplied_bodies
), ),
( (
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 5, ts: 5,
interrupted: true, interrupted: true,
result: agen::EngineResult::LimitReached, result: agen::EngineResult::LimitReached,
@@ -662,6 +664,143 @@ fn fork_clips_parent_suffix_and_maps_inherited_copy_to_original_segment() {
); );
} }
#[test]
fn schema_v3_compaction_migrates_persistently_and_strict_reader_keeps_original_ids() {
for standalone in [false, true] {
let temp = tempfile::tempdir().unwrap();
let session = crate::new_session_id();
let root = crate::new_segment_id();
let compact = crate::new_segment_id();
let mut summary = message(
"summary",
LoggedRole::System,
"[Compacted context summary]\n\nold summary",
);
summary.metadata.origin = LoggedSessionHistoryOrigin::DerivedSummary;
summary.metadata.derivation = Some(crate::LoggedHistoryDerivation {
sources: vec![LoggedSessionHistoryEntryId("u".into())],
});
let mut start = seed(session, vec![summary.clone()]);
if let LogEntry::AnnotatedSegmentStart { compacted_from, .. } = &mut start {
*compacted_from = Some(SegmentOrigin {
segment_id: root,
at_turn_index: 0,
});
}
let records = [
start,
output("new", "new"),
cancelled(),
LogEntry::RunCompleted {
ts: 6,
entry_id: None,
interrupted: false,
result: agen::EngineResult::Finished,
active_run_turn_count: None,
},
LogEntry::PausedTurnAbandoned {
ts: 7,
entry_id: None,
},
];
let session_root = if standalone {
temp.path().join(session.to_string())
} else {
temp.path().to_path_buf()
};
let paths = if standalone {
session_root.clone()
} else {
session_root.join("segments")
};
std::fs::create_dir_all(&paths).unwrap();
for (id, entries) in [
(
root,
vec![seed(
session,
vec![message("u", LoggedRole::User, "request")],
)],
),
(compact, records.to_vec()),
] {
let bytes: String = entries
.iter()
.map(|entry| format!("{}\n", serde_json::to_string(entry).unwrap()))
.collect();
std::fs::write(paths.join(format!("{id}.jsonl")), bytes).unwrap();
}
if !standalone {
std::fs::write(
session_root.join("session.json"),
serde_json::to_vec(
&serde_json::json!({"schema_version": 3, "session_id": session}),
)
.unwrap(),
)
.unwrap();
}
let open = || {
if standalone {
SessionPublicMaterialReader::open_fs(
temp.path(),
session,
compact,
Default::default(),
)
} else {
SessionPublicMaterialReader::open_worker(&session_root, compact, Default::default())
}
};
assert!(
open().is_err(),
"strict reader must not reinterpret old provenance"
);
let migrate = || {
if standalone {
FsStore::new(temp.path()).unwrap();
} else {
WorkerSessionStore::new(&session_root).unwrap();
}
};
migrate();
let reader = open().unwrap();
assert_eq!(&ids(&reader)[..2], ["Eu", "Enew"]);
let path = paths.join(format!("{compact}.jsonl"));
let bytes = std::fs::read(&path).unwrap();
let entries: Vec<LogEntry> = String::from_utf8(bytes.clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
let LogEntry::AnnotatedSegmentStart { history, .. } = &entries[0] else {
panic!()
};
summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary;
assert_eq!(history, &[summary]);
// Run-transition ids synthesized from byte offsets must be frozen before
// the changed summary record shifts every subsequent record offset.
let mut old_offset = 0;
for (index, original) in records.iter().enumerate() {
if index >= 2 {
let mut expected = original.clone();
expected.ensure_legacy_session_entry_id(session, compact, old_offset);
assert_eq!(
serde_json::to_value(&entries[index]).unwrap(),
serde_json::to_value(expected).unwrap()
);
assert_eq!(
ids(&reader)[index],
format!("El-run-{session}-{compact}-{old_offset}")
);
}
old_offset += serde_json::to_vec(original).unwrap().len() as u64 + 1;
}
migrate();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
}
#[test] #[test]
fn midturn_compaction_uses_exact_source_ids_and_preserves_discarded_originals() { fn midturn_compaction_uses_exact_source_ids_and_preserves_discarded_originals() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -1705,6 +1705,7 @@ pub(crate) fn project_session_snapshot_for_segment(
interrupted, interrupted,
result, result,
active_run_turn_count: persisted_count, active_run_turn_count: persisted_count,
..
} => { } => {
if *interrupted && matches!(result, agen::EngineResult::Yielded) { if *interrupted && matches!(result, agen::EngineResult::Yielded) {
let count = (*persisted_count) let count = (*persisted_count)
@@ -2038,6 +2039,7 @@ mod tests {
fn complete(ts: u64) -> LogEntry { fn complete(ts: u64) -> LogEntry {
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts, ts,
interrupted: false, interrupted: false,
result: agen::EngineResult::Finished, result: agen::EngineResult::Finished,
+1
View File
@@ -329,6 +329,7 @@ pub fn save_run_completed(
session_id, session_id,
segment_id, segment_id,
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted, interrupted,
result, result,
+25 -6
View File
@@ -122,6 +122,8 @@ pub enum LogEntry {
/// Replay restores both interruption state and any resumable logical-run /// Replay restores both interruption state and any resumable logical-run
/// turn budget. /// turn budget.
RunCompleted { RunCompleted {
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
ts: u64, ts: u64,
interrupted: bool, interrupted: bool,
result: EngineResult, result: EngineResult,
@@ -194,7 +196,11 @@ pub enum LogEntry {
/// A paused interrupted turn was explicitly abandoned without calling /// A paused interrupted turn was explicitly abandoned without calling
/// `run()` or `resume()` again. Replay clears the interrupted marker so /// `run()` or `resume()` again. Replay clears the interrupted marker so
/// the restored Worker is idle and future user input starts a normal new turn. /// the restored Worker is idle and future user input starts a normal new turn.
PausedTurnAbandoned { ts: u64 }, PausedTurnAbandoned {
ts: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<crate::LoggedSessionHistoryEntryId>,
},
/// `RequestConfig` changed. /// `RequestConfig` changed.
ConfigChanged { ts: u64, config: RequestConfig }, ConfigChanged { ts: u64, config: RequestConfig },
@@ -253,7 +259,9 @@ impl LogEntry {
/// written to storage is also the value published to live subscribers. /// written to storage is also the value published to live subscribers.
pub fn ensure_session_entry_id(&mut self) { pub fn ensure_session_entry_id(&mut self) {
let entry_id = match self { let entry_id = match self {
Self::RunYielded { entry_id, .. } Self::RunCompleted { entry_id, .. }
| Self::PausedTurnAbandoned { entry_id, .. }
| Self::RunYielded { entry_id, .. }
| Self::RunResumed { entry_id, .. } | Self::RunResumed { entry_id, .. }
| Self::RunCancelled { entry_id, .. } | Self::RunCancelled { entry_id, .. }
| Self::RunErrored { entry_id, .. } => entry_id, | Self::RunErrored { entry_id, .. } => entry_id,
@@ -275,7 +283,9 @@ impl LogEntry {
record_start: u64, record_start: u64,
) { ) {
let entry_id = match self { let entry_id = match self {
Self::RunYielded { entry_id, .. } Self::RunCompleted { entry_id, .. }
| Self::PausedTurnAbandoned { entry_id, .. }
| Self::RunYielded { entry_id, .. }
| Self::RunResumed { entry_id, .. } | Self::RunResumed { entry_id, .. }
| Self::RunCancelled { entry_id, .. } | Self::RunCancelled { entry_id, .. }
| Self::RunErrored { entry_id, .. } => entry_id, | Self::RunErrored { entry_id, .. } => entry_id,
@@ -586,6 +596,7 @@ mod tests {
turn_count: 1, turn_count: 1,
}, },
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 3200, ts: 3200,
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
@@ -893,12 +904,16 @@ mod tests {
compacted_from: None, compacted_from: None,
}, },
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 100, ts: 100,
interrupted: true, interrupted: true,
result: EngineResult::Paused, result: EngineResult::Paused,
active_run_turn_count: Some(1), active_run_turn_count: Some(1),
}, },
LogEntry::PausedTurnAbandoned { ts: 200 }, LogEntry::PausedTurnAbandoned {
entry_id: None,
ts: 200,
},
]); ]);
assert!(!state.last_run_interrupted); assert!(!state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None); assert_eq!(state.active_run_turn_count, None);
@@ -1056,6 +1071,7 @@ mod tests {
turn_count: 2, turn_count: 2,
}, },
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 300, ts: 300,
interrupted: true, interrupted: true,
result: EngineResult::LimitReached, result: EngineResult::LimitReached,
@@ -1069,13 +1085,16 @@ mod tests {
#[test] #[test]
fn paused_turn_abandoned_entry_round_trip_via_json() { fn paused_turn_abandoned_entry_round_trip_via_json() {
let entry = LogEntry::PausedTurnAbandoned { ts: 12345 }; let entry = LogEntry::PausedTurnAbandoned {
entry_id: None,
ts: 12345,
};
let json = serde_json::to_string(&entry).unwrap(); let json = serde_json::to_string(&entry).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["kind"], "paused_turn_abandoned"); assert_eq!(parsed["kind"], "paused_turn_abandoned");
let decoded: LogEntry = serde_json::from_str(&json).unwrap(); let decoded: LogEntry = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
LogEntry::PausedTurnAbandoned { ts } => assert_eq!(ts, 12345), LogEntry::PausedTurnAbandoned { ts, .. } => assert_eq!(ts, 12345),
other => panic!("expected PausedTurnAbandoned, got {other:?}"), other => panic!("expected PausedTurnAbandoned, got {other:?}"),
} }
} }
@@ -36,7 +36,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 3; const SESSION_SCHEMA_VERSION: u32 = 4;
const ANNOTATED_SESSION_SCHEMA_VERSION: u32 = 3;
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2; const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1; const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json"; const SESSION_FILE: &str = "session.json";
@@ -212,9 +213,11 @@ impl WorkerSessionStore {
SESSION_SCHEMA_VERSION => { SESSION_SCHEMA_VERSION => {
validate_canonical_segment_logs(&root)?; validate_canonical_segment_logs(&root)?;
} }
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => { LEGACY_SESSION_SCHEMA_VERSION
migrate_segment_logs_to_v3( | PREVIOUS_SESSION_SCHEMA_VERSION
&root, | ANNOTATED_SESSION_SCHEMA_VERSION => {
migrate_segment_logs_to_v4(
segment_log_paths(&root)?,
manifest.session_id, manifest.session_id,
manifest.schema_version, manifest.schema_version,
)?; )?;
@@ -1107,7 +1110,12 @@ fn open_retained_file(path: &Path) -> std::io::Result<File> {
} }
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> { fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR); segment_log_paths_in_directory(&root.join(SEGMENTS_DIR))
}
fn segment_log_paths_in_directory(
segments: &Path,
) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
if !segments.exists() { if !segments.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -1150,21 +1158,22 @@ fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreErro
Ok(paths) Ok(paths)
} }
fn migrate_segment_logs_to_v3( fn migrate_segment_logs_to_v4(
root: &Path, paths: Vec<(SegmentId, PathBuf)>,
session_id: SessionId, session_id: SessionId,
source_schema_version: u32, source_schema_version: u32,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
struct MigrationPlan { struct MigrationPlan {
path: PathBuf, path: PathBuf,
source: Vec<u8>, source: Vec<u8>,
entries: Vec<LogEntry>,
output: Vec<u8>, output: Vec<u8>,
} }
// Phase 1 is strictly read-only. Every segment must parse and canonicalize // Phase 1 is strictly read-only. Every segment must parse and canonicalize
// successfully before the first authoritative byte is replaced. // successfully before the first authoritative byte is replaced.
let mut plans = Vec::new(); let mut plans = Vec::new();
for (segment_id, path) in segment_log_paths(root)? { for (segment_id, path) in paths {
let source = fs::read(&path)?; let source = fs::read(&path)?;
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source) let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
.map_err(|error| StoreError::Corrupt { .map_err(|error| StoreError::Corrupt {
@@ -1174,17 +1183,25 @@ fn migrate_segment_logs_to_v3(
path.display() path.display()
), ),
})?; })?;
let mut output = Vec::new();
for entry in canonical {
serde_json::to_writer(&mut output, &entry)?;
output.push(b'\n');
}
plans.push(MigrationPlan { plans.push(MigrationPlan {
path, path,
source, source,
output, entries: canonical,
output: Vec::new(),
}); });
} }
// Resolve trusted creation boundaries first, then update inherited copies by
// logical identity too (fork/rewind seeds retain the same entry id).
let summaries = crate::legacy_session_log::compaction_summary_ids(
plans.iter().flat_map(|plan| plan.entries.iter()),
);
for plan in &mut plans {
crate::legacy_session_log::migrate_compaction_origins(&mut plan.entries, &summaries);
for entry in std::mem::take(&mut plan.entries) {
serde_json::to_writer(&mut plan.output, &entry)?;
plan.output.push(b'\n');
}
}
// Fence the complete preflight snapshot before starting phase 2. Session // Fence the complete preflight snapshot before starting phase 2. Session
// open is the exclusive restore boundary; this additionally fails closed // open is the exclusive restore boundary; this additionally fails closed
@@ -1207,6 +1224,30 @@ fn migrate_segment_logs_to_v3(
Ok(()) Ok(())
} }
/// Upgrade the standalone per-Session layout using the same persisted format
/// migration as Worker aggregates. Called only at writable Host store startup.
pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<(), StoreError> {
let marker = root.join("session-schema.json");
let version: u32 = match fs::read(&marker) {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ANNOTATED_SESSION_SCHEMA_VERSION
}
Err(error) => return Err(error.into()),
};
match version {
SESSION_SCHEMA_VERSION => Ok(()),
ANNOTATED_SESSION_SCHEMA_VERSION => {
migrate_segment_logs_to_v4(segment_log_paths_in_directory(root)?, session_id, version)?;
atomic_write_json(&marker, &SESSION_SCHEMA_VERSION)
}
_ => Err(StoreError::Corrupt {
line: 0,
message: format!("unsupported standalone Session schema {version}"),
}),
}
}
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> { fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
for (_, path) in segment_log_paths(root)? { for (_, path) in segment_log_paths(root)? {
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?; let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
@@ -1223,17 +1264,26 @@ fn parse_legacy_jsonl(
let text = std::str::from_utf8(bytes).map_err(|error| { let text = std::str::from_utf8(bytes).map_err(|error| {
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?; })?;
text.lines() let mut offset = 0_u64;
text.split_inclusive('\n')
.enumerate() .enumerate()
.filter(|(_, line)| !line.trim().is_empty()) .filter_map(|(line_index, line)| {
.map(|(line_index, line)| { let start = offset;
crate::legacy_session_log::decode_entry( offset += line.len() as u64;
(!line.trim().is_empty()).then_some((line_index, line, start))
})
.map(|(line_index, line, start)| {
let mut entry = crate::legacy_session_log::decode_entry(
schema_version, schema_version,
line, line,
session_id, session_id,
segment_id, segment_id,
line_index, line_index,
) )?;
// Byte offsets change during migration. Persist identities previously
// synthesized from the OLD offsets before rewriting any record.
entry.ensure_legacy_session_entry_id(session_id, segment_id, start);
Ok(entry)
}) })
.collect() .collect()
} }
+4 -2
View File
@@ -62,6 +62,7 @@ fn round_trip_write_and_read() {
turn_count: 1, turn_count: 1,
}, },
LogEntry::RunCompleted { LogEntry::RunCompleted {
entry_id: None,
ts: 3200, ts: 3200,
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
@@ -157,9 +158,10 @@ fn create_segment_replacement_keeps_open_old_log_and_commits_complete_new_log()
assert_eq!(reopened.read_entry_count(sid, segid).unwrap(), new.len()); assert_eq!(reopened.read_entry_count(sid, segid).unwrap(), new.len());
assert_eq!( assert_eq!(
std::fs::read_dir(path.parent().unwrap()).unwrap().count(), std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1, 2,
"successful replacement must not leave temporary files" "successful replacement leaves only the segment and Session schema marker"
); );
assert!(path.parent().unwrap().join("session-schema.json").is_file());
} }
#[test] #[test]
@@ -1693,6 +1693,7 @@ allow = []
// A valid JSON record without its final newline is not committed. // A valid JSON record without its final newline is not committed.
log.write_all( log.write_all(
&serde_json::to_vec(&LogEntry::RunCompleted { &serde_json::to_vec(&LogEntry::RunCompleted {
entry_id: None,
ts: 4, ts: 4,
interrupted: false, interrupted: false,
result: agen::EngineResult::Finished, result: agen::EngineResult::Finished,
+106 -17
View File
@@ -39,7 +39,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 10; const SCHEMA_VERSION: u32 = 11;
const EXECUTION_SCHEMA_VERSION: u32 = 10;
// Frozen source schemas: do not move their meaning when adding a migration. // Frozen source schemas: do not move their meaning when adding a migration.
const AGGREGATE_SCHEMA_VERSION: u32 = 9; const AGGREGATE_SCHEMA_VERSION: u32 = 9;
const PREVIOUS_SCHEMA_VERSION: u32 = 8; const PREVIOUS_SCHEMA_VERSION: u32 = 8;
@@ -640,8 +641,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"), format!("Runtime store schema version {schema_version} is out of range"),
) )
})?; })?;
let staging = migration_sibling(root, "schema-v10-staging")?; let staging = migration_sibling(root, "schema-v11-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?; let backup = migration_sibling(root, "pre-schema-v11-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -674,11 +675,12 @@ fn plan_runtime_store_migration(
| LEGACY_SCHEMA_VERSION | LEGACY_SCHEMA_VERSION
| PREVIOUS_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
| AGGREGATE_SCHEMA_VERSION | AGGREGATE_SCHEMA_VERSION
| EXECUTION_SCHEMA_VERSION
) { ) {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
&runtime_path, &runtime_path,
format!( format!(
"unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, or {SCHEMA_VERSION}" "unsupported Runtime store schema version {schema_version}; expected {OLDEST_SCHEMA_VERSION}, {LEGACY_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, {AGGREGATE_SCHEMA_VERSION}, {EXECUTION_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
), ),
)); ));
} }
@@ -1008,6 +1010,23 @@ fn migrate_worker_record(
mapping: Option<&LegacyWorkerIdentityMapping>, mapping: Option<&LegacyWorkerIdentityMapping>,
identity_path: &Path, identity_path: &Path,
) -> Result<WorkerAggregateRecord, RuntimeError> { ) -> Result<WorkerAggregateRecord, RuntimeError> {
if source_schema_version == EXECUTION_SCHEMA_VERSION {
if identity_document
.get("schema_version")
.and_then(serde_json::Value::as_u64)
!= Some(u64::from(EXECUTION_SCHEMA_VERSION))
{
return Err(runtime_store_corrupt(
identity_path,
"Worker schema does not match Runtime migration source".into(),
));
}
identity_document["schema_version"] = serde_json::Value::from(SCHEMA_VERSION);
let aggregate: WorkerAggregateRecord = serde_json::from_value(identity_document)
.map_err(|error| runtime_store_corrupt(identity_path, error.to_string()))?;
aggregate.clone().validate(identity_path)?;
return Ok(aggregate);
}
if source_schema_version == AGGREGATE_SCHEMA_VERSION { if source_schema_version == AGGREGATE_SCHEMA_VERSION {
migrate_v9_restore_guard(&mut identity_document, identity_path)?; migrate_v9_restore_guard(&mut identity_document, identity_path)?;
remove_legacy_job_input_counter(&mut identity_document); remove_legacy_job_input_counter(&mut identity_document);
@@ -1499,14 +1518,19 @@ fn remove_runtime_migration_directory(path: &Path) -> Result<(), RuntimeError> {
} }
fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> { fn recover_runtime_store_migration(root: &Path) -> Result<(), RuntimeError> {
for suffix in ["schema-v9-staging", "pre-schema-v9-backup"] { for suffix in [
"schema-v9-staging",
"pre-schema-v9-backup",
"schema-v10-staging",
"pre-schema-v10-backup",
] {
let earlier = migration_sibling(root, suffix)?; let earlier = migration_sibling(root, suffix)?;
if earlier.exists() { if earlier.exists() {
return Err(runtime_store_corrupt(root, "unfinished schema-v9 migration requires explicit recovery before schema-v10 startup".into())); return Err(runtime_store_corrupt(root, "unfinished earlier Runtime migration requires explicit recovery before schema-v11 startup".into()));
} }
} }
let staging = migration_sibling(root, "schema-v10-staging")?; let staging = migration_sibling(root, "schema-v11-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?; let backup = migration_sibling(root, "pre-schema-v11-backup")?;
let root_exists = migration_directory_exists(root)?; let root_exists = migration_directory_exists(root)?;
let staging_exists = migration_directory_exists(&staging)?; let staging_exists = migration_directory_exists(&staging)?;
let backup_exists = migration_directory_exists(&backup)?; let backup_exists = migration_directory_exists(&backup)?;
@@ -1708,8 +1732,8 @@ fn migrate_runtime_store(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); return Ok(plan);
} }
let staging = migration_sibling(root, "schema-v10-staging")?; let staging = migration_sibling(root, "schema-v11-staging")?;
let backup = migration_sibling(root, "pre-schema-v10-backup")?; let backup = migration_sibling(root, "pre-schema-v11-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -1903,6 +1927,7 @@ fn migrate_runtime_store_in_place(
), ),
)); ));
} }
crate::retention::migrate_staged_sessions(root)?;
let (document, _) = migrate_runtime_document( let (document, _) = migrate_runtime_document(
document, document,
plan.current_schema_version, plan.current_schema_version,
@@ -2683,6 +2708,70 @@ mod tests {
document["schema_version"].as_u64().unwrap() document["schema_version"].as_u64().unwrap()
} }
#[test]
fn schema_v10_startup_migrates_stopped_sessions_atomically() {
for corrupt in [false, true] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
write_empty_schema_v8_store(&root);
let mut runtime: serde_json::Value =
read_json(&root.join(RUNTIME_FILE), "test").unwrap();
runtime["schema_version"] = serde_json::json!(10);
atomic_write_json(&root.join(RUNTIME_FILE), &runtime, "test").unwrap();
let worker_id = WorkerId::now_v7();
let aggregate = root.join(WORKERS_DIR).join(worker_id.to_string());
fs::create_dir_all(&aggregate).unwrap();
let record = migrate_worker_record(
schema_v7_worker_document(worker_id),
7,
None,
&aggregate.join(WORKER_FILE),
)
.unwrap();
let mut record = serde_json::to_value(record).unwrap();
record["schema_version"] = serde_json::json!(10);
atomic_write_json(&aggregate.join(WORKER_FILE), &record, "test").unwrap();
let session = aggregate.join("session");
fs::create_dir_all(session.join("segments")).unwrap();
atomic_write_json(&session.join("session.json"), &serde_json::json!({"schema_version":3,"session_id":session_store::new_session_id()}), "test").unwrap();
if corrupt {
fs::write(
session
.join("segments")
.join(format!("{}.jsonl", session_store::new_segment_id())),
b"invalid\n",
)
.unwrap();
}
let before = fs::read(session.join("session.json")).unwrap();
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-test");
assert!(
FsRuntimeStore::migration_plan(&options)
.unwrap()
.migration_required
);
assert_eq!(fs::read(session.join("session.json")).unwrap(), before);
let result = FsRuntimeStore::open_or_create(root.clone(), "runtime-test");
if corrupt {
assert!(result.is_err());
assert_eq!(stored_schema_version(&root), 10);
assert_eq!(fs::read(session.join("session.json")).unwrap(), before);
let record: serde_json::Value =
read_json(&aggregate.join(WORKER_FILE), "test").unwrap();
assert_eq!(record["schema_version"], 10);
} else {
drop(result.unwrap());
assert_eq!(stored_schema_version(&root), 11);
let manifest: serde_json::Value =
read_json(&session.join("session.json"), "test").unwrap();
assert_eq!(manifest["schema_version"], 4);
let once = fs::read(session.join("session.json")).unwrap();
drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap());
assert_eq!(fs::read(session.join("session.json")).unwrap(), once);
}
}
}
#[test] #[test]
fn auth_initialized_directory_without_snapshot_is_a_fresh_store() { fn auth_initialized_directory_without_snapshot_is_a_fresh_store() {
let parent = tempfile::tempdir().unwrap(); let parent = tempfile::tempdir().unwrap();
@@ -3062,7 +3151,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); let staging = migration_sibling(&root, "schema-v11-staging").unwrap();
fs::create_dir_all(&staging).unwrap(); fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("partial"), b"partial").unwrap(); fs::write(staging.join("partial"), b"partial").unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -3077,7 +3166,7 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v11-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -3091,10 +3180,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); let staging = migration_sibling(&root, "schema-v11-staging").unwrap();
copy_runtime_tree(&root, &root, &staging).unwrap(); copy_runtime_tree(&root, &root, &staging).unwrap();
migrate_runtime_store_in_place(&staging, "test-runtime").unwrap(); migrate_runtime_store_in_place(&staging, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v11-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -3109,10 +3198,10 @@ mod tests {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
let staging = migration_sibling(&root, "schema-v10-staging").unwrap(); let staging = migration_sibling(&root, "schema-v11-staging").unwrap();
fs::create_dir_all(&staging).unwrap(); fs::create_dir_all(&staging).unwrap();
fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap(); fs::write(staging.join(RUNTIME_FILE), b"{\"schema_version\":8}").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v11-backup").unwrap();
fs::rename(&root, &backup).unwrap(); fs::rename(&root, &backup).unwrap();
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
@@ -3129,7 +3218,7 @@ mod tests {
let root = temp.path().join("runtime-store"); let root = temp.path().join("runtime-store");
write_empty_schema_v8_store(&root); write_empty_schema_v8_store(&root);
migrate_runtime_store_in_place(&root, "test-runtime").unwrap(); migrate_runtime_store_in_place(&root, "test-runtime").unwrap();
let backup = migration_sibling(&root, "pre-schema-v10-backup").unwrap(); let backup = migration_sibling(&root, "pre-schema-v11-backup").unwrap();
write_empty_schema_v8_store(&backup); write_empty_schema_v8_store(&backup);
let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap(); let store = FsRuntimeStore::open_or_create(root.clone(), "test-runtime").unwrap();
+398 -7
View File
@@ -640,7 +640,7 @@ impl FsWorkerRetentionProvider {
) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> { ) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> {
let (manifest, session_root) = self.session_archive_identity(archive_id)?; let (manifest, session_root) = self.session_archive_identity(archive_id)?;
let archive_dir = self.archive_dir(archive_id)?; let archive_dir = self.archive_dir(archive_id)?;
let manifest = validate_existing_archive(&archive_dir, &manifest)?; let manifest = validate_existing_archive(&archive_dir, &manifest, false)?;
Ok((manifest, session_root)) Ok((manifest, session_root))
} }
@@ -1108,7 +1108,7 @@ fn commit_session_archive(
}; };
let archive_dir = provider.archive_dir(archive_id)?; let archive_dir = provider.archive_dir(archive_id)?;
if archive_dir.exists() { if archive_dir.exists() {
return validate_existing_archive(&archive_dir, &manifest); return validate_existing_archive(&archive_dir, &manifest, true);
} }
let parent = archive_dir let parent = archive_dir
.parent() .parent()
@@ -1150,7 +1150,7 @@ fn commit_session_archive(
source, source,
})?; })?;
sync_directory(parent, "archive Worker Session")?; sync_directory(parent, "archive Worker Session")?;
validate_existing_archive(&archive_dir, &manifest) validate_existing_archive(&archive_dir, &manifest, true)
})(); })();
if result.is_err() { if result.is_err() {
let _ = fs::remove_dir_all(&staging); let _ = fs::remove_dir_all(&staging);
@@ -1158,17 +1158,165 @@ fn commit_session_archive(
result result
} }
// The public archive manifest is an immutable ingestion receipt also retained by
// Backend. Schema migration replaces the payload, not that receipt. This local
// storage attestation binds the new bytes to the exact verified ingestion receipt.
// It is published only with the entire staged Runtime tree, never by a reader.
#[derive(Serialize, Deserialize)]
struct MigratedArchiveStorage {
schema_version: u32,
ingestion: WorkerSessionArchiveManifest,
checksum_sha256: String,
bytes: u64,
file_count: u64,
}
const ARCHIVE_STORAGE_FILE: &str = "session-storage.json";
fn archive_storage(
archive_dir: &Path,
ingestion: &WorkerSessionArchiveManifest,
) -> Result<Option<MigratedArchiveStorage>, RuntimeError> {
let path = archive_dir.join(ARCHIVE_STORAGE_FILE);
match fs::read(&path) {
Ok(bytes) => {
let storage: MigratedArchiveStorage =
serde_json::from_slice(&bytes).map_err(|error| RuntimeError::StoreCorrupt {
operation: "read migrated archive storage",
path: path.clone(),
message: error.to_string(),
})?;
if storage.schema_version != 1 || &storage.ingestion != ingestion {
return Err(RuntimeError::StoreCorrupt {
operation: "read migrated archive storage",
path,
message: "archive storage attestation does not match ingestion receipt".into(),
});
}
Ok(Some(storage))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(RuntimeError::StoreIo {
operation: "read migrated archive storage",
path,
source,
}),
}
}
/// Called only on the private Runtime migration staging tree under its owner
/// lock. Failure discards the whole tree, including manifests and attestations.
pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> {
for directory in ["workers", "orphaned-workers", "archives/workers"] {
let directory = root.join(directory);
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(RuntimeError::StoreIo {
operation: "enumerate Session migration",
path: directory,
source,
});
}
};
for entry in entries {
let entry = entry.map_err(|source| RuntimeError::StoreIo {
operation: "enumerate Session migration",
path: directory.clone(),
source,
})?;
let aggregate = entry.path();
let session = aggregate.join("session");
if !session
.join("session.json")
.try_exists()
.map_err(|source| RuntimeError::StoreIo {
operation: "inspect Session migration",
path: session.clone(),
source,
})?
{
continue;
}
let identity: CanonicalSessionManifest = read_json(
&session.join("session.json"),
"read Session migration identity",
)?;
let session_id = identity.session_id.parse().map_err(|_| {
RuntimeError::InvalidRequest("Session migration identity is invalid".into())
})?;
match runtime_session_retention(root).ensure_live(session_id) {
Ok(()) => {}
// Erasure fences remain authoritative even if physical removal
// was interrupted. Do not inspect or migrate fenced originals.
Err(session_store::StoreError::SessionErased(_)) => continue,
Err(error) => return Err(retention_error(error)),
}
let archive = if directory == root.join("archives/workers") {
let manifest: WorkerSessionArchiveManifest = read_json(
&aggregate.join("manifest.json"),
"read archive before Session migration",
)?;
Some(validate_existing_archive(&aggregate, &manifest, false)?)
} else {
None
};
session_store::WorkerSessionStore::new(&session).map_err(|error| {
RuntimeError::StoreCorrupt {
operation: "migrate Session schema",
path: session.clone(),
message: error.to_string(),
}
})?;
if let Some(ingestion) = archive {
let (checksum_sha256, bytes, file_count) = checksum_files(
&collect_files(&session, "migrate archive storage")?,
"migrate archive storage",
)?;
atomic_write_json(
&aggregate.join(ARCHIVE_STORAGE_FILE),
&MigratedArchiveStorage {
schema_version: 1,
ingestion,
checksum_sha256,
bytes,
file_count,
},
"commit migrated archive storage",
)?;
}
}
}
Ok(())
}
fn validate_existing_archive( fn validate_existing_archive(
archive_dir: &Path, archive_dir: &Path,
expected: &WorkerSessionArchiveManifest, expected: &WorkerSessionArchiveManifest,
require_current_source: bool,
) -> Result<WorkerSessionArchiveManifest, RuntimeError> { ) -> Result<WorkerSessionArchiveManifest, RuntimeError> {
let existing: WorkerSessionArchiveManifest = read_json( let existing: WorkerSessionArchiveManifest = read_json(
&archive_dir.join("manifest.json"), &archive_dir.join("manifest.json"),
"verify Worker Session archive", "verify Worker Session archive",
)?; )?;
let storage = archive_storage(archive_dir, &existing)?;
let mut comparable_expected = expected.clone(); let mut comparable_expected = expected.clone();
comparable_expected.archived_at_unix_seconds = existing.archived_at_unix_seconds; comparable_expected.archived_at_unix_seconds = existing.archived_at_unix_seconds;
if existing != comparable_expected { let mut current = existing.clone();
if let Some(storage) = &storage {
current.content_checksum_sha256 = storage.checksum_sha256.clone();
current.content_bytes = storage.bytes;
current.content_file_count = storage.file_count;
}
// Reads use the original receipt; a retry with a migrated live source uses
// the current storage summary. All other identity/policy fields must agree.
let expected_manifest = if require_current_source {
&current
} else {
&existing
};
if expected_manifest != &comparable_expected {
return Err(RuntimeError::StoreCorrupt { return Err(RuntimeError::StoreCorrupt {
operation: "verify Worker Session archive", operation: "verify Worker Session archive",
path: archive_dir.join("manifest.json"), path: archive_dir.join("manifest.json"),
@@ -1180,9 +1328,9 @@ fn validate_existing_archive(
"verify Worker Session archive", "verify Worker Session archive",
)?; )?;
let (checksum, bytes, count) = checksum_files(&files, "verify Worker Session archive")?; let (checksum, bytes, count) = checksum_files(&files, "verify Worker Session archive")?;
if checksum != existing.content_checksum_sha256 if checksum != current.content_checksum_sha256
|| bytes != existing.content_bytes || bytes != current.content_bytes
|| count != existing.content_file_count || count != current.content_file_count
{ {
return Err(RuntimeError::StoreCorrupt { return Err(RuntimeError::StoreCorrupt {
operation: "verify Worker Session archive", operation: "verify Worker Session archive",
@@ -1660,6 +1808,249 @@ mod tests {
} }
} }
fn old_compacted_source(root: &Path, worker_id: WorkerId) -> session_store::SegmentId {
use session_store::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
Store,
};
let worker = root.join("workers").join(worker_id.to_string());
write_json(
&worker.join("worker.json"),
&serde_json::json!({"workspace_id": "workspace-a"}),
);
let store = session_store::WorkerSessionStore::new(worker.join("session")).unwrap();
let session_id = session_store::new_session_id();
let parent = session_store::new_segment_id();
let compact = session_store::new_segment_id();
let original = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::User,
content: vec![LoggedContentPart::Text {
text: "request".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId("original".into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None,
},
};
let summary = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::System,
content: vec![LoggedContentPart::Text {
text: "[Compacted context summary]\n\nsummary".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId("summary".into()),
origin: LoggedSessionHistoryOrigin::DerivedSummary,
derivation: Some(session_store::LoggedHistoryDerivation {
sources: vec![original.metadata.entry_id.clone()],
}),
},
};
for (id, history, compacted_from) in [
(parent, vec![original], None),
(
compact,
vec![summary],
Some(session_store::SegmentOrigin {
segment_id: parent,
at_turn_index: 0,
}),
),
] {
store
.create_segment(
session_id,
id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: Default::default(),
history,
forked_from: None,
compacted_from,
}],
)
.unwrap();
}
let mut metadata = session_store::WorkerMetadata::new(
worker_id.to_string(),
Some(session_store::WorkerActiveSegmentRef::active_segment(
session_id, compact,
)),
);
metadata.workspace_id = Some("workspace-a".into());
write_json(&worker.join("metadata.json"), &metadata);
write_json(
&worker.join("session/session.json"),
&serde_json::json!({"schema_version": 3, "session_id": session_id}),
);
compact
}
#[test]
fn session_migration_repairs_failed_archive_and_preserves_ingestion_receipt_on_retry() {
let temp = tempfile::tempdir().unwrap();
let worker = WorkerId::now_v7();
let compact = old_compacted_source(temp.path(), worker);
let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker, SessionDisposition::Archive);
assert!(
provider
.execute(&request)
.unwrap_err()
.to_string()
.contains("Archived canonical source is unreadable")
);
let archive_dir = provider.archive_dir("archive-a").unwrap();
let original_manifest = fs::read(archive_dir.join("manifest.json")).unwrap();
// Production invokes this only in the private tree before atomic switch.
migrate_staged_sessions(temp.path()).unwrap();
assert_eq!(
fs::read(archive_dir.join("manifest.json")).unwrap(),
original_manifest
);
let (manifest, session_root) = provider.session_archive("archive-a").unwrap();
let reader = session_store::public_material::SessionPublicMaterialReader::open_worker(
&session_root,
compact,
Default::default(),
)
.unwrap();
assert_eq!(reader.descriptors().len(), 1);
let once = fs::read(archive_dir.join(ARCHIVE_STORAGE_FILE)).unwrap();
migrate_staged_sessions(temp.path()).unwrap();
assert_eq!(
fs::read(archive_dir.join(ARCHIVE_STORAGE_FILE)).unwrap(),
once
);
let result = provider.execute(&request).unwrap();
assert!(result.source_removed);
assert_eq!(result.archive.as_ref(), Some(&manifest));
assert_eq!(provider.execute(&request).unwrap(), result);
assert!(!provider.worker_dir(worker).exists());
// Both historical receipt and rewritten payload remain verified.
fs::write(
session_root
.join("segments")
.join(format!("{compact}.jsonl")),
b"tampered",
)
.unwrap();
assert!(provider.session_archive("archive-a").is_err());
}
#[test]
fn session_migration_updates_published_archive_without_changing_completed_receipt() {
let temp = tempfile::tempdir().unwrap();
let worker = WorkerId::now_v7();
let compact = old_compacted_source(temp.path(), worker);
let provider = FsWorkerRetentionProvider::new(temp.path());
let path = provider
.worker_dir(worker)
.join("session/segments")
.join(format!("{compact}.jsonl"));
let old = fs::read_to_string(&path).unwrap();
fs::write(&path, old.replace("derived_summary", "compaction_summary")).unwrap();
let request = request(worker, SessionDisposition::Archive);
// Frozen schema-v3 completed-operation fixture: the old Runtime had
// already accepted this payload and removed its live source.
let archive =
commit_session_archive(&provider, &request, &provider.worker_dir(worker)).unwrap();
let completed = WorkerRetentionExecutionResult {
operation_id: request.operation_id.clone(),
input_fingerprint: request.input_fingerprint.clone(),
worker_id: worker,
session_disposition: SessionDisposition::Archive,
diagnostics_disposition: DiagnosticsDisposition::Purge,
archive: Some(archive),
source_removed: true,
diagnostics_retained: false,
};
write_json(
&provider.operation_path(&request.operation_id).unwrap(),
&RetentionOperationReceipt {
schema_version: OPERATION_SCHEMA_VERSION,
request: request.clone(),
result: completed.clone(),
},
);
fs::remove_dir_all(provider.worker_dir(worker)).unwrap();
let receipt = fs::read(provider.operation_path(&request.operation_id).unwrap()).unwrap();
migrate_staged_sessions(temp.path()).unwrap();
let (manifest, root) = provider.session_archive("archive-a").unwrap();
assert_eq!(completed.archive.as_ref(), Some(&manifest));
assert_eq!(
fs::read(provider.operation_path(&request.operation_id).unwrap()).unwrap(),
receipt
);
assert_eq!(provider.execute(&request).unwrap(), completed);
assert!(session_store::WorkerSessionStore::open_read_only(&root).is_ok());
let storage_path = provider
.archive_dir("archive-a")
.unwrap()
.join(ARCHIVE_STORAGE_FILE);
let mut storage: MigratedArchiveStorage = read_json(&storage_path, "test").unwrap();
storage.ingestion.archive_id = "another-archive".into();
write_json(&storage_path, &storage);
assert!(provider.session_archive("archive-a").is_err());
}
#[test]
fn session_migration_rejects_corrupt_archive_before_replacing_its_bytes() {
let temp = tempfile::tempdir().unwrap();
let worker = WorkerId::now_v7();
let compact = old_compacted_source(temp.path(), worker);
let provider = FsWorkerRetentionProvider::new(temp.path());
let request = request(worker, SessionDisposition::Archive);
assert!(provider.execute(&request).is_err());
let archive = provider.archive_dir("archive-a").unwrap();
let path = archive
.join("session/segments")
.join(format!("{compact}.jsonl"));
fs::write(&path, b"corrupt\n").unwrap();
assert!(migrate_staged_sessions(temp.path()).is_err());
assert_eq!(fs::read(&path).unwrap(), b"corrupt\n");
assert!(!archive.join(ARCHIVE_STORAGE_FILE).exists());
}
#[test]
fn session_migration_does_not_read_or_upgrade_erased_originals() {
let temp = tempfile::tempdir().unwrap();
let worker = WorkerId::now_v7();
let compact = old_compacted_source(temp.path(), worker);
let session = temp
.path()
.join("workers")
.join(worker.to_string())
.join("session");
let manifest = fs::read(session.join("session.json")).unwrap();
let identity: CanonicalSessionManifest = serde_json::from_slice(&manifest).unwrap();
let id = identity.session_id.parse().unwrap();
let retention = runtime_session_retention(temp.path());
retention
.delete(
id,
session_store::retention::SessionDeletionIntent::ErasePermanently,
|| Ok(()),
)
.unwrap();
let path = session.join("segments").join(format!("{compact}.jsonl"));
fs::write(&path, b"unreadable fenced content\n").unwrap();
migrate_staged_sessions(temp.path()).unwrap();
assert_eq!(fs::read(session.join("session.json")).unwrap(), manifest);
assert_eq!(fs::read(path).unwrap(), b"unreadable fenced content\n");
assert!(matches!(
retention.ensure_live(id),
Err(session_store::StoreError::SessionErased(_))
));
}
#[test] #[test]
fn archive_is_verified_before_source_removal_and_retry_converges() { fn archive_is_verified_before_source_removal_and_retry_converges() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
+5 -7
View File
@@ -13097,11 +13097,9 @@ mod tests {
display_name: None, display_name: None,
}) })
.unwrap_err(); .unwrap_err();
assert!( assert!(error.to_string().contains(
error "unsupported Runtime store schema version 2; expected 6, 7, 8, 9, 10, or 11"
.to_string() ));
.contains("unsupported Runtime store schema version 2; expected 6, 7, 8, 9, or 10")
);
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
@@ -13142,7 +13140,7 @@ mod tests {
let worker_aggregate: serde_json::Value = let worker_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap()) serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap())
.unwrap(); .unwrap();
assert_eq!(worker_aggregate["schema_version"], serde_json::json!(10)); assert_eq!(worker_aggregate["schema_version"], serde_json::json!(11));
assert_eq!(worker_aggregate["status"], serde_json::json!("stopped")); assert_eq!(worker_aggregate["status"], serde_json::json!("stopped"));
assert_eq!( assert_eq!(
worker_aggregate["execution_state"]["state"], worker_aggregate["execution_state"]["state"],
@@ -14166,7 +14164,7 @@ mod tests {
); );
let migrated_aggregate: serde_json::Value = let migrated_aggregate: serde_json::Value =
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap(); serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(10)); assert_eq!(migrated_aggregate["schema_version"], serde_json::json!(11));
assert!(migrated_aggregate.get("request").is_none()); assert!(migrated_aggregate.get("request").is_none());
assert!(migrated_aggregate.get("execution").is_none()); assert!(migrated_aggregate.get("execution").is_none());
assert_eq!( assert_eq!(
+5 -35
View File
@@ -101,10 +101,8 @@ fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadat
} }
/// Rebuild typed Worker history directly from the append-only Session Log. /// Rebuild typed Worker history directly from the append-only Session Log.
/// Missing metadata is not inferred from role or arbitrary plaintext. The one adopted legacy /// Persisted origins are authoritative. Legacy Host summaries are upgraded by
/// Host seed is an opening `DerivedSummary` system item in an annotated compacted Segment, /// the Session schema migration, never reinterpreted during normal restoration.
/// bearing the exact Host summary marker. It is interpreted as `CompactionSummary` without
/// rewriting the durable record; later items and pasted markers never qualify.
pub(crate) fn restore_history_entries( pub(crate) fn restore_history_entries(
_session_id: SessionId, _session_id: SessionId,
_segment_id: SegmentId, _segment_id: SegmentId,
@@ -113,32 +111,8 @@ pub(crate) fn restore_history_entries(
let mut history = Vec::new(); let mut history = Vec::new();
for entry in entries { for entry in entries {
match entry { match entry {
LogEntry::AnnotatedSegmentStart { LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
history: seed,
compacted_from,
..
} => {
history = seed.iter().map(from_logged).collect(); history = seed.iter().map(from_logged).collect();
// Older Host compactions always put this DerivedSummary system item
// first in the replacement seed. Only that trusted construction boundary
// may interpret the marker; never infer origin from appended/pasted text.
if compacted_from.is_some()
&& let Some(first) = history.first_mut()
&& first.annotation.origin == WorkerHistoryProvenance::DerivedSummary
&& matches!(
&first.item,
Item::Message {
role: agen::Role::System,
..
}
)
&& first
.item
.as_text()
.is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_HEADER))
{
first.annotation.origin = WorkerHistoryProvenance::CompactionSummary;
}
} }
LogEntry::AnnotatedUserInput { history: input, .. } => { LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged)) history.extend(input.iter().map(from_logged))
@@ -181,7 +155,7 @@ mod tests {
use session_store::LogEntry; use session_store::LogEntry;
#[test] #[test]
fn restore_recognizes_only_legacy_host_compaction_seed_summary() { fn restore_preserves_persisted_origin_without_legacy_reinterpretation() {
let summary = history_entry( let summary = history_entry(
Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")), Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")),
WorkerHistoryProvenance::DerivedSummary, WorkerHistoryProvenance::DerivedSummary,
@@ -213,11 +187,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
restored[0].annotation.origin, restored[0].annotation.origin,
if compacted { WorkerHistoryProvenance::DerivedSummary
WorkerHistoryProvenance::CompactionSummary
} else {
WorkerHistoryProvenance::DerivedSummary
}
); );
assert_eq!( assert_eq!(
restored[1].annotation.origin, restored[1].annotation.origin,
+4
View File
@@ -5576,6 +5576,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.apply_interrupt_prep()?; self.apply_interrupt_prep()?;
self.last_run_interrupted = false; self.last_run_interrupted = false;
self.commit_entry(LogEntry::PausedTurnAbandoned { self.commit_entry(LogEntry::PausedTurnAbandoned {
entry_id: None,
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
})?; })?;
Ok(()) Ok(())
@@ -6636,6 +6637,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
EngineRunExit::Yielded | EngineRunExit::Interrupted(_) => unreachable!(), EngineRunExit::Yielded | EngineRunExit::Interrupted(_) => unreachable!(),
}; };
self.commit_entry(LogEntry::RunCompleted { self.commit_entry(LogEntry::RunCompleted {
entry_id: None,
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted, interrupted,
result, result,
@@ -6653,6 +6655,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => { EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
self.commit_entry(LogEntry::RunCompleted { self.commit_entry(LogEntry::RunCompleted {
entry_id: None,
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted: false, interrupted: false,
result: EngineResult::LimitReached, result: EngineResult::LimitReached,
@@ -14512,6 +14515,7 @@ permission = "write"
); );
worker worker
.commit_entry(LogEntry::RunCompleted { .commit_entry(LogEntry::RunCompleted {
entry_id: None,
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted: false, interrupted: false,
result: EngineResult::Finished, result: EngineResult::Finished,
+5 -14
View File
@@ -2468,8 +2468,7 @@ async fn pasted_markers_and_other_derived_context_remain_ordinary_overview_mater
} }
#[tokio::test] #[tokio::test]
async fn restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker() async fn restore_does_not_reinterpret_legacy_summary_markers() {
{
use session_store::LoggedSessionHistoryOrigin::{ use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, LegacyUnknown, CompactionSummary, DerivedSummary, LegacyUnknown,
}; };
@@ -2525,13 +2524,9 @@ async fn restored_legacy_summary_requires_compacted_lineage_first_position_origi
.iter() .iter()
.find(|entry| entry.annotation.entry_id == candidate_id) .find(|entry| entry.annotation.entry_id == candidate_id)
.unwrap(); .unwrap();
assert_eq!( assert_ne!(restored.annotation.origin, CompactionSummary, "{case}");
restored.annotation.origin == CompactionSummary, // Migration belongs to the persisted store, not this restore path.
case == "trusted", // Even a legacy Host seed must not be silently reinterpreted here.
"{case}"
);
// A restore-time upgrade is an in-memory interpretation of the trusted
// seed; it must not silently rewrite the historical durable record.
assert!( assert!(
replacement_seed(&fixture.worker) replacement_seed(&fixture.worker)
.iter() .iter()
@@ -2541,11 +2536,7 @@ async fn restored_legacy_summary_requires_compacted_lineage_first_position_origi
fixture.worker.compact(0).await.unwrap(); fixture.worker.compact(0).await.unwrap();
let input = first_compactor_input(&observed.requests()[1]); let input = first_compactor_input(&observed.requests()[1]);
if case == "trusted" { assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}");
assert_full_initial_summary(&input, &inherited);
} else {
assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}");
}
} }
} }
@@ -7,3 +7,11 @@ schema変更では、既存の順序付きmigrationへ新しい処理を追加
検証には、最古の対応schemaからの更新、保存値と制約の維持、新規作成結果との一致、失敗時のrollbackを含める。途中まで完了した更新の再開や、元DBを変更しないdry-runを提供する場合は、その保証も実装経路で確認する。schema番号だけのassertionでは不足する。 検証には、最古の対応schemaからの更新、保存値と制約の維持、新規作成結果との一致、失敗時のrollbackを含める。途中まで完了した更新の再開や、元DBを変更しないdry-runを提供する場合は、その保証も実装経路で確認する。schema番号だけのassertionでは不足する。
古いbaselineの対応終了は、対象となる保存データを移行または明示的に破棄する判断を含め、独立にレビューする。残す最古のschema、移行・破棄の責任、影響するテストを明記する。所有storeのmigration経路と検証を共用し、起動・管理操作ごとに別のDDL判定を増やさない。 古いbaselineの対応終了は、対象となる保存データを移行または明示的に破棄する判断を含め、独立にレビューする。残す最古のschema、移行・破棄の責任、影響するテストを明記する。所有storeのmigration経路と検証を共用し、起動・管理操作ごとに別のDDL判定を増やさない。
## Session履歴の移行
旧Host compactionのoriginはSession storeのversioned migrationで永続的に更新する。通常復元やpublic readerでは文字列markerによる読み替えを行わない。本文・logical entry ID・derivationを維持し、位置由来のRun boundary IDは書換え前のoffsetから保存する。StandaloneのSessionも同じ変換を使う。
Runtimeの移行はowner lock下のstaging treeで停止中Worker・orphan・既存Session archiveを更新し、成功したtreeだけを公開する。全treeのコピーに必要な空き容量が必要。失敗時は元treeを維持し、切替中の中断は既存のstaging/backup回復経路で扱う。
Archiveの`manifest.json`はBackend catalogと完了receiptにも保存された取り込み時の証拠なので変更しない。移行前のchecksumを検証したうえでpayloadを現行形式に置換し、元manifest全体と現行payloadのchecksum/bytes/file countを結ぶ`session-storage.json`を同じtree切替で公開する。旧payloadは読取り用に残さない。通常のarchive検証では現行payloadを検証し、APIとoperation replayには元の取り込みreceiptを返す。移行後の削除再試行では、現行原本とarchiveの現行storage summaryが一致することを要求する。