fix: route retained Session migration through Runtime staging

This commit is contained in:
2026-10-20 03:52:55 +09:00
parent 86602240fa
commit daf1f6ed50
4 changed files with 680 additions and 50 deletions
+45 -12
View File
@@ -657,18 +657,32 @@ fn plan_runtime_store_migration(
));
}
if current_schema_version == SCHEMA_VERSION {
// Runtime and Session schemas evolve independently. Probe only through
// Session Store's non-mutating current-schema admission boundary; any
// conversion still runs on the existing owner-locked staging tree.
let migration_required = crate::retention::sessions_require_migration(root)?;
let excluded_ephemeral_paths = if migration_required {
runtime_tree_exclusions(root)?
} else {
Vec::new()
};
let archived_orphan_worker_count = if migration_required {
orphaned_worker_directories(root)?.len()
} else {
0
};
let plan = FsRuntimeStoreMigrationPlan {
current_schema_version,
target_schema_version: SCHEMA_VERSION,
migration_required: false,
migration_required,
worker_count: 0,
archived_orphan_worker_count: 0,
archived_orphan_worker_count,
migrated_worker_aggregate_count: 0,
migrated_diagnostic_worker_ref_count: 0,
cleared_diagnostic_worker_ref_count: 0,
mapping_digest: legacy_worker_identity_mapping_digest(&[]),
mappings: Vec::new(),
excluded_ephemeral_paths: Vec::new(),
excluded_ephemeral_paths,
};
return Ok((plan, Vec::new()));
}
@@ -1808,7 +1822,9 @@ fn migrate_runtime_store(
Ok(plan)
}
fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeError> {
fn orphaned_worker_directories(
root: &Path,
) -> Result<Vec<(std::ffi::OsString, PathBuf)>, RuntimeError> {
let workers_dir = root.join(WORKERS_DIR);
let mut orphaned = fs::read_dir(&workers_dir)
.map_err(|source| runtime_io_error("read Worker records", &workers_dir, source))?
@@ -1821,6 +1837,11 @@ fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeErro
})
.collect::<Vec<_>>();
orphaned.sort_by(|(left, _), (right, _)| left.cmp(right));
Ok(orphaned)
}
fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeError> {
let orphaned = orphaned_worker_directories(root)?;
if orphaned.is_empty() {
return Ok(0);
}
@@ -1855,6 +1876,21 @@ fn migrate_runtime_store_in_place(
if !plan.migration_required {
return Ok(plan);
}
if plan.current_schema_version == SCHEMA_VERSION {
// Session-only migration: do not reserialize Runtime/Worker identity,
// Restore guards, recovery fences, or retained execution receipts.
// As in a Runtime schema migration, retain orphan trees outside the
// Worker catalog before validating the completed staging tree.
let archived = archive_orphaned_worker_directories(root)?;
if archived != plan.archived_orphan_worker_count {
return Err(runtime_store_corrupt(
root,
"orphan count changed during Session migration".into(),
));
}
crate::retention::migrate_staged_sessions(root)?;
return Ok(plan);
}
let runtime_path = root.join(RUNTIME_FILE);
let bytes =
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?;
@@ -1948,11 +1984,9 @@ fn migrate_runtime_store_in_place(
),
));
}
// Schema 11 already migrated Sessions; the recovery-fence bump must not
// rewrite retained history or archive attestations.
if plan.current_schema_version != SOURCE_SCHEMA_VERSION {
crate::retention::migrate_staged_sessions(root)?;
}
// A Runtime schema bump says nothing about the retained Session schema.
// Session Store owns versioned conversion, including v4 -> generic v5.
crate::retention::migrate_staged_sessions(root)?;
let (document, _) = migrate_runtime_document(
document,
plan.current_schema_version,
@@ -3011,9 +3045,8 @@ mod tests {
} else {
drop(result.unwrap());
assert_eq!(stored_schema_version(&root), u64::from(SCHEMA_VERSION));
let manifest: serde_json::Value =
read_json(&session.join("session.json"), "test").unwrap();
assert_eq!(manifest["schema_version"], 4);
session_store::WorkerSessionStore::open_read_only(&session)
.expect("startup migration must admit the current Session schema");
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);
+74 -38
View File
@@ -1204,9 +1204,13 @@ fn archive_storage(
}
}
/// 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> {
// Share discovery and erasure fencing between the read-only Runtime preflight
// and its private staging pass. Session Store remains the authority for schema
// admission and conversion; Runtime must not duplicate its version table.
fn for_each_live_session(
root: &Path,
mut visit: impl FnMut(&Path, bool) -> Result<(), RuntimeError>,
) -> Result<(), RuntimeError> {
for directory in ["workers", "orphaned-workers", "archives/workers"] {
let directory = root.join(directory);
let entries = match fs::read_dir(&directory) {
@@ -1253,44 +1257,72 @@ pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> {
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",
)?;
}
visit(&aggregate, directory == root.join("archives/workers"))?;
}
}
Ok(())
}
/// Read-only admission probe: a rejected Session needs the existing staged
/// migration attempt (which will also reject corrupt/unsupported inputs).
/// Never create a writable Session store or rewrite archive attestations here.
pub(crate) fn sessions_require_migration(root: &Path) -> Result<bool, RuntimeError> {
let mut required = false;
for_each_live_session(root, |aggregate, _| {
required |=
session_store::WorkerSessionStore::open_read_only(aggregate.join("session")).is_err();
Ok(())
})?;
Ok(required)
}
/// 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_each_live_session(root, |aggregate, is_archive| {
let session = aggregate.join("session");
let archive = if is_archive {
let manifest: WorkerSessionArchiveManifest = read_json(
&aggregate.join("manifest.json"),
"read archive before Session migration",
)?;
Some(validate_existing_archive(aggregate, &manifest, false)?)
} else {
None
};
// Current Sessions need no writable admission, and their storage
// attestations must remain byte-for-byte unchanged.
if session_store::WorkerSessionStore::open_read_only(&session).is_ok() {
return Ok(());
}
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(
archive_dir: &Path,
expected: &WorkerSessionArchiveManifest,
@@ -1753,12 +1785,16 @@ fn temporary_path(path: &Path) -> PathBuf {
path.with_file_name(format!(".{name}.tmp-{}-{sequence}", std::process::id()))
}
#[cfg(all(test, feature = "fs-store"))]
#[path = "retention/runtime_v5_tests.rs"]
mod runtime_v5_tests;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Barrier};
fn write_json(path: &Path, value: &impl Serialize) {
pub(super) fn write_json(path: &Path, value: &impl Serialize) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
}
@@ -1786,7 +1822,7 @@ mod tests {
fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
}
fn request(
pub(super) fn request(
worker_id: WorkerId,
disposition: SessionDisposition,
) -> WorkerRetentionExecutionRequest {
@@ -0,0 +1,2 @@
{"kind":"annotated_system_item","ts":101,"entry":{"item":{"kind":"journal_startup","body":"journal body\nunchanged","snapshot":{"entries":[{"text":"private snapshot"}]},"snapshot_sha256":"snapshot-digest","prompt_provenance":{"workspace_id":"workspace","source_digest":"source","projection_digest":"projection","logical_name":"journal_startup"}},"metadata":{"entry_id":"journal-id","origin":{"kind":"legacy_unknown"},"derivation":{"sources":["source-id"]}}},"extensions":[{"domain":"migration-fixture","payload":{"kept":true}}]}
{"kind":"annotated_system_item","ts":103,"entry":{"item":{"kind":"subject_behavior_refresh","body":"rendered subject body","behavior_md":"exact behavior","prompt_provenance":{"source_digest":"subject-source","projection_digest":"subject-projection","logical_name":"subject_behavior"}},"metadata":{"entry_id":"subject-id","origin":{"kind":"backend_instruction","operation_id":"op"}}}}
@@ -0,0 +1,559 @@
//! Runtime integration, not a second implementation of Session migration.
//! The Feature records are frozen v4 wire copied from the Session Store fixture;
//! only the empty segment envelope is built with current APIs.
use super::tests::{request, write_json};
use super::*;
use crate::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
use serde_json::{Value, json};
use session_store::{LogEntry, SegmentId, SessionId, Store, WorkerSessionStore};
use std::collections::BTreeMap;
const V4_SYSTEM_INPUTS: &str = include_str!("fixtures/system-input-v4.jsonl");
fn write_v4_session(aggregate: &Path) -> (SessionId, SegmentId) {
let root = aggregate.join("session");
let session = session_store::new_session_id();
let segment = session_store::new_segment_id();
let store = WorkerSessionStore::new(&root).unwrap();
store
.create_segment(
session,
segment,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: session,
system_prompt: None,
config: Default::default(),
history: vec![],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let path = root.join("segments").join(format!("{segment}.jsonl"));
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(V4_SYSTEM_INPUTS.as_bytes());
fs::write(path, bytes).unwrap();
write_json(
&root.join("session.json"),
&json!({
"schema_version": 4, "session_id": session,
}),
);
(session, segment)
}
fn stopped_worker(root: &Path, schema: u32) -> (WorkerId, SessionId, SegmentId) {
let worker = WorkerId::now_v7();
let aggregate = root.join("workers").join(worker.to_string());
// Serialize request defaults just as a production aggregate writer does.
let request: crate::catalog::CreateWorkerRequest = serde_json::from_value(json!({
"worker_id": worker,
"workspace_api": {"workspace_id": "workspace-a", "base_url": "https://workspace.invalid"},
"create_fingerprint": "runtime-v5-fixture",
"profile": {"kind": "builtin", "value": "builtin:coder"},
"profile_source": {"kind": "workspace_config", "archive": {
"id": "archive-1", "digest": "sha256:archive", "size_bytes": 0,
"source_graph": {"source_count": 0, "total_source_bytes": 0,
"entrypoints": {}, "import_count": 0}
}}
}))
.unwrap();
write_json(
&aggregate.join("worker.json"),
&json!({
"schema_version": schema,
"worker_ref": {"worker_id": worker}, "worker_id": worker,
"profile": request.profile,
"profile_source": request.profile_source.reference(),
"display_name": null, "config_bundle": null,
"created_at_ms": 1234, "status": "stopped", "workspace_id": "workspace-a",
"last_finished_submission_request_id": "retained-submission",
"restore_guard": {"observation_token": "retained-token",
"active_request_id": null, "owners": {}},
"execution_state": {"state": "available", "execution": {
"request": request, "binding": {}, "restore_intent": "explicit"
}}
}),
);
let (session, segment) = write_v4_session(&aggregate);
let mut metadata = session_store::WorkerMetadata::new(
format!("worker-runtime-{worker}"),
Some(session_store::WorkerActiveSegmentRef::active_segment(
session, segment,
)),
);
metadata.workspace_id = Some("workspace-a".into());
write_json(&aggregate.join("metadata.json"), &metadata);
(worker, session, segment)
}
fn runtime_fixture(root: &Path, schema: u32) -> (WorkerId, SessionId, SegmentId) {
fs::create_dir_all(root.join("workers")).unwrap();
let fenced_worker = WorkerId::now_v7();
write_json(
&root.join("runtime.json"),
&json!({
"schema_version": schema, "display_name": "retained Runtime",
"backend": "fs_store", "status": "running", "next_diagnostic_id": 1,
"config_bundles": {}, "workspace_owners": {"workspace-a": "owner-a"},
"diagnostics": [], "subject_releases": [],
"subject_create_recovery_fences": {fenced_worker.to_string(): {
"request": {"workspace_id": "workspace-a", "worker_id": fenced_worker,
"create_fingerprint": "retained-create", "operation_id": "retained-recovery"},
"response": null
}}
}),
);
stopped_worker(root, schema)
}
// Include every file, not just schema markers: failures must preserve original
// history, metadata, archive receipts, checksums and retention evidence together.
fn tree_bytes(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
fn walk(root: &Path, at: &Path, result: &mut BTreeMap<PathBuf, Vec<u8>>) {
for entry in fs::read_dir(at).unwrap() {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_dir() {
walk(root, &entry.path(), result);
} else {
result.insert(
entry.path().strip_prefix(root).unwrap().to_path_buf(),
fs::read(entry.path()).unwrap(),
);
}
}
}
let mut result = BTreeMap::new();
walk(root, root, &mut result);
result
}
fn assert_generic(root: &Path, session: SessionId, segment: SegmentId) {
use session_store::{InputCompactionUse, InputEvidenceUse, InputObservationUse};
let store = WorkerSessionStore::open_read_only(root).unwrap();
let entries = store.read_all_read_only(session, segment).unwrap();
assert_eq!(entries.len(), 3);
let originals: Vec<Value> = V4_SYSTEM_INPUTS
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
for (log, old) in entries[1..].iter().zip(originals) {
let LogEntry::AnnotatedSystemItem { entry, .. } = log else {
panic!("lost System record: {log:?}");
};
let current = serde_json::to_value(log).unwrap();
assert_eq!(current["ts"], old["ts"]);
for field in ["entry_id", "origin", "derivation"] {
assert_eq!(
current["entry"]["metadata"][field],
old["entry"]["metadata"][field]
);
}
assert_eq!(current["entry"]["item"]["kind"], "input");
assert_eq!(
entry.item.history_text(),
old["entry"]["item"]["body"].as_str().unwrap()
);
let input = entry.metadata.system_input.as_ref().unwrap();
let usage = input.usage();
assert_eq!(usage.recording_material, InputEvidenceUse::Exclude);
assert_eq!(usage.memory_evidence, InputEvidenceUse::Exclude);
assert_eq!(usage.observation, InputObservationUse::Hidden);
let kind = old["entry"]["item"]["kind"].as_str().unwrap();
let schema = if kind == "journal_startup" {
assert_eq!(usage.compaction, InputCompactionUse::ReferenceInput);
assert_eq!(current["extensions"], old["extensions"]);
"journal:startup"
} else {
assert_eq!(usage.compaction, InputCompactionUse::Retain);
"subject:behavior"
};
let payload = input.feature_payload().unwrap();
assert_eq!(payload.schema(), schema);
assert_eq!(payload.version(), 1);
let mut expected_payload = old["entry"]["item"].clone();
expected_payload.as_object_mut().unwrap().remove("kind");
expected_payload.as_object_mut().unwrap().remove("body");
assert_eq!(
payload.decode::<Value>(schema, 1).unwrap(),
expected_payload
);
let public = serde_json::to_value(input.public_item().unwrap()).unwrap();
assert_eq!(public["kind"], kind);
assert_eq!(public["body"], old["entry"]["item"]["body"]);
assert_eq!(public["presentation"], json!({"kind": "context"}));
}
}
fn completed_v4_archive(
root: &Path,
schema: u32,
) -> (
SessionId,
SegmentId,
WorkerRetentionExecutionRequest,
WorkerRetentionExecutionResult,
) {
let (worker, session, segment) = stopped_worker(root, schema);
let provider = FsWorkerRetentionProvider::new(root);
let request = request(worker, SessionDisposition::Archive);
// Frozen old-runtime receipt: ingestion and removal completed before the
// current strict decoder existed. Do not ask the v5 reader to mint it.
let manifest =
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(manifest),
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();
(session, segment, request, completed)
}
#[test]
fn runtime_v11_and_current_route_v4_stopped_orphan_archive_through_staging() {
for runtime_schema in [11, 12] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
let (worker, session, segment) = runtime_fixture(&root, runtime_schema);
let orphan = root
.join("orphaned-workers")
.join(WorkerId::now_v7().to_string());
let (orphan_session, orphan_segment) = write_v4_session(&orphan);
// Include an unregistered tree still under workers, not just an
// already-archived orphan. Both Runtime source schemas must fence it
// out of the live catalog without discarding its Session.
let loose_id = WorkerId::now_v7();
let loose = root.join("workers").join(loose_id.to_string());
let (loose_session, loose_segment) = write_v4_session(&loose);
let retained_loose = root.join("orphaned-workers").join(loose_id.to_string());
let (archived_session, archived_segment, request, completed) =
completed_v4_archive(&root, runtime_schema);
let provider = FsWorkerRetentionProvider::new(&root);
let archive = provider.archive_dir("archive-a").unwrap();
// Also cover an archive which already has a local storage attestation
// from an earlier schema migration. Keep ingestion bytes immutable;
// harmless marker whitespace makes current storage differ from them.
if runtime_schema == 12 {
let marker = archive.join("session/session.json");
let mut bytes = fs::read(&marker).unwrap();
bytes.push(b'\n');
fs::write(marker, bytes).unwrap();
let ingestion = completed.archive.clone().unwrap();
let (checksum_sha256, bytes, file_count) = checksum_files(
&collect_files(&archive.join("session"), "test").unwrap(),
"test",
)
.unwrap();
write_json(
&archive.join(ARCHIVE_STORAGE_FILE),
&MigratedArchiveStorage {
schema_version: 1,
ingestion,
checksum_sha256,
bytes,
file_count,
},
);
}
let storage_before = fs::read(archive.join(ARCHIVE_STORAGE_FILE)).ok();
let manifest_before = fs::read(archive.join("manifest.json")).unwrap();
let receipt_path = provider.operation_path(&request.operation_id).unwrap();
let receipt_before = fs::read(&receipt_path).unwrap();
let stopped = root.join("workers").join(worker.to_string());
assert!(WorkerSessionStore::open_read_only(stopped.join("session")).is_err());
// Current decode itself must not be made permissive to fix routing.
for line in V4_SYSTEM_INPUTS.lines() {
assert!(serde_json::from_str::<LogEntry>(line).is_err());
}
let before = tree_bytes(&root);
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
let plan = FsRuntimeStore::migration_plan(&options).unwrap();
assert!(plan.migration_required);
assert_eq!(plan.archived_orphan_worker_count, 1);
assert_eq!(plan.current_schema_version, runtime_schema);
assert_eq!(tree_bytes(&root), before, "preflight mutated source");
// Exercise normal startup, not just the private migration helper.
let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-a").unwrap();
assert!(
matches!(
FsRuntimeStore::migrate(&options),
Err(RuntimeError::RuntimeStoreAlreadyOpen { .. })
),
"owner lock not fenced"
);
drop(opened);
assert_generic(&stopped.join("session"), session, segment);
assert_generic(&orphan.join("session"), orphan_session, orphan_segment);
assert!(!loose.exists());
assert_generic(
&retained_loose.join("session"),
loose_session,
loose_segment,
);
assert_generic(&archive.join("session"), archived_session, archived_segment);
assert_eq!(
fs::read(archive.join("manifest.json")).unwrap(),
manifest_before
);
assert_eq!(fs::read(&receipt_path).unwrap(), receipt_before);
let (manifest, archive_session) = provider.session_archive("archive-a").unwrap();
assert_eq!(completed.archive.as_ref(), Some(&manifest));
assert_eq!(provider.execute(&request).unwrap(), completed);
if let Some(storage_before) = storage_before {
assert_ne!(
fs::read(archive.join(ARCHIVE_STORAGE_FILE)).unwrap(),
storage_before
);
}
let storage: MigratedArchiveStorage =
read_json(&archive.join(ARCHIVE_STORAGE_FILE), "test").unwrap();
assert_eq!(storage.ingestion, manifest);
let (checksum, bytes, count) =
checksum_files(&collect_files(&archive_session, "test").unwrap(), "test").unwrap();
assert_eq!(
(storage.checksum_sha256, storage.bytes, storage.file_count),
(checksum, bytes, count)
);
if runtime_schema == 12 {
for name in [
PathBuf::from("runtime.json"),
PathBuf::from(format!("workers/{worker}/worker.json")),
PathBuf::from(format!("workers/{worker}/metadata.json")),
] {
assert_eq!(fs::read(root.join(&name)).unwrap(), before[&name]);
}
}
let once = tree_bytes(&root);
assert!(
!FsRuntimeStore::migrate(&options)
.unwrap()
.migration_required
);
drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-a").unwrap());
assert_eq!(tree_bytes(&root), once, "retry rewrote migrated evidence");
assert!(!temp.path().join(".runtime.schema-v12-staging").exists());
assert!(!temp.path().join(".runtime.pre-schema-v12-backup").exists());
// session-storage, not the immutable receipt's old checksum, attests
// current bytes. Tampering with those bytes must remain detectable.
let path = archive_session
.join("segments")
.join(format!("{archived_segment}.jsonl"));
fs::write(path, b"tampered\n").unwrap();
assert!(provider.session_archive("archive-a").is_err());
}
}
#[test]
fn runtime_v5_archive_failure_discards_staging_without_losing_originals_and_retry_converges() {
for runtime_schema in [11, 12] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
let (worker, session, segment) = runtime_fixture(&root, runtime_schema);
let (archived_session, archived_segment, request, completed) =
completed_v4_archive(&root, runtime_schema);
let provider = FsWorkerRetentionProvider::new(&root);
let archive = provider.archive_dir("archive-a").unwrap();
let path = archive
.join("session/segments")
.join(format!("{archived_segment}.jsonl"));
let original = fs::read(&path).unwrap();
fs::write(&path, b"corrupt archive\n").unwrap();
let before = tree_bytes(&root);
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
assert!(
FsRuntimeStore::migration_plan(&options)
.unwrap()
.migration_required
);
// workers are visited before archives: earlier successful staged
// conversion must not leak into the original tree on this later error.
assert!(FsRuntimeStore::open_or_create(root.clone(), "runtime-a").is_err());
assert_eq!(tree_bytes(&root), before);
assert!(!archive.join(ARCHIVE_STORAGE_FILE).exists());
fs::write(&path, original).unwrap();
FsRuntimeStore::migrate(&options).unwrap();
assert_generic(
&root
.join("workers")
.join(worker.to_string())
.join("session"),
session,
segment,
);
assert_generic(&archive.join("session"), archived_session, archived_segment);
assert_eq!(provider.execute(&request).unwrap(), completed);
}
}
#[test]
fn runtime_v5_typed_legacy_payload_failure_keeps_valid_archive_evidence_on_retries() {
for (from, to) in [
(
"\"snapshot_sha256\":\"snapshot-digest\"",
"\"snapshot_sha256\":42",
),
("\"behavior_md\":\"exact behavior\"", "\"behavior_md\":[]"),
] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
runtime_fixture(&root, 12);
let (worker, _, segment) = stopped_worker(&root, 12);
let provider = FsWorkerRetentionProvider::new(&root);
let path = provider
.worker_dir(worker)
.join("session/segments")
.join(format!("{segment}.jsonl"));
let original = fs::read_to_string(&path).unwrap();
assert!(original.contains(from));
fs::write(&path, original.replace(from, to)).unwrap();
let request = request(worker, SessionDisposition::Archive);
let manifest =
commit_session_archive(&provider, &request, &provider.worker_dir(worker)).unwrap();
fs::remove_dir_all(provider.worker_dir(worker)).unwrap();
let archive = provider.archive_dir("archive-a").unwrap();
// This archive checksum is valid; failure must come from the legacy
// typed decoder, not from deliberately broken ingestion evidence.
validate_existing_archive(&archive, &manifest, false).unwrap();
let before = tree_bytes(&root);
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
for _ in 0..2 {
let error = FsRuntimeStore::migrate(&options).unwrap_err();
let RuntimeError::StoreCorrupt {
operation, path, ..
} = error
else {
panic!("unexpected failure: {error}");
};
assert_eq!(operation, "migrate Session schema");
assert!(path.starts_with(temp.path().join(".runtime.schema-v12-staging")));
assert_eq!(tree_bytes(&root), before);
validate_existing_archive(&archive, &manifest, false).unwrap();
}
}
}
#[test]
fn runtime_v5_probe_and_staging_skip_erased_originals() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
let (worker, live_session, live_segment) = runtime_fixture(&root, 12);
let erased = root
.join("orphaned-workers")
.join(WorkerId::now_v7().to_string());
let (session, segment) = write_v4_session(&erased);
runtime_session_retention(&root)
.delete(
session,
session_store::retention::SessionDeletionIntent::ErasePermanently,
|| Ok(()),
)
.unwrap();
fs::write(
erased
.join("session/segments")
.join(format!("{segment}.jsonl")),
b"fenced malformed JSON\n",
)
.unwrap();
let before = tree_bytes(&erased);
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
assert!(
FsRuntimeStore::migration_plan(&options)
.unwrap()
.migration_required
);
FsRuntimeStore::migrate(&options).unwrap();
assert_generic(
&root
.join("workers")
.join(worker.to_string())
.join("session"),
live_session,
live_segment,
);
assert_eq!(tree_bytes(&erased), before);
assert!(
!FsRuntimeStore::migration_plan(&options)
.unwrap()
.migration_required
);
assert!(matches!(
runtime_session_retention(&root).ensure_live(session),
Err(session_store::StoreError::SessionErased(_))
));
}
#[test]
fn runtime_v5_rejects_broken_or_future_source_without_rewriting_evidence() {
for corruption in ["broken-json", "future-schema"] {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
let (worker, session, segment) = runtime_fixture(&root, 12);
let session_root = root
.join("workers")
.join(worker.to_string())
.join("session");
if corruption == "broken-json" {
fs::write(
session_root
.join("segments")
.join(format!("{segment}.jsonl")),
b"{not valid JSON\n",
)
.unwrap();
} else {
write_json(
&session_root.join("session.json"),
&json!({
"schema_version": 999, "session_id": session,
}),
);
}
let before = tree_bytes(&root);
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
assert!(
FsRuntimeStore::migration_plan(&options)
.unwrap()
.migration_required
);
assert_eq!(tree_bytes(&root), before);
assert!(FsRuntimeStore::migrate(&options).is_err());
assert_eq!(tree_bytes(&root), before);
assert!(!temp.path().join(".runtime.schema-v12-staging").exists());
assert!(!temp.path().join(".runtime.pre-schema-v12-backup").exists());
}
}
#[cfg(unix)]
#[test]
fn current_runtime_session_migration_still_rejects_live_worker_sockets() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
let (worker, _, _) = runtime_fixture(&root, 12);
let before = tree_bytes(&root);
let runs = root.join("workers").join(worker.to_string()).join("runs");
fs::create_dir_all(runs.join("1")).unwrap();
let listener = std::os::unix::net::UnixListener::bind(runs.join("1/worker.sock")).unwrap();
let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a");
let error = FsRuntimeStore::migrate(&options).unwrap_err();
assert!(error.to_string().contains("active Worker socket"));
drop(listener);
fs::remove_dir_all(runs).unwrap();
assert_eq!(tree_bytes(&root), before);
assert!(!temp.path().join(".runtime.schema-v12-staging").exists());
}