Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 52 additions & 4 deletions crates/tracedecay-domain/src/code_intelligence/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -896,11 +896,19 @@ pub struct CodeBlock {
///
/// The ID format is `"kind:32hexchars"` where the hex portion is the first 32
/// characters of the SHA-256 hash of the input components.
///
/// `name` is extractor output derived from arbitrary user source, not a
/// programmer-supplied invariant, so an empty name is accepted input rather
/// than a contract violation: legal constructs such as `test.describe("", …)`,
/// `export default () => {}`, and IIFEs are genuinely unnamed. Identity does
/// not depend on the name being non-empty — file path, kind, and line already
/// separate two anonymous constructs — so an empty name hashes like any other
/// and behaves identically in debug and release builds. A `debug_assert!` here
/// previously panicked the indexing pool on such files in debug/perf profiles
/// while release silently produced this same id; callers that want a readable
/// display name must synthesize one (see the `<anonymous>` convention used
/// across the extractors) instead of relying on an assertion here.
pub fn generate_node_id(file_path: &str, kind: &NodeKind, name: &str, line: u32) -> String {
debug_assert!(
!name.is_empty(),
"generate_node_id called with empty name for {file_path}:{line}"
);
let input = format!("{}:{}:{}:{}", file_path, kind.as_str(), name, line);
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
Expand All @@ -924,3 +932,43 @@ pub struct ResolvedRef {
pub confidence: f64,
pub resolved_by: String,
}

#[cfg(test)]
mod empty_name_node_id_tests {
use super::{NodeKind, generate_node_id};

/// Real source contains anonymous constructs (`test.describe("", …)`,
/// `export default () => {}`). Extracted names are parser output, not a
/// programmer contract, so an empty name must produce an id in every
/// build profile instead of tripping a debug-only assertion.
#[test]
fn empty_name_yields_a_deterministic_id_in_every_profile() {
let first = generate_node_id(
"integration/fs-routes-test.ts",
&NodeKind::Function,
"",
286,
);
let second = generate_node_id(
"integration/fs-routes-test.ts",
&NodeKind::Function,
"",
286,
);
assert_eq!(first, second, "empty-name ids must be deterministic");
assert!(
first.starts_with("function:"),
"unexpected id shape: {first}"
);
}

/// Uniqueness of an empty-name id comes from file path, kind, and line,
/// so distinct anonymous constructs never collide onto one node.
#[test]
fn empty_name_ids_stay_distinct_per_file_kind_and_line() {
let base = generate_node_id("a.ts", &NodeKind::Function, "", 286);
assert_ne!(base, generate_node_id("b.ts", &NodeKind::Function, "", 286));
assert_ne!(base, generate_node_id("a.ts", &NodeKind::Class, "", 286));
assert_ne!(base, generate_node_id("a.ts", &NodeKind::Function, "", 287));
}
}
52 changes: 52 additions & 0 deletions src/daemon/code_index_scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,36 @@ impl CodeIndexSchedulerErrorV1 {
}
}

/// A refusal that is transient *by construction*: this pass was turned away
/// because a bounded shared resource was already fully held, and it is
/// released by whoever holds it rather than by anything about this input.
///
/// The background worker schedules its own delayed retry for exactly these,
/// because releasing shared capacity emits no wake: a sibling worktree or
/// artifact build finishing does not notify this worktree, so without a
/// self-scheduled retry it stayed stale until an unrelated query or edit
/// happened to wake it.
///
/// The distinction the admission failure carries is the whole point. A
/// request that exceeds the *entire* process limit is shaped like a
/// capacity refusal and is not one — no other holder can release enough for
/// it — so it is classified permanent and never self-retried. Identity
/// failures, git and IO faults, production and privacy refusals, adjustment
/// invariant breaks, an uninstalled worker plan, and publication conflicts
/// likewise reproduce over the same input or already have an owner that
/// re-drives them; self-scheduling those is precisely the unbounded-retry
/// failure this module exists to stop.
pub(super) fn is_transient_capacity_failure(&self) -> bool {
match self {
Self::WorkerMemoryAdmission(failure) | Self::SnapshotMemoryAdmission(failure) => {
failure.requested_bytes <= failure.limit_bytes
}
Self::SnapshotMemoryCapacityUnavailable => true,
Self::GraphProjection(CodeGraphProjectionError::BudgetExhausted { .. }) => true,
_ => false,
}
}

pub(super) fn is_graph_activation_refusal(&self) -> bool {
matches!(self, Self::GraphActivationRefused(_))
|| matches!(
Expand Down Expand Up @@ -2628,6 +2658,10 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 {
/// `retained_snapshot_bytes`; worker scratch is admitted separately only
/// after capture has completed.
_retained_snapshot_memory: Vec<ResidentMemoryReservationV1>,
/// Deterministic reconcile fault used only by the worker-loop isolation
/// tests; production never installs one.
#[cfg(test)]
reconcile_fault: Option<Arc<reconcile_panic_guard::ReconcileFaultInjectionV1>>,
/// Process resident-memory authority artifact builds and readers reserve
/// through. Standalone opens get a private default-limit authority; the
/// registry rebinds its shared process authority at mount.
Expand Down Expand Up @@ -2734,6 +2768,8 @@ impl CodeIndexWorktreeSchedulerV1 {
byte_pool,
retained_snapshot_bytes: Vec::new(),
_retained_snapshot_memory: Vec::new(),
#[cfg(test)]
reconcile_fault: None,
resident_memory: Arc::new(ProcessResidentMemoryV1::new(
DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1,
)),
Expand Down Expand Up @@ -3056,10 +3092,25 @@ impl CodeIndexWorktreeSchedulerV1 {
Some(self.bind_latest_complete(generation))
}

/// Install a deterministic reconcile fault for one mounted worktree so a
/// test can drive the real background worker loop over a pass that panics
/// or fails, and count the attempts the loop actually makes.
#[cfg(test)]
pub(in crate::daemon::code_index_scheduler) fn install_reconcile_fault_for_test(
&mut self,
fault: Arc<reconcile_panic_guard::ReconcileFaultInjectionV1>,
) {
self.reconcile_fault = Some(fault);
}

/// Retained-owner activation entry point. Foreground reads never call this.
pub(super) fn activate_or_reconcile(
&mut self,
) -> Result<CodeIndexReconcileOutcomeV1, CodeIndexSchedulerErrorV1> {
#[cfg(test)]
if let Some(fault) = self.reconcile_fault.clone() {
fault.arrive()?;
Comment on lines +3110 to +3112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the test-only reconcile fault port

This production execution path now branches through a #[cfg(test)] fault-injection field and setter, allowing the worker tests to fabricate panic, capacity, and identity failures before the real source-indexing and resident-memory authorities run. Those tests can therefore remain green while the production failure paths drift, and the repository explicitly prohibits test-only production ports; drive the actual failure boundaries instead.

AGENTS.md reference: AGENTS.md:L82-L84

Useful? React with 👍 / 👎.

}
// The in-progress signal must cover the retained-activation branch
// too: the worker has already claimed the pending wake, so without it
// a failing activation pass would leave query admission unable to see
Expand Down Expand Up @@ -4114,6 +4165,7 @@ pub(in crate::daemon) mod observability;
mod privacy;
pub(in crate::daemon) mod queries;
pub(in crate::daemon) mod query_runtime;
mod reconcile_panic_guard;
mod registry;
pub(crate) mod semantic_query_runtime;
pub(crate) mod semantic_vector_graph;
Expand Down
Loading
Loading