From 047746ae5eaf8df1ac33b8d4dc70a69fb22f67f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:20:47 +0000 Subject: [PATCH 1/3] fix(cli): report installed binary version to readiness The GitHub upgrade reported the release tag as the identity the restarted daemon must match. Readiness compares that string to build_version and keeps opening a handshake against the live daemon until they match, so the tag made every poll a mismatch. Ask the installed binary, the same way the package-manager path already does. Co-authored-by: Zack Jackson --- crates/tracedecay-cli/src/upgrade.rs | 72 ++++++++++++++++--- .../src/service/probe.rs | 34 +++++++++ .../src/service/tests.rs | 13 ++++ 3 files changed, 108 insertions(+), 11 deletions(-) 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 3cec2582bd..cdcf937889 100644 --- a/crates/tracedecay-daemon-control/src/service/probe.rs +++ b/crates/tracedecay-daemon-control/src/service/probe.rs @@ -638,6 +638,40 @@ fn missing_loopback_authority() -> TraceDecayError { } } +#[cfg(test)] +mod identity_classification_tests { + use super::{DaemonProtocolState, classify_daemon_protocol_identity}; + + const SHA: &str = "84598a0b9c841b914565f46b20bb6c765706e8e5"; + + fn identity(version: &str) -> (Option, Option) { + (Some("tracedecay".to_owned()), Some(version.to_owned())) + } + + /// The GitHub release tag and the binary that tag ships are not the same + /// string. Readiness that is handed the tag classifies the live daemon as + /// a mismatch and the maintenance wait keeps opening a handshake. + #[test] + fn a_release_tag_is_not_the_build_the_daemon_advertises() { + let build = format!("0.1.0-beta.47+{SHA}"); + assert!(matches!( + classify_daemon_protocol_identity(Ok(identity(&build)), "0.1.0-beta.47"), + DaemonProtocolState::IdentityMismatch { .. } + )); + } + + /// The probe stops when the expected identity is the string the daemon + /// answered with. That string is the installed binary's `--version`. + #[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)] mod timeout_classification_tests { use std::io::{self, Cursor, Read, Write}; diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index 485b2faa43..ad201c9fed 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 + // expected identity is the release tag and the answer is the build. 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.47+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned(),), + expected_version: "0.1.0-beta.47".to_owned(), + }, + )); } #[cfg(unix)] From 23b3a62b512e00625e217f4696293bb55a0c80b1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:40:37 +0000 Subject: [PATCH 2/3] test(daemon-control): use a stale release in the restore mismatch case The restored_service_matches case this branch added named a build and the bare release tag it ships. 5e03dfa9c5 landed on master and classifies that pair as one identity, so the comment ("the expected identity is the release tag and the answer is the build") now describes a state readiness no longer produces, even though the hand-constructed IdentityMismatch still made the assertion pass. Names a genuinely stale release instead, which is still a mismatch under `versions_name_same_build`, so the case keeps testing what it claims: a live daemon that answered initialize is not a restored service while its identity differs. Co-Authored-By: Claude Fable 5.1 --- crates/tracedecay-daemon-control/src/service/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index ad201c9fed..f2d12c1a18 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -368,7 +368,7 @@ fn strict_restoration_requires_readiness_only_for_running_state() { &super::probe::DaemonProtocolState::Unresponsive("not TraceDecay".to_string()), )); // A live daemon that answered initialize is still not restored while the - // expected identity is the release tag and the answer is the build. 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, @@ -376,8 +376,8 @@ fn strict_restoration_requires_readiness_only_for_running_state() { super::probe::DaemonSocketState::Connectable, &super::probe::DaemonProtocolState::IdentityMismatch { name: Some("tracedecay".to_owned()), - version: Some("0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned(),), - expected_version: "0.1.0-beta.47".to_owned(), + version: Some("0.1.0-beta.46+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned()), + expected_version: "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5".to_owned(), }, )); } From 06f883881070e7e69031c1fb8f93a0a79240c4e0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:22:00 +0000 Subject: [PATCH 3/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()), - ) -}