diff --git a/crates/tracedecay-cli/src/upgrade.rs b/crates/tracedecay-cli/src/upgrade.rs index 032cf81c0c..04da93665c 100644 --- a/crates/tracedecay-cli/src/upgrade.rs +++ b/crates/tracedecay-cli/src/upgrade.rs @@ -554,15 +554,16 @@ pub enum UpgradeOutcome { /// binary: `which_tracedecay()`'s current-exe-first order can point /// at the OLD binary (e.g. a stale Homebrew keg) after an upgrade. binary: Option, - /// Version of the freshly installed binary: the release-manifest - /// version for GitHub-release installs, the linked binary's - /// self-reported version for package-manager installs. Daemon restore - /// validates this version, the binary it actually restarts, instead - /// of the one that was running before the upgrade. `None` only when - /// the manager's install could not be interrogated; restore - /// verification then validates the pre-upgrade version and, if a new - /// daemon really was installed, fails with a typed identity mismatch - /// rather than silently passing. + /// Protocol identity of the freshly installed binary, the string + /// `tracedecay --version` prints and the daemon advertises as + /// `build_version()`. That is `{release}+{sha}[.dirty]`, not the + /// GitHub release tag. Daemon restore compares it to the answering + /// process with `versions_name_same_build`, which ignores build + /// metadata a side omits, so a bare release tag would accept ANY + /// build of that release. `None` when the binary could not be read: + /// restore then keeps the pre-upgrade identity and fails with a + /// typed mismatch if a different daemon starts, rather than + /// widening the window to a whole release. version: Option, }, /// Already on the latest version. The binary was not replaced. @@ -796,8 +797,12 @@ fn run_versioned_upgrade(current: &str, is_beta: bool) -> Result record_previous_version(); eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{latest}!"); Ok(UpgradeOutcome::Installed { + // The release tag is the bare semver the operator downloaded. The + // daemon advertises `{release}+{sha}`, and a bare tag matches every + // build of that release, so the window must expect the binary's own + // identity rather than `latest`. + version: installed_protocol_identity(binary.as_deref()), binary, - version: Some(latest.to_owned()), }) } @@ -949,6 +954,38 @@ fn installed_binary_version(path: &Path) -> std::result::Result`. Reporting the tag therefore made the maintenance +/// window accept any build of that release, disabling the same-release +/// different-commit skew detection 367a44ad00 added. An unreadable binary +/// is `None`, never the tag: a less specific identity is what widened the +/// window in the first place. +fn installed_protocol_identity(binary: Option<&Path>) -> Option { + match binary.map(installed_binary_version) { + Some(Ok(version)) => Some(version), + Some(Err(reason)) => { + eprintln!( + " \x1b[33mwarning:\x1b[0m could not read the installed binary's protocol identity \ + ({reason}); daemon restore will not substitute the release tag" + ); + None + } + None => { + eprintln!( + " \x1b[33mwarning:\x1b[0m installed binary path is unknown; daemon restore will \ + not substitute the release tag" + ); + None + } + } +} + fn installed_binary_version_within( path: &Path, deadline: Duration, @@ -1235,6 +1272,7 @@ mod tests { use super::super::{ VersionProbeError, installed_binary_version, installed_binary_version_within, + installed_protocol_identity, }; fn script(dir: &Path, body: &str) -> PathBuf { @@ -1252,6 +1290,36 @@ mod tests { assert_eq!(installed_binary_version(&binary).unwrap(), "1.2.3+abcdef"); } + /// The `tracedecay update` failure: the release tag is `0.1.0-beta.47` + /// and the binary that tag installs names itself with `+`. A + /// bare tag names every build of that release, so the maintenance + /// window must be handed the binary's own identity. + #[test] + fn a_direct_install_reports_the_binary_identity_not_the_release_tag() { + let dir = tempfile::tempdir().unwrap(); + let build = "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5"; + let binary = script(dir.path(), &format!("printf 'tracedecay {build}\\n'")); + + assert_eq!( + installed_protocol_identity(Some(&binary)).as_deref(), + Some(build) + ); + + let bare = script(dir.path(), "printf 'tracedecay 0.1.0-beta.47\\n'"); + assert_eq!( + installed_protocol_identity(Some(&bare)).as_deref(), + Some("0.1.0-beta.47") + ); + + let missing = dir.path().join("absent"); + assert_eq!( + installed_protocol_identity(Some(&missing)), + None, + "an unreadable binary must not fall back to the release tag" + ); + assert_eq!(installed_protocol_identity(None), None); + } + #[test] fn failed_exits_and_malformed_output_are_not_version_evidence() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs index 10818733c1..632898ca24 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs @@ -119,11 +119,11 @@ impl SessionTemporalRefreshProjector for CanonicalSessionTemporalProjector { // Empty remaining range is a durable no-op: terminalize with an // empty complete progress batch instead of deferring forever. Ok(None) => canonical_noop_complete_effect(&recovery), - Err(error) if error.is_storage() => Err( - SessionTemporalRefreshProjectorError::retryable(format!( + Err(error) if error.is_storage() => { + Err(SessionTemporalRefreshProjectorError::retryable(format!( "source_busy: {error}" - )), - ), + ))) + } Err(_) => Err(SessionTemporalRefreshProjectorError::terminal( "projector_failed", )), diff --git a/crates/tracedecay-session-temporal-store/src/query.rs b/crates/tracedecay-session-temporal-store/src/query.rs index 367b14bb79..ae0ab6b4de 100644 --- a/crates/tracedecay-session-temporal-store/src/query.rs +++ b/crates/tracedecay-session-temporal-store/src/query.rs @@ -246,8 +246,7 @@ pub(super) async fn read_observations( // ceiling. Split until a single observation remains; that // observation is then a typed storage failure, not a retry // that looks like a busy source. - if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 - { + if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 { let mid = start + chunk.len() / 2; pending.push((mid, end)); pending.push((start, mid)); @@ -289,11 +288,18 @@ fn observation_prefetch_exceeded_materialization_limit(error: &SessionStoreError } } +/// The error `read_observation` raises for an id the store does not hold, reused +/// by callers that resolve prefetched observations out of a batch map. +pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { + storage_message( + PERSIST_OPERATION, + format!("source observation {} is missing", observation_id.as_str()), + ) +} + #[cfg(test)] mod tests { - use super::{ - PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage, - }; + use super::{PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage}; #[test] fn materialization_limit_is_the_prefetch_split_signal() { @@ -308,15 +314,8 @@ mod tests { assert!(observation_prefetch_exceeded_materialization_limit( &exceeded )); - assert!(!observation_prefetch_exceeded_materialization_limit(&locked)); + assert!(!observation_prefetch_exceeded_materialization_limit( + &locked + )); } } - -/// The error `read_observation` raises for an id the store does not hold, reused -/// by callers that resolve prefetched observations out of a batch map. -pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { - storage_message( - PERSIST_OPERATION, - format!("source observation {} is missing", observation_id.as_str()), - ) -}