From 1e425eb89f1d42259d5e64ba65ea6092baa4cecd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 22:21:59 +0000 Subject: [PATCH 01/11] refactor(code-index): own exact branch publication --- .../src/code_index_scheduler.rs | 1 + .../branch_publication.rs | 612 +++++++++++ .../src/code_index_scheduler/tests.rs | 1 + .../tests/branch_publication_tests.rs | 45 + crates/tracedecay/src/daemon/branch_add.rs | 960 ++---------------- .../src/daemon/pr_autotrack/tests.rs | 19 +- .../src/daemon/production_harness.rs | 16 +- .../tracedecay/src/project_store_runtime.rs | 4 +- 8 files changed, 786 insertions(+), 872 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs index 7d59d5d921..867d2b6b29 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs @@ -8989,6 +8989,7 @@ mod tests; mod activation; pub mod branch_generations; +pub mod branch_publication; mod cadence; mod classification; mod freshness_witness; diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs new file mode 100644 index 0000000000..e50a4225b9 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs @@ -0,0 +1,612 @@ +//! Exact branch-generation publication through the retained code-index owner. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::Instant; +use tracedecay_dashboard_api::code_index_freshness_api::{ + CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, +}; +use tracedecay_domain::ProjectId; +use tracedecay_domain::errors::TraceDecayError; +use tracedecay_runtime_core::branch::{ + BranchAddOutcome, BranchTrackingPreparation, PreparedBranchRollbackOutcome, +}; +use tracedecay_runtime_core::branch_meta::{ + BranchGraphSourceDraftV1, BranchGraphSourcePublicationV1, BranchGraphSourcePublishOutcomeV1, + BranchGraphSourceRollbackOutcomeV1, +}; + +use super::{ + CodeIndexPublishedGenerationV1, CodeIndexSchedulerRegistryV1, + ServingGenerationInstallationOutcomeV1, ServingGenerationRollbackOutcomeV1, +}; + +const CODE_INDEX_SCHEDULER_UNAVAILABLE: &str = "code_index_scheduler_unavailable"; +const CODE_INDEX_ACTIVATION_UNAVAILABLE: &str = "code_index_activation_unavailable"; +const CODE_INDEX_IDENTITY_MISMATCH: &str = "code_index_scheduler_identity_mismatch"; +const GIT_SNAPSHOT_UNAVAILABLE: &str = "git_snapshot_unavailable"; +const BRANCH_TRACKING_FAILED: &str = "branch_tracking_failed"; +const BRANCH_GENERATION_IDLE_TIMEOUT: Duration = Duration::from_secs(20); +const BRANCH_GENERATION_HARD_TIMEOUT: Duration = Duration::from_mins(30); + +/// Immutable project identity and layout required to publish branch metadata. +#[derive(Clone, Debug)] +pub struct BranchPublicationContextV1 { + project_id: ProjectId, + project_root: PathBuf, + data_root: PathBuf, +} + +impl BranchPublicationContextV1 { + pub fn new( + project_id: Option<&str>, + project_root: &Path, + data_root: &Path, + ) -> Result { + let project_id = project_id.ok_or_else(|| { + TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + "branch graph publication requires an authoritative project identity", + ) + })?; + let project_id = ProjectId::new(project_id.to_owned()).map_err(|error| { + TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "branch graph publication has an invalid project identity '{project_id}': {error}" + ), + ) + })?; + Ok(Self { + project_id, + project_root: project_root.to_path_buf(), + data_root: data_root.to_path_buf(), + }) + } + + /// Seal and publish the exact generation currently mounted for a branch worktree. + #[hotpath::measure(label = "daemon.code_index.branch_publication.track", future = true)] + pub async fn track_exact_worktree_branch( + &self, + schedulers: &CodeIndexSchedulerRegistryV1, + project_root: &Path, + worktree_root: &Path, + branch: &str, + ) -> Result { + let canonical_project_root = project_root.canonicalize().map_err(|error| { + TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "failed to canonicalize branch project root '{}': {error}", + project_root.display() + ), + ) + })?; + if !self.owns_project(&canonical_project_root) { + return Err(TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "branch project root '{}' is not owned by the retained project graph", + canonical_project_root.display() + ), + )); + } + let canonical_worktree_root = worktree_root.canonicalize().map_err(|error| { + TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "failed to canonicalize branch worktree '{}': {error}", + worktree_root.display() + ), + ) + })?; + let source_branch = tracedecay_runtime_core::branch::current_branch( + &canonical_worktree_root, + ) + .ok_or_else(|| { + TraceDecayError::project_route( + GIT_SNAPSHOT_UNAVAILABLE, + false, + format!( + "branch graph publication requires an attached source branch for '{}'", + canonical_worktree_root.display() + ), + ) + })?; + let source = self + .capture_exact_branch_source( + schedulers, + &canonical_project_root, + &canonical_worktree_root, + &source_branch, + ) + .await?; + let prepared = match tracedecay_runtime_core::branch::prepare_branch_tracking_in_layout( + &canonical_worktree_root, + branch, + &self.data_root, + ) + .await + .map_err(|error| { + TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + false, + format!("failed to prepare branch tracking for '{branch}': {error}"), + ) + })? { + BranchTrackingPreparation::Added(prepared) => Some(prepared), + BranchTrackingPreparation::AlreadyTracked => None, + BranchTrackingPreparation::Deferred => return Ok(BranchAddOutcome::Deferred), + }; + let expected_source = tracedecay_runtime_core::branch_meta::load_branch_meta( + &self.data_root, + ) + .and_then(|meta| { + meta.branches + .get(branch) + .and_then(|entry| entry.graph_source.clone()) + }); + let generation = match self + .await_exact_branch_generation(schedulers, &canonical_worktree_root, &source) + .await + { + Ok(generation) => generation, + Err(error) => { + self.rollback_failed_branch_tracking(prepared.as_deref(), None, &error) + .await?; + return Err(error); + } + }; + let ServingGenerationInstallationOutcomeV1::Installed(installation) = schedulers + .install_exact_serving_generation(&canonical_worktree_root, &generation) + .await + else { + let error = TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "exact branch generation was replaced before publication for '{}'", + canonical_worktree_root.display() + ), + ); + self.rollback_failed_branch_tracking(prepared.as_deref(), None, &error) + .await?; + return Err(error); + }; + let publication = tracedecay_runtime_core::branch_meta::publish_graph_source( + &self.data_root, + branch, + expected_source.as_ref(), + source.clone(), + ) + .map_err(|error| { + TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + format!("failed to publish branch source for '{branch}': {error}"), + ) + }); + match publication { + Ok(BranchGraphSourcePublishOutcomeV1::Published(publication)) => { + match schedulers + .commit_serving_generation_installation(&canonical_worktree_root, installation) + .await + { + ServingGenerationRollbackOutcomeV1::Cleared => Ok(BranchAddOutcome::Added), + ServingGenerationRollbackOutcomeV1::NoMatch => { + let error = TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "serving generation changed while publishing branch '{branch}'" + ), + ); + self.rollback_failed_branch_tracking( + prepared.as_deref(), + Some(&publication), + &error, + ) + .await?; + Err(error) + } + } + } + Ok(BranchGraphSourcePublishOutcomeV1::AlreadyPublished(_)) => { + match schedulers + .commit_serving_generation_installation(&canonical_worktree_root, installation) + .await + { + ServingGenerationRollbackOutcomeV1::Cleared => { + Ok(BranchAddOutcome::AlreadyTracked) + } + ServingGenerationRollbackOutcomeV1::NoMatch => { + Err(TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "serving generation changed before exact branch replay completed for '{branch}'" + ), + )) + } + } + } + Ok(BranchGraphSourcePublishOutcomeV1::CompareAndSwapMiss { + observed: Some(observed), + }) if observed.matches_draft(&source) => { + match schedulers + .commit_serving_generation_installation(&canonical_worktree_root, installation) + .await + { + ServingGenerationRollbackOutcomeV1::Cleared => { + Ok(BranchAddOutcome::AlreadyTracked) + } + ServingGenerationRollbackOutcomeV1::NoMatch => { + Err(TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "serving generation changed before exact branch replay completed for '{branch}'" + ), + )) + } + } + } + Ok(outcome) => { + let error = TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + format!( + "branch source publication did not commit exact provenance for '{branch}': {outcome:?}" + ), + ); + let _ = schedulers + .commit_serving_generation_installation(&canonical_worktree_root, installation) + .await; + self.rollback_failed_branch_tracking(prepared.as_deref(), None, &error) + .await?; + Err(error) + } + Err(error) => { + let _ = schedulers + .commit_serving_generation_installation(&canonical_worktree_root, installation) + .await; + self.rollback_failed_branch_tracking(prepared.as_deref(), None, &error) + .await?; + Err(error) + } + } + } + + /// Capture the exact Git identity for a mounted branch worktree. + #[hotpath::measure( + label = "daemon.code_index.branch_publication.capture_source", + future = true + )] + pub async fn capture_exact_branch_source( + &self, + schedulers: &CodeIndexSchedulerRegistryV1, + canonical_project_root: &Path, + canonical_worktree_root: &Path, + branch: &str, + ) -> Result { + if !self.owns_project(canonical_project_root) { + return Err(TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "branch project root '{}' is not owned by the retained project graph", + canonical_project_root.display() + ), + )); + } + let scope = schedulers + .serving_code_scope(canonical_worktree_root) + .await + .ok_or_else(|| { + TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler authority is unavailable for branch worktree '{}' in project '{}'", + canonical_worktree_root.display(), + canonical_project_root.display() + ), + ) + })?; + if scope + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler is shutting down for branch worktree '{}'", + canonical_worktree_root.display() + ), + )); + } + let snapshot = crate::git_transactions::capture_exact_snapshot( + canonical_worktree_root, + self.project_id.clone(), + scope.repository_id.clone(), + scope.worktree_id.clone(), + tracedecay_contracts::now_micros(), + ) + .map_err(|error| { + TraceDecayError::project_route( + GIT_SNAPSHOT_UNAVAILABLE, + true, + format!( + "failed to capture exact Git snapshot for branch worktree '{}': {error}", + canonical_worktree_root.display() + ), + ) + })?; + if snapshot.project_id != self.project_id + || snapshot.repository_id != scope.repository_id + || snapshot.worktree_id.as_ref() != Some(&scope.worktree_id) + { + return Err(TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "exact Git snapshot does not match the mounted scheduler route for '{}'", + canonical_worktree_root.display() + ), + )); + } + let (snapshot_branch, source_oid) = match snapshot.head { + tracedecay_domain::GitHeadStateV1::Attached { branch, commit } => { + (branch, commit.as_str().to_owned()) + } + tracedecay_domain::GitHeadStateV1::Detached { .. } + | tracedecay_domain::GitHeadStateV1::Unborn { .. } => { + return Err(TraceDecayError::project_route( + GIT_SNAPSHOT_UNAVAILABLE, + true, + format!( + "branch graph publication requires an attached committed head for '{}'", + canonical_worktree_root.display() + ), + )); + } + }; + let expected_reference = format!("refs/heads/{branch}"); + if snapshot_branch != expected_reference { + return Err(TraceDecayError::project_route( + CODE_INDEX_IDENTITY_MISMATCH, + false, + format!( + "exact Git snapshot is attached to branch '{snapshot_branch}', not requested branch '{expected_reference}'" + ), + )); + } + Ok(BranchGraphSourceDraftV1 { + project_id: self.project_id.as_str().to_owned(), + repository_id: scope.repository_id.as_str().to_owned(), + worktree_id: scope.worktree_id.as_str().to_owned(), + worktree_root: canonical_worktree_root.to_string_lossy().into_owned(), + reference: snapshot_branch, + source_oid, + }) + } + + async fn await_exact_branch_generation( + &self, + schedulers: &CodeIndexSchedulerRegistryV1, + canonical_worktree_root: &Path, + source: &BranchGraphSourceDraftV1, + ) -> Result, TraceDecayError> { + let mut serving_changes = schedulers + .subscribe_serving_generation_changes(canonical_worktree_root) + .await + .ok_or_else(|| { + TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler is unavailable for branch worktree '{}'", + canonical_worktree_root.display() + ), + ) + })?; + if !schedulers + .notify_hook_overflow(canonical_worktree_root) + .await + { + return Err(TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler rejected refresh for branch worktree '{}'", + canonical_worktree_root.display() + ), + )); + } + let hard_deadline = Instant::now() + BRANCH_GENERATION_HARD_TIMEOUT; + let mut idle_deadline = Instant::now() + BRANCH_GENERATION_IDLE_TIMEOUT; + loop { + let scope = schedulers + .serving_code_scope(canonical_worktree_root) + .await + .ok_or_else(|| { + TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler disappeared for branch worktree '{}'", + canonical_worktree_root.display() + ), + ) + })?; + if scope + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(TraceDecayError::project_route( + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + format!( + "code-index scheduler is shutting down for branch worktree '{}'", + canonical_worktree_root.display() + ), + )); + } + if let Some(generation) = scope + .serving_generation + .filter(|generation| generation_matches_branch_source(generation, source)) + { + return Ok(generation); + } + let now = Instant::now(); + if now >= hard_deadline { + return Err(branch_generation_timeout_error( + canonical_worktree_root, + source, + )); + } + if schedulers + .dashboard_freshness(canonical_worktree_root) + .await + .as_ref() + .is_some_and(branch_generation_work_is_active) + { + idle_deadline = now + BRANCH_GENERATION_IDLE_TIMEOUT; + } else if now >= idle_deadline { + return Err(branch_generation_timeout_error( + canonical_worktree_root, + source, + )); + } + tokio::select! { + result = serving_changes.changed() => { + if result.is_err() { + return Err(TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "code-index serving owner closed for branch worktree '{}'", + canonical_worktree_root.display() + ), + )); + } + } + () = tokio::time::sleep_until(idle_deadline.min(hard_deadline)) => {} + } + } + } + + async fn rollback_failed_branch_tracking( + &self, + prepared: Option<&tracedecay_runtime_core::branch::PreparedBranchTracking>, + publication: Option<&BranchGraphSourcePublicationV1>, + cause: &TraceDecayError, + ) -> Result<(), TraceDecayError> { + let publication_rolled_back = match publication { + Some(publication) => { + match tracedecay_runtime_core::branch_meta::rollback_graph_source_publication( + &self.data_root, + publication, + ) + .map_err(|error| { + TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + format!( + "branch publication failed: {cause}; source rollback failed: {error}" + ), + ) + })? { + BranchGraphSourceRollbackOutcomeV1::Restored => true, + BranchGraphSourceRollbackOutcomeV1::NoMatch => false, + } + } + None => true, + }; + if !publication_rolled_back { + return Ok(()); + } + if let Some(prepared) = prepared { + match tracedecay_runtime_core::branch::rollback_prepared_branch_tracking( + &self.data_root, + prepared, + ) + .map_err(|error| { + TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + format!("branch publication failed: {cause}; branch rollback failed: {error}"), + ) + })? { + PreparedBranchRollbackOutcome::RolledBack + | PreparedBranchRollbackOutcome::NoMatch => {} + } + } + Ok(()) + } + + fn owns_project(&self, canonical_root: &Path) -> bool { + self.project_root == canonical_root + || self + .project_root + .canonicalize() + .ok() + .is_some_and(|root| root == canonical_root) + } +} + +pub(super) fn branch_generation_work_is_active(freshness: &CodeIndexWorktreeFreshnessV1) -> bool { + freshness.rebuild_in_flight + || matches!( + freshness.code_graph_serving, + Some(CodeGraphServingReadinessV1::Pending) + ) +} + +fn branch_generation_timeout_error( + canonical_worktree_root: &Path, + source: &BranchGraphSourceDraftV1, +) -> TraceDecayError { + TraceDecayError::project_route( + CODE_INDEX_ACTIVATION_UNAVAILABLE, + true, + format!( + "code-index scheduler did not publish exact branch source '{}' at '{}' for '{}'", + source.reference, + source.source_oid, + canonical_worktree_root.display() + ), + ) +} + +fn generation_matches_branch_source( + generation: &CodeIndexPublishedGenerationV1, + source: &BranchGraphSourceDraftV1, +) -> bool { + let snapshot = generation.snapshot(); + generation.manifest().project_id.as_str() == source.project_id + && snapshot.repository.as_str() == source.repository_id + && snapshot + .worktree + .as_ref() + .map(tracedecay_domain::WorktreeId::as_str) + == Some(source.worktree_id.as_str()) + && snapshot + .reference + .as_ref() + .map(tracedecay_domain::RefId::as_str) + == Some(source.reference.as_str()) + && snapshot + .source_revision + .as_ref() + .map(tracedecay_domain::CommitId::as_str) + == Some(source.source_oid.as_str()) +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs index 4f29aa94f2..9a2c5fc837 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests.rs @@ -92,6 +92,7 @@ use tracedecay_runtime_core::resident_memory::{ #[global_allocator] static HOTPATH_ALLOCATOR: hotpath::CountingAllocator = hotpath::CountingAllocator::new(); +mod branch_publication_tests; mod noop_reconcile_tests; mod search_permit_release; mod semantic_schedule_order_tests; diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs new file mode 100644 index 0000000000..fb315cc153 --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs @@ -0,0 +1,45 @@ +use tempfile::TempDir; +use tracedecay_dashboard_api::code_index_freshness_api::{ + CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, +}; + +use super::super::branch_publication::{ + BranchPublicationContextV1, branch_generation_work_is_active, +}; + +#[test] +fn branch_publication_requires_authoritative_project_identity() { + let project = TempDir::new().expect("project root"); + let store = TempDir::new().expect("store root"); + + let error = BranchPublicationContextV1::new(None, project.path(), store.path()) + .expect_err("missing project identity must fail closed"); + + assert_eq!( + error.project_route_context(), + Some(( + "code_index_scheduler_identity_mismatch", + false, + "branch graph publication requires an authoritative project identity", + )) + ); +} + +#[test] +fn pending_graph_activation_keeps_exact_branch_wait_live() { + let pending = CodeIndexWorktreeFreshnessV1 { + rebuild_in_flight: false, + code_graph_serving: Some(CodeGraphServingReadinessV1::Pending), + ..CodeIndexWorktreeFreshnessV1::default() + }; + assert!(branch_generation_work_is_active(&pending)); + + let terminal = CodeIndexWorktreeFreshnessV1 { + rebuild_in_flight: false, + code_graph_serving: Some(CodeGraphServingReadinessV1::Refused { + reason: "fixture refusal".to_owned(), + }), + ..CodeIndexWorktreeFreshnessV1::default() + }; + assert!(!branch_generation_work_is_active(&terminal)); +} diff --git a/crates/tracedecay/src/daemon/branch_add.rs b/crates/tracedecay/src/daemon/branch_add.rs index d371bbb804..61c6f73c5b 100644 --- a/crates/tracedecay/src/daemon/branch_add.rs +++ b/crates/tracedecay/src/daemon/branch_add.rs @@ -1,31 +1,19 @@ -use tracedecay_domain::errors::TraceDecayError; -use tracedecay_mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; -use tracedecay_runtime_core::branch::BranchAddOutcome; - use std::path::Path; use std::sync::Arc; -use std::time::Duration; -use tokio::time::Instant; - -use super::{DaemonHandshake, StoreAdministration}; use tracedecay_code_index_runtime::code_index_scheduler::{ - CodeIndexSchedulerRegistryV1, ServingGenerationInstallationOutcomeV1, - ServingGenerationRollbackOutcomeV1, -}; -use tracedecay_dashboard_api::code_index_freshness_api::{ - CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, + CodeIndexSchedulerRegistryV1, branch_publication::BranchPublicationContextV1, }; +use tracedecay_domain::errors::TraceDecayError; +use tracedecay_mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; +use tracedecay_runtime_core::branch::BranchAddOutcome; + +use super::{DaemonHandshake, StoreAdministration}; const BRANCH_ADD_TOOL_NAME: &str = "tracedecay_admin_branch_add"; const CODE_INDEX_SCHEDULER_UNAVAILABLE: &str = "code_index_scheduler_unavailable"; const PROJECT_PATH_UNAVAILABLE: &str = "project_path_unavailable"; -const CODE_INDEX_ACTIVATION_UNAVAILABLE: &str = "code_index_activation_unavailable"; -const CODE_INDEX_IDENTITY_MISMATCH: &str = "code_index_scheduler_identity_mismatch"; -const GIT_SNAPSHOT_UNAVAILABLE: &str = "git_snapshot_unavailable"; const BRANCH_TRACKING_FAILED: &str = "branch_tracking_failed"; -const BRANCH_GENERATION_IDLE_TIMEOUT: Duration = Duration::from_secs(20); -const BRANCH_GENERATION_HARD_TIMEOUT: Duration = Duration::from_mins(30); pub(super) struct BranchAddRequest { pub(super) id: serde_json::Value, @@ -66,92 +54,73 @@ pub(super) async fn branch_add_response( handshake: &DaemonHandshake, request: &BranchAddRequest, ) -> JsonRpcResponse { - branch_add_response_inner(administration, schedulers, handshake, request).await -} - -fn branch_add_response_inner<'a>( - administration: &'a StoreAdministration, - schedulers: Option<&'a CodeIndexSchedulerRegistryV1>, - handshake: &'a DaemonHandshake, - request: &'a BranchAddRequest, -) -> std::pin::Pin + Send + 'a>> { - // Erase the deeply nested future before it reaches the measured wrapper - // so every profiling feature can compute its layout. - Box::pin(async move { - let branch = match request.branch.as_deref() { - Ok(branch) => branch, - Err(message) => { - return JsonRpcResponse::error( - request.id.clone(), - ErrorCode::InvalidParams, - message.clone(), - ); - } - }; - - let Some(schedulers) = schedulers else { - return typed_project_route_error( + let branch = match request.branch.as_deref() { + Ok(branch) => branch, + Err(message) => { + return JsonRpcResponse::error( request.id.clone(), - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - "code-index scheduler authority is unavailable for branch activation", + ErrorCode::InvalidParams, + message.clone(), ); - }; - - let Some(project_root) = handshake.project_path.as_deref() else { - return typed_project_route_error( - request.id.clone(), - PROJECT_PATH_UNAVAILABLE, - false, - "branch add requires a project path", - ); - }; - let canonical_root = project_root - .canonicalize() - .unwrap_or_else(|_| project_root.to_path_buf()); - let mounted = administration.mounted_project_graphs().await; - let Some(graph) = mounted - .iter() - .find(|graph| graph_matches_project(graph, &canonical_root)) - .cloned() - else { - return typed_project_route_error( - request.id.clone(), - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - "retained branch-add graph is unavailable", - ); - }; + } + }; + let Some(schedulers) = schedulers else { + return typed_project_route_error( + request.id.clone(), + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + "code-index scheduler authority is unavailable for branch activation", + ); + }; + let Some(project_root) = handshake.project_path.as_deref() else { + return typed_project_route_error( + request.id.clone(), + PROJECT_PATH_UNAVAILABLE, + false, + "branch add requires a project path", + ); + }; + let canonical_root = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let mounted = administration.mounted_project_graphs().await; + let Some(graph) = mounted + .iter() + .find(|graph| graph_matches_project(graph, &canonical_root)) + .cloned() + else { + return typed_project_route_error( + request.id.clone(), + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + "retained branch-add graph is unavailable", + ); + }; - #[cfg(unix)] - { - match activate_and_track_manual_branch(&canonical_root, &graph, schedulers, branch) - .await - { - Ok(activation) => JsonRpcResponse::success( - request.id.clone(), - branch_add_tool_result(&activation), - ), - Err(error) => typed_tracking_error(request.id.clone(), &error), + #[cfg(unix)] + { + match activate_and_track_manual_branch(&canonical_root, &graph, schedulers, branch).await { + Ok(activation) => { + JsonRpcResponse::success(request.id.clone(), branch_add_tool_result(&activation)) } + Err(error) => typed_tracking_error(request.id.clone(), &error), } + } - #[cfg(not(unix))] - { - let _ = (administration, graph, branch); - typed_project_route_error( - request.id.clone(), - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - "code-index scheduler authority is unavailable for branch activation", - ) - } - }) + #[cfg(not(unix))] + { + let _ = (administration, graph, branch); + typed_project_route_error( + request.id.clone(), + CODE_INDEX_SCHEDULER_UNAVAILABLE, + true, + "code-index scheduler authority is unavailable for branch activation", + ) + } } /// Production branch-add journey: activate the requested linked worktree, -/// then seal the exact scheduler generation and its Git provenance into the -/// canonical project-store branch metadata. +/// then ask the code-index runtime to seal its exact generation and provenance. #[cfg(unix)] #[hotpath::measure(label = "daemon.branch_add.activate_and_track", future = true)] async fn activate_and_track_manual_branch( @@ -170,10 +139,8 @@ async fn activate_and_track_manual_branch( let schedulers = schedulers.clone(); let branch = branch.to_owned(); - // This operation owns the exact branch lifecycle lease through Git - // replacement, scheduler mount, metadata sealing, and rollback. A host - // request may be cancelled, but its bounded owner must finish before a - // retry can observe or replace this branch's artifacts. + // The spawned owner keeps the exact lifecycle lease after request + // cancellation so retries cannot observe a half-published branch. tokio::spawn(async move { activate_and_track_manual_branch_owned( project_root, @@ -205,741 +172,64 @@ async fn activate_and_track_manual_branch_owned( data_root: std::path::PathBuf, lifecycle: super::pr_autotrack::ManualBranchLifecycleLeaseV1, ) -> Result { - activate_and_track_manual_branch_owned_inner( - project_root, - graph, - schedulers, - branch, - data_root, - lifecycle, + let activation = super::pr_autotrack::activate_manual_branch_head_with_lifecycle( + &project_root, + &graph, + Some(&schedulers), + &branch, + &lifecycle, ) .await -} - -#[cfg(unix)] -fn activate_and_track_manual_branch_owned_inner( - project_root: std::path::PathBuf, - graph: Arc, - schedulers: CodeIndexSchedulerRegistryV1, - branch: String, - data_root: std::path::PathBuf, - lifecycle: super::pr_autotrack::ManualBranchLifecycleLeaseV1, -) -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + 'static, - >, -> { - // Erase the deeply nested future before it reaches the measured wrapper - // so every profiling feature can compute its layout. - Box::pin(async move { - let activation = super::pr_autotrack::activate_manual_branch_head_with_lifecycle( - &project_root, - &graph, - Some(&schedulers), - &branch, - &lifecycle, - ) - .await - .map_err(|error| { - TraceDecayError::project_route(error.reason_code(), error.retryable(), error.detail()) - })?; - let tracked = track_exact_worktree_branch_with_lifecycle( - &graph, - &schedulers, - &project_root, - &activation.worktree, - &branch, - &lifecycle, - ) - .await; - match tracked { - Ok(outcome) => Ok(outcome), - Err(error) if activation.outcome == BranchAddOutcome::Added => { - super::pr_autotrack::cleanup_manual_branch_activation( - &project_root, - &data_root, - &schedulers, - &activation, - &lifecycle, - ) - .await - .map_err(|cleanup| { - TraceDecayError::project_route( - cleanup.reason_code(), - cleanup.retryable(), - format!( - "branch sealing failed: {error}; exact activation cleanup failed: {cleanup}" - ), - ) - })?; - Err(error) - } - Err(error) => Err(error), - } - }) -} - -/// Seals the current, exact Git snapshot for one mounted branch worktree. -/// -/// This wrapper is the in-process production composition harness's entry to -/// the shared `track_exact_worktree_branch_with_lifecycle` authority. It -/// intentionally captures one Git snapshot, requests a scheduler refresh, -/// then requires exact repository/worktree/ref/OID equality before -/// publishing metadata. -#[cfg(any(test, feature = "test-transport"))] -pub(crate) async fn track_exact_worktree_branch( - graph: &Arc, - schedulers: &CodeIndexSchedulerRegistryV1, - project_root: &Path, - worktree_root: &Path, - branch: &str, -) -> Result { - let lifecycle = super::pr_autotrack::try_acquire_manual_branch_lifecycle( - &graph.store_layout().data_root, - branch, - ) .map_err(|error| { TraceDecayError::project_route(error.reason_code(), error.retryable(), error.detail()) })?; - track_exact_worktree_branch_with_lifecycle( - graph, - schedulers, - project_root, - worktree_root, - branch, - &lifecycle, - ) - .await -} - -#[hotpath::measure(label = "daemon.branch_add.track", future = true)] -async fn track_exact_worktree_branch_with_lifecycle( - graph: &Arc, - schedulers: &CodeIndexSchedulerRegistryV1, - project_root: &Path, - worktree_root: &Path, - branch: &str, - lifecycle: &super::pr_autotrack::ManualBranchLifecycleLeaseV1, -) -> Result { - track_exact_worktree_branch_with_lifecycle_inner( - graph, - schedulers, - project_root, - worktree_root, - branch, - lifecycle, - ) - .await -} - -fn track_exact_worktree_branch_with_lifecycle_inner<'a>( - graph: &'a Arc, - schedulers: &'a CodeIndexSchedulerRegistryV1, - project_root: &'a Path, - worktree_root: &'a Path, - branch: &'a str, - lifecycle: &'a super::pr_autotrack::ManualBranchLifecycleLeaseV1, -) -> std::pin::Pin< - Box> + Send + 'a>, -> { - // Erase the deeply nested future before it reaches the measured wrapper - // so every profiling feature can compute its layout. - Box::pin(async move { - if !lifecycle.matches_branch(branch) { - return Err(TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - "manual branch lifecycle lease does not match branch sealing request", - )); - } - let canonical_project_root = project_root.canonicalize().map_err(|error| { - TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!( - "failed to canonicalize branch project root '{}': {error}", - project_root.display() - ), - ) - })?; - if !graph_matches_project(graph, &canonical_project_root) { - return Err(TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!( - "branch project root '{}' is not owned by the retained project graph", - canonical_project_root.display() - ), - )); - } - let canonical_worktree_root = worktree_root.canonicalize().map_err(|error| { - TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!( - "failed to canonicalize branch worktree '{}': {error}", - worktree_root.display() - ), - ) - })?; - let source_branch = tracedecay_runtime_core::branch::current_branch( - &canonical_worktree_root, - ) - .ok_or_else(|| { - TraceDecayError::project_route( - GIT_SNAPSHOT_UNAVAILABLE, - false, - format!( - "branch graph publication requires an attached source branch for '{}'", - canonical_worktree_root.display() - ), - ) - })?; - let source = capture_exact_branch_source( - graph, - schedulers, - &canonical_project_root, - &canonical_worktree_root, - &source_branch, - ) - .await?; - let data_root = graph.store_layout().data_root.clone(); - let prepared = match tracedecay_runtime_core::branch::prepare_branch_tracking_in_layout( - &canonical_worktree_root, - branch, - &data_root, - ) - .await - .map_err(|error| { - TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - false, - format!("failed to prepare branch tracking for '{branch}': {error}"), - ) - })? { - tracedecay_runtime_core::branch::BranchTrackingPreparation::Added(prepared) => { - Some(prepared) - } - tracedecay_runtime_core::branch::BranchTrackingPreparation::AlreadyTracked => None, - tracedecay_runtime_core::branch::BranchTrackingPreparation::Deferred => { - return Ok(BranchAddOutcome::Deferred); - } - }; - let expected_source = tracedecay_runtime_core::branch_meta::load_branch_meta(&data_root) - .and_then(|meta| { - meta.branches - .get(branch) - .and_then(|entry| entry.graph_source.clone()) - }); - let generation = match await_exact_branch_generation( - schedulers, - &canonical_worktree_root, - &source, - ) - .await - { - Ok(generation) => generation, - Err(error) => { - rollback_failed_branch_tracking(&data_root, prepared.as_deref(), None, &error) - .await?; - return Err(error); - } - }; - let ServingGenerationInstallationOutcomeV1::Installed(installation) = schedulers - .install_exact_serving_generation(&canonical_worktree_root, &generation) - .await - else { - let error = TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "exact branch generation was replaced before publication for '{}'", - canonical_worktree_root.display() - ), - ); - rollback_failed_branch_tracking(&data_root, prepared.as_deref(), None, &error).await?; - return Err(error); - }; - let publication = tracedecay_runtime_core::branch_meta::publish_graph_source( - &data_root, - branch, - expected_source.as_ref(), - source.clone(), - ) - .map_err(|error| { - TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - format!("failed to publish branch source for '{branch}': {error}"), - ) - }); - match publication { - Ok(tracedecay_runtime_core::branch_meta::BranchGraphSourcePublishOutcomeV1::Published(publication)) => { - match schedulers - .commit_serving_generation_installation(&canonical_worktree_root, installation) - .await - { - ServingGenerationRollbackOutcomeV1::Cleared => Ok(BranchAddOutcome::Added), - ServingGenerationRollbackOutcomeV1::NoMatch => { - let error = TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "serving generation changed while publishing branch '{branch}'" - ), - ); - rollback_failed_branch_tracking( - &data_root, - prepared.as_deref(), - Some(&publication), - &error, - ) - .await?; - Err(error) - } - } - } - Ok(tracedecay_runtime_core::branch_meta::BranchGraphSourcePublishOutcomeV1::AlreadyPublished(_)) => { - match schedulers - .commit_serving_generation_installation(&canonical_worktree_root, installation) - .await - { - ServingGenerationRollbackOutcomeV1::Cleared => { - Ok(BranchAddOutcome::AlreadyTracked) - } - ServingGenerationRollbackOutcomeV1::NoMatch => { - Err(TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "serving generation changed before exact branch replay completed for '{branch}'" - ), - )) - } - } - } - Ok(tracedecay_runtime_core::branch_meta::BranchGraphSourcePublishOutcomeV1::CompareAndSwapMiss { - observed: Some(observed), - }) if observed.matches_draft(&source) => match schedulers - .commit_serving_generation_installation(&canonical_worktree_root, installation) - .await - { - ServingGenerationRollbackOutcomeV1::Cleared => Ok(BranchAddOutcome::AlreadyTracked), - ServingGenerationRollbackOutcomeV1::NoMatch => Err(TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "serving generation changed before exact branch replay completed for '{branch}'" - ), - )), - }, - Ok(outcome) => { - let error = TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - format!( - "branch source publication did not commit exact provenance for '{branch}': {outcome:?}" - ), - ); - let _ = schedulers - .commit_serving_generation_installation(&canonical_worktree_root, installation) - .await; - rollback_failed_branch_tracking(&data_root, prepared.as_deref(), None, &error) - .await?; - Err(error) - } - Err(error) => { - let _ = schedulers - .commit_serving_generation_installation(&canonical_worktree_root, installation) - .await; - rollback_failed_branch_tracking(&data_root, prepared.as_deref(), None, &error) - .await?; - Err(error) - } - } - }) -} - -#[hotpath::measure(label = "daemon.branch_add.capture_source", future = true)] -pub(crate) async fn capture_exact_branch_source( - graph: &Arc, - schedulers: &CodeIndexSchedulerRegistryV1, - canonical_project_root: &Path, - canonical_worktree_root: &Path, - branch: &str, -) -> Result { - capture_exact_branch_source_inner( - graph, - schedulers, - canonical_project_root, - canonical_worktree_root, - branch, - ) - .await -} - -#[allow(clippy::type_complexity)] -fn capture_exact_branch_source_inner<'a>( - graph: &'a Arc, - schedulers: &'a CodeIndexSchedulerRegistryV1, - canonical_project_root: &'a Path, - canonical_worktree_root: &'a Path, - branch: &'a str, -) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1, - TraceDecayError, - >, - > + Send - + 'a, - >, -> { - // Erase the deeply nested future before it reaches the measured wrapper - // so every profiling feature can compute its layout. - Box::pin(async move { - let project_id = graph - .store_layout() - .identity - .project_id - .as_deref() - .ok_or_else(|| { - TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - "branch graph publication requires an authoritative project identity", - ) - })?; - let scope = schedulers - .serving_code_scope(canonical_worktree_root) - .await - .ok_or_else(|| { - TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - format!( - "code-index scheduler authority is unavailable for branch worktree '{}' in project '{}'", - canonical_worktree_root.display(), - canonical_project_root.display() - ), - ) - })?; - if scope - .shutting_down - .load(std::sync::atomic::Ordering::Acquire) - { - return Err(TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - format!( - "code-index scheduler is shutting down for branch worktree '{}'", - canonical_worktree_root.display() - ), - )); - } - let project_identity = tracedecay_domain::ProjectId::new(project_id.to_owned()).map_err( - |error| { - TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!("branch graph publication has an invalid project identity '{project_id}': {error}"), - ) - }, - )?; - let snapshot = tracedecay_code_index_runtime::git_transactions::capture_exact_snapshot( - canonical_worktree_root, - project_identity.clone(), - scope.repository_id.clone(), - scope.worktree_id.clone(), - tracedecay_contracts::now_micros(), - ) - .map_err(|error| { - TraceDecayError::project_route( - GIT_SNAPSHOT_UNAVAILABLE, - true, - format!( - "failed to capture exact Git snapshot for branch worktree '{}': {error}", - canonical_worktree_root.display() - ), + if !lifecycle.matches_branch(&branch) { + return Err(TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + "manual branch lifecycle lease does not match branch sealing request", + )); + } + let publication = branch_publication_context(&graph)?; + let tracked = publication + .track_exact_worktree_branch(&schedulers, &project_root, &activation.worktree, &branch) + .await; + match tracked { + Ok(outcome) => Ok(outcome), + Err(error) if activation.outcome == BranchAddOutcome::Added => { + super::pr_autotrack::cleanup_manual_branch_activation( + &project_root, + &data_root, + &schedulers, + &activation, + &lifecycle, ) - })?; - if snapshot.project_id != project_identity - || snapshot.repository_id != scope.repository_id - || snapshot.worktree_id.as_ref() != Some(&scope.worktree_id) - { - return Err(TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!( - "exact Git snapshot does not match the mounted scheduler route for '{}'", - canonical_worktree_root.display() - ), - )); - } - let (snapshot_branch, source_oid) = match snapshot.head { - tracedecay_domain::GitHeadStateV1::Attached { branch, commit } => { - (branch, commit.as_str().to_owned()) - } - tracedecay_domain::GitHeadStateV1::Detached { .. } - | tracedecay_domain::GitHeadStateV1::Unborn { .. } => { - return Err(TraceDecayError::project_route( - GIT_SNAPSHOT_UNAVAILABLE, - true, - format!( - "branch graph publication requires an attached committed head for '{}'", - canonical_worktree_root.display() - ), - )); - } - }; - let expected_reference = format!("refs/heads/{branch}"); - if snapshot_branch != expected_reference { - return Err(TraceDecayError::project_route( - CODE_INDEX_IDENTITY_MISMATCH, - false, - format!( - "exact Git snapshot is attached to branch '{snapshot_branch}', not requested branch '{expected_reference}'" - ), - )); - } - Ok( - tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1 { - project_id: project_id.to_owned(), - repository_id: scope.repository_id.as_str().to_owned(), - worktree_id: scope.worktree_id.as_str().to_owned(), - worktree_root: canonical_worktree_root.to_string_lossy().into_owned(), - reference: snapshot_branch, - source_oid, - }, - ) - }) -} - -#[hotpath::measure(label = "daemon.branch_add.await_generation", future = true)] -pub(crate) async fn await_exact_branch_generation( - schedulers: &CodeIndexSchedulerRegistryV1, - canonical_worktree_root: &Path, - source: &tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1, -) -> Result, TraceDecayError> { - await_exact_branch_generation_inner(schedulers, canonical_worktree_root, source).await -} - -#[allow(clippy::type_complexity)] -fn await_exact_branch_generation_inner<'a>( - schedulers: &'a CodeIndexSchedulerRegistryV1, - canonical_worktree_root: &'a Path, - source: &'a tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1, -) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result< - Arc, - TraceDecayError, - >, - > + Send - + 'a, - >, -> { - // Erase the deeply nested future before it reaches the measured wrapper - // so every profiling feature can compute its layout. - Box::pin(async move { - let mut serving_changes = schedulers - .subscribe_serving_generation_changes(canonical_worktree_root) .await - .ok_or_else(|| { + .map_err(|cleanup| { TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, + cleanup.reason_code(), + cleanup.retryable(), format!( - "code-index scheduler is unavailable for branch worktree '{}'", - canonical_worktree_root.display() + "branch sealing failed: {error}; exact activation cleanup failed: {cleanup}" ), ) })?; - if !schedulers - .notify_hook_overflow(canonical_worktree_root) - .await - { - return Err(TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - format!( - "code-index scheduler rejected refresh for branch worktree '{}'", - canonical_worktree_root.display() - ), - )); + Err(error) } - let hard_deadline = Instant::now() + BRANCH_GENERATION_HARD_TIMEOUT; - let mut idle_deadline = Instant::now() + BRANCH_GENERATION_IDLE_TIMEOUT; - loop { - let scope = schedulers - .serving_code_scope(canonical_worktree_root) - .await - .ok_or_else(|| { - TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - format!( - "code-index scheduler disappeared for branch worktree '{}'", - canonical_worktree_root.display() - ), - ) - })?; - if scope - .shutting_down - .load(std::sync::atomic::Ordering::Acquire) - { - return Err(TraceDecayError::project_route( - CODE_INDEX_SCHEDULER_UNAVAILABLE, - true, - format!( - "code-index scheduler is shutting down for branch worktree '{}'", - canonical_worktree_root.display() - ), - )); - } - if let Some(generation) = scope - .serving_generation - .filter(|generation| generation_matches_branch_source(generation, source)) - { - return Ok(generation); - } - let now = Instant::now(); - if now >= hard_deadline { - return Err(branch_generation_timeout_error( - canonical_worktree_root, - source, - )); - } - if schedulers - .dashboard_freshness(canonical_worktree_root) - .await - .as_ref() - .is_some_and(branch_generation_work_is_active) - { - idle_deadline = now + BRANCH_GENERATION_IDLE_TIMEOUT; - } else if now >= idle_deadline { - return Err(branch_generation_timeout_error( - canonical_worktree_root, - source, - )); - } - tokio::select! { - result = serving_changes.changed() => { - if result.is_err() { - return Err(TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "code-index serving owner closed for branch worktree '{}'", - canonical_worktree_root.display() - ), - )); - } - } - () = tokio::time::sleep_until(idle_deadline.min(hard_deadline)) => {} - } - } - }) -} - -fn branch_generation_work_is_active(freshness: &CodeIndexWorktreeFreshnessV1) -> bool { - freshness.rebuild_in_flight - || matches!( - freshness.code_graph_serving, - Some(CodeGraphServingReadinessV1::Pending) - ) + Err(error) => Err(error), + } } -fn branch_generation_timeout_error( - canonical_worktree_root: &Path, - source: &tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1, -) -> TraceDecayError { - TraceDecayError::project_route( - CODE_INDEX_ACTIVATION_UNAVAILABLE, - true, - format!( - "code-index scheduler did not publish exact branch source '{}' at '{}' for '{}'", - source.reference, - source.source_oid, - canonical_worktree_root.display() - ), +pub(crate) fn branch_publication_context( + graph: &crate::tracedecay::TraceDecay, +) -> Result { + BranchPublicationContextV1::new( + graph.store_layout().identity.project_id.as_deref(), + graph.project_root(), + &graph.store_layout().data_root, ) } -fn generation_matches_branch_source( - generation: &crate::code_index::production::CodeIndexPublishedGenerationV1, - source: &tracedecay_runtime_core::branch_meta::BranchGraphSourceDraftV1, -) -> bool { - let snapshot = generation.snapshot(); - generation.manifest().project_id.as_str() == source.project_id - && snapshot.repository.as_str() == source.repository_id - && snapshot - .worktree - .as_ref() - .map(tracedecay_domain::WorktreeId::as_str) - == Some(source.worktree_id.as_str()) - && snapshot - .reference - .as_ref() - .map(tracedecay_domain::RefId::as_str) - == Some(source.reference.as_str()) - && snapshot - .source_revision - .as_ref() - .map(tracedecay_domain::CommitId::as_str) - == Some(source.source_oid.as_str()) -} - -#[hotpath::measure(label = "daemon.branch_add.rollback", future = true)] -async fn rollback_failed_branch_tracking( - data_root: &Path, - prepared: Option<&tracedecay_runtime_core::branch::PreparedBranchTracking>, - publication: Option<&tracedecay_runtime_core::branch_meta::BranchGraphSourcePublicationV1>, - cause: &TraceDecayError, -) -> Result<(), TraceDecayError> { - let publication_rolled_back = match publication { - Some(publication) => { - match tracedecay_runtime_core::branch_meta::rollback_graph_source_publication(data_root, publication) - .map_err(|error| { - TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - format!( - "branch publication failed: {cause}; source rollback failed: {error}" - ), - ) - })? { - tracedecay_runtime_core::branch_meta::BranchGraphSourceRollbackOutcomeV1::Restored => true, - tracedecay_runtime_core::branch_meta::BranchGraphSourceRollbackOutcomeV1::NoMatch => false, - } - } - None => true, - }; - if !publication_rolled_back { - return Ok(()); - } - if let Some(prepared) = prepared { - match tracedecay_runtime_core::branch::rollback_prepared_branch_tracking( - data_root, prepared, - ) - .map_err(|error| { - TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - format!("branch publication failed: {cause}; branch rollback failed: {error}"), - ) - })? { - tracedecay_runtime_core::branch::PreparedBranchRollbackOutcome::RolledBack - | tracedecay_runtime_core::branch::PreparedBranchRollbackOutcome::NoMatch => {} - } - } - Ok(()) -} - fn graph_matches_project( graph: &crate::tracedecay::TraceDecay, canonical_root: &std::path::Path, @@ -996,41 +286,3 @@ fn branch_add_outcome_name(outcome: &BranchAddOutcome) -> &'static str { BranchAddOutcome::Deferred => "deferred", } } - -#[cfg(test)] -mod wait_policy_tests { - use super::*; - - #[test] - fn pending_graph_activation_keeps_exact_branch_wait_live() { - let pending = CodeIndexWorktreeFreshnessV1 { - rebuild_in_flight: false, - code_graph_serving: Some(CodeGraphServingReadinessV1::Pending), - ..CodeIndexWorktreeFreshnessV1::default() - }; - assert!(branch_generation_work_is_active(&pending)); - - let terminal = CodeIndexWorktreeFreshnessV1 { - rebuild_in_flight: false, - code_graph_serving: Some(CodeGraphServingReadinessV1::Refused { - reason: "fixture refusal".to_owned(), - }), - ..CodeIndexWorktreeFreshnessV1::default() - }; - assert!(!branch_generation_work_is_active(&terminal)); - } - - #[test] - fn the_hard_deadline_bounds_every_seating_wake() { - let start = Instant::now(); - let hard = start + BRANCH_GENERATION_HARD_TIMEOUT; - - // A live pass keeps pushing the idle deadline out. The wake must sleep - // to whichever bound arrives first, or an extended idle deadline would - // outlive the hard bound the wait is supposed to fail closed on. - let idle = start + BRANCH_GENERATION_IDLE_TIMEOUT; - assert_eq!(idle.min(hard), idle); - let extended = start + BRANCH_GENERATION_HARD_TIMEOUT + BRANCH_GENERATION_IDLE_TIMEOUT; - assert_eq!(extended.min(hard), hard); - } -} diff --git a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs index eea22e6906..3dd0f06777 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs @@ -579,15 +579,16 @@ async fn manual_branch_activates_when_scheduler_is_injected() { )); let synthetic_branch = tracedecay_runtime_core::branch::current_branch(&activation.worktree) .expect("manual worktree has an attached synthetic branch"); - let source = crate::daemon::branch_add::capture_exact_branch_source( - &graph, - &schedulers, - repo.path(), - &activation.worktree, - &synthetic_branch, - ) - .await - .expect("synthetic branch source uses exact Git ref identity"); + let source = crate::daemon::branch_add::branch_publication_context(&graph) + .expect("branch publication context") + .capture_exact_branch_source( + &schedulers, + repo.path(), + &activation.worktree, + &synthetic_branch, + ) + .await + .expect("synthetic branch source uses exact Git ref identity"); assert_eq!( source.reference, "refs/heads/tracedecay/track/feature-manual" diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index 4203c558bc..c84c09491c 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -917,14 +917,14 @@ impl ProductionProjectCompositionHarnessV1 { .ok_or_else(|| TraceDecayError::Config { message: "production-composition harness is shut down".to_owned(), })?; - super::branch_add::track_exact_worktree_branch( - &graph, - &resources.invocation.code_index_schedulers, - &canonical_project_root, - worktree_root.as_ref(), - branch, - ) - .await + super::branch_add::branch_publication_context(&graph)? + .track_exact_worktree_branch( + &resources.invocation.code_index_schedulers, + &canonical_project_root, + worktree_root.as_ref(), + branch, + ) + .await } #[hotpath::measure(label = "daemon.harness.call_tool", future = true)] diff --git a/crates/tracedecay/src/project_store_runtime.rs b/crates/tracedecay/src/project_store_runtime.rs index 4d423f7633..c7983c6d56 100644 --- a/crates/tracedecay/src/project_store_runtime.rs +++ b/crates/tracedecay/src/project_store_runtime.rs @@ -45,7 +45,9 @@ pub(crate) async fn open_project_store_runtime( identity: LocalProfileIdentityAuthorityV1, ) -> Result> { crate::register_runtime_ports()?; - Ok(Arc::new(DaemonSessionRuntimeRegistryV1::open(identity).await?)) + Ok(Arc::new( + DaemonSessionRuntimeRegistryV1::open(identity).await?, + )) } impl crate::tracedecay::TraceDecay { From 5cfe98a04abb7bd1ce1cc8038de08d813b75764a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 23:05:07 +0000 Subject: [PATCH 02/11] refactor(application): own PR discovery and state --- crates/tracedecay-application/src/lib.rs | 1 + .../tracedecay-application/src/pr_tracking.rs | 497 ++++++++++++++++++ .../tests/pr_tracking.rs | 24 + crates/tracedecay-cli/Cargo.toml | 4 +- crates/tracedecay-cli/src/commands/branch.rs | 2 +- crates/tracedecay/src/config.rs | 2 +- crates/tracedecay/src/daemon/pr_autotrack.rs | 467 +--------------- .../src/daemon/pr_autotrack/runtime.rs | 10 +- .../src/daemon/pr_autotrack/tests.rs | 195 +------ .../tests/daemon_suite/pr_autotrack_test.rs | 11 +- 10 files changed, 552 insertions(+), 661 deletions(-) create mode 100644 crates/tracedecay-application/src/pr_tracking.rs create mode 100644 crates/tracedecay-application/tests/pr_tracking.rs diff --git a/crates/tracedecay-application/src/lib.rs b/crates/tracedecay-application/src/lib.rs index 0aaba3dfa5..d7700f0dde 100644 --- a/crates/tracedecay-application/src/lib.rs +++ b/crates/tracedecay-application/src/lib.rs @@ -82,6 +82,7 @@ pub mod native_integration; pub mod observability; pub mod observation; pub mod operation_stream; +pub mod pr_tracking; pub mod primitives; pub mod semantic_runtime; pub mod settings_control; diff --git a/crates/tracedecay-application/src/pr_tracking.rs b/crates/tracedecay-application/src/pr_tracking.rs new file mode 100644 index 0000000000..bd438c1da5 --- /dev/null +++ b/crates/tracedecay-application/src/pr_tracking.rs @@ -0,0 +1,497 @@ +//! Git-backed pull-request discovery and durable managed-PR state. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tracedecay_runtime_core::cancellation::CancellationToken; +use tracedecay_runtime_core::git::{GitCommandBounds, GitCommandError}; + +const STATE_FILENAME: &str = "pr-autotrack.json"; +const PR_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const PR_COMMAND_STDOUT_LIMIT: usize = 8 * 1024 * 1024; +const PR_COMMAND_STDERR_LIMIT: usize = 64 * 1024; +const GH_PR_LIST_LIMIT: usize = 1_000; + +/// Bounded command control shared by PR discovery and managed worktree changes. +#[derive(Clone, Debug)] +pub struct PrCommandControlV1 { + cancellation: Option, + command_timeout: Duration, + max_stdout_bytes: usize, + max_stderr_bytes: usize, +} + +impl PrCommandControlV1 { + pub fn with_cancellation(cancellation: CancellationToken) -> Self { + Self { + cancellation: Some(cancellation), + ..Self::default() + } + } + + #[cfg(any(test, feature = "test-helpers"))] + pub fn with_timeout(command_timeout: Duration) -> Self { + Self { + command_timeout, + ..Self::default() + } + } + + #[cfg(test)] + fn with_stdout_limit(max_stdout_bytes: usize) -> Self { + Self { + max_stdout_bytes, + ..Self::default() + } + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + } +} + +impl Default for PrCommandControlV1 { + fn default() -> Self { + Self { + cancellation: None, + command_timeout: PR_COMMAND_TIMEOUT, + max_stdout_bytes: PR_COMMAND_STDOUT_LIMIT, + max_stderr_bytes: PR_COMMAND_STDERR_LIMIT, + } + } +} + +/// A same-repository PR head discovered on the origin remote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredPr { + pub number: u64, + pub head_branch: String, + pub head_sha: String, +} + +/// One complete or explicitly partial discovery pass. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrDiscovery { + pub open: Vec, + pub skipped_forks: Vec, + /// A partial discovery suppresses removals in the reconciliation owner. + pub partial: bool, +} + +/// A currently managed PR branch persisted in the project store. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedPr { + pub pr: u64, + pub head_branch: String, + #[serde(default)] + pub head_sha: String, + pub worktree: PathBuf, + pub tracking_ref: String, +} + +/// Durable managed-PR state keyed by collision-proof synthetic branch label. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PrAutotrackState { + #[serde(default)] + pub managed: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ManagedPrSummary { + pub branch: String, + pub pr: u64, + pub head_branch: String, +} + +pub fn pr_label(number: u64) -> String { + format!("tracedecay/autotrack/pr/{number}") +} + +pub fn pr_tracking_ref(number: u64) -> String { + format!("refs/tracedecay/pr/{number}") +} + +pub fn load_state(data_root: &Path) -> PrAutotrackState { + let Ok(content) = std::fs::read_to_string(state_path(data_root)) else { + return PrAutotrackState::default(); + }; + serde_json::from_str(&content).unwrap_or_default() +} + +pub fn save_state(data_root: &Path, state: &PrAutotrackState) -> std::io::Result<()> { + let path = state_path(data_root); + let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; + let temp = path.with_extension("json.tmp"); + tracedecay_runtime_core::storage::PrivateStoreIo::write_file_atomically( + &path, + &temp, + json.as_bytes(), + ) +} + +pub fn managed_summary(data_root: &Path) -> Vec { + let mut summaries = load_state(data_root) + .managed + .into_iter() + .map(|(branch, managed)| ManagedPrSummary { + branch, + pr: managed.pr, + head_branch: managed.head_branch, + }) + .collect::>(); + summaries.sort_by_key(|summary| summary.pr); + summaries +} + +fn state_path(data_root: &Path) -> PathBuf { + data_root.join(STATE_FILENAME) +} + +#[derive(Debug, Deserialize)] +struct GhPr { + number: u64, + #[serde(default, rename = "headRefName")] + head_ref_name: String, + #[serde(default, rename = "headRefOid")] + head_ref_oid: String, + #[serde(default)] + state: String, + #[serde(default, rename = "isCrossRepository")] + is_cross_repository: bool, +} + +pub fn run_git_with_control( + repo_root: &Path, + args: &[&str], + control: &PrCommandControlV1, +) -> Result { + let mut command = std::process::Command::new(tracedecay_runtime_core::git::try_git_program()?); + command.args(args).current_dir(repo_root); + disable_git_credential_prompt(&mut command); + tracedecay_runtime_core::git::bounded_command_output( + command, + None, + &GitCommandBounds { + deadline: Instant::now() + control.command_timeout, + cancel: control.cancellation.clone(), + max_stdout_bytes: control.max_stdout_bytes, + max_stderr_bytes: control.max_stderr_bytes, + }, + ) +} + +pub fn successful_git_with_control( + repo_root: &Path, + args: &[&str], + control: &PrCommandControlV1, +) -> Option { + run_git_with_control(repo_root, args, control) + .ok() + .filter(|output| output.status.success()) +} + +/// Discover open, same-repository PR heads without treating command failure as +/// an empty remote. +pub fn discover_open_prs(repo_root: &Path) -> Result { + discover_open_prs_with_control(repo_root, default_pr_command_control()) +} + +pub fn default_pr_command_control() -> &'static PrCommandControlV1 { + static CONTROL: OnceLock = OnceLock::new(); + CONTROL.get_or_init(PrCommandControlV1::default) +} + +#[hotpath::measure(label = "application.pr_tracking.discover")] +pub fn discover_open_prs_with_control( + repo_root: &Path, + control: &PrCommandControlV1, +) -> Result { + if origin_is_github(repo_root, control) + && gh_available(control) + && let Some(discovery) = discover_via_gh(repo_root, control) + { + return Ok(discovery); + } + discover_via_ls_remote(repo_root, control) +} + +fn parse_gh_pr_list(json: &str, limit: usize) -> serde_json::Result { + let prs: Vec = serde_json::from_str(json)?; + let mut discovery = PrDiscovery { + partial: limit > 0 && prs.len() >= limit, + ..PrDiscovery::default() + }; + for pr in prs { + if !pr.state.eq_ignore_ascii_case("open") { + continue; + } + if pr.is_cross_repository || pr.head_ref_name.is_empty() || pr.head_ref_oid.is_empty() { + discovery.skipped_forks.push(pr.number); + } else { + discovery.open.push(DiscoveredPr { + number: pr.number, + head_branch: pr.head_ref_name, + head_sha: pr.head_ref_oid, + }); + } + } + Ok(discovery) +} + +fn parse_ls_remote_heads(output: &str) -> HashMap { + output + .lines() + .filter_map(split_ls_remote_line) + .filter_map(|(sha, reference)| { + reference + .strip_prefix("refs/heads/") + .map(|branch| (sha.to_owned(), branch.to_owned())) + }) + .collect() +} + +fn parse_ls_remote_pull_heads(output: &str) -> Vec<(u64, String)> { + output + .lines() + .filter_map(split_ls_remote_line) + .filter_map(|(sha, reference)| { + reference + .strip_prefix("refs/pull/") + .and_then(|rest| rest.strip_suffix("/head")) + .and_then(|number| number.parse::().ok()) + .map(|number| (number, sha.to_owned())) + }) + .collect() +} + +fn split_ls_remote_line(line: &str) -> Option<(&str, &str)> { + let mut parts = line.split_whitespace(); + let sha = parts.next()?; + let reference = parts.next()?; + (!sha.is_empty() && !reference.is_empty()).then_some((sha, reference)) +} + +fn map_pull_heads_to_branches( + pull_heads: &[(u64, String)], + head_shas: &HashMap, +) -> PrDiscovery { + let mut discovery = PrDiscovery::default(); + for (number, sha) in pull_heads { + match head_shas.get(sha) { + Some(branch) => discovery.open.push(DiscoveredPr { + number: *number, + head_branch: branch.clone(), + head_sha: sha.clone(), + }), + None => discovery.skipped_forks.push(*number), + } + } + discovery.open.sort_by_key(|pr| pr.number); + discovery.skipped_forks.sort_unstable(); + discovery +} + +fn disable_git_credential_prompt(command: &mut std::process::Command) { + command + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "echo"); +} + +fn origin_is_github(repo_root: &Path, control: &PrCommandControlV1) -> bool { + static CACHE: OnceLock>> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(origins) = cache.lock() + && let Some(cached) = origins.get(repo_root) + { + return *cached; + } + let result = successful_git_with_control(repo_root, &["remote", "get-url", "origin"], control) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .is_some_and(|url| url.contains("github.com")); + if let Ok(mut origins) = cache.lock() { + origins.insert(repo_root.to_path_buf(), result); + } + result +} + +fn gh_available(control: &PrCommandControlV1) -> bool { + if control + .cancellation + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + return false; + } + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + let mut command = std::process::Command::new("gh"); + command.arg("--version"); + disable_git_credential_prompt(&mut command); + tracedecay_runtime_core::git::bounded_command_output( + command, + None, + &GitCommandBounds { + deadline: Instant::now() + control.command_timeout, + cancel: control.cancellation.clone(), + max_stdout_bytes: control.max_stdout_bytes, + max_stderr_bytes: control.max_stderr_bytes, + }, + ) + .is_ok_and(|output| output.status.success()) + }) +} + +#[hotpath::measure(label = "application.pr_tracking.discover_gh")] +fn discover_via_gh(repo_root: &Path, control: &PrCommandControlV1) -> Option { + let limit = GH_PR_LIST_LIMIT.to_string(); + let mut command = std::process::Command::new("gh"); + command + .args([ + "pr", + "list", + "--state", + "open", + "--limit", + &limit, + "--json", + "number,headRefName,headRefOid,state,isCrossRepository", + ]) + .current_dir(repo_root); + disable_git_credential_prompt(&mut command); + let output = tracedecay_runtime_core::git::bounded_command_output( + command, + None, + &GitCommandBounds { + deadline: Instant::now() + control.command_timeout, + cancel: control.cancellation.clone(), + max_stdout_bytes: control.max_stdout_bytes, + max_stderr_bytes: control.max_stderr_bytes, + }, + ) + .ok() + .filter(|output| output.status.success())?; + parse_gh_pr_list(&String::from_utf8(output.stdout).ok()?, GH_PR_LIST_LIMIT).ok() +} + +#[hotpath::measure(label = "application.pr_tracking.discover_ls_remote")] +fn discover_via_ls_remote( + repo_root: &Path, + control: &PrCommandControlV1, +) -> Result { + let pull_heads = successful_git_with_control( + repo_root, + &["ls-remote", "origin", "refs/pull/*/head"], + control, + ) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .ok_or_else(|| "git ls-remote of PR head refs failed".to_owned())?; + let head_shas = + successful_git_with_control(repo_root, &["ls-remote", "--heads", "origin"], control) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .ok_or_else(|| "git ls-remote of head refs failed".to_owned())?; + Ok(map_pull_heads_to_branches( + &parse_ls_remote_pull_heads(&pull_heads), + &parse_ls_remote_heads(&head_shas), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_commands_enforce_deadline_cancellation_and_output_limits() { + let root = tempfile::tempdir().expect("repository root"); + assert!(matches!( + run_git_with_control( + root.path(), + &["--version"], + &PrCommandControlV1::with_timeout(Duration::ZERO), + ), + Err(GitCommandError::DeadlineExceeded) + )); + + let cancellation = CancellationToken::new(); + cancellation.cancel(); + assert!(matches!( + run_git_with_control( + root.path(), + &["--version"], + &PrCommandControlV1::with_cancellation(cancellation), + ), + Err(GitCommandError::Cancelled) + )); + assert!(matches!( + run_git_with_control( + root.path(), + &["--version"], + &PrCommandControlV1::with_stdout_limit(1), + ), + Err(GitCommandError::OutputLimitExceeded { + stream: "stdout", + bound: 1 + }) + )); + } + + #[test] + fn gh_discovery_splits_same_repository_prs_from_forks() { + let discovery = parse_gh_pr_list( + r#"[ + {"number":1,"headRefName":"feature","headRefOid":"sha-1","state":"OPEN","isCrossRepository":false}, + {"number":2,"headRefName":"fork","headRefOid":"sha-2","state":"OPEN","isCrossRepository":true}, + {"number":3,"headRefName":"closed","headRefOid":"sha-3","state":"CLOSED","isCrossRepository":false} + ]"#, + 200, + ) + .expect("parse gh response"); + assert_eq!( + discovery.open, + vec![DiscoveredPr { + number: 1, + head_branch: "feature".to_owned(), + head_sha: "sha-1".to_owned(), + }] + ); + assert_eq!(discovery.skipped_forks, vec![2]); + assert!(!discovery.partial); + } + + #[test] + fn remote_ref_discovery_matches_same_repository_heads() { + let pull_heads = parse_ls_remote_pull_heads( + "sha-feature\trefs/pull/1/head\nsha-fork\trefs/pull/2/head\n", + ); + let heads = parse_ls_remote_heads("sha-feature\trefs/heads/feature\n"); + let discovery = map_pull_heads_to_branches(&pull_heads, &heads); + assert_eq!(discovery.open[0].number, 1); + assert_eq!(discovery.skipped_forks, vec![2]); + } + + #[test] + fn reaching_the_gh_limit_marks_discovery_partial() { + let json = r#"[ + {"number":1,"headRefName":"a","headRefOid":"s1","state":"OPEN","isCrossRepository":false}, + {"number":2,"headRefName":"b","headRefOid":"s2","state":"OPEN","isCrossRepository":false} + ]"#; + assert!(parse_gh_pr_list(json, 2).expect("partial list").partial); + assert!(!parse_gh_pr_list(json, 3).expect("complete list").partial); + } + + #[test] + fn legacy_state_without_head_sha_remains_refreshable() { + let store = tempfile::tempdir().expect("store root"); + std::fs::write( + state_path(store.path()), + r#"{"managed":{"pr/8":{"pr":8,"head_branch":"legacy","worktree":"pr-worktrees/pr-8","tracking_ref":"refs/tracedecay/pr/8"}}}"#, + ) + .expect("legacy state"); + + assert_eq!(load_state(store.path()).managed["pr/8"].head_sha, ""); + } +} diff --git a/crates/tracedecay-application/tests/pr_tracking.rs b/crates/tracedecay-application/tests/pr_tracking.rs new file mode 100644 index 0000000000..88876b593a --- /dev/null +++ b/crates/tracedecay-application/tests/pr_tracking.rs @@ -0,0 +1,24 @@ +use tracedecay_application::pr_tracking::{ + ManagedPr, PrAutotrackState, load_state, managed_summary, save_state, +}; + +#[test] +fn managed_pr_state_round_trips_through_application_owner() { + let store = tempfile::tempdir().expect("store root"); + let mut state = PrAutotrackState::default(); + state.managed.insert( + "tracedecay/autotrack/pr/7".to_owned(), + ManagedPr { + pr: 7, + head_branch: "feature-7".to_owned(), + head_sha: "sha-7".to_owned(), + worktree: store.path().join("pr-worktrees/pr-7"), + tracking_ref: "refs/tracedecay/pr/7".to_owned(), + }, + ); + + save_state(store.path(), &state).expect("persist managed PR state"); + + assert_eq!(load_state(store.path()).managed, state.managed); + assert_eq!(managed_summary(store.path())[0].pr, 7); +} diff --git a/crates/tracedecay-cli/Cargo.toml b/crates/tracedecay-cli/Cargo.toml index 50e9cba728..b370e7f098 100644 --- a/crates/tracedecay-cli/Cargo.toml +++ b/crates/tracedecay-cli/Cargo.toml @@ -167,6 +167,7 @@ tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7.19", features = ["codec"] } tracedecay = { path = "../tracedecay", version = "0.1.0-beta.37", default-features = false } tracedecay-agent-hosts = { path = "../tracedecay-agent-hosts", version = "0.1.0" } +tracedecay-application = { path = "../tracedecay-application", version = "0.1.0" } tracedecay-automation-runtime = { path = "../tracedecay-automation-runtime", version = "0.1.0" } tracedecay-api = { path = "../tracedecay-api", version = "0.1.0" } tracedecay-contracts = { path = "../tracedecay-contracts", version = "0.1.0" } @@ -217,9 +218,6 @@ tracedecay-semantic = { path = "../tracedecay-semantic", version = "0.1.0", feat tracedecay-semantic-contracts.workspace = true tracedecay-store-runtime = { path = "../tracedecay-store-runtime", version = "0.1.0" } tracedecay-session-temporal-store = { path = "../tracedecay-session-temporal-store", version = "0.1.0" } -# Dev-only: `tests/work_route_exposure_conformance.rs` still asserts against -# the usecases `operation_stream` authority; production code is off usecases. -tracedecay-application = { path = "../tracedecay-application", version = "0.1.0" } ureq = { version = "3", features = ["json"] } [target.'cfg(target_os = "linux")'.dev-dependencies] diff --git a/crates/tracedecay-cli/src/commands/branch.rs b/crates/tracedecay-cli/src/commands/branch.rs index 7581496476..555ebb5ad6 100644 --- a/crates/tracedecay-cli/src/commands/branch.rs +++ b/crates/tracedecay-cli/src/commands/branch.rs @@ -403,7 +403,7 @@ async fn handle_branch_autotrack_action( #[cfg(unix)] { let data_root = resolve_branch_data_root(&resolved.project_path).await?; - let managed = tracedecay::daemon::pr_autotrack::managed_summary(&data_root); + let managed = tracedecay_application::pr_tracking::managed_summary(&data_root); if managed.is_empty() { eprintln!("Tracked PR branches: none"); } else { diff --git a/crates/tracedecay/src/config.rs b/crates/tracedecay/src/config.rs index cd1939cffa..4015f57490 100644 --- a/crates/tracedecay/src/config.rs +++ b/crates/tracedecay/src/config.rs @@ -936,7 +936,7 @@ impl tracedecay_dashboard_api::DashboardPrAutoTrackReadPort for DaemonPrAutoTrac &self, store_root: &Path, ) -> Vec { - crate::daemon::pr_autotrack::managed_summary(store_root) + tracedecay_application::pr_tracking::managed_summary(store_root) .into_iter() .map( |entry| tracedecay_dashboard_api::DashboardPrAutoTrackEntryV1 { diff --git a/crates/tracedecay/src/daemon/pr_autotrack.rs b/crates/tracedecay/src/daemon/pr_autotrack.rs index 8f961d91a6..c9ceb128b6 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack.rs @@ -1,10 +1,11 @@ -//! Daemon PR-branch auto-tracking (opt-in via `sync.auto_track_pr_branches`). +//! Daemon adaptation for PR-branch activation and reconciliation. //! -//! When a project enables `sync.auto_track_pr_branches`, a daemon poll loop -//! discovers the open pull requests on the repo's `origin` remote and activates -//! each same-repo PR head as a registered linked worktree through the daemon's -//! retained code-index scheduler. Manual `activate_manual_branch` uses that -//! same mount path for an operator-requested branch head. Public +//! [`tracedecay_application::pr_tracking`] owns Git discovery and managed state. +//! When a project enables `sync.auto_track_pr_branches`, this adapter activates +//! each discovered same-repository PR head as a registered linked worktree +//! through the daemon's retained code-index scheduler. Manual +//! `activate_manual_branch` uses that same mount path for an +//! operator-requested branch head. Public //! `reconcile_project` and the no-scheduler manual entry stay fail-closed: //! those APIs have no scheduler to inject. The poll runtime and the daemon //! branch-add handler receive that authority and still refuse Git or @@ -33,12 +34,17 @@ //! forks would mean fetching untrusted `refs/pull/N/head` from arbitrary //! repositories; that is deliberately out of scope. -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; -use serde::{Deserialize, Serialize}; +#[cfg(test)] +use tracedecay_application::pr_tracking::discover_open_prs; +use tracedecay_application::pr_tracking::{ + DiscoveredPr, ManagedPr, PrAutotrackState, PrCommandControlV1 as PrCommandControl, PrDiscovery, + default_pr_command_control, discover_open_prs_with_control, load_state, pr_label, + pr_tracking_ref, run_git_with_control, save_state, successful_git_with_control, +}; use tracedecay_domain::ProjectId; use tracedecay_domain::canonical_text::sha256_hex; @@ -301,446 +307,9 @@ impl<'a> PrStoreAdministration<'a> { } } -/// Filename of the PR-autotrack state sidecar, stored next to `branch-meta.json` -/// in the project's store data root. -const STATE_FILENAME: &str = "pr-autotrack.json"; /// Maximum number of *new* PR branches tracked per poll cycle, so a repo with /// 100 open PRs ramps up gradually instead of forking 100 syncs at once. const MAX_NEW_TRACKS_PER_CYCLE: usize = 10; -const PR_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); -const PR_COMMAND_STDOUT_LIMIT: usize = 8 * 1024 * 1024; -const PR_COMMAND_STDERR_LIMIT: usize = 64 * 1024; - -#[derive(Clone, Debug)] -struct PrCommandControl { - cancellation: Option, - command_timeout: Duration, - max_stdout_bytes: usize, - max_stderr_bytes: usize, -} - -impl Default for PrCommandControl { - fn default() -> Self { - Self { - cancellation: None, - command_timeout: PR_COMMAND_TIMEOUT, - max_stdout_bytes: PR_COMMAND_STDOUT_LIMIT, - max_stderr_bytes: PR_COMMAND_STDERR_LIMIT, - } - } -} - -fn default_pr_command_control() -> &'static PrCommandControl { - static CONTROL: std::sync::OnceLock = std::sync::OnceLock::new(); - CONTROL.get_or_init(PrCommandControl::default) -} - -/// A PR head discovered on the origin remote that we can track (same-repo). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiscoveredPr { - /// PR number. - pub number: u64, - /// The PR's head branch name (display only). - pub head_branch: String, - /// The exact remote head commit observed during discovery. - pub head_sha: String, -} - -/// The result of one discovery pass over a repo's `origin` remote. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PrDiscovery { - /// Open, same-repo PR heads that can be tracked. - pub open: Vec, - /// PR numbers skipped because their head lives on a fork. - pub skipped_forks: Vec, - /// True when discovery may be *incomplete* — e.g. `gh pr list` returned - /// exactly its page limit, so PRs beyond it were not seen. Reconciliation - /// suppresses removals against a partial discovery so a still-open PR that - /// merely fell outside the listing window is never mistaken for closed and - /// untracked. A failed discovery command is a different case: it never - /// produces a `PrDiscovery` at all (see [`discover_open_prs`]). - pub partial: bool, -} - -/// A currently-managed PR branch, persisted in the state sidecar. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ManagedPr { - /// PR number. - pub pr: u64, - /// The PR's head branch name (display only). - pub head_branch: String, - /// Last remote head commit successfully indexed. - #[serde(default)] - pub head_sha: String, - /// Path to the linked worktree on the owned synthetic branch. - pub worktree: PathBuf, - /// The deterministic local ref the PR head was fetched into. - pub tracking_ref: String, -} - -/// PR-autotrack persistent state: internal branch label → managed entry. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PrAutotrackState { - /// Managed PR branches keyed by their internal synthetic branch label. - #[serde(default)] - pub managed: BTreeMap, -} - -/// The collision-proof internal tracking label for a PR. -fn pr_label(number: u64) -> String { - format!("tracedecay/autotrack/pr/{number}") -} - -/// The deterministic local ref a PR head is fetched into. -fn pr_tracking_ref(number: u64) -> String { - format!("refs/tracedecay/pr/{number}") -} - -fn state_path(data_root: &Path) -> PathBuf { - data_root.join(STATE_FILENAME) -} - -/// Loads the PR-autotrack state, returning an empty state when absent/corrupt. -pub fn load_state(data_root: &Path) -> PrAutotrackState { - let path = state_path(data_root); - let Ok(content) = std::fs::read_to_string(&path) else { - return PrAutotrackState::default(); - }; - serde_json::from_str(&content).unwrap_or_default() -} - -fn save_state(data_root: &Path, state: &PrAutotrackState) -> std::io::Result<()> { - let path = state_path(data_root); - let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; - let temp = path.with_extension("json.tmp"); - tracedecay_runtime_core::storage::PrivateStoreIo::write_file_atomically( - &path, - &temp, - json.as_bytes(), - ) -} - -/// A summary of managed PR branches for status surfaces (dashboard / CLI). -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ManagedPrSummary { - /// Internal synthetic branch label. - pub branch: String, - /// PR number. - pub pr: u64, - /// PR head branch name. - pub head_branch: String, -} - -/// Returns the managed PR branches (sorted by PR number) for a project's store. -pub fn managed_summary(data_root: &Path) -> Vec { - let state = load_state(data_root); - let mut out: Vec = state - .managed - .into_iter() - .map(|(branch, m)| ManagedPrSummary { - branch, - pr: m.pr, - head_branch: m.head_branch, - }) - .collect(); - out.sort_by_key(|s| s.pr); - out -} - -// --------------------------------------------------------------------------- -// Discovery (pure parsers + one impure orchestrator) -// --------------------------------------------------------------------------- - -/// One entry from `gh pr list --json number,headRefName,headRefOid,state,isCrossRepository`. -#[derive(Debug, Deserialize)] -struct GhPr { - number: u64, - #[serde(default, rename = "headRefName")] - head_ref_name: String, - #[serde(default, rename = "headRefOid")] - head_ref_oid: String, - #[serde(default)] - state: String, - #[serde(default, rename = "isCrossRepository")] - is_cross_repository: bool, -} - -/// Parses `gh pr list` JSON into a discovery result. Open same-repo PRs go to -/// `open`; open cross-repository PRs are recorded as skipped forks; non-open PRs -/// are ignored. -/// -/// `limit` is the `--limit` passed to `gh`: if the result count reaches it the -/// listing was truncated (there may be more open PRs), so the discovery is -/// flagged `partial` and reconciliation will not untrack anything this pass. -fn parse_gh_pr_list(json: &str, limit: usize) -> serde_json::Result { - let prs: Vec = serde_json::from_str(json)?; - let mut discovery = PrDiscovery { - partial: limit > 0 && prs.len() >= limit, - ..Default::default() - }; - for pr in prs { - if !pr.state.eq_ignore_ascii_case("open") { - continue; - } - if pr.is_cross_repository || pr.head_ref_name.is_empty() || pr.head_ref_oid.is_empty() { - discovery.skipped_forks.push(pr.number); - } else { - discovery.open.push(DiscoveredPr { - number: pr.number, - head_branch: pr.head_ref_name, - head_sha: pr.head_ref_oid, - }); - } - } - Ok(discovery) -} - -/// Parses `git ls-remote --heads origin` into a `sha → branch` map. -fn parse_ls_remote_heads(output: &str) -> HashMap { - let mut map = HashMap::new(); - for line in output.lines() { - let Some((sha, refname)) = split_ls_remote_line(line) else { - continue; - }; - if let Some(branch) = refname.strip_prefix("refs/heads/") { - map.insert(sha.to_string(), branch.to_string()); - } - } - map -} - -/// Parses `git ls-remote origin 'refs/pull/*/head'` into `(pr_number, sha)` -/// pairs, ignoring `refs/pull/*/merge` and malformed lines. -fn parse_ls_remote_pull_heads(output: &str) -> Vec<(u64, String)> { - let mut out = Vec::new(); - for line in output.lines() { - let Some((sha, refname)) = split_ls_remote_line(line) else { - continue; - }; - let Some(rest) = refname.strip_prefix("refs/pull/") else { - continue; - }; - let Some(num_str) = rest.strip_suffix("/head") else { - continue; - }; - if let Ok(number) = num_str.parse::() { - out.push((number, sha.to_string())); - } - } - out -} - -fn split_ls_remote_line(line: &str) -> Option<(&str, &str)> { - let mut parts = line.split_whitespace(); - let sha = parts.next()?; - let refname = parts.next()?; - if sha.is_empty() || refname.is_empty() { - return None; - } - Some((sha, refname)) -} - -/// Maps PR head SHAs to branch names via the origin's `refs/heads/*` SHA index. -/// A PR whose head SHA matches a head ref is a same-repo PR (tracked under -/// `head_branch`); one that matches nothing is treated as a fork and skipped. -fn map_pull_heads_to_branches( - pull_heads: &[(u64, String)], - head_shas: &HashMap, -) -> PrDiscovery { - let mut discovery = PrDiscovery::default(); - for (number, sha) in pull_heads { - match head_shas.get(sha) { - Some(branch) => discovery.open.push(DiscoveredPr { - number: *number, - head_branch: branch.clone(), - head_sha: sha.clone(), - }), - None => discovery.skipped_forks.push(*number), - } - } - discovery.open.sort_by_key(|d| d.number); - discovery.skipped_forks.sort_unstable(); - discovery -} - -#[hotpath::measure(label = "daemon.pr_autotrack.run_git")] -fn run_git_with_control( - repo_root: &Path, - args: &[&str], - control: &PrCommandControl, -) -> Result { - let mut command = std::process::Command::new(tracedecay_runtime_core::git::try_git_program()?); - command.args(args).current_dir(repo_root); - disable_git_credential_prompt(&mut command); - let bounds = tracedecay_runtime_core::git::GitCommandBounds { - deadline: std::time::Instant::now() + control.command_timeout, - cancel: control.cancellation.clone(), - max_stdout_bytes: control.max_stdout_bytes, - max_stderr_bytes: control.max_stderr_bytes, - }; - tracedecay_runtime_core::git::bounded_command_output(command, None, &bounds) -} - -fn successful_git_with_control( - repo_root: &Path, - args: &[&str], - control: &PrCommandControl, -) -> Option { - run_git_with_control(repo_root, args, control) - .ok() - .filter(|output| output.status.success()) -} - -/// Forbids interactive credential prompts on a spawned git/gh subprocess. The -/// daemon's single poll loop awaits each project sequentially, so one git -/// process blocking on `/dev/tty` for a password (uncached HTTPS credential, -/// passphrase-protected SSH key with no agent) would freeze PR-autotrack for -/// *every* registered project. Failing fast instead keeps the loop live; the -/// failure is then surfaced as a discovery error, never as "zero open PRs". -fn disable_git_credential_prompt(command: &mut std::process::Command) { - command - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_ASKPASS", "echo"); -} - -/// Whether a repo's `origin` remote points at GitHub. Memoized per repo root: -/// the remote URL is effectively constant for a checkout, so re-spawning -/// `git remote get-url origin` every poll cycle (once per project, every minute) -/// only re-decides a constant. A rare remote-URL change is picked up on the next -/// daemon restart. -fn origin_is_github(repo_root: &Path, control: &PrCommandControl) -> bool { - static CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - let cache = CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); - if let Ok(map) = cache.lock() - && let Some(&cached) = map.get(repo_root) - { - return cached; - } - let result = successful_git_with_control(repo_root, &["remote", "get-url", "origin"], control) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .is_some_and(|url| url.contains("github.com")); - if let Ok(mut map) = cache.lock() { - map.insert(repo_root.to_path_buf(), result); - } - result -} - -/// Upper bound on PRs fetched in one `gh pr list` call. Reaching it means the -/// listing was truncated, which flags the discovery `partial` (removals are then -/// suppressed) rather than silently dropping the tail as if those PRs closed. -const GH_PR_LIST_LIMIT: usize = 1000; - -/// Whether the `gh` CLI is installed and runnable. Memoized process-wide: the -/// answer is a property of the host binary, not of any repo, so probing it every -/// poll cycle (once per enabled project, every minute) only re-decides a -/// constant. The daemon restarts to pick up a newly installed `gh`. -fn gh_available(control: &PrCommandControl) -> bool { - if control - .cancellation - .as_ref() - .is_some_and(tracedecay_runtime_core::cancellation::CancellationToken::is_cancelled) - { - return false; - } - static AVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); - *AVAILABLE.get_or_init(|| { - let mut command = std::process::Command::new("gh"); - command.arg("--version"); - disable_git_credential_prompt(&mut command); - let bounds = tracedecay_runtime_core::git::GitCommandBounds { - deadline: std::time::Instant::now() + control.command_timeout, - cancel: control.cancellation.clone(), - max_stdout_bytes: control.max_stdout_bytes, - max_stderr_bytes: control.max_stderr_bytes, - }; - tracedecay_runtime_core::git::bounded_command_output(command, None, &bounds) - .is_ok_and(|output| output.status.success()) - }) -} - -/// Discovers open PR head branches on the repo's `origin` remote. -/// -/// Prefers `gh pr list` when `gh` is on PATH and `origin` is GitHub; otherwise -/// falls back to `git ls-remote` and SHA-matching. Same-repo PRs are returned in -/// `open`; fork PRs are recorded in `skipped_forks`. -/// -/// Returns `Err` when the underlying discovery command *fails* (auth failure, -/// network outage, expired credentials). A failed command is never collapsed -/// into an empty `PrDiscovery`: the caller must skip reconciliation entirely, so -/// a transient `gh`/`git` failure can never masquerade as "every PR closed" and -/// mass-untrack the managed set. An empty `Ok` result means the remote genuinely -/// has no open PRs. -pub fn discover_open_prs(repo_root: &Path) -> Result { - discover_open_prs_with_control(repo_root, default_pr_command_control()) -} - -#[hotpath::measure(label = "daemon.pr_autotrack.discover")] -fn discover_open_prs_with_control( - repo_root: &Path, - control: &PrCommandControl, -) -> Result { - if origin_is_github(repo_root, control) - && gh_available(control) - && let Some(discovery) = discover_via_gh(repo_root, control) - { - return Ok(discovery); - } - // GitHub discovery was inapplicable, unavailable, or failed. `ls-remote` - // propagates its own failure as `Err` rather than empty. - discover_via_ls_remote(repo_root, control) -} - -#[hotpath::measure(label = "daemon.pr_autotrack.discover_gh")] -fn discover_via_gh(repo_root: &Path, control: &PrCommandControl) -> Option { - let limit = GH_PR_LIST_LIMIT.to_string(); - let mut command = std::process::Command::new("gh"); - command - .args([ - "pr", - "list", - "--state", - "open", - "--limit", - &limit, - "--json", - "number,headRefName,headRefOid,state,isCrossRepository", - ]) - .current_dir(repo_root); - disable_git_credential_prompt(&mut command); - let bounds = tracedecay_runtime_core::git::GitCommandBounds { - deadline: std::time::Instant::now() + control.command_timeout, - cancel: control.cancellation.clone(), - max_stdout_bytes: control.max_stdout_bytes, - max_stderr_bytes: control.max_stderr_bytes, - }; - let output = tracedecay_runtime_core::git::bounded_command_output(command, None, &bounds) - .ok() - .filter(|output| output.status.success())?; - let json = String::from_utf8(output.stdout).ok()?; - parse_gh_pr_list(&json, GH_PR_LIST_LIMIT).ok() -} - -#[hotpath::measure(label = "daemon.pr_autotrack.discover_ls_remote")] -fn discover_via_ls_remote( - repo_root: &Path, - control: &PrCommandControl, -) -> Result { - let pull_out = successful_git_with_control( - repo_root, - &["ls-remote", "origin", "refs/pull/*/head"], - control, - ) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .ok_or_else(|| "git ls-remote of PR head refs failed".to_string())?; - let heads_out = - successful_git_with_control(repo_root, &["ls-remote", "--heads", "origin"], control) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .ok_or_else(|| "git ls-remote of head refs failed".to_string())?; - let pull_heads = parse_ls_remote_pull_heads(&pull_out); - let head_shas = parse_ls_remote_heads(&heads_out); - Ok(map_pull_heads_to_branches(&pull_heads, &head_shas)) -} // --------------------------------------------------------------------------- // Lifecycle reconciliation @@ -2484,11 +2053,7 @@ fn remove_worktree(repo_root: &Path, worktree: &Path, command_control: &PrComman command_control, ); let _ = successful_git_with_control(repo_root, &["worktree", "prune"], command_control); - if command_control - .cancellation - .as_ref() - .is_some_and(tracedecay_runtime_core::cancellation::CancellationToken::is_cancelled) - { + if command_control.is_cancelled() { return; } if worktree.exists() { diff --git a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs index 72dbaee6a4..97ba8aa48d 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs @@ -173,10 +173,7 @@ async fn poll_project( return; }; let data_root = graph.store_layout().data_root.clone(); - let command_control = PrCommandControl { - cancellation: Some(cancellation.clone()), - ..PrCommandControl::default() - }; + let command_control = PrCommandControl::with_cancellation(cancellation.clone()); let repo_for_discovery = repo_root.clone(); let discovery_control = command_control.clone(); let discovery = match tokio::task::spawn_blocking(move || { @@ -248,10 +245,7 @@ async fn teardown_disabled_project_with_administration( if load_state(&data_root).managed.is_empty() { return; } - let command_control = PrCommandControl { - cancellation: Some(cancellation.clone()), - ..PrCommandControl::default() - }; + let command_control = PrCommandControl::with_cancellation(cancellation.clone()); let report = reconcile_project_with_administration( repo_root, &data_root, diff --git a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs index 3dd0f06777..6fca6d762a 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use super::*; @@ -14,190 +15,6 @@ async fn spawned_loop_is_cancellable_and_joinable() { ); } -#[test] -fn pr_git_commands_enforce_deadline_cancellation_and_output_limits() { - let root = tempfile::tempdir().unwrap(); - let expired = PrCommandControl { - command_timeout: Duration::ZERO, - ..PrCommandControl::default() - }; - assert!(matches!( - run_git_with_control(root.path(), &["--version"], &expired), - Err(tracedecay_runtime_core::git::GitCommandError::DeadlineExceeded) - )); - - let cancellation = tracedecay_runtime_core::cancellation::CancellationToken::new(); - cancellation.cancel(); - let cancelled = PrCommandControl { - cancellation: Some(cancellation), - ..PrCommandControl::default() - }; - assert!(matches!( - run_git_with_control(root.path(), &["--version"], &cancelled), - Err(tracedecay_runtime_core::git::GitCommandError::Cancelled) - )); - - let limited = PrCommandControl { - max_stdout_bytes: 1, - ..PrCommandControl::default() - }; - assert!(matches!( - run_git_with_control(root.path(), &["--version"], &limited), - Err( - tracedecay_runtime_core::git::GitCommandError::OutputLimitExceeded { - stream: "stdout", - bound: 1 - } - ) - )); -} - -// ---- Pure discovery parsers ------------------------------------------------- - -#[test] -fn gh_pr_list_splits_open_same_repo_from_forks() { - let json = r#"[ - {"number": 1, "headRefName": "feature-a", "headRefOid": "sha-a", "state": "OPEN", "isCrossRepository": false}, - {"number": 2, "headRefName": "fork-branch", "headRefOid": "sha-fork", "state": "OPEN", "isCrossRepository": true}, - {"number": 3, "headRefName": "closed-branch", "headRefOid": "sha-closed", "state": "CLOSED", "isCrossRepository": false}, - {"number": 4, "headRefName": "feature-b", "headRefOid": "sha-b", "state": "OPEN", "isCrossRepository": false} - ]"#; - let discovery = parse_gh_pr_list(json, 200).unwrap(); - assert!( - !discovery.partial, - "four PRs under a 200 limit are complete" - ); - assert_eq!( - discovery.open, - vec![ - DiscoveredPr { - number: 1, - head_branch: "feature-a".to_string(), - head_sha: "sha-a".to_string(), - }, - DiscoveredPr { - number: 4, - head_branch: "feature-b".to_string(), - head_sha: "sha-b".to_string(), - }, - ] - ); - assert_eq!(discovery.skipped_forks, vec![2]); -} - -#[test] -fn ls_remote_heads_indexes_branch_shas() { - let output = "\ -deadbeef00000000000000000000000000000001\trefs/heads/main -deadbeef00000000000000000000000000000002\trefs/heads/feature-1 -cafebabe00000000000000000000000000000003\trefs/tags/v1 -"; - let map = parse_ls_remote_heads(output); - assert_eq!(map.len(), 2); - assert_eq!( - map.get("deadbeef00000000000000000000000000000002").unwrap(), - "feature-1" - ); - assert!(!map.contains_key("cafebabe00000000000000000000000000000003")); -} - -#[test] -fn ls_remote_pull_heads_parses_numbers_and_ignores_merge_refs() { - let output = "\ -deadbeef00000000000000000000000000000002\trefs/pull/1/head -feed000000000000000000000000000000000009\trefs/pull/1/merge -beadfeed00000000000000000000000000000007\trefs/pull/42/head -"; - let heads = parse_ls_remote_pull_heads(output); - assert_eq!( - heads, - vec![ - (1, "deadbeef00000000000000000000000000000002".to_string()), - (42, "beadfeed00000000000000000000000000000007".to_string()), - ] - ); -} - -#[test] -fn map_pull_heads_matches_same_repo_and_skips_forks() { - let pull_heads = vec![ - (1, "sha_feature".to_string()), - (2, "sha_fork_only".to_string()), - ]; - let mut head_shas = HashMap::new(); - head_shas.insert("sha_feature".to_string(), "feature-1".to_string()); - head_shas.insert("sha_main".to_string(), "main".to_string()); - - let discovery = map_pull_heads_to_branches(&pull_heads, &head_shas); - assert_eq!( - discovery.open, - vec![DiscoveredPr { - number: 1, - head_branch: "feature-1".to_string(), - head_sha: "sha_feature".to_string(), - }] - ); - assert_eq!(discovery.skipped_forks, vec![2]); -} - -#[test] -fn gh_pr_list_flags_partial_when_result_reaches_limit() { - let json = r#"[ - {"number": 1, "headRefName": "a", "headRefOid": "s1", "state": "OPEN", "isCrossRepository": false}, - {"number": 2, "headRefName": "b", "headRefOid": "s2", "state": "OPEN", "isCrossRepository": false} - ]"#; - // Two results at a limit of two: the listing was truncated → partial. - let truncated = parse_gh_pr_list(json, 2).unwrap(); - assert!( - truncated.partial, - "count == limit must be treated as possibly truncated" - ); - // Same results under a higher limit are complete. - let complete = parse_gh_pr_list(json, 5).unwrap(); - assert!(!complete.partial); -} - -// ---- State persistence ------------------------------------------------------ - -#[test] -fn state_round_trips_and_defaults_when_absent() { - let dir = tempfile::tempdir().unwrap(); - assert!(load_state(dir.path()).managed.is_empty()); - - let mut state = PrAutotrackState::default(); - state.managed.insert( - "tracedecay/autotrack/pr/7".to_string(), - ManagedPr { - pr: 7, - head_branch: "feature-7".to_string(), - head_sha: "sha-7".to_string(), - worktree: dir.path().join("pr-worktrees/pr-7"), - tracking_ref: "refs/tracedecay/pr/7".to_string(), - }, - ); - save_state(dir.path(), &state).unwrap(); - - let reloaded = load_state(dir.path()); - assert_eq!(reloaded.managed.len(), 1); - assert_eq!(reloaded.managed["tracedecay/autotrack/pr/7"].pr, 7); - - let summary = managed_summary(dir.path()); - assert_eq!(summary.len(), 1); - assert_eq!(summary[0].branch, "tracedecay/autotrack/pr/7"); - assert_eq!(summary[0].head_branch, "feature-7"); - - std::fs::write( - state_path(dir.path()), - r#"{"managed":{"pr/8":{"pr":8,"head_branch":"legacy","worktree":"pr-worktrees/pr-8","tracking_ref":"refs/tracedecay/pr/8"}}}"#, - ) - .unwrap(); - assert_eq!( - load_state(dir.path()).managed["pr/8"].head_sha, - "", - "legacy state without a head SHA must migrate as needing refresh" - ); -} - // ---- Reconcile: removal + idempotency (no index required) ------------------- #[tokio::test] @@ -1131,10 +948,7 @@ fn manual_artifact_cleanup_keeps_exact_refs_when_git_authority_is_unavailable() "the sealed ref retry begins after the linked worktree is absent" ); - let unavailable = PrCommandControl { - command_timeout: Duration::ZERO, - ..PrCommandControl::default() - }; + let unavailable = PrCommandControl::with_timeout(Duration::ZERO); let error = cleanup_owned_worktree( repo.path(), &artifacts.worktree, @@ -1256,10 +1070,7 @@ async fn cancelled_activation_keeps_its_lifecycle_owner_bounded_during_stalled_e let owner = tokio::spawn(async move { let lifecycle = try_acquire_manual_branch_lifecycle(&owner_data_root, &owner_branch) .expect("activation owner acquires the exact lifecycle"); - let control = PrCommandControl { - command_timeout: Duration::from_millis(300), - ..PrCommandControl::default() - }; + let control = PrCommandControl::with_timeout(Duration::from_millis(300)); let outcome = activate_manual_branch_with_administration( &owner_repo, &owner_data_root, diff --git a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs b/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs index ddff40059e..c87661e9ec 100644 --- a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs +++ b/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use crate::common::fixture::{GitFixture, RegisteredProject, TestProfile, git_run}; use tracedecay::daemon::pr_autotrack; use tracedecay::tracedecay::TraceDecay; +use tracedecay_application::pr_tracking; struct PrProject { repo: GitFixture, @@ -58,13 +59,13 @@ impl PrProject { git_run(&self.origin, args); } - fn discover(&self) -> pr_autotrack::PrDiscovery { - pr_autotrack::discover_open_prs(self.root()).expect("PR discovery succeeds") + fn discover(&self) -> pr_tracking::PrDiscovery { + pr_tracking::discover_open_prs(self.root()).expect("PR discovery succeeds") } async fn reconcile( &self, - discovery: &pr_autotrack::PrDiscovery, + discovery: &pr_tracking::PrDiscovery, cap: usize, ) -> pr_autotrack::ReconcileReport { pr_autotrack::reconcile_project( @@ -145,7 +146,7 @@ async fn reconciliation_without_scheduler_fails_before_git_or_state_mutation() { .1 .starts_with("code_index_scheduler_unavailable:") ); - assert!(pr_autotrack::managed_summary(fixture.data_root()).is_empty()); + assert!(pr_tracking::managed_summary(fixture.data_root()).is_empty()); assert!(!fixture.data_root().join("pr-worktrees").exists()); assert!( !fixture @@ -192,7 +193,7 @@ async fn failed_discovery_is_not_reported_as_an_empty_success() { let fixture = PrProject::enrolled_with_origin().await; fixture.git(&["remote", "set-url", "origin", "/definitely/not/a/repo.git"]); - let result = pr_autotrack::discover_open_prs(fixture.root()); + let result = pr_tracking::discover_open_prs(fixture.root()); assert!( result.is_err(), From 0db8610d59f98ae40e89e26b52242a9fca7f001c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 00:30:58 +0000 Subject: [PATCH 03/11] fix(daemon): serialize harness branch publication --- .../src/daemon/production_harness.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index c84c09491c..df6680eb58 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -917,6 +917,13 @@ impl ProductionProjectCompositionHarnessV1 { .ok_or_else(|| TraceDecayError::Config { message: "production-composition harness is shut down".to_owned(), })?; + let _lifecycle = super::pr_autotrack::try_acquire_manual_branch_lifecycle( + &graph.store_layout().data_root, + branch, + ) + .map_err(|error| { + TraceDecayError::project_route(error.reason_code(), error.retryable(), error.detail()) + })?; super::branch_add::branch_publication_context(&graph)? .track_exact_worktree_branch( &resources.invocation.code_index_schedulers, @@ -1192,6 +1199,7 @@ mod code_index_activation_test { use std::sync::Arc; use tempfile::TempDir; + use tracedecay_runtime_core::cancellation::CancellationToken; use super::*; @@ -1441,6 +1449,77 @@ mod code_index_activation_test { ); harness.shutdown().await; } + + #[tokio::test(flavor = "multi_thread")] + #[hotpath::skip] + async fn branch_publication_respects_lifecycle_contention_until_owner_cancellation() { + let isolation = TempDir::new().expect("production harness isolation"); + let project = isolation.path().join("project"); + std::fs::create_dir_all(&project).expect("project root"); + std::fs::write(project.join("lib.rs"), "pub fn indexed_symbol() {}\n") + .expect("project source"); + for arguments in [ + vec!["init", "-q", "-b", "main"], + vec!["add", "."], + vec![ + "-c", + "user.name=TraceDecay Test", + "-c", + "user.email=tracedecay@example.invalid", + "commit", + "-qm", + "seed project", + ], + ] { + let status = Command::new( + tracedecay_runtime_core::git::try_git_program() + .expect("absolute git executable should resolve"), + ) + .args(&arguments) + .current_dir(&project) + .status() + .expect("git fixture command"); + assert!(status.success(), "git {arguments:?}"); + } + + let harness = + ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("production harness"); + let data_root = harness + .project_data_root(&project) + .await + .expect("project data root"); + let cancellation = CancellationToken::new(); + let owner_cancellation = cancellation.clone(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let owner = tokio::spawn(async move { + let _lease = crate::daemon::pr_autotrack::try_acquire_manual_branch_lifecycle( + &data_root, "main", + ) + .expect("lifecycle owner"); + ready_tx.send(()).expect("publish owner readiness"); + owner_cancellation.cancelled().await; + }); + ready_rx.await.expect("lifecycle owner started"); + + let error = harness + .track_worktree_branch(&project, &project, "main") + .await + .expect_err("harness publication must not bypass the lifecycle owner"); + assert!( + error.to_string().contains("lifecycle is already active"), + "{error}" + ); + + cancellation.cancel(); + owner.await.expect("cancelled lifecycle owner"); + harness + .track_worktree_branch(&project, &project, "main") + .await + .expect("publication proceeds after lifecycle owner cancellation"); + harness.shutdown().await; + } } #[cfg(test)] From c4dada86f905d04efe313049894c521544e40d69 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 02:34:09 +0000 Subject: [PATCH 04/11] fix(lifecycle): fail closed on retained owner state --- .../tracedecay-application/src/pr_tracking.rs | 26 ++-- .../tests/pr_tracking.rs | 37 +++++- crates/tracedecay-cli/src/commands/branch.rs | 2 +- .../branch_publication.rs | 22 ++-- .../tests/branch_publication_tests.rs | 119 ++++++++++++++++++ .../src/settings_api.rs | 39 +++--- crates/tracedecay/src/config.rs | 25 ++-- crates/tracedecay/src/daemon/pr_autotrack.rs | 7 +- .../src/daemon/pr_autotrack/runtime.rs | 56 +++++++-- .../src/daemon/pr_autotrack/tests.rs | 92 ++++++++++---- .../tests/daemon_suite/pr_autotrack_test.rs | 6 +- 11 files changed, 346 insertions(+), 85 deletions(-) diff --git a/crates/tracedecay-application/src/pr_tracking.rs b/crates/tracedecay-application/src/pr_tracking.rs index bd438c1da5..6207c49ff0 100644 --- a/crates/tracedecay-application/src/pr_tracking.rs +++ b/crates/tracedecay-application/src/pr_tracking.rs @@ -6,6 +6,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; +use tracedecay_domain::errors::TraceDecayError; use tracedecay_runtime_core::cancellation::CancellationToken; use tracedecay_runtime_core::git::{GitCommandBounds, GitCommandError}; @@ -116,11 +117,15 @@ pub fn pr_tracking_ref(number: u64) -> String { format!("refs/tracedecay/pr/{number}") } -pub fn load_state(data_root: &Path) -> PrAutotrackState { - let Ok(content) = std::fs::read_to_string(state_path(data_root)) else { - return PrAutotrackState::default(); +pub fn load_state(data_root: &Path) -> std::result::Result { + let content = match std::fs::read_to_string(state_path(data_root)) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PrAutotrackState::default()); + } + Err(error) => return Err(error.into()), }; - serde_json::from_str(&content).unwrap_or_default() + Ok(serde_json::from_str(&content)?) } pub fn save_state(data_root: &Path, state: &PrAutotrackState) -> std::io::Result<()> { @@ -134,8 +139,10 @@ pub fn save_state(data_root: &Path, state: &PrAutotrackState) -> std::io::Result ) } -pub fn managed_summary(data_root: &Path) -> Vec { - let mut summaries = load_state(data_root) +pub fn managed_summary( + data_root: &Path, +) -> std::result::Result, TraceDecayError> { + let mut summaries = load_state(data_root)? .managed .into_iter() .map(|(branch, managed)| ManagedPrSummary { @@ -145,7 +152,7 @@ pub fn managed_summary(data_root: &Path) -> Vec { }) .collect::>(); summaries.sort_by_key(|summary| summary.pr); - summaries + Ok(summaries) } fn state_path(data_root: &Path) -> PathBuf { @@ -492,6 +499,9 @@ mod tests { ) .expect("legacy state"); - assert_eq!(load_state(store.path()).managed["pr/8"].head_sha, ""); + assert_eq!( + load_state(store.path()).expect("load legacy state").managed["pr/8"].head_sha, + "" + ); } } diff --git a/crates/tracedecay-application/tests/pr_tracking.rs b/crates/tracedecay-application/tests/pr_tracking.rs index 88876b593a..ea22ac133e 100644 --- a/crates/tracedecay-application/tests/pr_tracking.rs +++ b/crates/tracedecay-application/tests/pr_tracking.rs @@ -1,6 +1,7 @@ use tracedecay_application::pr_tracking::{ ManagedPr, PrAutotrackState, load_state, managed_summary, save_state, }; +use tracedecay_domain::errors::TraceDecayError; #[test] fn managed_pr_state_round_trips_through_application_owner() { @@ -19,6 +20,38 @@ fn managed_pr_state_round_trips_through_application_owner() { save_state(store.path(), &state).expect("persist managed PR state"); - assert_eq!(load_state(store.path()).managed, state.managed); - assert_eq!(managed_summary(store.path())[0].pr, 7); + assert_eq!( + load_state(store.path()) + .expect("load managed PR state") + .managed, + state.managed + ); + assert_eq!( + managed_summary(store.path()).expect("summarize managed PR state")[0].pr, + 7 + ); +} + +#[test] +fn malformed_managed_pr_state_is_a_typed_json_error() { + let store = tempfile::tempdir().expect("store root"); + std::fs::write(store.path().join("pr-autotrack.json"), "{not json") + .expect("write malformed state"); + + assert!(matches!( + load_state(store.path()), + Err(TraceDecayError::Json(_)) + )); +} + +#[test] +fn unreadable_managed_pr_state_is_a_typed_io_error() { + let store = tempfile::tempdir().expect("store root"); + std::fs::create_dir(store.path().join("pr-autotrack.json")) + .expect("create unreadable state path"); + + assert!(matches!( + load_state(store.path()), + Err(TraceDecayError::Io(_)) + )); } diff --git a/crates/tracedecay-cli/src/commands/branch.rs b/crates/tracedecay-cli/src/commands/branch.rs index 555ebb5ad6..d471827e6c 100644 --- a/crates/tracedecay-cli/src/commands/branch.rs +++ b/crates/tracedecay-cli/src/commands/branch.rs @@ -403,7 +403,7 @@ async fn handle_branch_autotrack_action( #[cfg(unix)] { let data_root = resolve_branch_data_root(&resolved.project_path).await?; - let managed = tracedecay_application::pr_tracking::managed_summary(&data_root); + let managed = tracedecay_application::pr_tracking::managed_summary(&data_root)?; if managed.is_empty() { eprintln!("Tracked PR branches: none"); } else { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs index e50a4225b9..8082e6b700 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs @@ -87,7 +87,7 @@ impl BranchPublicationContextV1 { ), ) })?; - if !self.owns_project(&canonical_project_root) { + if !self.owns_project(&canonical_project_root)? { return Err(TraceDecayError::project_route( CODE_INDEX_IDENTITY_MISMATCH, false, @@ -289,14 +289,14 @@ impl BranchPublicationContextV1 { label = "daemon.code_index.branch_publication.capture_source", future = true )] - pub async fn capture_exact_branch_source( + pub(super) async fn capture_exact_branch_source( &self, schedulers: &CodeIndexSchedulerRegistryV1, canonical_project_root: &Path, canonical_worktree_root: &Path, branch: &str, ) -> Result { - if !self.owns_project(canonical_project_root) { + if !self.owns_project(canonical_project_root)? { return Err(TraceDecayError::project_route( CODE_INDEX_IDENTITY_MISMATCH, false, @@ -553,13 +553,17 @@ impl BranchPublicationContextV1 { Ok(()) } - fn owns_project(&self, canonical_root: &Path) -> bool { - self.project_root == canonical_root - || self - .project_root + fn owns_project(&self, canonical_root: &Path) -> Result { + let retained_root = + self.project_root .canonicalize() - .ok() - .is_some_and(|root| root == canonical_root) + .map_err(|error| TraceDecayError::File { + message: format!( + "failed to canonicalize retained branch project root: {error}" + ), + path: self.project_root.display().to_string(), + })?; + Ok(retained_root == canonical_root) } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs index fb315cc153..65555aa2d0 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs @@ -1,3 +1,6 @@ +#[cfg(unix)] +use std::os::unix::fs::symlink; + use tempfile::TempDir; use tracedecay_dashboard_api::code_index_freshness_api::{ CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, @@ -6,6 +9,22 @@ use tracedecay_dashboard_api::code_index_freshness_api::{ use super::super::branch_publication::{ BranchPublicationContextV1, branch_generation_work_is_active, }; +use super::{ALPHA_LIB_V1, CodeIndexSchedulerRegistryV1, GitFixture, test_project_id}; + +async fn mounted_registry(fixture: &GitFixture, store: &TempDir) -> CodeIndexSchedulerRegistryV1 { + let registry = CodeIndexSchedulerRegistryV1::new(1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount worktree"); + super::wait_for_initial_generation(®istry, fixture.path()).await; + registry +} #[test] fn branch_publication_requires_authoritative_project_identity() { @@ -43,3 +62,103 @@ fn pending_graph_activation_keeps_exact_branch_wait_live() { }; assert!(!branch_generation_work_is_active(&terminal)); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exact_branch_source_uses_the_mounted_git_identity() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let project_id = test_project_id(); + let registry = mounted_registry(&fixture, &store).await; + let context = + BranchPublicationContextV1::new(Some(project_id.as_str()), fixture.path(), store.path()) + .expect("branch publication context"); + + let source = context + .capture_exact_branch_source(®istry, fixture.path(), fixture.path(), "main") + .await + .expect("capture exact branch source"); + + assert_eq!(source.project_id, project_id.as_str()); + assert_eq!(source.reference, "refs/heads/main"); + assert_eq!( + source.source_oid, + super::git_stdout(fixture.path(), &["rev-parse", "HEAD"]) + ); + registry.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_retained_project_root_is_a_typed_path_error() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let registry = mounted_registry(&fixture, &store).await; + let missing = store.path().join("missing-project"); + let context = + BranchPublicationContextV1::new(Some(test_project_id().as_str()), &missing, store.path()) + .expect("branch publication context"); + + let error = context + .capture_exact_branch_source(®istry, fixture.path(), fixture.path(), "main") + .await + .expect_err("missing retained root must fail as a path error"); + + assert!(matches!( + error, + tracedecay_domain::errors::TraceDecayError::File { path, .. } + if path == missing.display().to_string() + )); + registry.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn foreign_project_root_is_denied_before_snapshot_capture() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let foreign = GitFixture::new(&[("src/lib.rs", "pub fn foreign() {}\n")]); + let store = TempDir::new().expect("store root"); + let registry = mounted_registry(&fixture, &store).await; + let context = BranchPublicationContextV1::new( + Some(test_project_id().as_str()), + fixture.path(), + store.path(), + ) + .expect("branch publication context"); + + let error = context + .capture_exact_branch_source(®istry, foreign.path(), fixture.path(), "main") + .await + .expect_err("foreign project root must be denied"); + + assert_eq!( + error.project_route_context().map(|context| context.0), + Some("code_index_scheduler_identity_mismatch") + ); + registry.shutdown().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unreadable_retained_project_root_is_a_typed_path_error() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let registry = mounted_registry(&fixture, &store).await; + let unreadable = store.path().join("project-loop"); + symlink("project-loop", &unreadable).expect("create unreadable project root"); + let context = BranchPublicationContextV1::new( + Some(test_project_id().as_str()), + &unreadable, + store.path(), + ) + .expect("branch publication context"); + + let error = context + .capture_exact_branch_source(®istry, fixture.path(), fixture.path(), "main") + .await + .expect_err("unreadable retained root must fail as a path error"); + + assert!(matches!( + error, + tracedecay_domain::errors::TraceDecayError::File { path, .. } + if path == unreadable.display().to_string() + )); + registry.shutdown().await; +} diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index 64827af460..1be43ca504 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -311,7 +311,10 @@ pub struct DashboardPrAutoTrackEntryV1 { } pub trait DashboardPrAutoTrackReadPort: Send + Sync { - fn managed_summary(&self, store_root: &Path) -> Vec; + fn managed_summary( + &self, + store_root: &Path, + ) -> tracedecay_domain::errors::Result>; } static PR_AUTOTRACK_READ_PORT: OnceLock> = OnceLock::new(); @@ -610,7 +613,7 @@ async fn settings_envelope( configuration_revision_id: project_configuration.revision_id().as_str().to_owned(), config: project_editable_settings(&project_configuration), tracedecay_dir_gitignored: crate::config::is_in_gitignore(&state.project_root), - pr_autotrack: pr_autotrack_payload(state), + pr_autotrack: pr_autotrack_payload(state)?, }, user: user_settings_payload(&user, &worker_configuration), automation, @@ -732,21 +735,23 @@ fn automation_settings_payload( /// Lists the PR branches the daemon currently auto-tracks for this project, read /// from the store's PR-autotrack state sidecar. Empty on non-unix or when the /// feature has tracked nothing yet. -fn pr_autotrack_payload(state: &DashboardState) -> PrAutoTrackPayloadV1 { - let tracked = PR_AUTOTRACK_READ_PORT - .get() - .map(|port| { - port.managed_summary(&state.store_root) - .into_iter() - .map(|entry| PrAutoTrackEntryV1 { - branch: entry.branch, - pr: entry.pr, - head_branch: entry.head_branch, - }) - .collect() - }) - .unwrap_or_default(); - PrAutoTrackPayloadV1 { tracked } +fn pr_autotrack_payload( + state: &DashboardState, +) -> std::result::Result { + let tracked = match PR_AUTOTRACK_READ_PORT.get() { + Some(port) => port + .managed_summary(&state.store_root) + .map_err(|_| configuration_authority_unavailable_error())? + .into_iter() + .map(|entry| PrAutoTrackEntryV1 { + branch: entry.branch, + pr: entry.pr, + head_branch: entry.head_branch, + }) + .collect(), + None => Vec::new(), + }; + Ok(PrAutoTrackPayloadV1 { tracked }) } fn environment_payload() -> EnvironmentSettingsPayloadV1 { diff --git a/crates/tracedecay/src/config.rs b/crates/tracedecay/src/config.rs index 4015f57490..7b04c2e224 100644 --- a/crates/tracedecay/src/config.rs +++ b/crates/tracedecay/src/config.rs @@ -935,17 +935,20 @@ impl tracedecay_dashboard_api::DashboardPrAutoTrackReadPort for DaemonPrAutoTrac fn managed_summary( &self, store_root: &Path, - ) -> Vec { - tracedecay_application::pr_tracking::managed_summary(store_root) - .into_iter() - .map( - |entry| tracedecay_dashboard_api::DashboardPrAutoTrackEntryV1 { - branch: entry.branch, - pr: entry.pr, - head_branch: entry.head_branch, - }, - ) - .collect() + ) -> tracedecay_domain::errors::Result> + { + Ok( + tracedecay_application::pr_tracking::managed_summary(store_root)? + .into_iter() + .map( + |entry| tracedecay_dashboard_api::DashboardPrAutoTrackEntryV1 { + branch: entry.branch, + pr: entry.pr, + head_branch: entry.head_branch, + }, + ) + .collect(), + ) } } diff --git a/crates/tracedecay/src/daemon/pr_autotrack.rs b/crates/tracedecay/src/daemon/pr_autotrack.rs index c9ceb128b6..e65f1705bd 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack.rs @@ -47,6 +47,7 @@ use tracedecay_application::pr_tracking::{ }; use tracedecay_domain::ProjectId; use tracedecay_domain::canonical_text::sha256_hex; +use tracedecay_domain::errors::TraceDecayError; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; @@ -1373,8 +1374,8 @@ async fn reconcile_project_with_administration( discovery: &PrDiscovery, cap: usize, administration: PrStoreAdministration<'_>, -) -> ReconcileReport { - let mut state = load_state(data_root); +) -> std::result::Result { + let mut state = load_state(data_root)?; let mut report = ReconcileReport { skipped_forks: discovery.skipped_forks.clone(), ..Default::default() @@ -1556,7 +1557,7 @@ async fn reconcile_project_with_administration( .push(("".to_string(), reason.clone())); log_pr_skip(repo_root, None, None, &reason); } - report + Ok(report) } /// Fetches a PR head, checks it out into a linked worktree, and mounts that diff --git a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs index 97ba8aa48d..7a51bd0e8b 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::time::Instant; +use tracedecay_domain::errors::TraceDecayError; use tracedecay_runtime_core::cancellation::CancellationToken; use crate::daemon::branch_admin::StoreAdministration; @@ -28,6 +29,10 @@ pub struct PrAutotrackTask { } impl PrAutotrackTask { + pub(crate) fn cancellation(&self) -> CancellationToken { + self.cancellation.clone() + } + #[hotpath::skip] pub async fn shutdown(self) { self.cancellation.cancel(); @@ -207,15 +212,28 @@ async fn poll_project( Err(_) => return, }; - let report = reconcile_project_with_administration( + let report = match reconcile_project_with_administration( &repo_root, &data_root, &discovery, MAX_NEW_TRACKS_PER_CYCLE, PrStoreAdministration::with_control(schedulers, &graph, &command_control), ) - .await; - let managed = load_state(&data_root).managed.len(); + .await + { + Ok(report) => report, + Err(error) => { + log_state_error(&repo_root, "poll", &error); + return; + } + }; + let managed = match load_state(&data_root) { + Ok(state) => state.managed.len(), + Err(error) => { + log_state_error(&repo_root, "poll", &error); + return; + } + }; log_daemon_event( "pr_autotrack", &[ @@ -242,18 +260,30 @@ async fn teardown_disabled_project_with_administration( return; }; let data_root = graph.store_layout().data_root.clone(); - if load_state(&data_root).managed.is_empty() { - return; + match load_state(&data_root) { + Ok(state) if state.managed.is_empty() => return, + Ok(_) => {} + Err(error) => { + log_state_error(repo_root, "teardown", &error); + return; + } } let command_control = PrCommandControl::with_cancellation(cancellation.clone()); - let report = reconcile_project_with_administration( + let report = match reconcile_project_with_administration( repo_root, &data_root, &PrDiscovery::default(), MAX_NEW_TRACKS_PER_CYCLE, PrStoreAdministration::with_control(schedulers, &graph, &command_control), ) - .await; + .await + { + Ok(report) => report, + Err(error) => { + log_state_error(repo_root, "teardown", &error); + return; + } + }; log_daemon_event( "pr_autotrack", &[ @@ -263,3 +293,15 @@ async fn teardown_disabled_project_with_administration( ], ); } + +fn log_state_error(repo_root: &Path, action: &str, error: &TraceDecayError) { + log_daemon_event( + "pr_autotrack", + &[ + ("project", repo_root.display().to_string()), + ("action", action.to_owned()), + ("outcome", "error".to_owned()), + ("reason", error.to_string()), + ], + ); +} diff --git a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs index 6fca6d762a..3de7134444 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs @@ -71,7 +71,8 @@ async fn reconcile_preserves_closed_pr_when_scheduler_retirement_is_unavailable( 10, administration, ) - .await; + .await + .expect("load managed PR state"); assert!(report.untracked.is_empty()); assert!(report.tracked.is_empty()); @@ -81,12 +82,50 @@ async fn reconcile_preserves_closed_pr_when_scheduler_retirement_is_unavailable( .1 .starts_with("code_index_scheduler_unavailable:") ); - assert!(load_state(data_root.path()).managed.contains_key("pr/5")); + assert!( + load_state(data_root.path()) + .expect("load managed PR state") + .managed + .contains_key("pr/5") + ); let reloaded = load_branch_meta(data_root.path()).unwrap(); assert!(reloaded.is_tracked("pr/5")); assert!(data_root.path().join("branches/pr_5.db").exists()); } +#[tokio::test] +async fn reconcile_refuses_malformed_state_before_branch_mutation() { + let data_root = tempfile::tempdir().expect("data root"); + let repo_root = tempfile::tempdir().expect("repository root"); + std::fs::write(data_root.path().join("pr-autotrack.json"), "{not json") + .expect("write malformed state"); + let discovery = PrDiscovery { + open: vec![DiscoveredPr { + number: 9, + head_branch: "feature-9".to_owned(), + head_sha: "sha-9".to_owned(), + }], + ..PrDiscovery::default() + }; + let daemon_administration = StoreAdministration::default(); + + let error = reconcile_project_with_administration( + repo_root.path(), + data_root.path(), + &discovery, + 10, + PrStoreAdministration::state_only(&daemon_administration), + ) + .await + .expect_err("malformed durable state must fail closed"); + + assert!(matches!( + error, + tracedecay_domain::errors::TraceDecayError::Json(_) + )); + assert!(!data_root.path().join("pr-worktrees").exists()); +} + #[tokio::test] async fn reconcile_does_not_prepare_new_pr_without_scheduler_activation() { let data_root = tempfile::tempdir().unwrap(); @@ -108,7 +147,8 @@ async fn reconcile_does_not_prepare_new_pr_without_scheduler_activation() { 10, PrStoreAdministration::state_only(&daemon_administration), ) - .await; + .await + .expect("load managed PR state"); assert!(report.tracked.is_empty()); assert_eq!(report.failures.len(), 1); @@ -117,7 +157,12 @@ async fn reconcile_does_not_prepare_new_pr_without_scheduler_activation() { .1 .starts_with("code_index_scheduler_unavailable:") ); - assert!(load_state(data_root.path()).managed.is_empty()); + assert!( + load_state(data_root.path()) + .expect("load managed PR state") + .managed + .is_empty() + ); assert!(!data_root.path().join("pr-worktrees").exists()); } @@ -174,7 +219,8 @@ async fn reconcile_activates_discovered_pr_head_when_scheduler_is_injected() { 10, PrStoreAdministration::with_control(&schedulers, &graph, &command_control), ) - .await; + .await + .expect("load managed PR state"); assert_eq!(report.failures, Vec::<(String, String)>::new()); assert_eq!(report.tracked, vec![pr_label(11)]); @@ -184,7 +230,12 @@ async fn reconcile_activates_discovered_pr_head_when_scheduler_is_injected() { schedulers.is_worktree_mounted(&worktree).await, "scheduler must mount the registered PR worktree" ); - assert!(load_state(&data_root).managed.contains_key(&pr_label(11))); + assert!( + load_state(&data_root) + .expect("load managed PR state") + .managed + .contains_key(&pr_label(11)) + ); schedulers.shutdown().await; } @@ -256,13 +307,15 @@ async fn reconcile_is_idempotent_for_already_managed_pr() { 10, PrStoreAdministration::state_only(&daemon_administration), ) - .await; + .await + .expect("load managed PR state"); // Already managed and still open: nothing changes. assert!(report.tracked.is_empty()); assert!(report.untracked.is_empty()); assert!( load_state(data_root.path()) + .expect("load managed PR state") .managed .contains_key("tracedecay/autotrack/pr/3") ); @@ -308,7 +361,8 @@ async fn partial_discovery_suppresses_removals() { 10, PrStoreAdministration::state_only(&daemon_administration), ) - .await; + .await + .expect("load managed PR state"); assert!( report.removals_suppressed, @@ -316,7 +370,10 @@ async fn partial_discovery_suppresses_removals() { ); assert!(report.untracked.is_empty(), "no untrack on a partial view"); assert!( - load_state(data_root.path()).managed.contains_key("pr/5"), + load_state(data_root.path()) + .expect("load managed PR state") + .managed + .contains_key("pr/5"), "managed entry survives a partial discovery" ); assert!( @@ -394,23 +451,6 @@ async fn manual_branch_activates_when_scheduler_is_injected() { repo.path(), "refs/tracedecay/branch/feature-manual" )); - let synthetic_branch = tracedecay_runtime_core::branch::current_branch(&activation.worktree) - .expect("manual worktree has an attached synthetic branch"); - let source = crate::daemon::branch_add::branch_publication_context(&graph) - .expect("branch publication context") - .capture_exact_branch_source( - &schedulers, - repo.path(), - &activation.worktree, - &synthetic_branch, - ) - .await - .expect("synthetic branch source uses exact Git ref identity"); - assert_eq!( - source.reference, - "refs/heads/tracedecay/track/feature-manual" - ); - assert_eq!(source.source_oid, activation.head_sha); schedulers.shutdown().await; } diff --git a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs b/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs index c87661e9ec..9b265dcc2f 100644 --- a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs +++ b/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs @@ -146,7 +146,11 @@ async fn reconciliation_without_scheduler_fails_before_git_or_state_mutation() { .1 .starts_with("code_index_scheduler_unavailable:") ); - assert!(pr_tracking::managed_summary(fixture.data_root()).is_empty()); + assert!( + pr_tracking::managed_summary(fixture.data_root()) + .expect("read managed PR state") + .is_empty() + ); assert!(!fixture.data_root().join("pr-worktrees").exists()); assert!( !fixture From 29f7c7f5722475de77b29af87390ba30d7b8aa0c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 02:34:32 +0000 Subject: [PATCH 05/11] fix(daemon): own branch mutation shutdown --- crates/tracedecay/src/daemon/branch_add.rs | 28 +++--- crates/tracedecay/src/daemon/branch_admin.rs | 96 +++++++++++++++++++ .../tracedecay/src/daemon/engine/shutdown.rs | 34 +++++-- .../src/daemon/tests/scheduler_shutdown.rs | 89 +++++++++++++++++ 4 files changed, 226 insertions(+), 21 deletions(-) diff --git a/crates/tracedecay/src/daemon/branch_add.rs b/crates/tracedecay/src/daemon/branch_add.rs index 61c6f73c5b..d33815b34d 100644 --- a/crates/tracedecay/src/daemon/branch_add.rs +++ b/crates/tracedecay/src/daemon/branch_add.rs @@ -99,7 +99,15 @@ pub(super) async fn branch_add_response( #[cfg(unix)] { - match activate_and_track_manual_branch(&canonical_root, &graph, schedulers, branch).await { + match activate_and_track_manual_branch( + administration, + &canonical_root, + &graph, + schedulers, + branch, + ) + .await + { Ok(activation) => { JsonRpcResponse::success(request.id.clone(), branch_add_tool_result(&activation)) } @@ -124,6 +132,7 @@ pub(super) async fn branch_add_response( #[cfg(unix)] #[hotpath::measure(label = "daemon.branch_add.activate_and_track", future = true)] async fn activate_and_track_manual_branch( + administration: &StoreAdministration, project_root: &Path, graph: &Arc, schedulers: &CodeIndexSchedulerRegistryV1, @@ -139,27 +148,16 @@ async fn activate_and_track_manual_branch( let schedulers = schedulers.clone(); let branch = branch.to_owned(); - // The spawned owner keeps the exact lifecycle lease after request - // cancellation so retries cannot observe a half-published branch. - tokio::spawn(async move { - activate_and_track_manual_branch_owned( + administration + .run_manual_branch_publication(activate_and_track_manual_branch_owned( project_root, graph, schedulers, branch, data_root, lifecycle, - ) + )) .await - }) - .await - .map_err(|error| { - TraceDecayError::project_route( - BRANCH_TRACKING_FAILED, - true, - format!("manual branch lifecycle owner stopped before completion: {error}"), - ) - })? } #[cfg(unix)] diff --git a/crates/tracedecay/src/daemon/branch_admin.rs b/crates/tracedecay/src/daemon/branch_admin.rs index 45821edf2b..20220fd8bd 100644 --- a/crates/tracedecay/src/daemon/branch_admin.rs +++ b/crates/tracedecay/src/daemon/branch_admin.rs @@ -10,6 +10,8 @@ use serde_json::json; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport}; +#[cfg(unix)] +use tracedecay_runtime_core::branch::BranchAddOutcome; #[cfg(any(unix, test))] use super::ProjectServerKey; @@ -456,6 +458,8 @@ pub(super) struct StoreAdministration { #[cfg(unix)] automation_schedulers: Arc>>, + #[cfg(unix)] + manual_branch_publications: Arc, session_temporal_refresh_schedulers: Arc, git_index_transaction_services: Arc, native_integration_services: Arc, @@ -471,6 +475,13 @@ pub(super) struct StoreAdministration { Arc>>, } +#[cfg(unix)] +#[derive(Default)] +struct ManualBranchPublicationTasks { + closed: AtomicBool, + tasks: tokio::sync::Mutex>, +} + /// Waitable receipt for the durable account-deletion tombstone persist. /// /// Subscribe before starting deletion. `wait` fails closed if the @@ -540,6 +551,8 @@ impl Default for StoreAdministration { store_telemetry_sampling: super::maintenance::StoreTelemetrySamplingRegistry::default(), #[cfg(unix)] automation_schedulers: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + #[cfg(unix)] + manual_branch_publications: Arc::new(ManualBranchPublicationTasks::default()), session_temporal_refresh_schedulers: Arc::new( SessionTemporalRefreshSchedulerRegistry::default(), ), @@ -565,6 +578,89 @@ impl Default for StoreAdministration { } impl StoreAdministration { + #[cfg(unix)] + pub(super) async fn run_manual_branch_publication( + &self, + publication: Task, + ) -> Result + where + Task: Future> + Send + 'static, + { + if self + .manual_branch_publications + .closed + .load(Ordering::Acquire) + { + return Err(TraceDecayError::project_route( + "branch_tracking_failed", + true, + "manual branch publication admission is closed", + )); + } + let (result_sender, result_receiver) = tokio::sync::oneshot::channel(); + { + let mut tasks = self.manual_branch_publications.tasks.lock().await; + if self + .manual_branch_publications + .closed + .load(Ordering::Acquire) + { + return Err(TraceDecayError::project_route( + "branch_tracking_failed", + true, + "manual branch publication admission is closed", + )); + } + while let Some(result) = tasks.try_join_next() { + if let Err(error) = result { + super::log_daemon_event( + "manual_branch_publication", + &[ + ("action", "reap".to_owned()), + ("outcome", "task_join_failed".to_owned()), + ("reason", error.to_string()), + ], + ); + } + } + tasks.spawn(async move { + let _ = result_sender.send(publication.await); + }); + } + result_receiver.await.map_err(|error| { + TraceDecayError::project_route( + "branch_tracking_failed", + true, + format!("manual branch publication owner stopped before completion: {error}"), + ) + })? + } + + #[cfg(unix)] + pub(super) fn cancel_manual_branch_publications(&self) { + self.manual_branch_publications + .closed + .store(true, Ordering::Release); + } + + #[cfg(unix)] + pub(super) async fn shutdown_manual_branch_publications(&self) { + self.cancel_manual_branch_publications(); + let mut tasks = self.manual_branch_publications.tasks.lock().await; + while let Some(result) = tasks.join_next().await { + if let Err(error) = result { + super::log_daemon_event( + "manual_branch_publication", + &[ + ("action", "shutdown".to_owned()), + ("outcome", "task_join_failed".to_owned()), + ("reason", error.to_string()), + ], + ); + } + } + } + pub(super) fn configure_codex_preparation_resources( &self, memory: Arc, diff --git a/crates/tracedecay/src/daemon/engine/shutdown.rs b/crates/tracedecay/src/daemon/engine/shutdown.rs index 7cf25b6b7d..9a043cd529 100644 --- a/crates/tracedecay/src/daemon/engine/shutdown.rs +++ b/crates/tracedecay/src/daemon/engine/shutdown.rs @@ -33,6 +33,8 @@ impl DaemonEngine { pub(in crate::daemon) async fn shutdown_owner_phases(&self) -> Vec> { let project_open = project_open_tasks(&self.project_open_gates).await; + let manual_branch_cancel = self.store_administration.clone(); + let manual_branch_join = self.store_administration.clone(); let invocation_join = self.invocation.clone(); let session_refresh = Arc::clone( @@ -53,9 +55,21 @@ impl DaemonEngine { let watcher_cancel = self.git_watcher.clone(); let watcher_join = self.git_watcher.clone(); - let pr_join = Arc::clone(&self.pr_autotrack_task); + let pr_task = self.pr_autotrack_task.lock().await.take(); + let pr_cancel = pr_task + .as_ref() + .map(crate::daemon::pr_autotrack::PrAutotrackTask::cancellation); vec![ + vec![ShutdownOwner::new( + "manual_branch_publication", + move || manual_branch_cancel.cancel_manual_branch_publications(), + async move { + manual_branch_join + .shutdown_manual_branch_publications() + .await; + }, + )], vec![ShutdownOwner::with_deadline_status( "invocation", { @@ -122,11 +136,19 @@ impl DaemonEngine { } }, ), - ShutdownOwner::new("pr_autotrack", || {}, async move { - if let Some(task) = pr_join.lock().await.take() { - task.shutdown().await; - } - }), + ShutdownOwner::new( + "pr_autotrack", + move || { + if let Some(cancellation) = pr_cancel { + cancellation.cancel(); + } + }, + async move { + if let Some(task) = pr_task { + task.shutdown().await; + } + }, + ), ShutdownOwner::new("session_sync", || {}, async move { session_sync_join.shutdown_session_sync().await; }), diff --git a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs index 0c48fcff80..bbc8fd6178 100644 --- a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs +++ b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs @@ -4,6 +4,95 @@ use super::*; #[cfg(unix)] const MAINTENANCE_TEST_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); +#[cfg(unix)] +#[tokio::test] +async fn pr_autotrack_is_cancelled_before_invocation_join() { + let engine = DaemonEngine::default(); + let task = crate::daemon::pr_autotrack::spawn_with_administration( + engine.store_administration.clone(), + tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1::new(1), + ); + let cancellation = task.cancellation(); + let engine = engine.with_pr_autotrack_task(task).await; + + let owner_phases = engine.shutdown_owner_phases().await; + assert!(!cancellation.is_cancelled()); + + let prepared = + crate::daemon::shutdown_coordination::prepare_shutdown_owner_phases(owner_phases); + assert!( + cancellation.is_cancelled(), + "PR auto-track must be cancelled before invocation join begins" + ); + + let _ = prepared + .join(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; +} + +#[cfg(unix)] +#[tokio::test] +async fn manual_branch_add_journey_is_joined_by_daemon_shutdown() { + let engine = DaemonEngine::default(); + let administration = engine.store_administration.clone(); + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let (release_sender, release_receiver) = tokio::sync::oneshot::channel(); + let request = tokio::spawn(async move { + administration + .run_manual_branch_publication(async move { + let _ = started_sender.send(()); + let _ = release_receiver.await; + Ok(tracedecay_runtime_core::branch::BranchAddOutcome::Added) + }) + .await + }); + started_receiver + .await + .expect("manual branch publication starts"); + + let mut owner_phases = engine.shutdown_owner_phases().await; + let manual_branch_phase = owner_phases.remove(0); + let prepared = crate::daemon::shutdown_coordination::prepare_shutdown_owner_phases(vec![ + manual_branch_phase, + ]); + let denied = engine + .store_administration + .run_manual_branch_publication(async { + Ok(tracedecay_runtime_core::branch::BranchAddOutcome::AlreadyTracked) + }) + .await + .expect_err("shutdown closes manual branch publication admission"); + assert_eq!( + denied.project_route_context().map(|(reason, _, _)| reason), + Some("branch_tracking_failed") + ); + + let shutdown = tokio::spawn(async move { + prepared + .join(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await + }); + tokio::task::yield_now().await; + assert!( + !shutdown.is_finished(), + "daemon shutdown must retain the active manual branch publication" + ); + + let _ = release_sender.send(()); + assert_eq!( + request + .await + .expect("manual branch request task joins") + .expect("manual branch publication succeeds"), + tracedecay_runtime_core::branch::BranchAddOutcome::Added + ); + let receipt = shutdown.await.expect("daemon shutdown task joins"); + assert!( + receipt.unfinished().is_empty(), + "manual branch publication shutdown must complete cleanly" + ); +} + #[cfg(unix)] #[tokio::test] async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { From 49f4aabf26b5772d0f605e54d8e1824a7fb24ec2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 03:58:10 +0000 Subject: [PATCH 06/11] fix(lifecycle): close remaining ownership gaps --- .../tests/pr_tracking.rs | 25 +++++++++++++++++++ .../src/settings_api.rs | 18 ++++++------- crates/tracedecay/src/daemon/branch_add.rs | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-application/tests/pr_tracking.rs b/crates/tracedecay-application/tests/pr_tracking.rs index ea22ac133e..1d7bf52443 100644 --- a/crates/tracedecay-application/tests/pr_tracking.rs +++ b/crates/tracedecay-application/tests/pr_tracking.rs @@ -3,6 +3,23 @@ use tracedecay_application::pr_tracking::{ }; use tracedecay_domain::errors::TraceDecayError; +#[test] +fn missing_managed_pr_state_is_empty() { + let store = tempfile::tempdir().expect("store root"); + + assert!( + load_state(store.path()) + .expect("load missing state") + .managed + .is_empty() + ); + assert!( + managed_summary(store.path()) + .expect("summarize missing state") + .is_empty() + ); +} + #[test] fn managed_pr_state_round_trips_through_application_owner() { let store = tempfile::tempdir().expect("store root"); @@ -42,6 +59,10 @@ fn malformed_managed_pr_state_is_a_typed_json_error() { load_state(store.path()), Err(TraceDecayError::Json(_)) )); + assert!(matches!( + managed_summary(store.path()), + Err(TraceDecayError::Json(_)) + )); } #[test] @@ -54,4 +75,8 @@ fn unreadable_managed_pr_state_is_a_typed_io_error() { load_state(store.path()), Err(TraceDecayError::Io(_)) )); + assert!(matches!( + managed_summary(store.path()), + Err(TraceDecayError::Io(_)) + )); } diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index 3956d19469..ee2a0afae9 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -727,18 +727,18 @@ fn automation_settings_payload( } /// Lists the PR branches the daemon currently auto-tracks for this project, read -/// from the store's PR-autotrack state sidecar. Empty on non-unix or when the -/// feature has tracked nothing yet. +/// from the store's PR-autotrack state sidecar. fn pr_autotrack_payload( state: &DashboardState, ) -> std::result::Result { - let tracked = match &state.pr_autotrack_reader { - Some(reader) => map_managed_pr_autotrack_entries( - reader(state.store_root.clone()) - .map_err(|_| configuration_authority_unavailable_error())?, - ), - None => Vec::new(), - }; + let reader = state + .pr_autotrack_reader + .as_ref() + .ok_or_else(configuration_authority_unavailable_error)?; + let tracked = map_managed_pr_autotrack_entries( + reader(state.store_root.clone()) + .map_err(|_| configuration_authority_unavailable_error())?, + ); Ok(PrAutoTrackPayloadV1 { tracked }) } diff --git a/crates/tracedecay/src/daemon/branch_add.rs b/crates/tracedecay/src/daemon/branch_add.rs index d33815b34d..ebf182adac 100644 --- a/crates/tracedecay/src/daemon/branch_add.rs +++ b/crates/tracedecay/src/daemon/branch_add.rs @@ -170,6 +170,7 @@ async fn activate_and_track_manual_branch_owned( data_root: std::path::PathBuf, lifecycle: super::pr_autotrack::ManualBranchLifecycleLeaseV1, ) -> Result { + let publication = branch_publication_context(&graph)?; let activation = super::pr_autotrack::activate_manual_branch_head_with_lifecycle( &project_root, &graph, @@ -188,7 +189,6 @@ async fn activate_and_track_manual_branch_owned( "manual branch lifecycle lease does not match branch sealing request", )); } - let publication = branch_publication_context(&graph)?; let tracked = publication .track_exact_worktree_branch(&schedulers, &project_root, &activation.worktree, &branch) .await; From e56f9bcf34d7eba1277c881dd1754b7d1a510bc4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 07:51:31 +0000 Subject: [PATCH 07/11] fix(lifecycle): settle branch publication teardown --- .../tracedecay-application/src/pr_tracking.rs | 59 ++- .../branch_publication.rs | 36 +- crates/tracedecay/src/daemon/branch_add.rs | 45 +- crates/tracedecay/src/daemon/branch_admin.rs | 29 +- crates/tracedecay/src/daemon/pr_autotrack.rs | 390 +++++++++++------- .../src/daemon/pr_autotrack/runtime.rs | 4 +- .../src/daemon/pr_autotrack/tests.rs | 128 +++++- .../src/daemon/production_harness.rs | 46 ++- .../src/daemon/tests/scheduler_shutdown.rs | 63 ++- crates/tracedecay/tests/daemon_suite/main.rs | 1 - .../tests/daemon_suite/pr_autotrack_test.rs | 206 --------- 11 files changed, 603 insertions(+), 404 deletions(-) delete mode 100644 crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs diff --git a/crates/tracedecay-application/src/pr_tracking.rs b/crates/tracedecay-application/src/pr_tracking.rs index 6207c49ff0..c7ffce772f 100644 --- a/crates/tracedecay-application/src/pr_tracking.rs +++ b/crates/tracedecay-application/src/pr_tracking.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; +use std::process::ExitStatus; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -192,20 +193,35 @@ pub fn run_git_with_control( ) } +#[derive(Debug, thiserror::Error)] +pub enum PrGitCommandError { + #[error(transparent)] + Command(#[from] GitCommandError), + #[error("git command '{arguments}' exited with {status}: {stderr}")] + NonZeroExit { + arguments: String, + status: ExitStatus, + stderr: String, + }, + #[error("git command '{arguments}' returned invalid output: {detail}")] + InvalidOutput { arguments: String, detail: String }, +} + pub fn successful_git_with_control( repo_root: &Path, args: &[&str], control: &PrCommandControlV1, -) -> Option { - run_git_with_control(repo_root, args, control) - .ok() - .filter(|output| output.status.success()) -} - -/// Discover open, same-repository PR heads without treating command failure as -/// an empty remote. -pub fn discover_open_prs(repo_root: &Path) -> Result { - discover_open_prs_with_control(repo_root, default_pr_command_control()) +) -> Result { + let output = run_git_with_control(repo_root, args, control)?; + if output.status.success() { + Ok(output) + } else { + Err(PrGitCommandError::NonZeroExit { + arguments: args.join(" "), + status: output.status, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } } pub fn default_pr_command_control() -> &'static PrCommandControlV1 { @@ -318,6 +334,7 @@ fn origin_is_github(repo_root: &Path, control: &PrCommandControlV1) -> bool { return *cached; } let result = successful_git_with_control(repo_root, &["remote", "get-url", "origin"], control) + .ok() .and_then(|output| String::from_utf8(output.stdout).ok()) .is_some_and(|url| url.contains("github.com")); if let Ok(mut origins) = cache.lock() { @@ -395,12 +412,18 @@ fn discover_via_ls_remote( &["ls-remote", "origin", "refs/pull/*/head"], control, ) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .ok_or_else(|| "git ls-remote of PR head refs failed".to_owned())?; + .map_err(|error| format!("git ls-remote of PR head refs failed: {error}")) + .and_then(|output| { + String::from_utf8(output.stdout) + .map_err(|error| format!("git ls-remote PR output was not UTF-8: {error}")) + })?; let head_shas = successful_git_with_control(repo_root, &["ls-remote", "--heads", "origin"], control) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .ok_or_else(|| "git ls-remote of head refs failed".to_owned())?; + .map_err(|error| format!("git ls-remote of head refs failed: {error}")) + .and_then(|output| { + String::from_utf8(output.stdout) + .map_err(|error| format!("git ls-remote head output was not UTF-8: {error}")) + })?; Ok(map_pull_heads_to_branches( &parse_ls_remote_pull_heads(&pull_heads), &parse_ls_remote_heads(&head_shas), @@ -444,6 +467,14 @@ mod tests { bound: 1 }) )); + assert!(matches!( + successful_git_with_control( + root.path(), + &["rev-parse", "--verify", "missing"], + default_pr_command_control(), + ), + Err(PrGitCommandError::NonZeroExit { .. }) + )); } #[test] diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs index 8082e6b700..0a6f3e3938 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs @@ -17,6 +17,7 @@ use tracedecay_runtime_core::branch_meta::{ BranchGraphSourceDraftV1, BranchGraphSourcePublicationV1, BranchGraphSourcePublishOutcomeV1, BranchGraphSourceRollbackOutcomeV1, }; +use tracedecay_runtime_core::cancellation::CancellationToken; use super::{ CodeIndexPublishedGenerationV1, CodeIndexSchedulerRegistryV1, @@ -31,6 +32,14 @@ const BRANCH_TRACKING_FAILED: &str = "branch_tracking_failed"; const BRANCH_GENERATION_IDLE_TIMEOUT: Duration = Duration::from_secs(20); const BRANCH_GENERATION_HARD_TIMEOUT: Duration = Duration::from_mins(30); +fn branch_publication_cancelled_error(branch: &str) -> TraceDecayError { + TraceDecayError::project_route( + BRANCH_TRACKING_FAILED, + true, + format!("branch publication was cancelled for '{branch}'"), + ) +} + /// Immutable project identity and layout required to publish branch metadata. #[derive(Clone, Debug)] pub struct BranchPublicationContextV1 { @@ -76,6 +85,7 @@ impl BranchPublicationContextV1 { project_root: &Path, worktree_root: &Path, branch: &str, + cancellation: &CancellationToken, ) -> Result { let canonical_project_root = project_root.canonicalize().map_err(|error| { TraceDecayError::project_route( @@ -97,6 +107,9 @@ impl BranchPublicationContextV1 { ), )); } + if cancellation.is_cancelled() { + return Err(branch_publication_cancelled_error(branch)); + } let canonical_worktree_root = worktree_root.canonicalize().map_err(|error| { TraceDecayError::project_route( CODE_INDEX_IDENTITY_MISMATCH, @@ -128,6 +141,9 @@ impl BranchPublicationContextV1 { &source_branch, ) .await?; + if cancellation.is_cancelled() { + return Err(branch_publication_cancelled_error(branch)); + } let prepared = match tracedecay_runtime_core::branch::prepare_branch_tracking_in_layout( &canonical_worktree_root, branch, @@ -145,6 +161,12 @@ impl BranchPublicationContextV1 { BranchTrackingPreparation::AlreadyTracked => None, BranchTrackingPreparation::Deferred => return Ok(BranchAddOutcome::Deferred), }; + if cancellation.is_cancelled() { + let error = branch_publication_cancelled_error(branch); + self.rollback_failed_branch_tracking(prepared.as_deref(), None, &error) + .await?; + return Err(error); + } let expected_source = tracedecay_runtime_core::branch_meta::load_branch_meta( &self.data_root, ) @@ -154,7 +176,12 @@ impl BranchPublicationContextV1 { .and_then(|entry| entry.graph_source.clone()) }); let generation = match self - .await_exact_branch_generation(schedulers, &canonical_worktree_root, &source) + .await_exact_branch_generation( + schedulers, + &canonical_worktree_root, + &source, + cancellation, + ) .await { Ok(generation) => generation, @@ -404,6 +431,7 @@ impl BranchPublicationContextV1 { schedulers: &CodeIndexSchedulerRegistryV1, canonical_worktree_root: &Path, source: &BranchGraphSourceDraftV1, + cancellation: &CancellationToken, ) -> Result, TraceDecayError> { let mut serving_changes = schedulers .subscribe_serving_generation_changes(canonical_worktree_root) @@ -434,6 +462,9 @@ impl BranchPublicationContextV1 { let hard_deadline = Instant::now() + BRANCH_GENERATION_HARD_TIMEOUT; let mut idle_deadline = Instant::now() + BRANCH_GENERATION_IDLE_TIMEOUT; loop { + if cancellation.is_cancelled() { + return Err(branch_publication_cancelled_error(&source.reference)); + } let scope = schedulers .serving_code_scope(canonical_worktree_root) .await @@ -487,6 +518,9 @@ impl BranchPublicationContextV1 { )); } tokio::select! { + () = cancellation.cancelled() => { + return Err(branch_publication_cancelled_error(&source.reference)); + } result = serving_changes.changed() => { if result.is_err() { return Err(TraceDecayError::project_route( diff --git a/crates/tracedecay/src/daemon/branch_add.rs b/crates/tracedecay/src/daemon/branch_add.rs index ebf182adac..929a62f5c4 100644 --- a/crates/tracedecay/src/daemon/branch_add.rs +++ b/crates/tracedecay/src/daemon/branch_add.rs @@ -1,12 +1,14 @@ use std::path::Path; use std::sync::Arc; +use tracedecay_application::pr_tracking::PrCommandControlV1; use tracedecay_code_index_runtime::code_index_scheduler::{ CodeIndexSchedulerRegistryV1, branch_publication::BranchPublicationContextV1, }; use tracedecay_domain::errors::TraceDecayError; use tracedecay_mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse}; use tracedecay_runtime_core::branch::BranchAddOutcome; +use tracedecay_runtime_core::cancellation::CancellationToken; use super::{DaemonHandshake, StoreAdministration}; @@ -139,24 +141,33 @@ async fn activate_and_track_manual_branch( branch: &str, ) -> Result { let data_root = graph.store_layout().data_root.clone(); - let lifecycle = super::pr_autotrack::try_acquire_manual_branch_lifecycle(&data_root, branch) - .map_err(|error| { - TraceDecayError::project_route(error.reason_code(), error.retryable(), error.detail()) - })?; let project_root = project_root.to_path_buf(); let graph = Arc::clone(graph); let schedulers = schedulers.clone(); let branch = branch.to_owned(); administration - .run_manual_branch_publication(activate_and_track_manual_branch_owned( - project_root, - graph, - schedulers, - branch, - data_root, - lifecycle, - )) + .run_manual_branch_publication(|cancellation| async move { + let lifecycle = + super::pr_autotrack::try_acquire_manual_branch_lifecycle(&data_root, &branch) + .map_err(|error| { + TraceDecayError::project_route( + error.reason_code(), + error.retryable(), + error.detail(), + ) + })?; + activate_and_track_manual_branch_owned( + project_root, + graph, + schedulers, + branch, + data_root, + lifecycle, + cancellation, + ) + .await + }) .await } @@ -169,6 +180,7 @@ async fn activate_and_track_manual_branch_owned( branch: String, data_root: std::path::PathBuf, lifecycle: super::pr_autotrack::ManualBranchLifecycleLeaseV1, + cancellation: CancellationToken, ) -> Result { let publication = branch_publication_context(&graph)?; let activation = super::pr_autotrack::activate_manual_branch_head_with_lifecycle( @@ -177,6 +189,7 @@ async fn activate_and_track_manual_branch_owned( Some(&schedulers), &branch, &lifecycle, + &PrCommandControlV1::with_cancellation(cancellation.clone()), ) .await .map_err(|error| { @@ -190,7 +203,13 @@ async fn activate_and_track_manual_branch_owned( )); } let tracked = publication - .track_exact_worktree_branch(&schedulers, &project_root, &activation.worktree, &branch) + .track_exact_worktree_branch( + &schedulers, + &project_root, + &activation.worktree, + &branch, + &cancellation, + ) .await; match tracked { Ok(outcome) => Ok(outcome), diff --git a/crates/tracedecay/src/daemon/branch_admin.rs b/crates/tracedecay/src/daemon/branch_admin.rs index 16ef7ad562..6a3c1d8927 100644 --- a/crates/tracedecay/src/daemon/branch_admin.rs +++ b/crates/tracedecay/src/daemon/branch_admin.rs @@ -12,6 +12,8 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport}; #[cfg(unix)] use tracedecay_runtime_core::branch::BranchAddOutcome; +#[cfg(unix)] +use tracedecay_runtime_core::cancellation::CancellationToken; #[cfg(any(unix, test))] use super::ProjectServerKey; @@ -476,12 +478,23 @@ pub(super) struct StoreAdministration { } #[cfg(unix)] -#[derive(Default)] struct ManualBranchPublicationTasks { closed: AtomicBool, + cancellation: CancellationToken, tasks: tokio::sync::Mutex>, } +#[cfg(unix)] +impl Default for ManualBranchPublicationTasks { + fn default() -> Self { + Self { + closed: AtomicBool::new(false), + cancellation: CancellationToken::new(), + tasks: tokio::sync::Mutex::new(tokio::task::JoinSet::new()), + } + } +} + /// Waitable receipt for the durable account-deletion tombstone persist. /// /// Subscribe before starting deletion. `wait` fails closed if the @@ -577,11 +590,12 @@ impl Default for StoreAdministration { impl StoreAdministration { #[cfg(unix)] - pub(super) async fn run_manual_branch_publication( + pub(super) async fn run_manual_branch_publication( &self, - publication: Task, + publication: Publication, ) -> Result where + Publication: FnOnce(CancellationToken) -> Task + Send + 'static, Task: Future> + Send + 'static, { if self @@ -621,8 +635,9 @@ impl StoreAdministration { ); } } + let cancellation = self.manual_branch_publications.cancellation.clone(); tasks.spawn(async move { - let _ = result_sender.send(publication.await); + let _ = result_sender.send(publication(cancellation).await); }); } result_receiver.await.map_err(|error| { @@ -639,12 +654,16 @@ impl StoreAdministration { self.manual_branch_publications .closed .store(true, Ordering::Release); + self.manual_branch_publications.cancellation.cancel(); } #[cfg(unix)] pub(super) async fn shutdown_manual_branch_publications(&self) { self.cancel_manual_branch_publications(); - let mut tasks = self.manual_branch_publications.tasks.lock().await; + let mut tasks = { + let mut owned = self.manual_branch_publications.tasks.lock().await; + std::mem::take(&mut *owned) + }; while let Some(result) = tasks.join_next().await { if let Err(error) = result { super::log_daemon_event( diff --git a/crates/tracedecay/src/daemon/pr_autotrack.rs b/crates/tracedecay/src/daemon/pr_autotrack.rs index acb31d2a4e..eb98b451b4 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack.rs @@ -3,14 +3,9 @@ //! [`tracedecay_application::pr_tracking`] owns Git discovery and managed state. //! When a project enables `sync.auto_track_pr_branches`, this adapter activates //! each discovered same-repository PR head as a registered linked worktree -//! through the daemon's retained code-index scheduler. Manual -//! `activate_manual_branch` uses that same mount path for an -//! operator-requested branch head. Public -//! `reconcile_project` and the no-scheduler manual entry stay fail-closed: -//! those APIs have no scheduler to inject. The poll runtime and the daemon -//! branch-add handler receive that authority and still refuse Git or -//! durable-state mutation when identity or Git discovery cannot name a -//! worktree root. +//! through the daemon's retained code-index scheduler. The poll runtime and +//! daemon branch-add handler receive that authority and refuse Git or durable +//! state mutation when identity or Git discovery cannot name a worktree root. //! //! # Why worktrees //! @@ -38,13 +33,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(test)] +use tracedecay_application::pr_tracking::managed_summary; use tracedecay_application::pr_tracking::{ DiscoveredPr, ManagedPr, PrAutotrackState, PrCommandControlV1 as PrCommandControl, PrDiscovery, - default_pr_command_control, discover_open_prs_with_control, load_state, pr_label, - pr_tracking_ref, run_git_with_control, save_state, successful_git_with_control, + PrGitCommandError, default_pr_command_control, discover_open_prs_with_control, load_state, + pr_label, pr_tracking_ref, run_git_with_control, save_state, successful_git_with_control, }; -#[cfg(test)] -use tracedecay_application::pr_tracking::{discover_open_prs, managed_summary}; use tracedecay_domain::ProjectId; use tracedecay_domain::canonical_text::sha256_hex; use tracedecay_domain::errors::TraceDecayError; @@ -73,15 +68,15 @@ async fn git_authority_available(repo_root: &Path) -> bool { /// Outcome of a successful manual branch-head activation. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ManualBranchActivation { +pub(crate) struct ManualBranchActivation { /// Operator-requested branch name. - pub branch: String, + pub(crate) branch: String, /// Resolved commit of that branch at activation time. - pub head_sha: String, + pub(crate) head_sha: String, /// Linked worktree checked out for the code-index scheduler. - pub worktree: PathBuf, + pub(crate) worktree: PathBuf, /// CLI/MCP outcome for the activation. - pub outcome: tracedecay_runtime_core::branch::BranchAddOutcome, + pub(crate) outcome: tracedecay_runtime_core::branch::BranchAddOutcome, } /// The exact Git and filesystem artifacts owned by one manually activated @@ -183,7 +178,7 @@ pub(crate) fn try_acquire_manual_branch_lifecycle( /// Typed failure for manual branch-head activation. Missing scheduler or /// identity is a project-route state, not a transport error or empty success. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ManualBranchActivationError { +pub(crate) enum ManualBranchActivationError { /// No injected code-index scheduler, retained graph, or project identity. SchedulerUnavailable { detail: String }, /// Git cannot name a worktree root for the requested project. @@ -199,7 +194,7 @@ pub enum ManualBranchActivationError { impl ManualBranchActivationError { /// Stable reason code for JSON-RPC / project-route mapping. - pub fn reason_code(&self) -> &'static str { + pub(crate) fn reason_code(&self) -> &'static str { match self { Self::SchedulerUnavailable { .. } => CODE_INDEX_SCHEDULER_UNAVAILABLE, Self::GitAuthorityUnavailable { .. } => GIT_AUTHORITY_UNAVAILABLE, @@ -210,7 +205,7 @@ impl ManualBranchActivationError { } /// Whether a later retry with the same arguments can succeed. - pub fn retryable(&self) -> bool { + pub(crate) fn retryable(&self) -> bool { match self { Self::SchedulerUnavailable { .. } | Self::ActivationFailed { .. } @@ -221,7 +216,7 @@ impl ManualBranchActivationError { } /// Human-readable detail carried beside [`Self::reason_code`]. - pub fn detail(&self) -> &str { + pub(crate) fn detail(&self) -> &str { match self { Self::SchedulerUnavailable { detail } | Self::GitAuthorityUnavailable { detail } @@ -275,7 +270,7 @@ use super::branch_admin::StoreAdministration; use super::log_daemon_event; mod runtime; -pub use runtime::PrAutotrackTask; +pub(crate) use runtime::PrAutotrackTask; pub(super) use runtime::spawn_with_administration; #[derive(Clone, Copy)] @@ -318,20 +313,20 @@ const MAX_NEW_TRACKS_PER_CYCLE: usize = 10; /// A summary of what one reconcile pass changed, for logging and tests. #[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct ReconcileReport { +pub(crate) struct ReconcileReport { /// Internal labels newly tracked or recovered this pass. - pub tracked: Vec, + pub(crate) tracked: Vec, /// Labels untracked this pass (PR closed/merged). - pub untracked: Vec, + pub(crate) untracked: Vec, /// PR numbers skipped as forks. - pub skipped_forks: Vec, + pub(crate) skipped_forks: Vec, /// True when the per-cycle new-track cap held some additions back. - pub capped: bool, + pub(crate) capped: bool, /// True when removals were skipped because the discovery was `partial` /// (possibly truncated) — no managed PR is untracked on an incomplete view. - pub removals_suppressed: bool, + pub(crate) removals_suppressed: bool, /// Tracking or persistence failures surfaced to callers. - pub failures: Vec<(String, String)>, + pub(crate) failures: Vec<(String, String)>, } /// Logs a `pr_autotrack` "skipped" daemon event with the optional branch label @@ -353,46 +348,6 @@ fn log_pr_skip(repo_root: &Path, branch_label: Option<&str>, pr: Option, re log_daemon_event("pr_autotrack", &fields); } -/// Reconciles the managed PR set against a discovery result. -/// -/// Additions are bounded by `cap` new tracks per call; removals (closed/merged -/// PRs) are always processed. Idempotent: PRs already managed and still open are -/// left untouched. State is persisted before returning. -pub async fn reconcile_project( - _graph: std::sync::Arc, - _repo_root: &Path, - _data_root: &Path, - _discovery: &PrDiscovery, - _cap: usize, -) -> ReconcileReport { - ReconcileReport { - failures: vec![( - "project".to_owned(), - scheduler_unavailable( - "code-index scheduler authority is unavailable for PR worktree activation", - ), - )], - ..ReconcileReport::default() - } -} - -/// Public manual branch-add entry with no scheduler to inject. Fails closed -/// before Git or durable-state mutation, matching [`reconcile_project`]. -pub async fn activate_manual_branch( - _graph: std::sync::Arc, - _repo_root: &Path, - _branch: &str, -) -> std::result::Result { - Err(ManualBranchActivationError::scheduler_unavailable( - "code-index scheduler authority is unavailable for branch activation", - )) -} - -/// Deterministic linked-worktree path for a manually activated branch head. -pub fn manual_branch_worktree_path(data_root: &Path, branch: &str) -> PathBuf { - ManualBranchArtifactsV1::for_branch(data_root, branch).worktree -} - /// Activates an operator-requested branch head through the same worktree /// prep + scheduler mount path as [`track_pr`]. #[cfg(test)] @@ -408,8 +363,15 @@ pub(crate) async fn activate_manual_branch_head( )); } let lifecycle = try_acquire_manual_branch_lifecycle(&graph.store_layout().data_root, branch)?; - activate_manual_branch_head_with_lifecycle(repo_root, graph, schedulers, branch, &lifecycle) - .await + activate_manual_branch_head_with_lifecycle( + repo_root, + graph, + schedulers, + branch, + &lifecycle, + default_pr_command_control(), + ) + .await } #[hotpath::measure(label = "daemon.pr_autotrack.activate", future = true)] @@ -419,13 +381,13 @@ pub(crate) async fn activate_manual_branch_head_with_lifecycle( schedulers: Option<&CodeIndexSchedulerRegistryV1>, branch: &str, lifecycle: &ManualBranchLifecycleLeaseV1, + command_control: &PrCommandControl, ) -> std::result::Result { if !lifecycle.matches_branch(branch) { return Err(ManualBranchActivationError::activation_failed( "manual branch lifecycle lease does not match requested branch", )); } - let command_control = default_pr_command_control(); let administration = match schedulers { Some(schedulers) => PrStoreAdministration::with_control(schedulers, graph, command_control), None => PrStoreAdministration { @@ -658,6 +620,7 @@ fn resolve_git_ref( &["rev-parse", "--verify", "--end-of-options", reference], command_control, ) + .ok() .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|sha| sha.trim().to_string()) .filter(|sha| !sha.is_empty()) @@ -671,14 +634,12 @@ fn prepare_manual_branch_worktree( expected_head: &str, command_control: &PrCommandControl, ) -> std::result::Result<(), String> { - let update = successful_git_with_control( + successful_git_with_control( repo_root, &["update-ref", tracking_ref, expected_head], command_control, - ); - if update.is_none() { - return Err("failed to publish branch tracking ref".to_string()); - } + ) + .map_err(|error| format!("failed to publish branch tracking ref: {error}"))?; checkout_linked_worktree(repo_root, worktree, tracking_ref, label, command_control) } @@ -693,13 +654,14 @@ async fn cleanup_failed_manual_track( ) -> std::result::Result { match retire_worktree_mount(administration.schedulers, worktree).await { Ok(()) => { + let cleanup_control = PrCommandControl::default(); if !cleanup_owned_worktree_off_runtime( repo_root, worktree, tracking_ref, label, head_sha, - administration.command_control.clone(), + cleanup_control, ) .await? { @@ -1768,7 +1730,8 @@ async fn cleanup_failed_track( true, administration.command_control.clone(), ) - .await; + .await + .map_err(|error| format!("{original_reason}; cleanup failed: {error}"))?; Err(original_reason.to_string()) } Err(cleanup_reason) => Err(format!( @@ -1790,16 +1753,15 @@ fn prepare_pr_worktree( command_control: &PrCommandControl, ) -> std::result::Result<(), String> { let pr_ref_spec = format!("+refs/pull/{pr_number}/head:{tracking_ref}"); - let fetch = successful_git_with_control( + successful_git_with_control( repo_root, &["fetch", "--no-tags", "origin", &pr_ref_spec], command_control, - ); - if fetch.is_none() { - return Err("fetch of PR head failed".to_string()); - } + ) + .map_err(|error| format!("fetch of PR head failed: {error}"))?; let fetched_head = successful_git_with_control(repo_root, &["rev-parse", tracking_ref], command_control) + .ok() .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|sha| sha.trim().to_string()); if fetched_head.as_deref() != Some(expected_head) { @@ -1819,10 +1781,11 @@ fn checkout_linked_worktree( if let Some(parent) = worktree.parent() { let _ = std::fs::create_dir_all(parent); } - remove_worktree(repo_root, worktree, command_control); + remove_worktree(repo_root, worktree, command_control) + .map_err(|error| format!("worktree replacement cleanup failed: {error}"))?; let wt_str = worktree.to_string_lossy(); - let add = successful_git_with_control( + successful_git_with_control( repo_root, &[ "worktree", @@ -1834,10 +1797,8 @@ fn checkout_linked_worktree( tracking_ref, ], command_control, - ); - if add.is_none() { - return Err("worktree add failed".to_string()); - } + ) + .map_err(|error| format!("worktree add failed: {error}"))?; Ok(()) } @@ -1874,7 +1835,8 @@ async fn untrack_pr( !is_legacy, administration.command_control.clone(), ) - .await; + .await + .map_err(|error| error.to_string())?; Ok(()) } @@ -1928,7 +1890,7 @@ async fn sweep_orphan_pr_worktrees( let label = pr_label(number); match remove_pr_store(repo_root, data_root, &label, administration).await { Ok(()) => { - cleanup_pr_worktree_off_runtime( + match cleanup_pr_worktree_off_runtime( repo_root, data_root, number, @@ -1936,16 +1898,21 @@ async fn sweep_orphan_pr_worktrees( true, administration.command_control.clone(), ) - .await; - log_daemon_event( - "pr_autotrack", - &[ - ("project", repo_root.display().to_string()), - ("action", "swept".to_string()), - ("pr", number.to_string()), - ("reason", "orphan worktree".to_string()), - ], - ); + .await + { + Ok(_) => log_daemon_event( + "pr_autotrack", + &[ + ("project", repo_root.display().to_string()), + ("action", "swept".to_string()), + ("pr", number.to_string()), + ("reason", "orphan worktree".to_string()), + ], + ), + Err(error) => { + log_pr_skip(repo_root, Some(&label), Some(number), &error.to_string()); + } + } } Err(reason) => log_pr_skip(repo_root, Some(&label), Some(number), &reason), } @@ -1959,11 +1926,11 @@ async fn cleanup_pr_worktree_off_runtime( expected_head: &str, remove_synthetic_branch: bool, command_control: PrCommandControl, -) { +) -> std::result::Result { let repo_root = repo_root.to_path_buf(); let data_root = data_root.to_path_buf(); let expected_head = expected_head.to_owned(); - if let Err(error) = tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { cleanup_pr_worktree( &repo_root, &data_root, @@ -1971,21 +1938,49 @@ async fn cleanup_pr_worktree_off_runtime( &expected_head, remove_synthetic_branch, &command_control, - ); + ) }) .await - { - log_daemon_event( - "pr_autotrack", - &[ - ("action", "cleanup_task_failed".to_string()), - ("pr", pr.to_string()), - ("reason", error.to_string()), - ], - ); + .map_err(|error| PrCleanupError::Join(error.to_string()))? +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PrCleanupArtifact { + Worktree(PathBuf), + Branch(String), + TrackingRef(String), +} + +impl std::fmt::Display for PrCleanupArtifact { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Worktree(path) => write!(formatter, "worktree '{}'", path.display()), + Self::Branch(reference) => write!(formatter, "branch '{reference}'"), + Self::TrackingRef(reference) => write!(formatter, "tracking ref '{reference}'"), + } } } +#[derive(Debug)] +struct PrCleanupReceipt; + +#[derive(Debug, thiserror::Error)] +enum PrCleanupError { + #[error("PR cleanup task failed to join: {0}")] + Join(String), + #[error("PR cleanup command failed for {artifact}: {source}")] + Command { + artifact: PrCleanupArtifact, + #[source] + source: PrGitCommandError, + }, + #[error( + "PR cleanup did not remove owned artifacts: {}", + .0.iter().map(ToString::to_string).collect::>().join(", ") + )] + Remaining(Vec), +} + #[hotpath::measure(label = "daemon.pr_autotrack.cleanup_worktree")] fn cleanup_pr_worktree( repo_root: &Path, @@ -1994,12 +1989,27 @@ fn cleanup_pr_worktree( expected_head: &str, remove_synthetic_branch: bool, command_control: &PrCommandControl, -) { +) -> std::result::Result { let worktree = data_root.join("pr-worktrees").join(format!("pr-{pr}")); let tracking_ref = pr_tracking_ref(pr); + let label = pr_label(pr); + let branch_ref = format!("refs/heads/{label}"); + let artifacts = || { + let mut artifacts = vec![ + PrCleanupArtifact::Worktree(worktree.clone()), + PrCleanupArtifact::TrackingRef(tracking_ref.clone()), + ]; + if remove_synthetic_branch { + artifacts.push(PrCleanupArtifact::Branch(branch_ref.clone())); + } + artifacts + }; + if command_control.is_cancelled() { + return Err(PrCleanupError::Remaining(artifacts())); + } let owned_head = if expected_head.is_empty() { - let ref_head = ref_sha(repo_root, &tracking_ref, command_control); - let worktree_head = ref_sha(&worktree, "HEAD", command_control); + let ref_head = ref_sha(repo_root, &tracking_ref, command_control)?; + let worktree_head = ref_sha(&worktree, "HEAD", command_control)?; match (ref_head, worktree_head) { (Some(ref_head), Some(worktree_head)) if ref_head == worktree_head => Some(ref_head), _ => None, @@ -2007,24 +2017,46 @@ fn cleanup_pr_worktree( } else { Some(expected_head.to_string()) }; - remove_worktree(repo_root, &worktree, command_control); - let label = pr_label(pr); - let branch_ref = format!("refs/heads/{label}"); + remove_worktree(repo_root, &worktree, command_control).map_err(|source| { + PrCleanupError::Command { + artifact: PrCleanupArtifact::Worktree(worktree.clone()), + source, + } + })?; if let Some(owned_head) = owned_head { if remove_synthetic_branch - && ref_points_to(repo_root, &branch_ref, &owned_head, command_control) + && ref_points_to(repo_root, &branch_ref, &owned_head, command_control)? { - let _ = - successful_git_with_control(repo_root, &["branch", "-D", &label], command_control); + successful_git_with_control(repo_root, &["branch", "-D", &label], command_control) + .map_err(|source| PrCleanupError::Command { + artifact: PrCleanupArtifact::Branch(branch_ref.clone()), + source, + })?; } - if ref_points_to(repo_root, &tracking_ref, &owned_head, command_control) { - let _ = successful_git_with_control( + if ref_points_to(repo_root, &tracking_ref, &owned_head, command_control)? { + successful_git_with_control( repo_root, &["update-ref", "-d", &tracking_ref], command_control, - ); + ) + .map_err(|source| PrCleanupError::Command { + artifact: PrCleanupArtifact::TrackingRef(tracking_ref.clone()), + source, + })?; } } + let verification_control = PrCommandControl::default(); + let remaining = remaining_pr_artifacts( + repo_root, + &worktree, + remove_synthetic_branch.then_some(branch_ref.as_str()), + &tracking_ref, + &verification_control, + )?; + if !remaining.is_empty() { + return Err(PrCleanupError::Remaining(remaining)); + } + Ok(PrCleanupReceipt) } fn ref_points_to( @@ -2032,34 +2064,116 @@ fn ref_points_to( reference: &str, expected_head: &str, command_control: &PrCommandControl, -) -> bool { - ref_sha(repo_root, reference, command_control).is_some_and(|sha| sha == expected_head) +) -> std::result::Result { + Ok(ref_sha(repo_root, reference, command_control)?.is_some_and(|sha| sha == expected_head)) } fn ref_sha( repo_root: &Path, reference: &str, command_control: &PrCommandControl, -) -> Option { - successful_git_with_control(repo_root, &["rev-parse", reference], command_control) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|sha| sha.trim().to_string()) +) -> std::result::Result, PrCleanupError> { + let output = run_git_with_control( + repo_root, + &["rev-parse", "--verify", "--end-of-options", reference], + command_control, + ) + .map_err(|source| PrCleanupError::Command { + artifact: cleanup_artifact_for_ref(reference), + source: PrGitCommandError::Command(source), + })?; + if !output.status.success() { + return Ok(None); + } + String::from_utf8(output.stdout) + .map(|sha| Some(sha.trim().to_owned())) + .map_err(|source| PrCleanupError::Command { + artifact: cleanup_artifact_for_ref(reference), + source: PrGitCommandError::InvalidOutput { + arguments: format!("rev-parse --verify --end-of-options {reference}"), + detail: source.to_string(), + }, + }) } -fn remove_worktree(repo_root: &Path, worktree: &Path, command_control: &PrCommandControl) { +fn cleanup_artifact_for_ref(reference: &str) -> PrCleanupArtifact { + if reference.starts_with("refs/heads/") { + PrCleanupArtifact::Branch(reference.to_owned()) + } else { + PrCleanupArtifact::TrackingRef(reference.to_owned()) + } +} + +fn remaining_pr_artifacts( + repo_root: &Path, + worktree: &Path, + branch_ref: Option<&str>, + tracking_ref: &str, + command_control: &PrCommandControl, +) -> std::result::Result, PrCleanupError> { + let worktrees = successful_git_with_control( + repo_root, + &["worktree", "list", "--porcelain"], + command_control, + ) + .map_err(|source| PrCleanupError::Command { + artifact: PrCleanupArtifact::Worktree(worktree.to_owned()), + source, + })?; + let listed = String::from_utf8(worktrees.stdout).map_err(|source| PrCleanupError::Command { + artifact: PrCleanupArtifact::Worktree(worktree.to_owned()), + source: PrGitCommandError::InvalidOutput { + arguments: "worktree list --porcelain".to_owned(), + detail: source.to_string(), + }, + })?; + let mut remaining = Vec::new(); + if worktree.exists() + || listed + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .any(|listed| Path::new(listed) == worktree) + { + remaining.push(PrCleanupArtifact::Worktree(worktree.to_owned())); + } + if let Some(branch_ref) = branch_ref + && ref_sha(repo_root, branch_ref, command_control)?.is_some() + { + remaining.push(PrCleanupArtifact::Branch(branch_ref.to_owned())); + } + if ref_sha(repo_root, tracking_ref, command_control)?.is_some() { + remaining.push(PrCleanupArtifact::TrackingRef(tracking_ref.to_owned())); + } + Ok(remaining) +} + +fn remove_worktree( + repo_root: &Path, + worktree: &Path, + command_control: &PrCommandControl, +) -> std::result::Result<(), PrGitCommandError> { let wt_str = worktree.to_string_lossy(); - let _ = successful_git_with_control( + match successful_git_with_control( repo_root, &["worktree", "remove", "--force", &wt_str], command_control, - ); - let _ = successful_git_with_control(repo_root, &["worktree", "prune"], command_control); + ) { + Ok(_) => {} + Err(_) if !worktree.exists() => {} + Err(error) => return Err(error), + } + successful_git_with_control(repo_root, &["worktree", "prune"], command_control)?; if command_control.is_cancelled() { - return; + return Err(PrGitCommandError::Command( + tracedecay_runtime_core::git::GitCommandError::Cancelled, + )); } if worktree.exists() { - let _ = std::fs::remove_dir_all(worktree); + std::fs::remove_dir_all(worktree).map_err(|source| { + PrGitCommandError::Command(tracedecay_runtime_core::git::GitCommandError::Wait(source)) + })?; } + Ok(()) } #[cfg(test)] diff --git a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs index 7a51bd0e8b..cee8895c5e 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/runtime.rs @@ -23,7 +23,7 @@ const BASE_TICK: Duration = Duration::from_mins(1); /// Retained owner for the PR-autotrack loop and every bounded child process it /// starts. Shutdown signals the same token carried into Git/GitHub commands /// before joining the task. -pub struct PrAutotrackTask { +pub(crate) struct PrAutotrackTask { cancellation: CancellationToken, task: tokio::task::JoinHandle<()>, } @@ -34,7 +34,7 @@ impl PrAutotrackTask { } #[hotpath::skip] - pub async fn shutdown(self) { + pub(crate) async fn shutdown(self) { self.cancellation.cancel(); if let Err(error) = self.task.await { log_daemon_event( diff --git a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs index e36e452c9e..f81d834612 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs @@ -93,6 +93,114 @@ async fn reconcile_preserves_closed_pr_when_scheduler_retirement_is_unavailable( assert!(data_root.path().join("branches/pr_5.db").exists()); } +#[tokio::test] +async fn cancelled_pr_teardown_preserves_artifacts_and_retries_exactly() { + let repo = tempfile::tempdir().expect("repository root"); + let data_root = tempfile::tempdir().expect("data root"); + git(repo.path(), &["init", "-q", "-b", "main"]); + git(repo.path(), &["config", "user.name", "TraceDecay Test"]); + git( + repo.path(), + &["config", "user.email", "tracedecay@example.invalid"], + ); + std::fs::write(repo.path().join("tracked.txt"), "tracked\n").expect("write fixture"); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-qm", "initial"]); + + let pr = 5; + let label = pr_label(pr); + let tracking_ref = pr_tracking_ref(pr); + let head_sha = git_output(repo.path(), &["rev-parse", "HEAD"]); + let worktree = data_root.path().join("pr-worktrees/pr-5"); + std::fs::create_dir_all(worktree.parent().expect("worktree parent")) + .expect("create worktree parent"); + git(repo.path(), &["update-ref", &tracking_ref, &head_sha]); + git( + repo.path(), + &[ + "worktree", + "add", + "-q", + "-b", + &label, + worktree.to_str().expect("utf-8 worktree"), + &head_sha, + ], + ); + let mut state = PrAutotrackState::default(); + state.managed.insert( + label.clone(), + ManagedPr { + pr, + head_branch: "feature-5".to_owned(), + head_sha: head_sha.clone(), + worktree: worktree.clone(), + tracking_ref: tracking_ref.clone(), + }, + ); + save_state(data_root.path(), &state).expect("persist managed state"); + + let schedulers = CodeIndexSchedulerRegistryV1::new(1); + let cancellation = tracedecay_runtime_core::cancellation::CancellationToken::new(); + cancellation.cancel(); + let cancelled_control = PrCommandControl::with_cancellation(cancellation); + let cancelled = PrStoreAdministration { + schedulers: Some(&schedulers), + graph: None, + command_control: &cancelled_control, + }; + let report = reconcile_project_with_administration( + repo.path(), + data_root.path(), + &PrDiscovery::default(), + 10, + cancelled, + ) + .await + .expect("cancelled reconciliation returns a report"); + + assert!(report.untracked.is_empty()); + assert_eq!(report.failures.len(), 1); + assert!( + load_state(data_root.path()) + .expect("reload cancelled state") + .managed + .contains_key(&label), + "cancelled cleanup must preserve durable ownership" + ); + assert!(worktree.exists(), "cancelled cleanup preserves worktree"); + assert!(git_ref_exists(repo.path(), &format!("refs/heads/{label}"))); + assert!(git_ref_exists(repo.path(), &tracking_ref)); + + let retry_control = PrCommandControl::default(); + let retry = PrStoreAdministration { + schedulers: Some(&schedulers), + graph: None, + command_control: &retry_control, + }; + let report = reconcile_project_with_administration( + repo.path(), + data_root.path(), + &PrDiscovery::default(), + 10, + retry, + ) + .await + .expect("retry reconciliation returns a report"); + + assert_eq!(report.untracked, vec![label.clone()]); + assert!(report.failures.is_empty()); + assert!( + load_state(data_root.path()) + .expect("reload cleaned state") + .managed + .is_empty() + ); + assert!(!worktree.exists(), "retry removes worktree"); + assert!(!git_ref_exists(repo.path(), &format!("refs/heads/{label}"))); + assert!(!git_ref_exists(repo.path(), &tracking_ref)); +} + #[tokio::test] async fn reconcile_refuses_malformed_state_before_branch_mutation() { let data_root = tempfile::tempdir().expect("data root"); @@ -206,7 +314,8 @@ async fn reconcile_activates_discovered_pr_head_when_scheduler_is_injected() { .expect("open project graph"), ); let data_root = graph.store_layout().data_root.clone(); - let discovery = discover_open_prs(repo.path()).expect("discover PR head"); + let discovery = discover_open_prs_with_control(repo.path(), default_pr_command_control()) + .expect("discover PR head"); assert_eq!(discovery.open.len(), 1); assert_eq!(discovery.open[0].number, 11); @@ -412,8 +521,8 @@ fn git_ref_exists(repo: &Path, reference: &str) -> bool { std::process::Command::new("git") .args(["rev-parse", "--verify", "--end-of-options", reference]) .current_dir(repo) - .status() - .is_ok_and(|status| status.success()) + .output() + .is_ok_and(|output| output.status.success()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -645,8 +754,8 @@ async fn manual_branch_identity_keeps_slashed_and_underscored_names_disjoint() { ); assert_ne!(slashed.worktree, underscored.worktree); assert_ne!( - manual_branch_worktree_path(&data_root, "feature/a"), - manual_branch_worktree_path(&data_root, "feature_a") + ManualBranchArtifactsV1::for_branch(&data_root, "feature/a").worktree, + ManualBranchArtifactsV1::for_branch(&data_root, "feature_a").worktree ); assert!(git_ref_exists( repo.path(), @@ -768,6 +877,7 @@ async fn failed_manual_branch_sealing_retires_the_exact_mount_worktree_and_track Some(&schedulers), "feature/failure-cleanup", &lifecycle, + default_pr_command_control(), ) .await .expect("activation before synthetic sealing failure"); @@ -928,7 +1038,7 @@ fn manual_artifact_cleanup_accepts_absence_but_refuses_foreign_provenance() { &["update-ref", &artifacts.tracking_ref, &foreign], default_pr_command_control(), ) - .is_some() + .is_ok() ); assert!( @@ -949,7 +1059,8 @@ fn manual_artifact_cleanup_accepts_absence_but_refuses_foreign_provenance() { &artifacts.tracking_ref, &foreign, default_pr_command_control(), - ), + ) + .expect("foreign tracking ref remains readable"), "the foreign tracking ref must remain untouched" ); assert!( @@ -982,7 +1093,8 @@ fn manual_artifact_cleanup_keeps_exact_refs_when_git_authority_is_unavailable() repo.path(), &artifacts.worktree, default_pr_command_control(), - ); + ) + .expect("remove exact worktree"); assert!( !artifacts.worktree.try_exists().expect("inspect worktree"), "the sealed ref retry begins after the linked worktree is absent" diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index df6680eb58..9191faa147 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -917,20 +917,33 @@ impl ProductionProjectCompositionHarnessV1 { .ok_or_else(|| TraceDecayError::Config { message: "production-composition harness is shut down".to_owned(), })?; - let _lifecycle = super::pr_autotrack::try_acquire_manual_branch_lifecycle( - &graph.store_layout().data_root, - branch, - ) - .map_err(|error| { - TraceDecayError::project_route(error.reason_code(), error.retryable(), error.detail()) - })?; - super::branch_add::branch_publication_context(&graph)? - .track_exact_worktree_branch( - &resources.invocation.code_index_schedulers, - &canonical_project_root, - worktree_root.as_ref(), - branch, - ) + let administration = resources.store_administration.clone(); + let schedulers = resources.invocation.code_index_schedulers.clone(); + let worktree_root = worktree_root.as_ref().to_path_buf(); + let branch = branch.to_owned(); + administration + .run_manual_branch_publication(|cancellation| async move { + let _lifecycle = super::pr_autotrack::try_acquire_manual_branch_lifecycle( + &graph.store_layout().data_root, + &branch, + ) + .map_err(|error| { + TraceDecayError::project_route( + error.reason_code(), + error.retryable(), + error.detail(), + ) + })?; + super::branch_add::branch_publication_context(&graph)? + .track_exact_worktree_branch( + &schedulers, + &canonical_project_root, + &worktree_root, + &branch, + &cancellation, + ) + .await + }) .await } @@ -1105,6 +1118,11 @@ impl Drop for ProductionProjectCompositionHarnessV1 { #[cfg(any(test, feature = "test-transport"))] async fn shutdown_production_project_harness(mut resources: ProductionProjectHarnessResourcesV1) { + #[cfg(unix)] + resources + .store_administration + .shutdown_manual_branch_publications() + .await; resources .store_administration .join_project_server_retirements() diff --git a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs index bbc8fd6178..eb78c02ce5 100644 --- a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs +++ b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs @@ -39,7 +39,7 @@ async fn manual_branch_add_journey_is_joined_by_daemon_shutdown() { let (release_sender, release_receiver) = tokio::sync::oneshot::channel(); let request = tokio::spawn(async move { administration - .run_manual_branch_publication(async move { + .run_manual_branch_publication(|_| async move { let _ = started_sender.send(()); let _ = release_receiver.await; Ok(tracedecay_runtime_core::branch::BranchAddOutcome::Added) @@ -57,7 +57,7 @@ async fn manual_branch_add_journey_is_joined_by_daemon_shutdown() { ]); let denied = engine .store_administration - .run_manual_branch_publication(async { + .run_manual_branch_publication(|_| async { Ok(tracedecay_runtime_core::branch::BranchAddOutcome::AlreadyTracked) }) .await @@ -93,6 +93,65 @@ async fn manual_branch_add_journey_is_joined_by_daemon_shutdown() { ); } +#[cfg(unix)] +#[tokio::test(start_paused = true)] +async fn stalled_manual_branch_publication_settles_before_shutdown_receipt() { + let engine = DaemonEngine::default(); + let administration = engine.store_administration.clone(); + let mutation_after_terminal = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mutation = std::sync::Arc::clone(&mutation_after_terminal); + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let request = tokio::spawn(async move { + administration + .run_manual_branch_publication( + move |cancellation: tracedecay_runtime_core::cancellation::CancellationToken| async move { + let _ = started_sender.send(()); + tokio::select! { + () = cancellation.cancelled() => { + Err(tracedecay_domain::errors::TraceDecayError::project_route( + "branch_tracking_failed", + true, + "manual branch publication cancelled by daemon shutdown", + )) + } + () = tokio::time::sleep(std::time::Duration::from_mins(1)) => { + mutation.store(true, std::sync::atomic::Ordering::Release); + Ok(tracedecay_runtime_core::branch::BranchAddOutcome::Added) + } + } + }, + ) + .await + }); + started_receiver + .await + .expect("manual branch publication starts"); + + let prepared = crate::daemon::shutdown_coordination::prepare_shutdown_owner_phases( + engine.shutdown_owner_phases().await, + ); + let shutdown = tokio::spawn(async move { + prepared + .join(tokio::time::Instant::now() + std::time::Duration::from_secs(15)) + .await + }); + tokio::time::advance(std::time::Duration::from_secs(16)).await; + let receipt = shutdown.await.expect("shutdown joins"); + assert!( + receipt.unfinished().is_empty(), + "cooperative cancellation must settle before the terminal receipt" + ); + assert!( + request.await.expect("publication request joins").is_err(), + "cancelled publication must report failure" + ); + tokio::time::advance(std::time::Duration::from_mins(1)).await; + assert!( + !mutation_after_terminal.load(std::sync::atomic::Ordering::Acquire), + "manual publication mutated state after shutdown terminal receipt" + ); +} + #[cfg(unix)] #[tokio::test] async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { diff --git a/crates/tracedecay/tests/daemon_suite/main.rs b/crates/tracedecay/tests/daemon_suite/main.rs index 6f7f46e62b..da47ec6e93 100644 --- a/crates/tracedecay/tests/daemon_suite/main.rs +++ b/crates/tracedecay/tests/daemon_suite/main.rs @@ -30,7 +30,6 @@ mod indexing_lifecycle_test; mod invocation_observability; mod invocation_primitives; #[cfg(unix)] -mod pr_autotrack_test; #[cfg(unix)] mod stale_client_resilience_test; mod workflow_handoff_test; diff --git a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs b/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs deleted file mode 100644 index 9b265dcc2f..0000000000 --- a/crates/tracedecay/tests/daemon_suite/pr_autotrack_test.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Production-boundary tests for PR discovery and scheduler admission. -//! -//! PR and manual-branch worktree activation is owned by the retained daemon -//! code-index scheduler. The public reconciliation and `activate_manual_branch` -//! boundaries therefore fail closed until that scheduler is injected; they -//! must not fall back to the retired per-branch SQLite graph implementation -//! or mutate Git state before admission. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use crate::common::fixture::{GitFixture, RegisteredProject, TestProfile, git_run}; -use tracedecay::daemon::pr_autotrack; -use tracedecay::tracedecay::TraceDecay; -use tracedecay_application::pr_tracking; - -struct PrProject { - repo: GitFixture, - origin: PathBuf, - project: RegisteredProject, -} - -impl PrProject { - async fn enrolled_with_origin() -> Self { - let profile = TestProfile::acquire().await; - let repo = GitFixture::primary(profile.path("project")); - fs::create_dir_all(repo.root().join("src")).unwrap(); - fs::write(repo.root().join("src/lib.rs"), "pub fn on_main() {}\n").unwrap(); - repo.commit_all("initial commit"); - - let project = profile.enroll(repo.root()).await; - let origin = repo.with_bare_origin(); - - Self { - repo, - origin, - project, - } - } - - fn root(&self) -> &Path { - self.project.root() - } - - fn data_root(&self) -> &Path { - self.project.data_root() - } - - fn graph(&self) -> &Arc { - self.project.graph() - } - - fn git(&self, args: &[&str]) { - self.repo.run(args); - } - - fn origin_git(&self, args: &[&str]) { - git_run(&self.origin, args); - } - - fn discover(&self) -> pr_tracking::PrDiscovery { - pr_tracking::discover_open_prs(self.root()).expect("PR discovery succeeds") - } - - async fn reconcile( - &self, - discovery: &pr_tracking::PrDiscovery, - cap: usize, - ) -> pr_autotrack::ReconcileReport { - pr_autotrack::reconcile_project( - Arc::clone(self.graph()), - self.root(), - self.data_root(), - discovery, - cap, - ) - .await - } - - fn add_same_repo_pr(&self, number: u64, symbol: &str) -> String { - let branch = format!("feature-{number}"); - self.git(&["checkout", "-b", &branch, "main"]); - fs::write( - self.root().join(format!("src/pr_{number}.rs")), - format!("pub fn {symbol}() {{}}\n"), - ) - .unwrap(); - self.repo.commit_all(&format!("PR {number} content")); - self.git(&["push", "origin", &branch]); - self.origin_git(&[ - "update-ref", - &format!("refs/pull/{number}/head"), - &format!("refs/heads/{branch}"), - ]); - self.git(&["checkout", "main"]); - self.git(&["branch", "-D", &branch]); - branch - } - - fn add_fork_pr(&self, number: u64, symbol: &str) { - self.git(&["checkout", "-b", "tmp-fork", "main"]); - fs::write( - self.root().join("src/fork.rs"), - format!("pub fn {symbol}() {{}}\n"), - ) - .unwrap(); - self.repo.commit_all("fork content"); - let sha = self.repo.head_sha(); - self.git(&["checkout", "main"]); - self.git(&["branch", "-D", "tmp-fork"]); - fs::remove_file(self.root().join("src/fork.rs")).ok(); - self.git(&["push", "origin", &format!("{sha}:refs/pull/{number}/head")]); - } -} - -#[tokio::test] -async fn discovery_classifies_same_repo_and_fork_pull_heads() { - let fixture = PrProject::enrolled_with_origin().await; - let head_branch = fixture.add_same_repo_pr(1, "pr_one_symbol"); - fixture.add_fork_pr(2, "fork_symbol"); - - let discovery = fixture.discover(); - - assert_eq!(discovery.open.len(), 1); - assert_eq!(discovery.open[0].number, 1); - assert_eq!(discovery.open[0].head_branch, head_branch); - assert!(!discovery.open[0].head_sha.is_empty()); - assert_eq!(discovery.skipped_forks, vec![2]); -} - -#[tokio::test] -async fn reconciliation_without_scheduler_fails_before_git_or_state_mutation() { - let fixture = PrProject::enrolled_with_origin().await; - fixture.add_same_repo_pr(7, "pr_seven_symbol"); - let discovery = fixture.discover(); - - let report = fixture.reconcile(&discovery, 10).await; - - assert!(report.tracked.is_empty()); - assert!(report.untracked.is_empty()); - assert_eq!(report.failures.len(), 1); - assert_eq!(report.failures[0].0, "project"); - assert!( - report.failures[0] - .1 - .starts_with("code_index_scheduler_unavailable:") - ); - assert!( - pr_tracking::managed_summary(fixture.data_root()) - .expect("read managed PR state") - .is_empty() - ); - assert!(!fixture.data_root().join("pr-worktrees").exists()); - assert!( - !fixture - .repo - .output(&["rev-parse", "--verify", "refs/tracedecay/pr/7"]) - .status - .success() - ); -} - -#[tokio::test] -async fn manual_branch_without_scheduler_fails_before_git_or_state_mutation() { - let fixture = PrProject::enrolled_with_origin().await; - fixture.git(&["checkout", "-b", "feature-manual", "main"]); - fixture.git(&["checkout", "main"]); - - let result = pr_autotrack::activate_manual_branch( - Arc::clone(fixture.graph()), - fixture.root(), - "feature-manual", - ) - .await; - - match result { - Err(pr_autotrack::ManualBranchActivationError::SchedulerUnavailable { .. }) => {} - other => panic!("expected SchedulerUnavailable, got {other:?}"), - } - assert!(!fixture.data_root().join("branch-worktrees").exists()); - assert!( - !fixture - .repo - .output(&[ - "rev-parse", - "--verify", - "refs/tracedecay/branch/feature-manual" - ]) - .status - .success() - ); -} - -#[tokio::test] -async fn failed_discovery_is_not_reported_as_an_empty_success() { - let fixture = PrProject::enrolled_with_origin().await; - fixture.git(&["remote", "set-url", "origin", "/definitely/not/a/repo.git"]); - - let result = pr_tracking::discover_open_prs(fixture.root()); - - assert!( - result.is_err(), - "a failed discovery command must surface as Err so callers cannot interpret it as every PR closing" - ); -} From 51d1cacbd12a8f93d52bb9b3884ad3bc7cea7a0d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 08:11:16 +0000 Subject: [PATCH 08/11] test(code-index): prove cancelled publication rollback --- .../tests/branch_publication_tests.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs index 65555aa2d0..c861196962 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/branch_publication_tests.rs @@ -5,6 +5,7 @@ use tempfile::TempDir; use tracedecay_dashboard_api::code_index_freshness_api::{ CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1, }; +use tracedecay_runtime_core::cancellation::CancellationToken; use super::super::branch_publication::{ BranchPublicationContextV1, branch_generation_work_is_active, @@ -87,6 +88,69 @@ async fn exact_branch_source_uses_the_mounted_git_identity() { registry.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelled_generation_wait_rolls_back_prepared_branch_metadata() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let project_id = test_project_id(); + let registry = mounted_registry(&fixture, &store).await; + std::fs::write( + fixture.path().join("src/lib.rs"), + b"pub fn changed_after_mount() {}\n", + ) + .expect("advance branch source"); + super::git(fixture.path(), &["add", "src/lib.rs"]); + super::git(fixture.path(), &["commit", "-qm", "advance branch"]); + + let context = + BranchPublicationContextV1::new(Some(project_id.as_str()), fixture.path(), store.path()) + .expect("branch publication context"); + let cancellation = CancellationToken::new(); + let publication_cancellation = cancellation.clone(); + let publication_registry = registry.clone(); + let project_root = fixture.path().to_path_buf(); + let worktree_root = project_root.clone(); + let publication = tokio::spawn(async move { + context + .track_exact_worktree_branch( + &publication_registry, + &project_root, + &worktree_root, + "cancelled-publication", + &publication_cancellation, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if tracedecay_runtime_core::branch_meta::load_branch_meta(store.path()) + .is_some_and(|meta| meta.is_tracked("cancelled-publication")) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("branch metadata preparation becomes visible"); + cancellation.cancel(); + + let error = publication + .await + .expect("publication task joins") + .expect_err("cancelled publication must fail"); + assert_eq!( + error.project_route_context().map(|context| context.0), + Some("branch_tracking_failed") + ); + assert!( + tracedecay_runtime_core::branch_meta::load_branch_meta(store.path()) + .is_some_and(|meta| !meta.is_tracked("cancelled-publication")), + "cancelled generation wait must roll back prepared metadata" + ); + registry.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn missing_retained_project_root_is_a_typed_path_error() { let fixture = GitFixture::new(ALPHA_LIB_V1); From 3b75d3b1df6d8fe4e21dc3c8d9a31c2f311452d7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 16:15:02 +0000 Subject: [PATCH 09/11] fix(settings): preflight PR state before configuration writes --- crates/tracedecay-dashboard-api/src/lib.rs | 10 ++++- .../src/settings_api.rs | 37 ++++++++++++++++--- .../tests/dashboard_api_test/runtime.rs | 16 +++++++- .../tests/dashboard_api_test/settings.rs | 32 ++++++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index f0fbe625e8..6b657bd0bf 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -535,6 +535,7 @@ pub struct DashboardHostAdmissionTestAuthorityV1 { profile_code_index_worker_settings: Option>, application_invocation_executor: Option>, + pr_autotrack_reader: Option, } #[cfg(feature = "test-transport")] @@ -560,9 +561,15 @@ impl DashboardHostAdmissionTestAuthorityV1 { delivery_read_authority: None, profile_code_index_worker_settings: None, application_invocation_executor: None, + pr_autotrack_reader: None, } } + pub fn with_pr_autotrack_reader(mut self, reader: PrAutoTrackManagedSummaryReader) -> Self { + self.pr_autotrack_reader = Some(reader); + self + } + /// Attaches the daemon-owned application runtime used by mutating /// dashboard routes in an integration-test transport. #[must_use] @@ -1082,7 +1089,8 @@ where code_index_freshness_reader: None, explorer_semantic_reader: None, feedback_status_reader: None, - pr_autotrack_reader: None, + pr_autotrack_reader: test_authority + .and_then(|authority| authority.pr_autotrack_reader.clone()), code_diagnostics_broker: Some(code_diagnostics_broker), application_invocation_executor: test_authority .and_then(|authority| authority.application_invocation_executor.clone()), diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index ee2a0afae9..ea6ebbf461 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -321,7 +321,9 @@ pub type PrAutoTrackManagedSummaryReader = Arc< #[hotpath::measure(label = "dashboard_api.settings.get", future = true)] pub async fn get_settings(State(state): State) -> ApiResult { - Ok(Json(settings_envelope(&state, None, None, None).await?)) + Ok(Json( + settings_envelope(&state, None, None, None, pr_autotrack_payload(&state)?).await?, + )) } #[hotpath::measure(label = "dashboard_api.settings.patch_project", future = true)] @@ -330,6 +332,7 @@ pub async fn patch_project_settings( Json(patch): Json, ) -> ProjectSettingsPatchResult { let patch = parse_project_settings_patch(patch)?; + let pr_autotrack = pr_autotrack_payload(&state)?; let idempotency_key = ConfigurationIdempotencyKey::new(patch.idempotency_key.clone()).map_err(|_| { settings_validation_error(json!([{ @@ -405,7 +408,14 @@ pub async fn patch_project_settings( Ok(Json(ProjectSettingsPatchResponseV1 { application_outcome, - current: settings_envelope(&state, Some(preview.resync_recommended), None, None).await?, + current: settings_envelope( + &state, + Some(preview.resync_recommended), + None, + None, + pr_autotrack, + ) + .await?, })) } @@ -415,6 +425,7 @@ pub async fn patch_user_settings( Json(patch): Json, ) -> ApiResult { let patch = parse_user_settings_patch(patch)?; + let pr_autotrack = pr_autotrack_payload(&state)?; validate_user_settings_patch(&patch, |value| parse_duration_millis(value).is_some())?; let idempotency_key = ConfigurationIdempotencyKey::new(patch.idempotency_key.clone()).map_err(|_| { @@ -470,7 +481,14 @@ pub async fn patch_user_settings( } Ok(Json( - settings_envelope(&state, None, Some(plan.restart_recommended), None).await?, + settings_envelope( + &state, + None, + Some(plan.restart_recommended), + None, + pr_autotrack, + ) + .await?, )) } @@ -483,6 +501,7 @@ pub async fn patch_code_index_worker_settings( Json(patch): Json, ) -> ApiResult { let patch = parse_code_index_worker_settings_patch(patch)?; + let pr_autotrack = pr_autotrack_payload(&state)?; validate_code_index_worker_settings_patch(&patch)?; let worker_admission_errors = code_index_worker_admission_errors( &patch.code_index_workers, @@ -531,7 +550,14 @@ pub async fn patch_code_index_worker_settings( }; Ok(Json( - settings_envelope(&state, None, Some(true), Some(&committed.current)).await?, + settings_envelope( + &state, + None, + Some(true), + Some(&committed.current), + pr_autotrack, + ) + .await?, )) } @@ -573,6 +599,7 @@ async fn settings_envelope( resync_recommended: Option, restart_recommended: Option, committed_worker_configuration: Option<&DashboardCodeIndexWorkerConfigurationV1>, + pr_autotrack: PrAutoTrackPayloadV1, ) -> std::result::Result, DashboardConfigurationRouteErrorV1> { let project_configuration = crate::config::cached_runtime_configuration(&state.project_root) @@ -607,7 +634,7 @@ async fn settings_envelope( configuration_revision_id: project_configuration.revision_id().as_str().to_owned(), config: project_editable_settings(&project_configuration), tracedecay_dir_gitignored: crate::config::is_in_gitignore(&state.project_root), - pr_autotrack: pr_autotrack_payload(state)?, + pr_autotrack, }, user: user_settings_payload(&user, &worker_configuration), automation, diff --git a/crates/tracedecay/tests/dashboard_api_test/runtime.rs b/crates/tracedecay/tests/dashboard_api_test/runtime.rs index 79f225506a..e13396e413 100644 --- a/crates/tracedecay/tests/dashboard_api_test/runtime.rs +++ b/crates/tracedecay/tests/dashboard_api_test/runtime.rs @@ -226,7 +226,21 @@ impl DashboardTestRuntimeV1 { Arc::clone(self), self.profile_database.clone(), self.project_database.clone(), - )) + ) + .with_pr_autotrack_reader(Arc::new(|root| { + tracedecay_application::pr_tracking::managed_summary(&root).map(|entries| { + entries + .into_iter() + .map( + |entry| tracedecay_dashboard_api::PrAutoTrackManagedSummaryEntryV1 { + branch: entry.branch, + pr: entry.pr, + head_branch: entry.head_branch, + }, + ) + .collect() + }) + }))) } /// The dashboard authority plus the daemon-owned LCM and verified graph diff --git a/crates/tracedecay/tests/dashboard_api_test/settings.rs b/crates/tracedecay/tests/dashboard_api_test/settings.rs index a1cc0404b7..580aa28200 100644 --- a/crates/tracedecay/tests/dashboard_api_test/settings.rs +++ b/crates/tracedecay/tests/dashboard_api_test/settings.rs @@ -535,3 +535,35 @@ fn settings_dashboard_api_round_trips_profile_worker_selection_after_reviewed_pa ); }); } + +#[test] +fn settings_patch_rejects_unreadable_pr_state_before_mutation() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + create_runtime().block_on(async { + let fixture = start_dashboard_configuration_fixture().await; + let agent = http_agent(); + let url = format!("{}/api/settings", fixture.base_url); + let (status, before) = get_json(&agent, &url); + assert_eq!(status, 200, "{before}"); + let payload = &before["payload"]; + let pr_state = std::path::Path::new(payload["storage"]["store_root"].as_str().unwrap()).join("pr-autotrack.json"); + std::fs::write(&pr_state, "{broken").unwrap(); + for (route, patch) in [ + ("project", json!({"expected_revision_id": payload["project"]["configuration_revision_id"], "idempotency_key": "configuration.idempotency.corrupt-pr-project", "max_file_size": 4096})), + ("user", json!({"expected_revision_id": payload["user"]["configuration_revision_id"], "idempotency_key": "configuration.idempotency.corrupt-pr-user", "watcher_debounce": "3s"})), + ("user/code-index-workers", json!({"expected_revision_id": payload["user"]["code_index_worker_configuration_revision_id"], "idempotency_key": "configuration.idempotency.corrupt-pr-workers", "code_index_workers": {"mode": "exact", "workers": 1}})), + ] { + let (status, error) = patch_json_body(&agent, &format!("{url}/{route}"), &patch); + assert_eq!(status, 503, "{route}: {error}"); + assert_eq!(error["code"], "configuration_authority_unavailable"); + } + std::fs::remove_file(pr_state).unwrap(); + let (status, after) = get_json(&agent, &url); + assert_eq!(status, 200, "{after}"); + for (scope, field) in [("project", "configuration_revision_id"), ("user", "configuration_revision_id"), ("user", "code_index_worker_configuration_revision_id")] { + assert_eq!(after["payload"][scope][field], payload[scope][field], "failed PATCH must not commit {scope}/{field}"); + } + }); +} From 3703e1b4db8361b127b13a7861bb374cfcad6e06 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 16:19:39 +0000 Subject: [PATCH 10/11] fix(daemon): retain publication failures in shutdown receipts --- crates/tracedecay/src/daemon/branch_admin.rs | 21 ++++++++++++++- .../tracedecay/src/daemon/engine/shutdown.rs | 12 ++++++--- .../src/daemon/production_harness.rs | 13 ++++++++-- .../src/daemon/tests/scheduler_shutdown.rs | 26 +++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay/src/daemon/branch_admin.rs b/crates/tracedecay/src/daemon/branch_admin.rs index 6a3c1d8927..4a24d3f42f 100644 --- a/crates/tracedecay/src/daemon/branch_admin.rs +++ b/crates/tracedecay/src/daemon/branch_admin.rs @@ -480,6 +480,7 @@ pub(super) struct StoreAdministration { #[cfg(unix)] struct ManualBranchPublicationTasks { closed: AtomicBool, + join_failed: AtomicBool, cancellation: CancellationToken, tasks: tokio::sync::Mutex>, } @@ -489,6 +490,7 @@ impl Default for ManualBranchPublicationTasks { fn default() -> Self { Self { closed: AtomicBool::new(false), + join_failed: AtomicBool::new(false), cancellation: CancellationToken::new(), tasks: tokio::sync::Mutex::new(tokio::task::JoinSet::new()), } @@ -625,6 +627,9 @@ impl StoreAdministration { } while let Some(result) = tasks.try_join_next() { if let Err(error) = result { + self.manual_branch_publications + .join_failed + .store(true, Ordering::Release); super::log_daemon_event( "manual_branch_publication", &[ @@ -658,7 +663,9 @@ impl StoreAdministration { } #[cfg(unix)] - pub(super) async fn shutdown_manual_branch_publications(&self) { + pub(super) async fn shutdown_manual_branch_publications( + &self, + ) -> std::result::Result<(), String> { self.cancel_manual_branch_publications(); let mut tasks = { let mut owned = self.manual_branch_publications.tasks.lock().await; @@ -666,6 +673,9 @@ impl StoreAdministration { }; while let Some(result) = tasks.join_next().await { if let Err(error) = result { + self.manual_branch_publications + .join_failed + .store(true, Ordering::Release); super::log_daemon_event( "manual_branch_publication", &[ @@ -676,6 +686,15 @@ impl StoreAdministration { ); } } + if self + .manual_branch_publications + .join_failed + .load(Ordering::Acquire) + { + Err("manual branch publication task failed to join".to_owned()) + } else { + Ok(()) + } } pub(super) fn configure_codex_preparation_resources( diff --git a/crates/tracedecay/src/daemon/engine/shutdown.rs b/crates/tracedecay/src/daemon/engine/shutdown.rs index 9a043cd529..551ab677fc 100644 --- a/crates/tracedecay/src/daemon/engine/shutdown.rs +++ b/crates/tracedecay/src/daemon/engine/shutdown.rs @@ -61,13 +61,17 @@ impl DaemonEngine { .map(crate::daemon::pr_autotrack::PrAutotrackTask::cancellation); vec![ - vec![ShutdownOwner::new( + vec![ShutdownOwner::with_deadline_status( "manual_branch_publication", move || manual_branch_cancel.cancel_manual_branch_publications(), - async move { - manual_branch_join + move |_| async move { + match manual_branch_join .shutdown_manual_branch_publications() - .await; + .await + { + Ok(()) => ShutdownStatus::Clean, + Err(reason) => ShutdownStatus::Failed(reason), + } }, )], vec![ShutdownOwner::with_deadline_status( diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index 9191faa147..43bd1d7f93 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -1119,10 +1119,19 @@ impl Drop for ProductionProjectCompositionHarnessV1 { #[cfg(any(test, feature = "test-transport"))] async fn shutdown_production_project_harness(mut resources: ProductionProjectHarnessResourcesV1) { #[cfg(unix)] - resources + if let Err(reason) = resources .store_administration .shutdown_manual_branch_publications() - .await; + .await + { + super::log_daemon_event( + "manual_branch_publication", + &[ + ("outcome", "harness_shutdown_failed".to_owned()), + ("reason", reason), + ], + ); + } resources .store_administration .join_project_server_retirements() diff --git a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs index eb78c02ce5..9d95c43c21 100644 --- a/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs +++ b/crates/tracedecay/src/daemon/tests/scheduler_shutdown.rs @@ -761,3 +761,29 @@ async fn scheduler_shutdown_does_not_wait_for_contended_administration_gate() { "normal scheduler shutdown must not queue behind unrelated writer administration" ); } + +#[cfg(unix)] +#[tokio::test] +async fn manual_branch_publication_panic_survives_reaping_in_shutdown_receipt() { + let engine = DaemonEngine::default(); + let failure = engine + .store_administration + .run_manual_branch_publication(|_| async { panic!("publication owner failed") }) + .await; + assert!(failure.is_err()); + engine + .store_administration + .run_manual_branch_publication(|_| async { + Ok(tracedecay_runtime_core::branch::BranchAddOutcome::AlreadyTracked) + }) + .await + .unwrap(); + let mut phases = engine.shutdown_owner_phases().await; + let receipt = + crate::daemon::shutdown_coordination::prepare_shutdown_owner_phases(vec![phases.remove(0)]) + .join(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; + assert!( + matches!(&receipt.owners[0].status, crate::daemon::shutdown_coordination::ShutdownStatus::Failed(reason) if reason.contains("failed to join")) + ); +} From 6f125b9a53492f8513c2e80de0655ee129aab022 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 9 Sep 2026 16:25:56 +0000 Subject: [PATCH 11/11] fix(branch): stage new heads before retiring published worktrees --- crates/tracedecay/src/daemon/branch_add.rs | 27 ++- crates/tracedecay/src/daemon/pr_autotrack.rs | 118 +++------- .../src/daemon/pr_autotrack/tests.rs | 205 +++++++++++++++--- 3 files changed, 237 insertions(+), 113 deletions(-) diff --git a/crates/tracedecay/src/daemon/branch_add.rs b/crates/tracedecay/src/daemon/branch_add.rs index 929a62f5c4..59e2040d43 100644 --- a/crates/tracedecay/src/daemon/branch_add.rs +++ b/crates/tracedecay/src/daemon/branch_add.rs @@ -173,7 +173,7 @@ async fn activate_and_track_manual_branch( #[cfg(unix)] #[hotpath::measure(label = "daemon.branch_add.owner", future = true)] -async fn activate_and_track_manual_branch_owned( +pub(super) async fn activate_and_track_manual_branch_owned( project_root: std::path::PathBuf, graph: Arc, schedulers: CodeIndexSchedulerRegistryV1, @@ -202,6 +202,12 @@ async fn activate_and_track_manual_branch_owned( "manual branch lifecycle lease does not match branch sealing request", )); } + let previous_source = tracedecay_runtime_core::branch_meta::load_branch_meta(&data_root) + .and_then(|meta| { + meta.branches + .get(&branch) + .and_then(|entry| entry.graph_source.clone()) + }); let tracked = publication .track_exact_worktree_branch( &schedulers, @@ -212,7 +218,24 @@ async fn activate_and_track_manual_branch_owned( ) .await; match tracked { - Ok(outcome) => Ok(outcome), + Ok(outcome) => { + if outcome != BranchAddOutcome::Deferred + && let Some(previous) = previous_source + && previous.source_oid != activation.head_sha + && previous.worktree_root != activation.worktree.to_string_lossy() + && super::pr_autotrack::manual_branch_source_owns_artifacts( + &data_root, &branch, &previous, + ) + { + super::pr_autotrack::cleanup_manual_branch_retirement( + &project_root, &data_root, &schedulers, &branch, &previous, lifecycle, + ).await.map_err(|error| TraceDecayError::project_route( + error.reason_code(), error.retryable(), + format!("branch publication committed; prior generation retirement failed: {error}"), + ))?; + } + Ok(outcome) + } Err(error) if activation.outcome == BranchAddOutcome::Added => { super::pr_autotrack::cleanup_manual_branch_activation( &project_root, diff --git a/crates/tracedecay/src/daemon/pr_autotrack.rs b/crates/tracedecay/src/daemon/pr_autotrack.rs index eb98b451b4..6dfb59835c 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack.rs @@ -105,6 +105,19 @@ impl ManualBranchArtifactsV1 { } } + /// Each head is staged independently so interruption cannot destroy the + /// artifacts still named by the previously published branch provenance. + pub(crate) fn for_head(data_root: &Path, branch: &str, head: &str) -> Self { + let mut artifacts = Self::for_branch(data_root, branch); + let generation = sha256_hex(head.as_bytes()); + artifacts + .worktree + .set_file_name(format!("{}-{generation}", artifacts.branch_digest)); + artifacts.tracking_ref = format!("{}-{generation}", artifacts.tracking_ref); + artifacts.label = format!("{}-{generation}", artifacts.label); + artifacts + } + /// Lifecycle locks live beside `branch-worktrees`, never inside it. The /// lease is taken before the branch identity is resolved, so a typed /// pre-mutation refusal (missing ref, unavailable Git authority) must not @@ -458,22 +471,26 @@ async fn activate_manual_branch_with_administration( "manual branch lifecycle lease changed before activation", )); } - let artifacts = ManualBranchArtifactsV1::for_branch(data_root, branch); + let artifacts = ManualBranchArtifactsV1::for_head(data_root, branch, &head_sha); let worktree = artifacts.worktree.clone(); if worktree.try_exists().map_err(|error| { ManualBranchActivationError::git_unavailable(format!( "cannot inspect manual worktree '{}': {error}", worktree.display() )) - })? && schedulers.is_worktree_mounted(&worktree).await - && manual_branch_artifacts_match_off_runtime( - repo_root, - &artifacts, - &head_sha, - administration.command_control.clone(), - ) - .await? + })? && manual_branch_artifacts_match_off_runtime( + repo_root, + &artifacts, + &head_sha, + administration.command_control.clone(), + ) + .await? { + if !schedulers.is_worktree_mounted(&worktree).await { + activate_linked_worktree(schedulers, graph, &worktree) + .await + .map_err(ManualBranchActivationError::activation_failed)?; + } return Ok(ManualBranchActivation { branch: branch.to_string(), head_sha, @@ -490,36 +507,10 @@ async fn activate_manual_branch_with_administration( worktree.display() )) })? { - let replacement_head = manual_branch_owned_head_off_runtime( - repo_root, - &artifacts, - administration.command_control.clone(), - ) - .await? - .ok_or_else(|| { - ManualBranchActivationError::activation_failed(format!( - "existing manual worktree '{}' does not prove ownership for branch '{branch}'", - worktree.display() - )) - })?; - retire_worktree_mount(Some(schedulers), &worktree) - .await - .map_err(ManualBranchActivationError::activation_failed)?; - if !cleanup_owned_worktree_off_runtime( - repo_root, - &worktree, - &tracking_ref, - &label, - &replacement_head, - administration.command_control.clone(), - ) - .await? - { - return Err(ManualBranchActivationError::activation_failed(format!( - "existing manual worktree '{}' changed before replacement", - worktree.display() - ))); - } + return Err(ManualBranchActivationError::activation_failed(format!( + "existing manual worktree '{}' does not match requested branch generation", + worktree.display() + ))); } let repo = repo_root.to_path_buf(); let wt = worktree.clone(); @@ -694,7 +685,8 @@ pub(crate) async fn cleanup_manual_branch_activation( "manual branch lifecycle lease does not match failed activation", )); } - let artifacts = ManualBranchArtifactsV1::for_branch(data_root, &activation.branch); + let artifacts = + ManualBranchArtifactsV1::for_head(data_root, &activation.branch, &activation.head_sha); if artifacts.worktree != activation.worktree { return Err(ManualBranchActivationError::activation_failed(format!( "failed activation worktree '{}' does not match exact branch identity", @@ -748,7 +740,7 @@ pub(crate) async fn cleanup_manual_branch_retirement( "stored branch provenance does not own manual artifacts for '{branch}'" ))); } - let artifacts = ManualBranchArtifactsV1::for_branch(data_root, branch); + let artifacts = ManualBranchArtifactsV1::for_head(data_root, branch, &source.source_oid); let expected_worktree = artifacts .worktree .canonicalize() @@ -807,7 +799,8 @@ pub(crate) fn manual_branch_source_owns_artifacts( let canonical_data_root = data_root .canonicalize() .unwrap_or_else(|_| data_root.to_path_buf()); - let artifacts = ManualBranchArtifactsV1::for_branch(&canonical_data_root, branch); + let artifacts = + ManualBranchArtifactsV1::for_head(&canonical_data_root, branch, &source.source_oid); let worktree = artifacts .worktree .canonicalize() @@ -920,24 +913,6 @@ async fn manual_branch_artifacts_match_off_runtime( })? } -async fn manual_branch_owned_head_off_runtime( - repo_root: &Path, - artifacts: &ManualBranchArtifactsV1, - command_control: PrCommandControl, -) -> std::result::Result, ManualBranchActivationError> { - let repo_root = repo_root.to_path_buf(); - let artifacts = artifacts.clone(); - tokio::task::spawn_blocking(move || { - manual_branch_owned_head(&repo_root, &artifacts, &command_control) - }) - .await - .map_err(|error| { - ManualBranchActivationError::activation_failed(format!( - "manual branch ownership inspection task did not complete: {error}" - )) - })? -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ManualBranchArtifactOwnershipV1 { Absent, @@ -1220,29 +1195,6 @@ fn manual_branch_artifacts_match( )?) } -fn manual_branch_owned_head( - repo_root: &Path, - artifacts: &ManualBranchArtifactsV1, - command_control: &PrCommandControl, -) -> std::result::Result, ManualBranchActivationError> { - let branch_ref = format!("refs/heads/{}", artifacts.label); - let ExactRefReadV1::Present(head) = - read_exact_ref(repo_root, &artifacts.tracking_ref, command_control)? - else { - return Ok(None); - }; - if manual_branch_artifacts_match(repo_root, artifacts, &head, command_control)? { - return Ok(Some(head)); - } - if !checked_path_exists(&artifacts.worktree)? - && exact_ref_ownership(repo_root, &branch_ref, &head, command_control)? - == ManualBranchArtifactOwnershipV1::Exact - { - return Ok(Some(head)); - } - Ok(None) -} - fn worktree_matches_branch_head( _repo_root: &Path, worktree: &Path, diff --git a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs index f81d834612..2f6c76f120 100644 --- a/crates/tracedecay/src/daemon/pr_autotrack/tests.rs +++ b/crates/tracedecay/src/daemon/pr_autotrack/tests.rs @@ -558,7 +558,12 @@ async fn manual_branch_activates_when_scheduler_is_injected() { ); assert!(git_ref_exists( repo.path(), - "refs/tracedecay/branch/feature-manual" + &ManualBranchArtifactsV1::for_head( + &graph.store_layout().data_root, + "feature-manual", + &activation.head_sha + ) + .tracking_ref )); schedulers.shutdown().await; } @@ -654,7 +659,8 @@ async fn retained_linked_worktree_honors_parent_native_graph_refusal() { default_pr_command_control(), ) .expect("resolve linked-worktree head"); - let artifacts = ManualBranchArtifactsV1::for_branch(&data_root, "feature-retained-refusal"); + let artifacts = + ManualBranchArtifactsV1::for_head(&data_root, "feature-retained-refusal", &head); prepare_manual_branch_worktree( repo.path(), &linked, @@ -754,22 +760,23 @@ async fn manual_branch_identity_keeps_slashed_and_underscored_names_disjoint() { ); assert_ne!(slashed.worktree, underscored.worktree); assert_ne!( - ManualBranchArtifactsV1::for_branch(&data_root, "feature/a").worktree, - ManualBranchArtifactsV1::for_branch(&data_root, "feature_a").worktree + ManualBranchArtifactsV1::for_head(&data_root, "feature/a", &slashed.head_sha).worktree, + ManualBranchArtifactsV1::for_head(&data_root, "feature_a", &underscored.head_sha).worktree ); assert!(git_ref_exists( repo.path(), - "refs/tracedecay/branch/feature/a" + &ManualBranchArtifactsV1::for_head(&data_root, "feature/a", &slashed.head_sha).tracking_ref )); assert!(git_ref_exists( repo.path(), - "refs/tracedecay/branch/feature_a" + &ManualBranchArtifactsV1::for_head(&data_root, "feature_a", &underscored.head_sha) + .tracking_ref )); schedulers.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn manual_branch_replaces_a_mounted_worktree_when_the_resolved_head_advances() { +async fn manual_branch_stages_new_head_without_replacing_published_worktree() { use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; let repo = tempfile::tempdir().unwrap(); @@ -785,6 +792,25 @@ async fn manual_branch_replaces_a_mounted_worktree_when_the_resolved_head_advanc .await .expect("initial activation"); + let publication = crate::daemon::branch_add::branch_publication_context(&graph).unwrap(); + publication + .track_exact_worktree_branch( + &schedulers, + repo.path(), + &initial.worktree, + "feature/advance", + &tracedecay_runtime_core::cancellation::CancellationToken::new(), + ) + .await + .expect("publish initial branch generation"); + let original_source = + tracedecay_runtime_core::branch_meta::load_branch_meta(&graph.store_layout().data_root) + .unwrap() + .branches["feature/advance"] + .graph_source + .clone() + .unwrap(); + git(repo.path(), &["checkout", "-q", "feature/advance"]); std::fs::write( repo.path().join("src/advanced.rs"), @@ -800,10 +826,104 @@ async fn manual_branch_replaces_a_mounted_worktree_when_the_resolved_head_advanc .unwrap(); git(repo.path(), &["checkout", "-q", "main"]); - let replay = - activate_manual_branch_head(repo.path(), &graph, Some(&schedulers), "feature/advance") - .await - .expect("advanced branch activation"); + let staged_graph = Arc::clone(&graph); + let staged_schedulers = schedulers.clone(); + let staged_repo = repo.path().to_path_buf(); + let (staged_sender, staged_receiver) = tokio::sync::oneshot::channel(); + let owner = tokio::spawn(async move { + let lifecycle = try_acquire_manual_branch_lifecycle( + &staged_graph.store_layout().data_root, + "feature/advance", + ) + .unwrap(); + let staged = activate_manual_branch_head_with_lifecycle( + &staged_repo, + &staged_graph, + Some(&staged_schedulers), + "feature/advance", + &lifecycle, + default_pr_command_control(), + ) + .await + .expect("stage advanced head"); + staged_sender.send(staged).unwrap(); + std::future::pending::<()>().await; + drop(lifecycle); + }); + let replay = staged_receiver.await.unwrap(); + // A hard owner abort after staging, before metadata publication, must leave + // the previously published worktree and its exact Git identity usable. + owner.abort(); + assert!(owner.await.unwrap_err().is_cancelled()); + assert_ne!(initial.worktree, replay.worktree); + assert!(schedulers.is_worktree_mounted(&initial.worktree).await); + assert_eq!( + git_output(&initial.worktree, &["rev-parse", "HEAD"]).trim(), + initial.head_sha + ); + assert_eq!( + tracedecay_runtime_core::branch_meta::load_branch_meta(&graph.store_layout().data_root) + .unwrap() + .branches["feature/advance"] + .graph_source + .as_ref(), + Some(&original_source) + ); + let data_root = graph.store_layout().data_root.clone(); + let metadata_lock = + tracedecay_runtime_core::branch::try_acquire_branch_add_lock(&data_root).unwrap(); + let deferred = crate::daemon::branch_add::activate_and_track_manual_branch_owned( + repo.path().to_path_buf(), + Arc::clone(&graph), + schedulers.clone(), + "feature/advance".to_owned(), + data_root.clone(), + try_acquire_manual_branch_lifecycle(&data_root, "feature/advance").unwrap(), + tracedecay_runtime_core::cancellation::CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!( + deferred, + tracedecay_runtime_core::branch::BranchAddOutcome::Deferred + ); + assert!(initial.worktree.exists()); + assert!(schedulers.is_worktree_mounted(&initial.worktree).await); + assert_eq!( + tracedecay_runtime_core::branch_meta::load_branch_meta(&data_root) + .unwrap() + .branches["feature/advance"] + .graph_source + .as_ref(), + Some(&original_source) + ); + drop(metadata_lock); + crate::daemon::branch_add::activate_and_track_manual_branch_owned( + repo.path().to_path_buf(), + Arc::clone(&graph), + schedulers.clone(), + "feature/advance".to_owned(), + data_root.clone(), + try_acquire_manual_branch_lifecycle(&data_root, "feature/advance").unwrap(), + tracedecay_runtime_core::cancellation::CancellationToken::new(), + ) + .await + .expect("publish staged generation after lock releases"); + assert!( + !initial.worktree.exists(), + "retire prior worktree only after publication commits" + ); + assert!(!schedulers.is_worktree_mounted(&initial.worktree).await); + assert_eq!( + tracedecay_runtime_core::branch_meta::load_branch_meta(&data_root) + .unwrap() + .branches["feature/advance"] + .graph_source + .as_ref() + .unwrap() + .source_oid, + replay.head_sha + ); let mounted_head = std::process::Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(&replay.worktree) @@ -818,7 +938,7 @@ async fn manual_branch_replaces_a_mounted_worktree_when_the_resolved_head_advanc assert_eq!( String::from_utf8_lossy(&advanced_head.stdout).trim(), String::from_utf8_lossy(&mounted_head.stdout).trim(), - "a mounted stale worktree must be replaced with the newly resolved branch head" + "the new candidate must carry the newly resolved branch head" ); schedulers.shutdown().await; } @@ -848,10 +968,18 @@ async fn manual_branch_activation_refuses_exact_lifecycle_contention_before_muta &error, ManualBranchActivationError::LifecycleContended { .. } )); - assert!(!git_ref_exists( - repo.path(), - "refs/tracedecay/branch/feature/contended" - )); + assert!( + git_output( + repo.path(), + &[ + "for-each-ref", + "--format=%(refname)", + "refs/tracedecay/branch" + ] + ) + .trim() + .is_empty() + ); drop(lifecycle); schedulers.shutdown().await; } @@ -899,7 +1027,12 @@ async fn failed_manual_branch_sealing_retires_the_exact_mount_worktree_and_track assert!( !git_ref_exists( repo.path(), - "refs/tracedecay/branch/feature/failure-cleanup" + &ManualBranchArtifactsV1::for_head( + &data_root, + "feature/failure-cleanup", + &activation.head_sha + ) + .tracking_ref ), "the exact tracking ref must not leak after sealing failure" ); @@ -932,10 +1065,18 @@ async fn manual_branch_fails_closed_without_scheduler_before_git_or_state_mutati )); assert_eq!(error.reason_code(), "code_index_scheduler_unavailable"); assert!(!data_root.join("branch-worktrees").exists()); - assert!(!git_ref_exists( - repo.path(), - "refs/tracedecay/branch/feature-denied" - )); + assert!( + git_output( + repo.path(), + &[ + "for-each-ref", + "--format=%(refname)", + "refs/tracedecay/branch" + ] + ) + .trim() + .is_empty() + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -971,10 +1112,18 @@ async fn manual_branch_missing_ref_is_typed_failure() { "a permanently missing branch identity must not become retryable" ); assert!(!data_root.join("branch-worktrees").exists()); - assert!(!git_ref_exists( - repo.path(), - "refs/tracedecay/branch/definitely-missing-branch" - )); + assert!( + git_output( + repo.path(), + &[ + "for-each-ref", + "--format=%(refname)", + "refs/tracedecay/branch" + ] + ) + .trim() + .is_empty() + ); schedulers.shutdown().await; } @@ -984,9 +1133,9 @@ fn manual_artifact_cleanup_accepts_absence_but_refuses_foreign_provenance() { let branch = "feature/exact-cleanup"; init_manual_branch_repo(repo.path(), branch); let data = tempfile::tempdir().unwrap(); - let artifacts = ManualBranchArtifactsV1::for_branch(data.path(), branch); let head = resolve_branch_head(repo.path(), branch, default_pr_command_control()) .expect("feature branch head"); + let artifacts = ManualBranchArtifactsV1::for_head(data.path(), branch, &head); prepare_manual_branch_worktree( repo.path(), @@ -1075,9 +1224,9 @@ fn manual_artifact_cleanup_keeps_exact_refs_when_git_authority_is_unavailable() let branch = "feature/retry-after-git-failure"; init_manual_branch_repo(repo.path(), branch); let data = tempfile::tempdir().unwrap(); - let artifacts = ManualBranchArtifactsV1::for_branch(data.path(), branch); let head = resolve_branch_head(repo.path(), branch, default_pr_command_control()) .expect("feature branch head"); + let artifacts = ManualBranchArtifactsV1::for_head(data.path(), branch, &head); let branch_ref = format!("refs/heads/{}", artifacts.label); prepare_manual_branch_worktree( @@ -1167,7 +1316,7 @@ async fn cancelled_activation_keeps_its_lifecycle_owner_bounded_during_stalled_e let activation = activate_manual_branch_head(repo.path(), &graph, Some(&schedulers), branch) .await .expect("initial activation creates exact artifacts"); - let artifacts = ManualBranchArtifactsV1::for_branch(&data_root, branch); + let artifacts = ManualBranchArtifactsV1::for_head(&data_root, branch, &activation.head_sha); // Ask Git for the loose-ref path rather than assuming the ref stayed loose // after activation: a loose entry is what Git's exact-ref reader opens // first, and it takes precedence over any packed entry, so the FIFO stalls