From b63b775245c392c7adf80916f43ce72153c339b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:33:46 +0000 Subject: [PATCH 1/3] fix(cli): report installed binary identity on update The GitHub upgrade path recorded the release tag as the daemon identity. The daemon advertises {release}+{sha}, and readiness compares those strings exactly, so update refused the binary it had just installed. Both install paths now record the binary's own --version and never substitute the tag. Co-authored-by: Zack Jackson --- crates/tracedecay-cli/src/upgrade.rs | 116 +++++++++++++++++++++------ 1 file changed, 90 insertions(+), 26 deletions(-) diff --git a/crates/tracedecay-cli/src/upgrade.rs b/crates/tracedecay-cli/src/upgrade.rs index 032cf81c0c..b93064d147 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 - /// 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. + /// Version the installed binary reports for itself (`--version`), + /// `{release}+{sha}[.dirty]`. Daemon restore compares this string to + /// the daemon's advertised build identity exactly, so a release tag + /// is not a substitute: the tag and the binary differ by build + /// metadata, and that mismatch is what failed `tracedecay update`'s + /// readiness wait. `None` only when the binary could not be + /// interrogated; restore then validates the pre-upgrade version and, + /// if a new daemon really was installed, fails with a typed identity + /// mismatch rather than accepting a less specific name. version: Option, }, /// Already on the latest version. The binary was not replaced. @@ -794,11 +794,20 @@ 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(); - eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{latest}!"); - Ok(UpgradeOutcome::Installed { - binary, - version: Some(latest.to_owned()), - }) + Ok(finish_versioned_upgrade(latest, binary)) +} + +/// Completes a GitHub-release install. +/// +/// `catalog_version` is the release name shown to the operator. It is not +/// the installed identity: the published binary names itself +/// `{release}+{sha}` and the daemon advertises that same string. Readiness +/// compares the two exactly, so recording the catalog tag refuses the binary +/// this function just installed. +fn finish_versioned_upgrade(catalog_version: &str, binary: Option) -> UpgradeOutcome { + let version = probed_installed_version(binary.as_deref(), "installed release"); + eprintln!("\x1b[32m✔\x1b[0m Successfully upgraded to v{catalog_version}!"); + UpgradeOutcome::Installed { binary, version } } /// Atomically replaces `target` with the contents of `source`: the bytes are @@ -971,6 +980,28 @@ fn installed_binary_version_within( parse_version_output(&text).ok_or(VersionProbeError::Unrecognized(text)) } +/// The version `binary` reports for itself, or `None` when there is nothing +/// to ask or it does not answer. +/// +/// A missing answer is not filled in from a release tag. The tag omits the +/// commit the binary and the daemon both name, and readiness treats that +/// omission as a different identity. +fn probed_installed_version(binary: Option<&Path>, owner: &str) -> Option { + let Some(path) = binary else { + return None; + }; + match installed_binary_version(path) { + Ok(version) => Some(version), + Err(reason) => { + eprintln!( + " \x1b[33mwarning:\x1b[0m could not read the {owner} binary's version \ + ({reason}); daemon restore will not invent an identity" + ); + None + } + } +} + /// Whether a delegated manager upgrade was a no-op: the binary the manager /// links reports exactly the build version this process is running, which /// is the same file unless the manager installed something. `None` @@ -1024,17 +1055,8 @@ fn run_delegated_upgrade( None } }; - let installed_version = match binary.as_deref().map(installed_binary_version) { - Some(Ok(version)) => Some(version), - Some(Err(reason)) => { - eprintln!( - " \x1b[33mwarning:\x1b[0m could not read the {label}-installed binary's version \ - ({reason}); assuming a new install so the refresh chain runs" - ); - None - } - None => None, - }; + let installed_version = + probed_installed_version(binary.as_deref(), &format!("{label}-installed")); if delegated_upgrade_was_noop( crate::product_runtime::PRODUCT_BUILD_VERSION, installed_version.as_deref(), @@ -1234,7 +1256,8 @@ mod tests { use tracedecay_runtime_core::git::GitCommandError; use super::super::{ - VersionProbeError, installed_binary_version, installed_binary_version_within, + UpgradeOutcome, VersionProbeError, finish_versioned_upgrade, installed_binary_version, + installed_binary_version_within, }; fn script(dir: &Path, body: &str) -> PathBuf { @@ -1368,6 +1391,47 @@ mod tests { "a successful parent exit does not close an inherited pipe; the deadline must" ); } + + /// The observed update failure: GitHub names the release `0.1.0-beta.47` + /// and the binary that release ships names + /// `0.1.0-beta.47+`. Readiness compares those strings exactly, so + /// the catalog tag must not be the version the outcome records. + #[test] + fn a_release_install_reports_the_binary_identity_not_the_catalog_tag() { + let dir = tempfile::tempdir().unwrap(); + let sha = "84598a0b9c841b914565f46b20bb6c765706e8e5"; + let identity = format!("0.1.0-beta.47+{sha}"); + let binary = script(dir.path(), &format!("printf 'tracedecay {identity}\\n'")); + let catalog = "0.1.0-beta.47"; + + let outcome = finish_versioned_upgrade(catalog, Some(binary)); + + let UpgradeOutcome::Installed { version, .. } = outcome else { + panic!("a published release is an install, got {outcome:?}"); + }; + assert_eq!(version.as_deref(), Some(identity.as_str())); + assert_ne!( + version.as_deref(), + Some(catalog), + "the catalog tag is not the identity the daemon advertises" + ); + } + + /// A binary that cannot be asked must not be labeled with the release + /// tag. Restore then fails closed against the pre-upgrade identity + /// instead of waiting for a version the new daemon will never report. + #[test] + fn an_unreadable_release_binary_is_not_labeled_with_the_catalog_tag() { + let catalog = "0.1.0-beta.47"; + let missing = PathBuf::from("/nonexistent/tracedecay-release"); + + let outcome = finish_versioned_upgrade(catalog, Some(missing)); + + let UpgradeOutcome::Installed { version, .. } = outcome else { + panic!("a published release is an install, got {outcome:?}"); + }; + assert_eq!(version, None); + } } // ── Installation ownership ────────────────────────────────────────── From 569660d52d72510360e08398afe130d52d560b30 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:22:00 +0000 Subject: [PATCH 2/3] style(session-temporal): keep the test module last and rustfmt master Master run 35431539771 failed Check formatting (projector.rs, query.rs) and Clippy (items_after_test_module in query.rs) after #1844/#1845 merged without CI. Co-Authored-By: Claude Fable 5.1 --- .../projector.rs | 8 ++--- .../src/query.rs | 29 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) 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()), - ) -} From fb58663dd361ba11b0e933d267c717fa8244b584 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:57:02 +0000 Subject: [PATCH 3/3] fix(cli): use ? for the missing install-probe binary `cargo clippy --workspace --all-targets --locked -- -D warnings` failed with clippy::question_mark on `probed_installed_version`'s `let...else`, which only returned `None`. Behaviour is unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/tracedecay-cli/src/upgrade.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tracedecay-cli/src/upgrade.rs b/crates/tracedecay-cli/src/upgrade.rs index b93064d147..da334e24d1 100644 --- a/crates/tracedecay-cli/src/upgrade.rs +++ b/crates/tracedecay-cli/src/upgrade.rs @@ -987,10 +987,7 @@ fn installed_binary_version_within( /// commit the binary and the daemon both name, and readiness treats that /// omission as a different identity. fn probed_installed_version(binary: Option<&Path>, owner: &str) -> Option { - let Some(path) = binary else { - return None; - }; - match installed_binary_version(path) { + match installed_binary_version(binary?) { Ok(version) => Some(version), Err(reason) => { eprintln!(