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
787892c
fix(code-index): keep directory pointer faults in publication family
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
1cef6c5
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 @@ -1745,7 +1745,27 @@ fn read_active_pointer(
store_root: &Path,
) -> Result<DurablePublicationPointerV1, CodeGenerationRetentionErrorV1> {
let path = store_root.join(ACTIVE_POINTER_FILE);
let bytes = std::fs::read(&path).map_err(storage)?;
// A directory in the pointer slot makes `read(2)` return EISDIR. That is
// the same corrupt authority the publication store refuses; do not let the
// OS error replace the typed unsafe-state.
match std::fs::metadata(&path) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => {
return Err(CodeGenerationRetentionErrorV1::UnsafeState(
"active code-generation pointer is not a regular file".to_owned(),
));
}
Err(error) => return Err(storage(error)),
}
let bytes = std::fs::read(&path).map_err(|error| {
if error.kind() == std::io::ErrorKind::IsADirectory {
CodeGenerationRetentionErrorV1::UnsafeState(
"active code-generation pointer is not a regular file".to_owned(),
)
} else {
storage(error)
}
})?;
serde_json::from_slice(&bytes).map_err(|error| {
CodeGenerationRetentionErrorV1::UnsafeState(format!(
"active pointer '{}' is corrupt: {error}",
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 @@ -871,6 +871,33 @@ impl DaemonCodeIndexPublicationStoreV1 {
CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(error.to_string())
}

/// A pointer slot that is not a regular file is a corrupt authority.
///
/// `read(2)` and `rename(2)` both report that shape as `EISDIR`. Mapping
/// the OS error to `Unavailable` (or letting it surface as a raw I/O
/// fault) misclassifies a broken publication pointer. Callers in the
/// scheduler publication family must see reset-required corruption.
fn corrupt_non_file_pointer() -> CodeIndexPublicationStoreErrorV1 {
Self::corruption("active code-generation pointer is not a regular file")
}

fn map_pointer_io(error: std::io::Error) -> CodeIndexPublicationStoreErrorV1 {
if error.kind() == std::io::ErrorKind::IsADirectory {
Self::corrupt_non_file_pointer()
} else {
Self::unavailable(error)
}
}

fn require_regular_pointer_slot(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> {
match std::fs::metadata(&self.active_path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(Self::corrupt_non_file_pointer()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Self::map_pointer_io(error)),
}
}

fn acquire_generation_read_lock(
&self,
) -> Result<CodeGenerationStoreLockV1, CodeIndexPublicationStoreErrorV1> {
Expand Down Expand Up @@ -1089,8 +1116,11 @@ impl DaemonCodeIndexPublicationStoreV1 {
.unwrap_or_else(PoisonError::into_inner) = None;
return Ok(None);
}
Err(error) => return Err(Self::unavailable(error)),
Err(error) => return Err(Self::map_pointer_io(error)),
};
if !metadata.file_type().is_file() {
return Err(Self::corrupt_non_file_pointer());
}
if metadata.len() > MAX_DURABLE_PUBLICATION_POINTER_BYTES {
return Err(Self::corruption(
"durable code-generation index exceeds its byte bound",
Expand All @@ -1102,7 +1132,7 @@ impl DaemonCodeIndexPublicationStoreV1 {
// a fixed-width pointer through another path, and a 1-second mtime
// filesystem can leave both unchanged while the bytes move. The memo
// is reused only when the file digest matches.
let bytes = std::fs::read(&self.active_path).map_err(Self::unavailable)?;
let bytes = std::fs::read(&self.active_path).map_err(Self::map_pointer_io)?;
let digest = Self::state_digest(&bytes);
{
let mut memo = self
Expand Down Expand Up @@ -2558,8 +2588,11 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 {
std::fs::remove_file(&temporary).map_err(Self::unavailable)?;
}
hotpath::measure_block!("code_index.generation.publish.pointer_commit", {
// Refuse a directory (or any non-file) before `rename(2)`. Replacing
// one returns EISDIR, which is not a publication-family fault.
self.require_regular_pointer_slot()?;
Self::write_durable(&temporary, &bytes)?;
std::fs::rename(&temporary, &self.active_path).map_err(Self::unavailable)?;
std::fs::rename(&temporary, &self.active_path).map_err(Self::map_pointer_io)?;
Self::sync_directory(
self.active_path
.parent()
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
Loading
Loading