diff --git a/crates/tracedecay-cli/src/upgrade.rs b/crates/tracedecay-cli/src/upgrade.rs index 032cf81c0c..a1fb885b9f 100644 --- a/crates/tracedecay-cli/src/upgrade.rs +++ b/crates/tracedecay-cli/src/upgrade.rs @@ -554,15 +554,15 @@ 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 + /// Version of the freshly installed binary, read from its `--version`. + /// That string is the identity the daemon advertises + /// (`{release}+{commit}`), not the GitHub release tag. 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. + /// the 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. version: Option, }, /// Already on the latest version. The binary was not replaced. @@ -794,11 +794,40 @@ fn run_versioned_upgrade(current: &str, is_beta: bool) -> Result eprintln!("Upgrading v{current} → v{latest}..."); let binary = install_upgrade_version(latest, is_beta)?; record_previous_version(); + // The tag is what GitHub published. The daemon we just installed + // advertises `build_version()` (`{release}+{commit}`), and readiness + // refuses anything else by opening a handshake on every poll. Reporting + // the tag is what made `tracedecay update` probe that live daemon until + // the maintenance window expired. Two builds of one release differ only + // by commit, so the tag is not a stand-in. + let version = release_install_identity(binary.as_deref()); eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{latest}!"); - Ok(UpgradeOutcome::Installed { - binary, - version: Some(latest.to_owned()), - }) + Ok(UpgradeOutcome::Installed { binary, version }) +} + +/// The identity the restarted daemon will advertise, read from the binary +/// that was just installed. +/// +/// A missing path or a `--version` that is not evidence is an unknown +/// version, never the release tag. Inventing the tag is the probe storm. +fn release_install_identity(binary: Option<&Path>) -> Option { + let Some(path) = binary else { + eprintln!( + " \x1b[33mwarning:\x1b[0m could not locate the installed binary; \ + daemon restore will validate the pre-upgrade version" + ); + return None; + }; + match installed_binary_version(path) { + Ok(version) => Some(version), + Err(reason) => { + eprintln!( + " \x1b[33mwarning:\x1b[0m could not read the installed binary's version \ + ({reason}); daemon restore will validate the pre-upgrade version" + ); + None + } + } } /// Atomically replaces `target` with the contents of `source`: the bytes are @@ -1289,6 +1318,27 @@ mod tests { )); } + #[test] + fn a_github_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!( + super::super::release_install_identity(Some(&binary)).as_deref(), + Some(build) + ); + } + + #[test] + fn an_unreadable_install_is_unknown_rather_than_the_release_tag() { + let dir = tempfile::tempdir().unwrap(); + let binary = script(dir.path(), "printf 'not a version\\n'"); + + assert_eq!(super::super::release_install_identity(Some(&binary)), None); + assert_eq!(super::super::release_install_identity(None), None); + } + #[test] fn a_child_that_overfills_its_stdout_pipe_is_refused_without_a_deadlock() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tracedecay-daemon-control/src/service/probe.rs b/crates/tracedecay-daemon-control/src/service/probe.rs index c974b6a920..6525f2e566 100644 --- a/crates/tracedecay-daemon-control/src/service/probe.rs +++ b/crates/tracedecay-daemon-control/src/service/probe.rs @@ -688,6 +688,18 @@ mod identity_classification_tests { DaemonProtocolState::IdentityMismatch { .. } )); } + + /// Once the release path reports the installed binary's own `--version`, + /// both sides name the same commit and readiness takes the exact-match + /// path rather than relying on build-metadata precedence. + #[test] + fn the_advertised_build_is_ready_against_itself() { + let build = format!("0.1.0-beta.47+{SHA}"); + assert_eq!( + classify_daemon_protocol_identity(Ok(identity(&build)), &build), + DaemonProtocolState::Ready + ); + } } #[cfg(test)] diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index 485b2faa43..f2d12c1a18 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -367,6 +367,19 @@ fn strict_restoration_requires_readiness_only_for_running_state() { super::probe::DaemonSocketState::Connectable, &super::probe::DaemonProtocolState::Unresponsive("not TraceDecay".to_string()), )); + // A live daemon that answered initialize is still not restored while the + // build it names is a different release than the one just installed. The + // maintenance wait keeps probing until this becomes Ready. + assert!(!super::restored_service_matches( + DaemonServiceState::RunningEnabled, + DaemonServiceState::RunningEnabled, + super::probe::DaemonSocketState::Connectable, + &super::probe::DaemonProtocolState::IdentityMismatch { + name: Some("tracedecay".to_owned()), + version: Some("0.1.0-beta.46+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned()), + expected_version: "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned(), + }, + )); } #[cfg(unix)] 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()), - ) -}