diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 65dc96cf..eb3724c3 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1689,6 +1689,64 @@ fn authorize_daemon_startup(project_root: &Path) -> Result Result<()> { + let Ok(paths) = index_paths(project_root) else { + // The gate reports the same resolution failure. + return Ok(()); + }; + // Lock-free first look, so a daemon for any other state never queues for + // the exclusive lease; the upgrade classifies again under it. + let ExtractionStatus::Outdated { built } = Store::extraction_status(&paths) else { + return Ok(()); + }; + tracing::info!( + project = %project_root.display(), + built, + current = codegraph_store::CURRENT_EXTRACTION_VERSION, + "upgrading an index an older codegraph built before serving it" + ); + match codegraph_watch::upgrade_outdated_index_once(project_root) { + Ok(codegraph_watch::OutdatedUpgrade::Upgraded(outcome)) => tracing::info!( + project = %project_root.display(), + files = outcome.files_reindexed, + "upgraded the index to the current extraction version" + ), + Ok(codegraph_watch::OutdatedUpgrade::Skipped(reason)) => tracing::info!( + project = %project_root.display(), + %reason, + "left the outdated index to the startup gate" + ), + Err(error) => { + return Err(error.context(format!( + "refusing to start a daemon for {}: could not upgrade its outdated index. This \ + daemon did not publish a pid or socket. Run `codegraph status \"{}\"` for \ + recovery guidance", + project_root.display(), + project_root.display() + ))); + } + } + Ok(()) +} + /// Ask this project's daemon (if any) to stop accepting, cancel its watcher lease /// loops, drain, remove its own pid/socket, and ACK. `Err(detail)` is the /// fail-closed signal; the pid is only ever reported, never signalled. @@ -2728,6 +2786,11 @@ fn cmd_serve( // startup gate below validates state/owner/tombstone under a // bounded SHARED index lease and RETAINS it across pid/socket // publication, so a concurrent `uninit --force` cannot interleave. + // An index an older extraction version built is upgraded first: + // the gate admits only `Current`, and nothing else upgrades it. + // A failed upgrade stops this daemon before the gate. + upgrade_outdated_index_for_daemon(&project_root) + .context("running as detached MCP daemon")?; let gate = |root: &Path| authorize_daemon_startup(root); return codegraph_daemon::run_foreground_gated( &project_root, diff --git a/crates/codegraph-cli/tests/plain_output.rs b/crates/codegraph-cli/tests/plain_output.rs index 4718b516..ddc611af 100644 --- a/crates/codegraph-cli/tests/plain_output.rs +++ b/crates/codegraph-cli/tests/plain_output.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; const ANSI: &[u8] = b"\x1b["; @@ -27,18 +28,33 @@ struct Project { registry: PathBuf, } +/// Next project directory suffix, unique within this test process. +static NEXT_PROJECT: AtomicU64 = AtomicU64::new(0); + +/// A fresh directory name for one project. The clock alone repeats when two +/// tests start within one tick, and the first test's `Drop` then deleted the +/// other's project mid-run; the process-wide sequence keeps every name distinct. +fn project_base(nanos: u128) -> PathBuf { + std::env::temp_dir().join(format!( + "codegraph-plain-output-{}-{}-{nanos}", + std::process::id(), + NEXT_PROJECT.fetch_add(1, Ordering::Relaxed) + )) +} + impl Project { fn indexed() -> Self { - let base = std::env::temp_dir().join(format!( - "codegraph-plain-output-{}-{}", - std::process::id(), + let base = project_base( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_nanos() - )); + .as_nanos(), + ); + // `create_dir` fails on an existing directory, so two projects can + // never share one. + std::fs::create_dir(&base).expect("create a fresh project directory"); let root = base.join("project"); - std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir(&root).unwrap(); std::fs::write( root.join("a.ts"), "export function alpha(): number { return beta(); }\n\ @@ -79,6 +95,11 @@ impl Drop for Project { } } +#[test] +fn projects_created_within_one_clock_tick_get_distinct_directories() { + assert_ne!(project_base(7), project_base(7)); +} + fn has(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index 87813375..b0218e61 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -4,11 +4,12 @@ //! released v0.54.0 binary (extraction version 21) wrote for the mini corpus. //! Each test plants it in a fresh project namespace, stages the state slot that //! release publishes (`phase=current`, extraction version 21), and upgrades it -//! through one of the two paths a user hits: `codegraph sync` from the shell, and -//! the catch-up sync a daemon or MCP session runs when it first opens the -//! project. Both must classify the namespace as outdated, rebuild it from source -//! exactly once, and leave an index at the current schema whose canonical graph -//! equals a fresh `init` of the same tree. +//! through the paths a user hits: `codegraph sync` from the shell, the catch-up +//! sync a daemon or MCP session runs when it first opens the project, and a cold +//! `serve --mcp` session in the default daemon mode, whose daemon must upgrade +//! the index before it starts serving. Each must classify the namespace as +//! outdated, rebuild it from source, and leave an index at the current schema +//! whose canonical graph equals a fresh `init` of the same tree. use std::fs; use std::path::{Path, PathBuf}; @@ -63,6 +64,442 @@ fn catch_up_sync_upgrades_a_v0_54_index_to_the_current_schema_and_extraction() { assert_upgraded_like_a_fresh_init(&project, "catch-up"); } +/// The default daemon mode: a cold `serve --mcp` session answers direct and +/// spawns the project's daemon, which used to refuse an `outdated` index at its +/// startup gate, so nothing ever upgraded it and every tool call failed until a +/// manual `codegraph sync`. The daemon must upgrade the index first, then serve +/// it and keep it live. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_upgrades_a_v0_54_index_through_its_daemon() { + let dir = TestDir::new("daemon"); + let project = plant_v0_54_index(&dir); + let paths = index_paths(&project); + let mut session = cold::Session::start(&project, dir.path()); + + let deadline = std::time::Instant::now() + cold::UPGRADED_WITHIN; + while Store::extraction_status(&paths) != ExtractionStatus::Current { + assert!( + std::time::Instant::now() < deadline, + "the v0.54.0 index was still {:?} {:?} after the cold session started;\n\ + daemon.log:\n{}\nsession stderr:\n{}", + Store::extraction_status(&paths), + cold::UPGRADED_WITHIN, + fs::read_to_string(paths.daemon_log()).unwrap_or_default(), + session.stderr() + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + cold::wait_for_daemon(&project, &session); + assert_upgraded_like_a_fresh_init(&project, "daemon"); + + let marker = "upgradedDaemonSessionMarker"; + fs::write( + project.join("src/upgraded.ts"), + format!("export function {marker}(): number {{\n return 1;\n}}\n"), + ) + .unwrap(); + session.wait_for_symbol(marker, "src/upgraded.ts"); + session.close(); + cold::shutdown_daemon(&project); +} + +/// A daemon never undoes an older `uninit --force`. That leaves an +/// `uninitialized` slot at the older extraction version, which the state gate +/// classifies as `outdated`, a tombstone, and no database; the daemon must +/// leave all of it alone and refuse to start, as it did before. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_leaves_an_older_uninit_alone() { + let dir = TestDir::new("daemon-uninit"); + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + let paths = index_paths(&project); + stage_state_slot( + &paths, + 3, + CURRENT_STORAGE_PROTOCOL, + V0_54_EXTRACTION_VERSION, + "uninitialized", + ); + let outdated = ExtractionStatus::Outdated { + built: V0_54_EXTRACTION_VERSION, + }; + assert_eq!(Store::extraction_status(&paths), outdated); + assert!(paths.tombstone().is_file(), "uninit leaves its tombstone"); + assert!(!paths.current_db().exists(), "uninit removes the database"); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "state-gated Store open rejected", + &session, + ); + assert!( + !paths.current_db().exists(), + "the daemon recreated the database an uninit removed:\n{log}" + ); + assert!(paths.tombstone().is_file(), "the tombstone must stay"); + assert_eq!(Store::extraction_status(&paths), outdated); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + log.contains("left the outdated index to the startup gate"), + "the daemon must say why it left the index alone:\n{log}" + ); + session.close(); +} + +/// A failed upgrade is a failed migration: the daemon stops before its startup +/// gate, and nothing changes. Here the project config cannot be parsed, so the +/// upgrade fails before it takes the index lease. +#[cfg(unix)] +#[test] +fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { + let dir = TestDir::new("daemon-failed-upgrade"); + let project = plant_v0_54_index(&dir); + let paths = index_paths(&project); + fs::write(paths.config_toml(), "[indexing\n").unwrap(); + let database = fs::read(paths.current_db()).unwrap(); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "could not upgrade its outdated index", + &session, + ); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: V0_54_EXTRACTION_VERSION + } + ); + assert_eq!( + fs::read(paths.current_db()).unwrap(), + database, + "a failed upgrade must leave the database as it was" + ); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + !log.contains("state-gated Store open"), + "a failed upgrade must stop the daemon before its startup gate:\n{log}" + ); + session.close(); +} + +/// The upgrade a daemon runs rebuilds only a complete older index. Each +/// namespace below is one `codegraph sync` would still migrate. +#[test] +fn the_daemon_upgrade_skips_an_interrupted_older_build() { + let dir = TestDir::new("skip-building"); + let project = plant_v0_54_index(&dir); + stage_state_slot( + &index_paths(&project), + 3, + CURRENT_STORAGE_PROTOCOL, + V0_54_EXTRACTION_VERSION, + "building", + ); + assert_upgrade_skipped_untouched(&project, "records phase building"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_with_a_tombstone() { + let dir = TestDir::new("skip-tombstone"); + let project = plant_v0_54_index(&dir); + fs::write(index_paths(&project).tombstone(), b"uninitialized\n").unwrap(); + assert_upgrade_skipped_untouched(&project, "tombstone is present"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_without_its_database() { + let dir = TestDir::new("skip-missing-db"); + let project = plant_v0_54_index(&dir); + fs::remove_file(index_paths(&project).current_db()).unwrap(); + assert_upgrade_skipped_untouched(&project, "database is missing"); +} + +fn assert_upgrade_skipped_untouched(project: &Path, reason: &str) { + let paths = index_paths(project); + let status = Store::extraction_status(&paths); + assert!( + matches!(status, ExtractionStatus::Outdated { .. }), + "each case starts outdated: {status:?}" + ); + let database = fs::read(paths.current_db()).ok(); + let tombstone = fs::read(paths.tombstone()).ok(); + let slots = state_slot_bytes(&paths); + match codegraph_watch::upgrade_outdated_index_once(project).expect("daemon upgrade entry") { + codegraph_watch::OutdatedUpgrade::Skipped(text) => { + assert!(text.contains(reason), "skipped for another reason: {text}"); + } + other => panic!("expected a skip ({reason}), got {other:?}"), + } + assert_eq!(Store::extraction_status(&paths), status); + assert_eq!(fs::read(paths.current_db()).ok(), database); + assert_eq!(fs::read(paths.tombstone()).ok(), tombstone); + assert_eq!(state_slot_bytes(&paths), slots); +} + +/// Both state slots, byte for byte (`None` for an absent slot). +fn state_slot_bytes(paths: &IndexPaths) -> [Option>; 2] { + paths.state_slots().map(|slot| fs::read(slot).ok()) +} + +/// A cold `serve --mcp --path ` over line-delimited JSON-RPC, the +/// harness of `cold_start_live_sync.rs` reduced to what the upgrade case needs. +#[cfg(unix)] +mod cold { + use std::fs; + use std::io::{BufRead, BufReader, Write}; + use std::path::{Path, PathBuf}; + use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + use std::time::{Duration, Instant}; + + use serde_json::{Value, json}; + + /// Upper bound for the daemon to upgrade the mini corpus and publish. + pub const UPGRADED_WITHIN: Duration = Duration::from_secs(60); + /// Upper bound for an edit to reach `codegraph_search` once it serves. + const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(10); + + pub struct Session { + child: Child, + stdin: Option, + stdout: BufReader, + stderr_log: PathBuf, + next_id: i64, + } + + impl Session { + pub fn start(project: &Path, scratch: &Path) -> Self { + let stderr_log = scratch.join("serve.stderr.log"); + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env_remove("CODEGRAPH_NO_DAEMON") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "1000") + .env("CODEGRAPH_DAEMON_RETRY_MS", "200") + .env("CODEGRAPH_DAEMON_RETRY_MAX_MS", "1000") + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&stderr_log).expect("create serve stderr log")) + .spawn() + .expect("spawn cold serve --mcp"); + let stdin = child.stdin.take().expect("serve stdin"); + let stdout = BufReader::new(child.stdout.take().expect("serve stdout")); + let mut session = Self { + child, + stdin: Some(stdin), + stdout, + stderr_log, + next_id: 1, + }; + let id = session.next_id(); + session.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "upgrade-from-v054", "version": "0" } + } + })); + let response = session.read_id(id); + assert_eq!( + response["result"]["serverInfo"]["name"], "codegraph", + "{response}" + ); + session.write(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + session + } + + fn next_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().expect("serve stdin stays open"); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read serve stdout"); + assert!( + read > 0, + "serve closed stdout before response {want}; stderr:\n{}", + self.stderr() + ); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: Value = serde_json::from_str(trimmed).unwrap_or_else(|error| { + panic!("non-JSON line on serve stdout ({error}): {trimmed:?}") + }); + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + } + + fn search(&mut self, query: &str) -> String { + let id = self.next_id(); + self.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": query } } + })); + let response = self.read_id(id); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string() + } + + pub fn wait_for_symbol(&mut self, symbol: &str, file: &str) { + let deadline = Instant::now() + EDIT_VISIBLE_WITHIN; + loop { + let text = self.search(symbol); + if text.contains(symbol) && text.contains(file) { + return; + } + assert!( + Instant::now() < deadline, + "`{symbol}` was not searchable within {EDIT_VISIBLE_WITHIN:?}; last result:\n{text}\nstderr:\n{}", + self.stderr() + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + pub fn stderr(&self) -> String { + fs::read_to_string(&self.stderr_log).unwrap_or_default() + } + + pub fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("cold serve did not exit after stdin EOF"); + } + } + + impl Drop for Session { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + } + + /// Upper bound for a refusing daemon to write its log lines. + const REFUSED_WITHIN: Duration = Duration::from_secs(30); + + /// Whether a live daemon owns `project`. + pub fn daemon_live(project: &Path) -> bool { + codegraph_daemon::daemon_pid_path(project) + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|raw| codegraph_daemon::decode_lock_info(&raw)) + .is_some_and(|info| info.pid > 0 && codegraph_daemon::is_process_alive(info.pid)) + } + + /// Wait until one line of the project's `daemon.log` contains both `first` + /// and `then`, in that order; returns the whole log. + pub fn wait_for_daemon_log( + paths: &codegraph_core::IndexPaths, + first: &str, + then: &str, + session: &Session, + ) -> String { + let deadline = Instant::now() + REFUSED_WITHIN; + loop { + let log = fs::read_to_string(paths.daemon_log()).unwrap_or_default(); + let shown = log.lines().any(|line| { + line.find(first) + .is_some_and(|at| line[at + first.len()..].contains(then)) + }); + if shown { + return log; + } + assert!( + Instant::now() < deadline, + "daemon.log did not show {first:?} … {then:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Wait until a live daemon owns `project`: it publishes only after the + /// upgrade and its startup gate. + pub fn wait_for_daemon(project: &Path, session: &Session) { + let deadline = Instant::now() + UPGRADED_WITHIN; + loop { + if daemon_live(project) { + return; + } + assert!( + Instant::now() < deadline, + "no live daemon within {UPGRADED_WITHIN:?}; stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + pub fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop the daemon: {other:?}"), + } + } +} + /// Copy the mini corpus into `dir`, create a namespace with the current binary, /// then replace its database and state slot with what v0.54.0 left behind. fn plant_v0_54_index(dir: &TestDir) -> PathBuf { diff --git a/crates/codegraph-watch/src/lib.rs b/crates/codegraph-watch/src/lib.rs index 30a08311..edaff3ac 100644 --- a/crates/codegraph-watch/src/lib.rs +++ b/crates/codegraph-watch/src/lib.rs @@ -45,9 +45,10 @@ pub use policy::{ CODEGRAPH_NO_WATCH, TooBroadRoot, WatchPolicy, too_broad_root_reason, watch_disabled_reason, }; pub use sync::{ - PendingChanges, SyncCancellation, SyncOutcome, pending_full_inventory, pending_project_changes, - pending_project_changes_detailed, sync_changed_paths, sync_project_once, - sync_project_once_cancellable, sync_project_once_with_progress, + OutdatedUpgrade, PendingChanges, SyncCancellation, SyncOutcome, pending_full_inventory, + pending_project_changes, pending_project_changes_detailed, sync_changed_paths, + sync_project_once, sync_project_once_cancellable, sync_project_once_with_progress, + upgrade_outdated_index_once, }; pub use watcher::{ CODEGRAPH_WATCH_SENTINEL_MS, LOCK_RECOVERY_INTERVAL, PendingFile, ProjectWatcher, WatchHealth, diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index ca2c7c8f..9819f37d 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -20,7 +20,9 @@ use codegraph_resolve::ReferenceResolver; use codegraph_resolve::framework::FrameworkExtractionContext; use codegraph_resolve::frameworks::godot_dsl_config::GodotDslConfig; use codegraph_store::queries::{FileReferenceSite, ReferenceSite, ResolutionReplacement}; -use codegraph_store::{IndexLease, Store, StoreWriteOpen, StoreWritePurpose}; +use codegraph_store::{ + ExtractionStatus, IndexLease, StatePhase, Store, StoreWriteOpen, StoreWritePurpose, classify, +}; use rayon::prelude::*; use crate::git_pending::{GitIndexCapture, PendingSource}; @@ -358,6 +360,94 @@ fn sync_project_once_with_scope( } } +/// What [`upgrade_outdated_index_once`] found under its exclusive lease. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutdatedUpgrade { + /// A complete index an older extraction version built, rebuilt from source + /// into the current namespace. + Upgraded(SyncOutcome), + /// Any other namespace, left untouched; the text says why. + Skipped(String), +} + +/// Rebuild a complete index an older extraction version built, and nothing +/// else: the upgrade a daemon runs before its startup gate. +/// +/// The state gate classifies an older version's every phase as `Outdated`, and +/// `codegraph sync` migrates all of them. An automatic upgrade must not undo an +/// older `uninit --force` or recreate a database someone removed, so this takes +/// ONE exclusive lease, reclassifies under it, and requires an `Outdated` +/// status whose authoritative slot records the `current` phase, no +/// uninitialized tombstone, and the database file. Only then does it run the +/// migration `sync` runs, under the same lease, so no writer can change the +/// namespace between the check and the rebuild. +pub fn upgrade_outdated_index_once(project_root: impl AsRef) -> Result { + let project_root = project_root.as_ref(); + let paths = index_paths(project_root)?; + let scope = ProjectScope::load(project_root, &paths)?; + let started = Instant::now(); + let lease = IndexLease::acquire_exclusive_existing( + &paths, + Instant::now() + SYNC_LEASE_TIMEOUT, + || false, + )?; + if let Some(reason) = not_a_complete_outdated_index(&paths)? { + return Ok(OutdatedUpgrade::Skipped(reason)); + } + match Store::open_for_write(&paths, lease, StoreWritePurpose::IncrementalSync)? { + StoreWriteOpen::FullRebuildRequired(authorization) => crate::migrate::migrate_project( + project_root, + &paths, + authorization, + &scope, + started, + |_, _| {}, + ) + .map(OutdatedUpgrade::Upgraded), + // The check above held this lease, so the gate classifies the same + // `Outdated` namespace; any other answer is refused, not written. + _ => bail!( + "the index state gate did not authorize upgrading {}", + paths.current_root().display() + ), + } +} + +/// Why the namespace is not a complete index an older extraction version +/// built, or `None` when it is. +fn not_a_complete_outdated_index(paths: &IndexPaths) -> Result> { + let classification = classify(paths); + if !matches!(classification.status(), ExtractionStatus::Outdated { .. }) { + return Ok(Some(format!("index state is {}", classification.status()))); + } + let record = classification.authoritative().map(|slot| &slot.record); + if record.and_then(|record| record.phase) != Some(StatePhase::Current) { + return Ok(Some(format!( + "the outdated index records phase {}, not {}", + record.map_or("unknown", |record| record.phase_raw.as_str()), + StatePhase::Current.as_wire() + ))); + } + match fs::symlink_metadata(paths.tombstone()) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => return Ok(Some("its uninitialized tombstone is present".to_string())), + Err(error) => { + return Err(error) + .with_context(|| format!("inspecting {}", paths.tombstone().display())); + } + } + match fs::symlink_metadata(paths.current_db()) { + Ok(metadata) if metadata.file_type().is_file() => Ok(None), + Ok(_) => Ok(Some("its database is not a regular file".to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Some("its database is missing".to_string())) + } + Err(error) => { + Err(error).with_context(|| format!("inspecting {}", paths.current_db().display())) + } + } +} + /// One project's immutable configuration for ONE sync operation. /// /// Loaded from the addressed project's resolved [`IndexPaths`] — its current-root diff --git a/docs/cli.md b/docs/cli.md index 0243910d..c4b33830 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1084,6 +1084,12 @@ attaches again, doubling the wait after each miss up to `CODEGRAPH_DAEMON_RETRY_MAX_MS`. It never starts a daemon for a project whose index is gone, and `CODEGRAPH_DAEMON_RETRY_MS=0` turns the retries off. +A daemon serves only a current index. Before it publishes its socket it upgrades +a complete index an older extraction version built, as +[Extraction-version upgrades](#extraction-version-upgrades) describes. It +refuses to start when that upgrade fails or the index is otherwise not current, +logging the reason to `.codegraph/daemon.log`. + A daemon keeps running the code it started with, so after an upgrade the project's daemon can be an older build than the session that finds it. A session that meets a daemon of another version reads the version from the @@ -1348,10 +1354,19 @@ codegraph index --force /path/to/project # only when the CLI explicitly requires ``` An index built by an older extraction version is a supported `sync` upgrade: -`status` reports it as outdated, and `sync` (or the catch-up a daemon or MCP -session runs when it opens the project) rebuilds it from source into the current -namespace, applying any pending schema migration on the way. The result equals a -fresh `init`. Do not run `index --force` solely because the extraction version +`status` reports it as outdated, and `sync` rebuilds it from source into the +current namespace, applying any pending schema migration on the way. The result +equals a fresh `init`. An MCP session upgrades a complete older index as well: +in the default daemon mode the project's daemon rebuilds it the same way before it +starts serving, and with `CODEGRAPH_NO_DAEMON=1` the session's own catch-up does. +Until the rebuild finishes, a tool call may wait for it, up to 30 seconds, or +fail; a later call succeeds once it is done. If the rebuild fails, the daemon +does not start. A failure before the rebuild begins replacing the index leaves +the older index as it was; a later one can leave it `building`, as an +interrupted `sync` does. The daemon also refuses to start on an older index that +is not complete (an interrupted build, an `uninit --force`, or a missing +database), and leaves it alone. Either way, run `codegraph status` for the state +the index is in and the recovery it needs. Do not run `index --force` solely because the extraction version changed. An older binary refuses an index a newer extraction version wrote and leaves it untouched.