Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
740052a
fix(sessions): refuse an authoritative zero from a partial generation
ScriptedAlchemy Sep 19, 2026
5e03dfa
fix(daemon): compare version identity per semver build rules
ScriptedAlchemy Sep 19, 2026
cc10394
fix(sessions): retire refused refresh progress instead of retrying
ScriptedAlchemy Sep 19, 2026
8b75523
perf(code-index): scan clone postings once per resumed page
ScriptedAlchemy Sep 19, 2026
e2384db
fix(sessions): retire only refused progress, not a cancelled pass
ScriptedAlchemy Sep 19, 2026
f2ab808
perf(code-index): index clone postings by occurrence for resume replay
ScriptedAlchemy Sep 19, 2026
f77e763
test(global-db): gate analytics append on abandonment, not poll
ScriptedAlchemy Sep 19, 2026
53d7f9d
fix(retention): skip an artifact reclaimed during the scan
ScriptedAlchemy Sep 19, 2026
b130bce
test(daemon): defer the retention plan while the store is busy
ScriptedAlchemy Sep 19, 2026
63c0784
fix(mcp): attribute risky sites by byte span, not line
ScriptedAlchemy Sep 19, 2026
6d8ef97
test(application): keep git auto-maintenance out of the fixture
ScriptedAlchemy Sep 19, 2026
ba835fc
test(advisory): accept peer-won Codex stop ingest
cursoragent Sep 19, 2026
b05cab2
fix(daemon): mount the published branch worktree's query authority
ScriptedAlchemy Sep 19, 2026
de5f920
fix(mcp): attribute field sites by byte span, not line
ScriptedAlchemy Sep 19, 2026
aab865a
test(runtime): settle the worker before sampling elapsed freshness
ScriptedAlchemy Sep 19, 2026
15f25b3
test(cli): keep the hotpath metrics port out of the quiet-pipeline test
ScriptedAlchemy Sep 19, 2026
575e762
Merge remote-tracking branch 'origin/fix/master-ci-green-3' into curs…
ScriptedAlchemy Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/tracedecay-application/src/git_intelligence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,14 @@ mod tests {
"user.email=fixture@example.com",
"-c",
"commit.gpgsign=false",
// `git commit` spawns a detached `git maintenance run --auto`
// that holds `.git/objects/maintenance.lock` after the commit
// returns; the byte-identical snapshot must not see it appear
// or vanish between its two walks.
"-c",
"maintenance.auto=false",
"-c",
"gc.auto=0",
])
.args(args)
.current_dir(self.path())
Expand Down
4 changes: 4 additions & 0 deletions crates/tracedecay-cli/tests/core_cli_suite/cli_boundary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ fn shipped_binary_stops_quietly_when_a_pipeline_reader_exits() {
let output = Command::new("sh")
.args(["-c", r#""$TRACEDECAY_BIN" tool | head -n 4"#])
.env("TRACEDECAY_BIN", env!("CARGO_BIN_EXE_tracedecay"))
// A hotpath-enabled binary binds its metrics port on start; when a
// sibling test's daemon already holds it, the bind failure lands on
// stderr and breaks the quiet-pipeline assertion below.
.env("HOTPATH_METRICS_SERVER_OFF", "true")
.output()
.expect("tracedecay tool pipeline should run");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,52 @@ fn text_artifact_retention_collects_staging_database_sidecars_with_their_owner()
);
}

/// The inventory scans the artifact root without the generation-store lock, so
/// the text-artifact builder can retire a `.staging` family between the
/// directory listing and the stat. A vanished entry is already reclaimed and
/// must leave the plan intact rather than failing it with a storage error.
#[test]
fn text_artifact_inventory_skips_an_entry_reclaimed_during_the_scan() {
let store = tempfile::TempDir::new().expect("artifact store");
let artifacts_root = code_text_artifacts_root(store.path());
std::fs::create_dir_all(&artifacts_root).expect("create artifact root");
let staging_family = ["a", "b", "c"]
.into_iter()
.map(|seed| {
let path = artifacts_root.join(format!(".text-artifact-{}.staging", seed.repeat(64)));
std::fs::write(&path, b"staging").expect("write staging evidence");
path
})
.collect::<Vec<_>>();

// The scan probes cancellation once on entry and once per directory entry,
// before it takes that entry. Retiring from the third probe on leaves the
// listing already taken and one entry already inspected, so every further
// name the scan holds names a file that is gone from disk.
let probes = std::sync::atomic::AtomicUsize::new(0);
let retire_during_the_scan = || {
if probes.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2 {
for path in &staging_family {
let _ = std::fs::remove_file(path);
}
}
false
};

let inventory = plan_collectable_text_artifacts_cancellable(
store.path(),
None,
GenerationDigestVerificationV1::Full,
&retire_during_the_scan,
)
.expect("an entry reclaimed mid-scan leaves the store plannable");
assert!(
inventory.candidates.len() < staging_family.len(),
"an entry that vanished before its stat is reclaimed, not planned: {:?}",
inventory.candidates
);
}

#[test]
fn applied_retention_refuses_a_busy_generation_store_and_retries() {
let (store, _) = fixture_store(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,22 @@ pub(super) fn plan_collectable_text_artifacts_cancellable(
)
})?;
let path = entry.path();
let metadata = std::fs::symlink_metadata(&path).map_err(storage)?;
// This inventory reads the artifact root without the generation-store
// lock, so an entry the listing just named can already be gone: the
// text-artifact builder retires a `.staging` family (the staging
// database and its `-journal`/`-wal`/`-shm` sidecars) under that lock
// while this scan runs. A vanished entry is reclaimed, which is what
// this inventory would have planned anyway, so it is not a candidate
// and not a failure. Failing the plan here turned every publish that
// raced a maintenance tick into a loud `retention_plan_failed` pass
// (master run 35422072661, `Storage("No such file or directory")`).
// A completed artifact the durable index *references* is verified
// above, before this scan, and stays fail-closed if it disappears.
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(storage(error)),
};
if !metadata.file_type().is_file() {
return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!(
"code text artifact inventory path '{}' is not a regular file",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4022,7 +4022,9 @@ async fn diagnostics_change_generation_advances_for_out_of_band_git_drift() {
async fn elapsed_freshness_window_alone_does_not_make_dashboard_state_stale() {
let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]);
let store = TempDir::new().expect("store root");
let registry = CodeIndexSchedulerRegistryV1::new(1);
// Single-permit admission: holding it below parks the background worker,
// which the host's default bound cannot do.
let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1);
registry
.mount_worktree(
test_project_id(),
Expand All @@ -4033,18 +4035,33 @@ async fn elapsed_freshness_window_alone_does_not_make_dashboard_state_stale() {
.expect("mount daemon-owned scheduler");
wait_for_initial_generation(&registry, fixture.path()).await;
wait_for_dashboard_ready(&registry, fixture.path()).await;
// The mount leaves clone backfill behind, and the wakes that drain it
// leave a banked permit whose no-op pass projects `Verifying` instead of
// `Fresh` (CI run 35425541839). Settle the mount-era chain, hold the
// admission so no pass can start under the sample, and prove the
// pending-wake slot stays empty, exactly as the text-progress test does.
drain_clone_backfill(&registry, fixture.path()).await;
settled_owner_with_idle_admission(&registry, fixture.path()).await;
let _quiet_owner = quiesced_background_reconcile_admission(&registry, fixture.path()).await;
let canonical = fixture.path().canonicalize().expect("canonical fixture");
{
let scope = {
let mounted = registry.mounted.lock().await;
mounted
.get(&canonical)
.expect("mounted worktree")
let worktree = mounted.get(&canonical).expect("mounted worktree");
worktree
.scheduler
.lock()
.expect("scheduler")
.policy
.staleness_threshold = Duration::ZERO;
}
tracedecay_contracts::ResolvedScope::new(
test_project_id(),
worktree.repository_id.clone(),
worktree.worktree_id.clone(),
None,
)
.expect("resolved scope")
};
clear_pending_wake_until_quiet(&registry, &scope).await;

let projected = registry
.dashboard_freshness(fixture.path())
Expand Down
54 changes: 53 additions & 1 deletion crates/tracedecay-daemon-control/src/service/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ fn classify_daemon_protocol_identity(
match identity {
Ok((name, version))
if name.as_deref() == Some("tracedecay")
&& version.as_deref() == Some(expected_version) =>
&& version.as_deref().is_some_and(|version| {
tracedecay_daemon_protocol::versions_name_same_build(version, expected_version)
}) =>
{
DaemonProtocolState::Ready
}
Expand Down Expand Up @@ -638,6 +640,56 @@ fn missing_loopback_authority() -> TraceDecayError {
}
}

#[cfg(test)]
mod identity_classification_tests {
use super::{DaemonProtocolState, classify_daemon_protocol_identity};

const SHA: &str = "84598a0b9c841b914565f46b20bb6c765706e8e5";

/// The identity a `tracedecay` daemon reporting `version` answers with.
fn identity(version: &str) -> (Option<String>, Option<String>) {
(Some("tracedecay".to_owned()), Some(version.to_owned()))
}

/// `tracedecay update` installs a release and then waits for the daemon
/// that binary starts. The release path knows the version it installed as
/// the bare release, while the daemon names the commit it was built from,
/// so readiness used to refuse the very binary it had just installed.
#[test]
fn a_daemon_naming_its_commit_is_ready_against_its_bare_release() {
assert_eq!(
classify_daemon_protocol_identity(
Ok(identity(&format!("0.1.0-beta.47+{SHA}"))),
"0.1.0-beta.47",
),
DaemonProtocolState::Ready
);
}

/// A genuinely stale daemon is still refused, whichever side names a
/// commit.
#[test]
fn a_different_build_is_still_an_identity_mismatch() {
let stale = format!("0.1.0-beta.46+{SHA}");
assert_eq!(
classify_daemon_protocol_identity(Ok(identity(&stale)), "0.1.0-beta.47"),
DaemonProtocolState::IdentityMismatch {
name: Some("tracedecay".to_owned()),
version: Some(stale),
expected_version: "0.1.0-beta.47".to_owned(),
}
);
let other_commit = format!("0.1.0-beta.47+{}", "b".repeat(40));
assert!(matches!(
classify_daemon_protocol_identity(
Ok(identity(&other_commit)),
&format!("0.1.0-beta.47+{SHA}"),
),
DaemonProtocolState::IdentityMismatch { .. }
));
}
}

#[cfg(test)]
mod timeout_classification_tests {
use std::io::{self, Cursor, Read, Write};
Expand Down
77 changes: 76 additions & 1 deletion crates/tracedecay-daemon-protocol/src/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,41 @@ impl DaemonHandshakeRefusal {
/// Old clients send no version (empty string); that is indistinguishable from
/// "same version before this field existed", so it never counts as skew.
pub fn client_version_skew(client_version: &str, daemon_version: &str) -> Option<String> {
if client_version.is_empty() || client_version == daemon_version {
if client_version.is_empty() || versions_name_same_build(client_version, daemon_version) {
return None;
}
Some(client_version.to_string())
}

/// Whether two reported versions name the same binary, the one comparison
/// every version identity check in the product runs.
///
/// A version is `"{release}"` or `"{release}+{full sha}[.dirty]"`, and `SemVer`
/// requires build metadata to be ignored for precedence. A side that reports
/// only the release is therefore **less specific**, not different: the release
/// tag `v0.1.0-beta.47` and the binary that names itself
/// `0.1.0-beta.47+<sha>` are one identity, and treating them as a mismatch is
/// what failed `tracedecay update`'s own readiness wait against the daemon it
/// had just installed.
///
/// When both sides do name a commit they must name the same one, which keeps
/// the skew this comparison was added to catch (367a44ad00): two checkout
/// builds of one release differ only by commit, and a daemon left running from
/// the previous build is exactly that case.
#[must_use]
pub fn versions_name_same_build(left: &str, right: &str) -> bool {
let (Some(left_release), Some(right_release)) = (release_version(left), release_version(right))
else {
// Neither side is a version this comparison understands, so refuse to
// guess and fall back to the literal texts.
return left == right;
};
left_release.cmp_precedence(&right_release) == std::cmp::Ordering::Equal
&& (left_release.build == right_release.build
|| left_release.build.is_empty()
|| right_release.build.is_empty())
}

fn release_version(version: &str) -> Option<semver::Version> {
semver::Version::parse(version.strip_prefix('v').unwrap_or(version)).ok()
}
Expand Down Expand Up @@ -227,6 +256,52 @@ mod handshake_refusal_tests {
);
}

/// The observed `tracedecay update` failure: the release path reports the
/// bare release it installed while the daemon that binary starts names its
/// own commit, so readiness compared `0.1.0-beta.47` against
/// `0.1.0-beta.47+<sha>` and refused the daemon it had just installed.
#[test]
fn a_bare_release_and_its_own_build_are_one_identity() {
let build = "0.1.0-beta.47+84598a0b9c841b914565f46b20bb6c765706e8e5";
assert!(versions_name_same_build("0.1.0-beta.47", build));
assert!(versions_name_same_build(build, "0.1.0-beta.47"));
assert!(
versions_name_same_build("v0.1.0-beta.47", build),
"the GitHub release tag names the same identity as the binary it ships"
);
assert_eq!(client_version_skew("0.1.0-beta.47", build), None);
assert_eq!(client_version_skew(build, "0.1.0-beta.47"), None);
}

/// Build metadata still separates two builds of one release, the skew this
/// comparison exists to catch.
#[test]
fn two_commits_of_one_release_stay_distinguishable() {
let older = "0.1.0-beta.47+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let newer = "0.1.0-beta.47+bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
assert!(!versions_name_same_build(older, newer));
assert_eq!(client_version_skew(older, newer), Some(older.to_owned()));
assert!(!versions_name_same_build(
"0.1.0-beta.46",
"0.1.0-beta.47+aaaa"
));
assert!(
!versions_name_same_build(
older,
"0.1.0-beta.47+aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.dirty"
),
"a dirty worktree is not the commit it was built from"
);
}

/// Nothing that fails to parse may be declared a match by accident.
#[test]
fn unparseable_versions_compare_literally() {
assert!(versions_name_same_build("not-a-version", "not-a-version"));
assert!(!versions_name_same_build("not-a-version", "0.1.0-beta.47"));
assert!(!versions_name_same_build("", "0.1.0-beta.47"));
}

#[test]
fn foreign_lines_never_parse_as_refusal_frames() {
assert_eq!(DaemonHandshakeRefusal::from_line("{}"), None);
Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-daemon-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ pub use contract::{
pub use handshake::{
DAEMON_HANDSHAKE_REFUSAL_PROTOCOL, DaemonHandshake, DaemonHandshakeRefusal,
DaemonHandshakeRefusalReason, MovedStoreAdoption, client_version_skew, version_skew_action,
versions_name_same_build,
};
pub use lsp_wire::{
ConnectionLocalRequestSequence, FramePoll, FrameSend, LspFrame, LspSessionAccess,
Expand Down
21 changes: 14 additions & 7 deletions crates/tracedecay-global-db/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,12 +739,19 @@ async fn single_analytics_append_commits_in_one_writer_dispatch() {
metadata_json: None,
};

let append = harness.registered.append_analytics_event(&event);
tokio::pin!(append);
assert!(matches!(
futures_util::poll!(&mut append),
std::task::Poll::Pending
));
// One poll hands the INSERT to the writer thread, which autocommits it and
// only afterwards answers the reply channel. Whether that answer has already
// arrived when the poll returns is a race with that thread, so the poll's own
// result is not the contract; abandoning the future before anyone reads the
// reply is. A single-dispatch autocommit survives that; a transaction-framed
// append would roll back and never reach the inspection connection below.
{
let append = harness.registered.append_analytics_event(&event);
tokio::pin!(append);
if let std::task::Poll::Ready(result) = futures_util::poll!(&mut append) {
result.expect("single analytics append");
}
}

let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
loop {
Expand All @@ -758,7 +765,7 @@ async fn single_analytics_append_commits_in_one_writer_dispatch() {
}
assert!(
std::time::Instant::now() < deadline,
"single append did not autocommit while its future remained unpolled"
"single append did not autocommit after its future was abandoned"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
Expand Down
Loading
Loading