diff --git a/.github/workflows/publish-node.yml b/.github/workflows/publish-node.yml index 1055c11f..47e9eebe 100644 --- a/.github/workflows/publish-node.yml +++ b/.github/workflows/publish-node.yml @@ -2,10 +2,10 @@ name: Publish Node SDK on: workflow_call: - workflow_dispatch: jobs: build: + if: startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: @@ -90,6 +90,7 @@ jobs: publish: name: Publish to npm + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: build steps: diff --git a/.github/workflows/publish-python-bootstrap.yml b/.github/workflows/publish-python-bootstrap.yml index 6c54fd88..ec03b6e9 100644 --- a/.github/workflows/publish-python-bootstrap.yml +++ b/.github/workflows/publish-python-bootstrap.yml @@ -7,11 +7,11 @@ name: Publish Python Bootstrap on: workflow_call: - workflow_dispatch: jobs: publish: name: Publish Python Bootstrap to PyPI + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 3340be36..153cf44a 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -12,12 +12,6 @@ on: required: false type: string description: "Specific target to build (e.g., x86_64-unknown-linux-gnu). If empty, builds all targets." - workflow_dispatch: - inputs: - target: - required: false - type: string - description: "Specific target to build (e.g., x86_64-unknown-linux-gnu). If empty, builds all targets." permissions: contents: write @@ -28,6 +22,7 @@ jobs: # These are uploaded to GitHub Releases, NOT to PyPI # ───────────────────────────────────────────────────────────────── build: + if: startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: @@ -101,6 +96,7 @@ jobs: # ───────────────────────────────────────────────────────────────── publish: name: Publish Python SDK + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: build steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00d9dbf3..ea495ead 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: tags: - "v*" - workflow_dispatch: permissions: contents: write @@ -21,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup workspace context run: bash .github/setup-workspace.sh @@ -39,14 +40,34 @@ jobs: - name: Clippy run: cargo clippy --workspace --lib --bins -- -D warnings - - name: Check version alignment - run: bash check-version.sh + - name: Validate release tag and version alignment + shell: bash + run: | + set -euo pipefail + case "$GITHUB_REF" in + refs/tags/v*) ;; + *) echo "Release publishing requires a v* tag" >&2; exit 1 ;; + esac + VERSION="${GITHUB_REF_NAME#v}" + test -n "$VERSION" + bash check-version.sh "$VERSION" + + - name: Check public API compatibility with v5.2.8 + run: | + cargo install cargo-semver-checks --version 0.48.0 --locked + bash scripts/check_semver.sh 5.2.8 - name: Check SDK protocol and API alignment run: | node scripts/generate_event_protocol_artifacts.mjs --check node scripts/sdk_api_alignment_check.mjs + - name: Check Node SDK + run: cargo check --manifest-path sdk/node/Cargo.toml + + - name: Check Python SDK + run: cargo check --manifest-path sdk/python/Cargo.toml + - name: Default tests run: cargo test --workspace @@ -122,7 +143,7 @@ jobs: # ─────────────────────────────────────────────── publish-node: name: Node SDK - needs: [ci, ci-windows] + needs: [publish-crate] uses: ./.github/workflows/publish-node.yml secrets: inherit @@ -133,7 +154,7 @@ jobs: # ─────────────────────────────────────────────── publish-python: name: Python SDK (native wheels → GH Release) - needs: [ci, ci-windows] + needs: [publish-crate] uses: ./.github/workflows/publish-python.yml secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d77a4ba..7c13f59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.3.0] - 2026-07-15 + +### Fixed + +- Made language-server initialization and post-initialization settling + cooperatively cancellable so session shutdown does not wait for a cold + semantic runtime. +- Stabilized the first navigation query for each saved document revision after + language-server initialization, preventing cold empty or partial reference + results without treating a legitimate empty result as an error. + ## [5.2.8] - 2026-07-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 61f2c9cc..90a93ce3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.2.8" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", diff --git a/README.md b/README.md index 0181c1e6..c92e8665 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ context providers, policies, stores, and active child work. The runtime can: - resume complete snapshots from a `SessionStore`; - atomically replace an idle persisted session with new runtime options; - list and close live sessions owned by an `Agent`; +- cancel and settle an active operation before safely reusing its session; - add, replace, list, and remove lifecycle-owned Skills while restoring an exactly shadowed registration on removal; - add and remove session-local MCP servers without disturbing inherited or @@ -302,9 +303,11 @@ npm install --global typescript typescript-language-server Queries use saved files only. Public lines and characters are zero-based, and characters count UTF-16 code units. Document results include the saved-content revision and hash plus a stale flag when the file changes while a query is in -flight. The semantic tools return metadata and workspace-relative locations; -agents continue to use `read`, `grep`, `edit`, and `patch` for source text and -mutations. +flight. The first navigation request for a saved revision includes a bounded, +cancellable stabilization pass so a protocol-ready but still-indexing server +does not leak a cold partial result. The semantic tools return metadata and +workspace-relative locations; agents continue to use `read`, `grep`, `edit`, +and `patch` for source text and mutations. See the [Code Intelligence design](manual/CODE_INTELLIGENCE_DESIGN.md) for the capability boundary, shared runtime architecture, lifecycle, and verification diff --git a/check-version.sh b/check-version.sh index e6108279..976923ef 100755 --- a/check-version.sh +++ b/check-version.sh @@ -6,4 +6,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -scripts/check_release_versions.sh +scripts/check_release_versions.sh "${1:-}" diff --git a/core/Cargo.toml b/core/Cargo.toml index 6f7d21a5..2ce3e5bd 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "5.2.8" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/core/src/agent/tests.rs b/core/src/agent/tests.rs index 2a93d045..ae4a4de8 100644 --- a/core/src/agent/tests.rs +++ b/core/src/agent/tests.rs @@ -4088,11 +4088,14 @@ async function run(ctx, inputs) { .await }); + // This deadline protects test setup only. QuickJS initialization can + // take several seconds on a loaded Windows runner; cancellation latency + // remains independently bounded below. tokio::time::timeout(std::time::Duration::from_secs(10), started.notified()) .await .expect("nested tool must start before cancellation"); cancellation.cancel(); - let result = tokio::time::timeout(std::time::Duration::from_secs(10), run) + let result = tokio::time::timeout(std::time::Duration::from_secs(2), run) .await .expect("cancellation must stop the dynamic workflow") .unwrap() diff --git a/core/src/agent_api.rs b/core/src/agent_api.rs index 22955c6d..e5518ec5 100644 --- a/core/src/agent_api.rs +++ b/core/src/agent_api.rs @@ -319,7 +319,8 @@ pub struct SessionOptions { /// Per-session tool round limit override. /// /// When set, overrides the agent-level `max_tool_rounds` for this session only. - /// Maps directly from [`AgentDefinition::max_steps`] when creating sessions + /// Maps directly from [`AgentDefinition::max_steps`](crate::subagent::AgentDefinition::max_steps) + /// when creating sessions /// via [`Agent::session_for_agent`]. pub max_tool_rounds: Option, /// Per-session parallel fan-out limit override. diff --git a/core/src/agent_api/agent_facade.rs b/core/src/agent_api/agent_facade.rs index a33eacf0..58495b3c 100644 --- a/core/src/agent_api/agent_facade.rs +++ b/core/src/agent_api/agent_facade.rs @@ -135,7 +135,8 @@ impl Agent { agent_sessions::create_session(self, workspace, options) } - /// Create a session pre-configured from an [`AgentDefinition`]. + /// Create a session pre-configured from an + /// [`AgentDefinition`](crate::subagent::AgentDefinition). /// /// Maps the definition's `permissions`, `prompt`, `model`, and `max_steps` /// directly into [`SessionOptions`], so markdown/YAML-defined subagents can diff --git a/core/src/agent_api/session_facade.rs b/core/src/agent_api/session_facade.rs index 807468e6..78e7e851 100644 --- a/core/src/agent_api/session_facade.rs +++ b/core/src/agent_api/session_facade.rs @@ -258,8 +258,8 @@ impl AgentSession { /// Return the names of all tools currently registered on this session. /// /// Equivalent to `tool_definitions().into_iter().map(|t| t.name).collect()`. - /// Tools added via [`add_mcp_server`] appear immediately; tools removed via - /// [`remove_mcp_server`] disappear immediately. + /// Tools added via [`Self::add_mcp_server`] appear immediately; tools + /// removed via [`Self::remove_mcp_server`] disappear immediately. pub fn tool_names(&self) -> Vec { DirectToolRuntime::from_session(self).names() } diff --git a/core/src/agent_api/session_options.rs b/core/src/agent_api/session_options.rs index 9c034c4d..344a3a50 100644 --- a/core/src/agent_api/session_options.rs +++ b/core/src/agent_api/session_options.rs @@ -240,7 +240,8 @@ impl SessionOptions { } /// Add skill directories to scan for skill files (*.md). - /// Merged with any global `skill_dirs` from [`CodeConfig`] at session build time. + /// Merged with any global `skill_dirs` from + /// [`CodeConfig`](crate::config::CodeConfig) at session build time. pub fn with_skill_dirs(mut self, dirs: impl IntoIterator>) -> Self { self.skill_dirs.extend(dirs.into_iter().map(Into::into)); self diff --git a/core/src/agent_api/tests.rs b/core/src/agent_api/tests.rs index 44723d87..19985944 100644 --- a/core/src/agent_api/tests.rs +++ b/core/src/agent_api/tests.rs @@ -5538,6 +5538,19 @@ async function run(ctx, inputs) { async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_permissions() { let dir = tempfile::tempdir().unwrap(); let agent = Agent::from_config(test_config()).await.unwrap(); + let workspace_fs: Arc = + Arc::new(TestWorkspaceFs::default()); + let runner = Arc::new(TestWorkspaceRunner::default()); + let runner_backend: Arc = runner.clone(); + let services = crate::workspace::WorkspaceServices::builder( + crate::workspace::WorkspaceRef::new( + "deep-research-permission-inheritance", + dir.path().to_string_lossy(), + ), + workspace_fs, + ) + .command_runner(runner_backend) + .build(); let client = Arc::new(ScriptedStreamingClient::new(vec![ scripted_tool_call_response( "bash-1", @@ -5550,6 +5563,7 @@ async fn test_dynamic_workflow_parallel_deep_research_inherits_parent_permission let opts = SessionOptions::new() .with_llm_client(client) .with_permission_policy(policy) + .with_workspace_backend(services) .with_max_parallel_tasks(2) .with_manual_delegation_enabled(true); let session = agent @@ -5620,6 +5634,11 @@ async function run(ctx, inputs) { "deep-research must inherit the parent confirmation context: {}", result.output ); + assert_eq!( + runner.commands.read().unwrap().as_slice(), + ["echo inherited-dynamic-workflow-deep-research"], + "the child must execute through the parent workspace runner" + ); } #[tokio::test] diff --git a/core/src/budget.rs b/core/src/budget.rs index 97283d78..1e6ad25b 100644 --- a/core/src/budget.rs +++ b/core/src/budget.rs @@ -4,13 +4,13 @@ //! decision points and emits structured events. The host //! implements [`BudgetGuard`] with whatever backend it likes //! (per-tenant counters in Redis, per-day USD caps in Postgres, etc.) -//! and plugs it into [`SessionOptions::with_budget_guard`]. +//! and plugs it into [`SessionOptions::with_budget_guard`](crate::SessionOptions::with_budget_guard). //! //! Decision points wired today: //! //! 1. **Before each LLM call** — [`BudgetGuard::check_before_llm`]. //! A `Deny` aborts the call; a `SoftLimit` lets the call proceed but -//! triggers an [`AgentEvent::BudgetThresholdHit`] so in-session +//! triggers an [`AgentEvent::BudgetThresholdHit`](crate::AgentEvent::BudgetThresholdHit) so in-session //! policy (hooks, custom prompts) can react. //! 2. **After each LLM call** — [`BudgetGuard::record_after_llm`]. //! The host updates its running spend total with the actual usage. diff --git a/core/src/code_intelligence/language_profile.rs b/core/src/code_intelligence/language_profile.rs index 842613b9..4736accd 100644 --- a/core/src/code_intelligence/language_profile.rs +++ b/core/src/code_intelligence/language_profile.rs @@ -7,6 +7,10 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::time::Duration; + +const DEFAULT_INITIALIZATION_SETTLE_DELAY: Duration = Duration::from_millis(750); +const DEFAULT_NAVIGATION_SETTLE_DELAY: Duration = Duration::from_millis(250); /// Process topology used by a language server profile. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -41,6 +45,8 @@ pub(crate) struct LanguageServerProfile { id: ProjectLanguageProfile, topology: ServerTopology, command: LanguageServerCommand, + initialization_settle_delay: Duration, + navigation_settle_delay: Duration, } impl LanguageServerProfile { @@ -49,6 +55,8 @@ impl LanguageServerProfile { id: ProjectLanguageProfile::Rust, topology: ServerTopology::MultiFolder, command: LanguageServerCommand::new(program, std::iter::empty::()), + initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, + navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, } } @@ -57,6 +65,8 @@ impl LanguageServerProfile { id: ProjectLanguageProfile::TypeScriptJavaScript, topology: ServerTopology::MultiFolder, command: LanguageServerCommand::new(program, ["--stdio"]), + initialization_settle_delay: DEFAULT_INITIALIZATION_SETTLE_DELAY, + navigation_settle_delay: DEFAULT_NAVIGATION_SETTLE_DELAY, } } @@ -80,6 +90,25 @@ impl LanguageServerProfile { &self.command } + pub(crate) fn initialization_settle_delay(&self) -> Duration { + self.initialization_settle_delay + } + + pub(crate) fn navigation_settle_delay(&self) -> Duration { + self.navigation_settle_delay + } + + #[cfg(test)] + pub(crate) fn with_settle_delays( + mut self, + initialization: Duration, + navigation: Duration, + ) -> Self { + self.initialization_settle_delay = initialization; + self.navigation_settle_delay = navigation; + self + } + pub(crate) fn language_ids(&self) -> &'static [&'static str] { match self.id { ProjectLanguageProfile::Rust => &["rust"], @@ -256,11 +285,10 @@ mod tests { let profile = LanguageServerProfile::rust("server"); let layout = layout(&["crates/a/Cargo.toml", "crates/b/Cargo.toml"]); let root = Path::new("/workspace"); + let linked_projects = ["crates/a/Cargo.toml", "crates/b/Cargo.toml"] + .map(|manifest| root.join(manifest).to_string_lossy().replace('\\', "/")); let expected = json!({ - "linkedProjects": [ - "/workspace/crates/a/Cargo.toml", - "/workspace/crates/b/Cargo.toml" - ], + "linkedProjects": linked_projects, "cargo": { "buildScripts": { "enable": false, diff --git a/core/src/code_intelligence/language_runtime.rs b/core/src/code_intelligence/language_runtime.rs index 04176512..10ce8cc8 100644 --- a/core/src/code_intelligence/language_runtime.rs +++ b/core/src/code_intelligence/language_runtime.rs @@ -9,7 +9,7 @@ mod protocol; mod tests; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, time::Duration, @@ -43,7 +43,7 @@ use super::{ }, project_layout::ProjectLayout, CodeDiagnostic, CodeIntelligenceCapabilities, CodeLocation, CodePosition, CodeQueryResult, - DocumentSymbol, NavigationKind, SymbolInformation, + DocumentRevision, DocumentSymbol, NavigationKind, SymbolInformation, }; use crate::workspace::WorkspacePath; @@ -131,6 +131,8 @@ pub(crate) struct LanguageRuntime { diagnostic_updates: Arc>, open_documents: Mutex>, document_sync: Mutex<()>, + navigation_revisions: Mutex>, + navigation_stabilization: Mutex<()>, notification_task: Mutex>>, timeout: Duration, } @@ -155,6 +157,7 @@ impl LanguageRuntime { layout: ProjectLayout, documents: Arc, diagnostics: Arc, + cancellation: CancellationToken, timeout: Duration, ) -> Result { validate_canonical_root(&canonical_root).await?; @@ -202,21 +205,24 @@ impl LanguageRuntime { env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), ); - let initialized = - match initialize(&client, &config, CancellationToken::new(), timeout).await { - Ok(initialized) => initialized, - Err(source) => { - let _ = process.shutdown(timeout, timeout).await; - notification_task.abort(); - let _ = notification_task.await; - return Err(LanguageRuntimeError::Client { + let initialized = match initialize(&client, &config, cancellation.clone(), timeout).await { + Ok(initialized) => initialized, + Err(source) => { + let _ = process.shutdown(timeout, timeout).await; + notification_task.abort(); + let _ = notification_task.await; + return if cancellation.is_cancelled() { + Err(LanguageRuntimeError::Cancelled) + } else { + Err(LanguageRuntimeError::Client { operation: "initialize", source, - }); - } - }; + }) + }; + } + }; - Ok(Self { + let runtime = Self { profile, canonical_root, layout, @@ -228,9 +234,19 @@ impl LanguageRuntime { diagnostic_updates, open_documents: Mutex::new(HashSet::new()), document_sync: Mutex::new(()), + navigation_revisions: Mutex::new(HashMap::new()), + navigation_stabilization: Mutex::new(()), notification_task: Mutex::new(Some(notification_task)), timeout, - }) + }; + if let Err(error) = runtime + .wait_for_settle(runtime.profile.initialization_settle_delay(), &cancellation) + .await + { + let _ = runtime.shutdown().await; + return Err(error); + } + Ok(runtime) } pub(crate) fn supports_path(&self, path: &WorkspacePath) -> bool { @@ -347,11 +363,63 @@ impl LanguageRuntime { let (uri, snapshot) = self .sync_saved_document(path, saved_content, &cancellation) .await?; + let items = if self + .navigation_revision_is_settled(path, snapshot.revision) + .await + { + self.navigation_items(kind, &uri, position, &cancellation) + .await? + } else { + let lock = self.navigation_stabilization.lock(); + tokio::pin!(lock); + let _stabilization = tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(LanguageRuntimeError::Cancelled), + guard = &mut lock => guard, + }; + if self + .navigation_revision_is_settled(path, snapshot.revision) + .await + { + self.navigation_items(kind, &uri, position, &cancellation) + .await? + } else { + // The protocol handshake can finish before a server has made + // its first saved document visible to cross-file navigation. + // Always discard one warmup response instead of guessing from + // whether it happens to be empty or how many locations it has. + let _ = self + .navigation_items(kind, &uri, position, &cancellation) + .await?; + self.wait_for_settle(self.profile.navigation_settle_delay(), &cancellation) + .await?; + let items = self + .navigation_items(kind, &uri, position, &cancellation) + .await?; + self.navigation_revisions + .lock() + .await + .insert(path.clone(), snapshot.revision); + items + } + }; + ensure_not_cancelled(&cancellation)?; + let (items, truncated) = bound_items(items, MAX_NAVIGATION_RESULTS); + Ok(self.document_result(path, snapshot, items, truncated).await) + } + + async fn navigation_items( + &self, + kind: NavigationKind, + uri: &lsp_types::Uri, + position: CodePosition, + cancellation: &CancellationToken, + ) -> Result, LanguageRuntimeError> { let text_position = TextDocumentPositionParams::new( - TextDocumentIdentifier::new(uri), + TextDocumentIdentifier::new(uri.clone()), Position::new(position.line, position.character), ); - let items = match kind { + match kind { NavigationKind::References => { let response: Option> = self .request_typed( @@ -370,7 +438,7 @@ impl LanguageRuntime { .await?; let mut mapped = Vec::new(); for location in response.unwrap_or_default() { - ensure_not_cancelled(&cancellation)?; + ensure_not_cancelled(cancellation)?; mapped.push( mapping::map_location(&self.canonical_root, location) .await @@ -380,7 +448,7 @@ impl LanguageRuntime { })?, ); } - mapped + Ok(mapped) } NavigationKind::Definition | NavigationKind::Declaration @@ -400,12 +468,34 @@ impl LanguageRuntime { .await?; mapping::map_definition_response(&self.canonical_root, response) .await - .map_err(|source| LanguageRuntimeError::Mapping { operation, source })? + .map_err(|source| LanguageRuntimeError::Mapping { operation, source }) } - }; - ensure_not_cancelled(&cancellation)?; - let (items, truncated) = bound_items(items, MAX_NAVIGATION_RESULTS); - Ok(self.document_result(path, snapshot, items, truncated).await) + } + } + + async fn navigation_revision_is_settled( + &self, + path: &WorkspacePath, + revision: DocumentRevision, + ) -> bool { + self.navigation_revisions.lock().await.get(path) == Some(&revision) + } + + async fn wait_for_settle( + &self, + delay: Duration, + cancellation: &CancellationToken, + ) -> Result<(), LanguageRuntimeError> { + ensure_not_cancelled(cancellation)?; + let delay = delay.min(self.timeout); + if delay.is_zero() { + return Ok(()); + } + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(LanguageRuntimeError::Cancelled), + _ = tokio::time::sleep(delay) => Ok(()), + } } pub(crate) async fn diagnostics( diff --git a/core/src/code_intelligence/language_runtime/integration_tests.rs b/core/src/code_intelligence/language_runtime/integration_tests.rs index 85c42cbb..76ecac5f 100644 --- a/core/src/code_intelligence/language_runtime/integration_tests.rs +++ b/core/src/code_intelligence/language_runtime/integration_tests.rs @@ -80,6 +80,7 @@ async fn saved_document_runtime_completes_a_real_process_protocol_lifecycle() { layout, Arc::new(DocumentStore::new(8)), Arc::new(DiagnosticsStore::new(8)), + CancellationToken::new(), Duration::from_secs(5), ) .await @@ -196,6 +197,7 @@ async fn publish_only_diagnostics_wait_for_the_current_document_revision() { ProjectLayoutResolver::resolve(&snapshot), Arc::new(DocumentStore::new(1)), Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), Duration::from_secs(5), ) .await @@ -216,6 +218,215 @@ async fn publish_only_diagnostics_wait_for_the_current_document_revision() { assert!(!protocol_log.contains("\"method\":\"textDocument/diagnostic\"")); } +#[tokio::test] +async fn initialization_settle_delays_readiness_and_honors_cancellation() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-initialization-settle-lsp.exe" + } else { + "code-intelligence-initialization-settle-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let layout = ProjectLayoutResolver::resolve(&snapshot); + + let started = tokio::time::Instant::now(); + let runtime = LanguageRuntime::start( + LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::from_millis(75), Duration::ZERO), + canonical_root.clone(), + layout.clone(), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(60)); + runtime.shutdown().await.unwrap(); + + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + trigger.cancel(); + }); + let cancelled = tokio::time::timeout( + Duration::from_secs(1), + LanguageRuntime::start( + LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::from_secs(5), Duration::ZERO), + canonical_root, + layout, + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + cancellation, + Duration::from_secs(5), + ), + ) + .await + .expect("cancellation must interrupt initialization settling"); + assert!(matches!( + cancelled, + Err(super::LanguageRuntimeError::Cancelled) + )); +} + +#[tokio::test] +async fn first_navigation_waits_for_empty_and_partial_cold_results_to_settle() { + for mode in ["cold-empty", "cold-partial"] { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(source_dir.join("lib.rs"), saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + format!("code-intelligence-{mode}-lsp.exe") + } else { + format!("code-intelligence-{mode}-lsp") + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let profile = LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::ZERO, Duration::from_millis(25)); + let runtime = LanguageRuntime::start( + profile, + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + + let first = runtime + .navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(first.items.len(), 3, "{mode} did not settle"); + + let second = runtime + .navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(second.items.len(), 3); + runtime.shutdown().await.unwrap(); + + let protocol_log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + assert_eq!( + protocol_log + .matches("\"method\":\"textDocument/references\"") + .count(), + 3, + "{mode} should retry only the first saved revision" + ); + } +} + +#[tokio::test] +async fn navigation_stabilization_is_cancellable() { + let workspace = tempfile::tempdir().unwrap(); + let source_dir = workspace.path().join("src"); + std::fs::create_dir(&source_dir).unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .unwrap(); + let saved = "pub fn answer() -> u32 { 42 }\n"; + std::fs::write(source_dir.join("lib.rs"), saved).unwrap(); + let canonical_root = std::fs::canonicalize(workspace.path()).unwrap(); + + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "code-intelligence-cancellable-lsp.exe" + } else { + "code-intelligence-cancellable-lsp" + }); + compile_fake_server(&server); + let snapshot = LocalWorkspaceManifestSnapshot { + version: 1, + root: canonical_root.clone(), + files: vec![manifest_file("Cargo.toml"), manifest_file("src/lib.rs")], + scanned_at_ms: 1, + }; + let profile = LanguageServerProfile::rust(&server) + .with_settle_delays(Duration::ZERO, Duration::from_secs(5)); + let runtime = LanguageRuntime::start( + profile, + canonical_root, + ProjectLayoutResolver::resolve(&snapshot), + Arc::new(DocumentStore::new(1)), + Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), + Duration::from_secs(5), + ) + .await + .unwrap(); + let path = WorkspacePath::from_normalized("src/lib.rs"); + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + trigger.cancel(); + }); + + let result = tokio::time::timeout( + Duration::from_secs(1), + runtime.navigate( + NavigationKind::References, + &path, + CodePosition::new(0, 7), + saved, + cancellation, + ), + ) + .await + .expect("cancellation must interrupt the settle delay"); + assert!(matches!( + result, + Err(super::LanguageRuntimeError::Cancelled) + )); + runtime.shutdown().await.unwrap(); +} + #[tokio::test] async fn unexpected_process_exit_exposes_state_and_bounded_stderr() { let workspace = tempfile::tempdir().unwrap(); @@ -240,6 +451,7 @@ async fn unexpected_process_exit_exposes_state_and_bounded_stderr() { ProjectLayoutResolver::resolve(&snapshot), Arc::new(DocumentStore::new(1)), Arc::new(DiagnosticsStore::new(1)), + CancellationToken::new(), Duration::from_secs(5), ) .await diff --git a/core/src/code_intelligence/language_runtime/protocol.rs b/core/src/code_intelligence/language_runtime/protocol.rs index 0455cc74..0992dc78 100644 --- a/core/src/code_intelligence/language_runtime/protocol.rs +++ b/core/src/code_intelligence/language_runtime/protocol.rs @@ -241,6 +241,7 @@ impl LanguageRuntime { cancellation: &CancellationToken, ) -> Result<(), LanguageRuntimeError> { self.documents.invalidate(path).await; + self.navigation_revisions.lock().await.remove(path); if self.open_documents.lock().await.contains(path) { self.close_document(path, cancellation).await?; self.open_documents.lock().await.remove(path); diff --git a/core/src/code_intelligence/workspace_runtime.rs b/core/src/code_intelligence/workspace_runtime.rs index f9d15696..43fbd31e 100644 --- a/core/src/code_intelligence/workspace_runtime.rs +++ b/core/src/code_intelligence/workspace_runtime.rs @@ -657,6 +657,7 @@ impl WorkspaceRuntime { self.layout.clone(), Arc::clone(&slot.documents), Arc::clone(&self.diagnostics), + cancellation.clone(), self.timeout, ) .await; diff --git a/core/src/event_protocol.rs b/core/src/event_protocol.rs index 28012741..67638cba 100644 --- a/core/src/event_protocol.rs +++ b/core/src/event_protocol.rs @@ -1,4 +1,4 @@ -//! Versioned wire protocol for [`AgentEvent`](crate::AgentEvent). +//! Versioned wire protocol for [`AgentEvent`]. //! //! `AgentEvent` is the runtime enum. [`EventEnvelopeV1`] is its stable, //! language-neutral representation. SDKs consume the envelope instead of diff --git a/core/src/host_env.rs b/core/src/host_env.rs index 446b549f..4da5c9fc 100644 --- a/core/src/host_env.rs +++ b/core/src/host_env.rs @@ -13,7 +13,7 @@ //! //! Hosts plug a custom impl via //! [`SessionOptions::with_host_env`](crate::agent_api::SessionOptions::with_host_env); -//! the framework uses [`SystemHostEnv`] (the wall-clock + random-UUID +//! the framework uses [`HostEnv::system`] (the wall-clock + random-UUID //! default) when none is supplied — observably identical to pre-P2 //! behaviour. @@ -43,7 +43,7 @@ pub trait Clock: Send + Sync + std::fmt::Debug { } /// Bundle of host-environment capabilities. Used as the single -/// `Option>` slot on [`AgentConfig`](crate::agent::AgentConfig) +/// `Option>` slot on `AgentConfig` /// and [`SessionOptions`](crate::agent_api::SessionOptions) — avoids /// growing two parallel `Arc` fields. #[derive(Debug, Clone)] diff --git a/core/src/mcp/manager.rs b/core/src/mcp/manager.rs index 0f8a3ac0..c8cebe81 100644 --- a/core/src/mcp/manager.rs +++ b/core/src/mcp/manager.rs @@ -321,7 +321,8 @@ impl McpManager { /// Get all MCP tools, grouped by server name. /// /// Returns `(server_name, tool)` pairs — the caller is responsible for - /// constructing the `mcp____` prefix (e.g. via [`create_mcp_tools`]). + /// constructing the `mcp____` prefix (e.g. via + /// [`create_mcp_tools`](crate::mcp::create_mcp_tools)). pub async fn get_all_tools(&self) -> Vec<(String, McpTool)> { let clients = self.clients.read().await; let mut all_tools = Vec::new(); diff --git a/core/src/sandbox.rs b/core/src/sandbox.rs index 9a937205..baec3a2a 100644 --- a/core/src/sandbox.rs +++ b/core/src/sandbox.rs @@ -1,6 +1,7 @@ //! Sandbox integration for bash tool execution. //! -//! When a [`BashSandbox`] is provided via [`ToolContext::with_sandbox`], the +//! When a [`BashSandbox`] is provided via +//! [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox), the //! `bash` built-in tool routes commands through that sandbox instead of //! `std::process::Command`. The workspace directory is mounted read-write //! at `/workspace` inside the sandbox. @@ -28,7 +29,7 @@ pub struct SandboxOutput { /// /// Implement this trait to provide a custom sandbox backend. The host /// application constructs the implementation and passes it to the session -/// via [`ToolContext::with_sandbox`]. +/// via [`ToolContext::with_sandbox`](crate::tools::ToolContext::with_sandbox). #[async_trait] pub trait BashSandbox: Send + Sync { /// Execute a shell command inside the sandbox. diff --git a/core/src/tools/builtin/mod.rs b/core/src/tools/builtin/mod.rs index 736f30e8..088b43a9 100644 --- a/core/src/tools/builtin/mod.rs +++ b/core/src/tools/builtin/mod.rs @@ -46,8 +46,9 @@ pub(crate) fn safe_http_source_url(value: &str) -> Option { /// and must be registered after the registry is wrapped in an Arc. pub fn register_builtins( registry: &ToolRegistry, - capabilities: &crate::workspace::WorkspaceCapabilities, + workspace_services: &crate::workspace::WorkspaceServices, ) { + let capabilities = workspace_services.capabilities(); if capabilities.read { registry.register_builtin(Arc::new(read::ReadTool)); registry.register_builtin(Arc::new(ls::LsTool)); @@ -66,7 +67,7 @@ pub fn register_builtins( registry.register_builtin(Arc::new(grep::GrepTool)); registry.register_builtin(Arc::new(glob_tool::GlobTool)); } - if capabilities.code_intelligence { + if workspace_services.code_intelligence().is_some() { code_intelligence::register(registry); } if capabilities.git { diff --git a/core/src/tools/mod.rs b/core/src/tools/mod.rs index 4b7ce853..c84c4d38 100644 --- a/core/src/tools/mod.rs +++ b/core/src/tools/mod.rs @@ -328,7 +328,7 @@ impl ToolExecutor { // Register native Rust built-in tools — only those whose required // workspace capability is available, so the model never sees a tool // the backend cannot service. - builtin::register_builtins(®istry, &workspace_services.capabilities()); + builtin::register_builtins(®istry, &workspace_services); // Batch tool requires Arc, registered separately builtin::register_batch(®istry); builtin::register_program(®istry); diff --git a/core/src/tools/skill.rs b/core/src/tools/skill.rs index ea5c6f6d..8ffc0520 100644 --- a/core/src/tools/skill.rs +++ b/core/src/tools/skill.rs @@ -7,7 +7,7 @@ //! //! ## Usage //! -//! ```rust +//! ```text //! // Agent calls: Skill("data-processor") //! // The skill's allowed-tools are temporarily granted //! // After execution, permissions are restored diff --git a/core/src/tools/task/parallel_execution.rs b/core/src/tools/task/parallel_execution.rs index 66325f23..d10e580d 100644 --- a/core/src/tools/task/parallel_execution.rs +++ b/core/src/tools/task/parallel_execution.rs @@ -36,7 +36,7 @@ impl TaskExecutor { /// /// Spawns all tasks concurrently and waits for all to complete. /// Returns results in the same order as the input tasks. Routed through - /// the [`AgentExecutor`](crate::orchestration::AgentExecutor) seam so the + /// the [`AgentExecutor`] seam so the /// same fan-out works whether steps run locally (default) or are placed /// on remote nodes by a host. pub async fn execute_parallel( diff --git a/core/src/workspace/manifest/scanner.rs b/core/src/workspace/manifest/scanner.rs index 36debca2..a67ba33d 100644 --- a/core/src/workspace/manifest/scanner.rs +++ b/core/src/workspace/manifest/scanner.rs @@ -415,7 +415,7 @@ mod cancellation_tests { #[cfg(unix)] #[test] - fn cancellable_command_kills_a_blocked_child() { + fn cancellable_command_kills_a_blocked_process_group() { let cancelled = Arc::new(AtomicBool::new(false)); let trigger = Arc::clone(&cancelled); let cancel_task = thread::spawn(move || { @@ -423,7 +423,9 @@ mod cancellation_tests { trigger.store(true, Ordering::Release); }); let mut command = Command::new("sh"); - command.args(["-c", "sleep 30"]); + // Keep a shell leader and a separate descendant alive so the test + // fails if cancellation kills only the direct child. + command.args(["-c", "sleep 30 & wait"]); let started = std::time::Instant::now(); let output = command_stdout_cancellable(command, &|| cancelled.load(Ordering::Acquire)); diff --git a/core/src/workspace/remote_git.rs b/core/src/workspace/remote_git.rs index e10ef861..ee739fab 100644 --- a/core/src/workspace/remote_git.rs +++ b/core/src/workspace/remote_git.rs @@ -817,7 +817,7 @@ fn emit_remote_git_event( } impl super::WorkspaceServices { - /// Attach a remote git provider to an existing [`WorkspaceServices`]. + /// Attach a remote git provider to an existing [`super::WorkspaceServices`]. /// /// Returns a new `Arc` with `git` and `git_stash` /// wired to the remote backend. The original `WorkspaceServices` is @@ -826,8 +826,7 @@ impl super::WorkspaceServices { /// onto a remote service (see RFC §8). All other fields — including /// `local_root`, the command runner, the search provider, the /// optional `file_system_ext` (S3 CAS), and `operation_timeout` — are - /// preserved verbatim via - /// [`super::WorkspaceServices::with_git_provider`]. + /// preserved verbatim via the internal `with_git_provider` constructor. pub fn with_remote_git(self: Arc, config: RemoteGitBackendConfig) -> Result> { let backend = RemoteGitBackend::new(config)?; let git: Arc = backend.clone(); diff --git a/core/src/workspace/tests.rs b/core/src/workspace/tests.rs index 24a5cf73..6bf5f26f 100644 --- a/core/src/workspace/tests.rs +++ b/core/src/workspace/tests.rs @@ -201,60 +201,6 @@ async fn write_for_edit_succeeds_on_matching_version() { assert_eq!(current, "beta"); } -#[tokio::test] -async fn write_for_edit_surfaces_conflict_when_version_changed() { - let fs = Arc::new(InMemoryFileSystem::new()); - let seeded_version = seed(&fs, "doc.md", "alpha").await; - let services = versioned_services(fs.clone()); - let path = WorkspacePath::from_normalized("doc.md"); - - let (_, version) = services.read_for_edit(&path).await.unwrap(); - // Simulate a concurrent overwrite — a real "second writer" doing - // exactly what the conflict protection is meant to catch. - fs.write_text(&path, "from-concurrent-writer") - .await - .unwrap(); - - let err = services - .write_for_edit(&path, "beta", version.as_deref()) - .await - .expect_err("write should reject with conflict"); - let WorkspaceError::VersionConflict(conflict) = err else { - panic!("expected WorkspaceError::VersionConflict, got {err:?}"); - }; - assert_eq!(conflict.path, "doc.md"); - assert_eq!(conflict.expected, seeded_version); - // We don't pin the actual version's exact value — only that the - // backend supplies one and that it differs from what we expected. - let actual = conflict - .actual - .as_deref() - .expect("conflict must report the current version"); - assert_ne!(actual, seeded_version); -} - -#[tokio::test] -async fn write_for_edit_falls_back_to_plain_write_when_version_is_none() { - // Even with fs_ext present, passing version=None must route through - // unconditional write_text (e.g. for fresh-create paths). - let fs = Arc::new(InMemoryFileSystem::new()); - seed(&fs, "doc.md", "alpha").await; - let services = versioned_services(fs.clone()); - let path = WorkspacePath::from_normalized("doc.md"); - - // Concurrent overwriter, but caller did not request CAS semantics: - fs.write_text(&path, "from-concurrent-writer") - .await - .unwrap(); - - services - .write_for_edit(&path, "beta", None) - .await - .expect("plain write should not check version"); - let current = fs.read_text(&path).await.unwrap(); - assert_eq!(current, "beta"); -} - struct TestCodeIntelligence { status: tokio::sync::watch::Sender, } @@ -351,6 +297,7 @@ fn workspace_services_builder_attaches_code_intelligence() { .build(); assert!(services.capabilities().code_intelligence); + assert!(services.code_intelligence().is_some()); assert_eq!( services.code_intelligence().unwrap().status().state, CodeIntelligenceState::Ready @@ -377,3 +324,57 @@ fn code_intelligence_decorator_preserves_existing_services() { assert!(decorated.code_intelligence().is_some()); assert_eq!(decorated.workspace_ref(), services.workspace_ref()); } + +#[tokio::test] +async fn write_for_edit_surfaces_conflict_when_version_changed() { + let fs = Arc::new(InMemoryFileSystem::new()); + let seeded_version = seed(&fs, "doc.md", "alpha").await; + let services = versioned_services(fs.clone()); + let path = WorkspacePath::from_normalized("doc.md"); + + let (_, version) = services.read_for_edit(&path).await.unwrap(); + // Simulate a concurrent overwrite — a real "second writer" doing + // exactly what the conflict protection is meant to catch. + fs.write_text(&path, "from-concurrent-writer") + .await + .unwrap(); + + let err = services + .write_for_edit(&path, "beta", version.as_deref()) + .await + .expect_err("write should reject with conflict"); + let WorkspaceError::VersionConflict(conflict) = err else { + panic!("expected WorkspaceError::VersionConflict, got {err:?}"); + }; + assert_eq!(conflict.path, "doc.md"); + assert_eq!(conflict.expected, seeded_version); + // We don't pin the actual version's exact value — only that the + // backend supplies one and that it differs from what we expected. + let actual = conflict + .actual + .as_deref() + .expect("conflict must report the current version"); + assert_ne!(actual, seeded_version); +} + +#[tokio::test] +async fn write_for_edit_falls_back_to_plain_write_when_version_is_none() { + // Even with fs_ext present, passing version=None must route through + // unconditional write_text (e.g. for fresh-create paths). + let fs = Arc::new(InMemoryFileSystem::new()); + seed(&fs, "doc.md", "alpha").await; + let services = versioned_services(fs.clone()); + let path = WorkspacePath::from_normalized("doc.md"); + + // Concurrent overwriter, but caller did not request CAS semantics: + fs.write_text(&path, "from-concurrent-writer") + .await + .unwrap(); + + services + .write_for_edit(&path, "beta", None) + .await + .expect("plain write should not check version"); + let current = fs.read_text(&path).await.unwrap(); + assert_eq!(current, "beta"); +} diff --git a/core/tests/fixtures/code_intelligence_fake_lsp.rs b/core/tests/fixtures/code_intelligence_fake_lsp.rs index d675f0f5..32ee550e 100644 --- a/core/tests/fixtures/code_intelligence_fake_lsp.rs +++ b/core/tests/fixtures/code_intelligence_fake_lsp.rs @@ -1,21 +1,30 @@ use std::{ fs::OpenOptions, - io::{self, BufRead, BufReader, Read, Write}, + io::{self, BufRead, BufReader, Write}, path::PathBuf, }; fn main() -> io::Result<()> { let executable = std::env::current_exe()?; let log_path = executable.with_extension("log"); - let push_diagnostics = executable + let executable_name = executable .file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains("push-diagnostics")); + .unwrap_or_default(); + let push_diagnostics = executable_name.contains("push-diagnostics"); + let cold_navigation = if executable_name.contains("cold-empty") { + ColdNavigation::Empty + } else if executable_name.contains("cold-partial") { + ColdNavigation::Partial + } else { + ColdNavigation::Disabled + }; let stdin = io::stdin(); let mut input = BufReader::new(stdin.lock()); let stdout = io::stdout(); let mut output = stdout.lock(); let mut document_uri = None; + let mut navigation_requests = 0_usize; while let Some(body) = read_message(&mut input)? { append_log(&log_path, &body)?; @@ -40,7 +49,16 @@ fn main() -> io::Result<()> { } continue; }; - let result = response_for(&method, document_uri.as_deref(), push_diagnostics); + if is_navigation_method(&method) { + navigation_requests += 1; + } + let result = response_for( + &method, + document_uri.as_deref(), + push_diagnostics, + cold_navigation, + navigation_requests, + ); write_message( &mut output, &format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}"), @@ -49,6 +67,13 @@ fn main() -> io::Result<()> { Ok(()) } +#[derive(Clone, Copy)] +enum ColdNavigation { + Disabled, + Empty, + Partial, +} + fn read_message(reader: &mut impl BufRead) -> io::Result> { let mut content_length = None; loop { @@ -114,7 +139,13 @@ fn string_field(body: &str, field: &str) -> Option { Some(rest[..end].to_owned()) } -fn response_for(method: &str, uri: Option<&str>, push_diagnostics: bool) -> String { +fn response_for( + method: &str, + uri: Option<&str>, + push_diagnostics: bool, + cold_navigation: ColdNavigation, + navigation_requests: usize, +) -> String { let uri = uri.unwrap_or("file:///missing.rs"); match method { "initialize" => initialize_response(push_diagnostics), @@ -130,7 +161,12 @@ fn response_for(method: &str, uri: Option<&str>, push_diagnostics: bool) -> Stri "textDocument/definition" | "textDocument/declaration" | "textDocument/references" - | "textDocument/implementation" => locations_response(uri), + | "textDocument/implementation" => match (cold_navigation, navigation_requests) { + (ColdNavigation::Empty, 1) => "[]".to_owned(), + (ColdNavigation::Partial, 1) => locations_response(uri), + (ColdNavigation::Empty | ColdNavigation::Partial, _) => settled_locations_response(uri), + (ColdNavigation::Disabled, _) => locations_response(uri), + }, "textDocument/diagnostic" => concat!( "{\"kind\":\"full\",\"resultId\":\"fixture-1\",\"items\":[{", "\"range\":{\"start\":{\"line\":0,\"character\":7},", @@ -143,6 +179,16 @@ fn response_for(method: &str, uri: Option<&str>, push_diagnostics: bool) -> Stri } } +fn is_navigation_method(method: &str) -> bool { + matches!( + method, + "textDocument/definition" + | "textDocument/declaration" + | "textDocument/references" + | "textDocument/implementation" + ) +} + fn initialize_response(push_diagnostics: bool) -> String { let mut response = concat!( "{\"capabilities\":{", @@ -199,3 +245,20 @@ fn locations_response(uri: &str) -> String { )); response } + +fn settled_locations_response(uri: &str) -> String { + let mut response = String::from("["); + for (index, character) in [0, 7, 14].into_iter().enumerate() { + if index > 0 { + response.push(','); + } + response.push_str("{\"uri\":\""); + response.push_str(uri); + response.push_str(&format!( + "\",\"range\":{{\"start\":{{\"line\":0,\"character\":{character}}},\"end\":{{\"line\":0,\"character\":{}}}}}}}", + character + 1 + )); + } + response.push(']'); + response +} diff --git a/manual/CODE_INTELLIGENCE_DESIGN.md b/manual/CODE_INTELLIGENCE_DESIGN.md index b194a884..468dedec 100644 --- a/manual/CODE_INTELLIGENCE_DESIGN.md +++ b/manual/CODE_INTELLIGENCE_DESIGN.md @@ -71,6 +71,15 @@ provider retires an idle superseded layout immediately. Shutdown is explicit and idempotent; process exit also changes status immediately and permits a clean restart on the next query. +Protocol initialization can complete before a language server finishes its +first semantic index. Status therefore remains starting through a bounded, +cancellable initialization-settle window. The first navigation query for each +saved document revision performs one warmup request and returns a second, +authoritative response after a short cancellable interval. This rule does not +infer readiness from an empty result or an assumed reference count; legitimate +empty results remain valid and later queries for the same revision avoid the +warmup cost. + The first workspace diagnostics query starts every language profile relevant to supported source files in the current manifest, then fairly queries a bounded number of saved documents with bounded concurrency. A missing runtime @@ -118,8 +127,8 @@ or another working language. ## Product Surfaces -Agent sessions expose three read-only tools when the workspace advertises the -capability: +Agent sessions expose three read-only tools when the workspace has an attached +Code Intelligence provider: - `code_symbols` for document outlines and workspace symbol search; - `code_navigation` for definitions, declarations, references, and @@ -161,5 +170,6 @@ location. Required regression coverage includes UTF-16 positions, stale evidence, unsupported capabilities, process crashes, delayed status subscribers, -workspace isolation, traversal and symlink escapes, bounded results, explicit +workspace isolation, cold empty and partial navigation results, cancellation +during stabilization, traversal and symlink escapes, bounded results, explicit shutdown, and the absence of duplicate file/search/edit behavior. diff --git a/scripts/check_release_versions.sh b/scripts/check_release_versions.sh index 4ac8e183..60c5dfac 100755 --- a/scripts/check_release_versions.sh +++ b/scripts/check_release_versions.sh @@ -119,6 +119,12 @@ def check_bootstrap_runtime_version(path): check_equal(f"{path} __version__", match.group(1)) +def check_changelog(path): + heading = f"## [{expected}]" + if heading not in read(path): + fail(f"{path}: missing release heading {heading}") + + if not expected: expected = first_manifest_version("core/Cargo.toml") or "" @@ -134,6 +140,8 @@ check_package_json("sdk/node/package.json") check_pyproject("sdk/python/pyproject.toml") check_pyproject("sdk/python-bootstrap/pyproject.toml") check_bootstrap_runtime_version("sdk/python-bootstrap/src/a3s_code/_bootstrap.py") +check_changelog("CHANGELOG.md") +check_changelog("sdk/python/CHANGELOG.md") check_cargo_lock("Cargo.lock") check_cargo_lock_package("sdk/node/Cargo.lock", "a3s-code-node") check_cargo_lock_package("sdk/python/Cargo.lock", "a3s-code-py") diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh new file mode 100644 index 00000000..2511c56d --- /dev/null +++ b/scripts/check_semver.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Compare the current public API with a verified published baseline. + +set -euo pipefail + +BASELINE_VERSION="${1:-5.2.8}" +PACKAGE="a3s-code-core" + +case "$BASELINE_VERSION" in + 5.2.8) + BASELINE_SHA256="059e9eefe6f2d0b816b9ec9f906878413a2f30fd1bc90a751c53b77972ff84a7" + ;; + 5.2.7) + BASELINE_SHA256="59993ad1e362628c7665d817318271faff5b3f775dfad0d648b1cc4a17099784" + ;; + 5.2.4) + BASELINE_SHA256="0066046ead6d44acac8a01a8bd1bd78c37aae02c14121f28ea77c15d9d0133a4" + ;; + *) + echo "unsupported SemVer baseline: $BASELINE_VERSION" >&2 + exit 1 + ;; +esac + +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/a3s-code-semver.XXXXXX")" +trap 'rm -rf "$TEMP_ROOT"' EXIT + +ARCHIVE="$TEMP_ROOT/${PACKAGE}-${BASELINE_VERSION}.crate" +SOURCE_ROOT="$TEMP_ROOT/${PACKAGE}-${BASELINE_VERSION}" + +curl \ + --fail \ + --location \ + --silent \ + --show-error \ + --retry 3 \ + --proto '=https' \ + --tlsv1.2 \ + --output "$ARCHIVE" \ + "https://static.crates.io/crates/${PACKAGE}/${PACKAGE}-${BASELINE_VERSION}.crate" + +python3 - "$ARCHIVE" "$BASELINE_SHA256" <<'PY' +import hashlib +import pathlib +import sys + +archive = pathlib.Path(sys.argv[1]) +expected = sys.argv[2] +actual = hashlib.sha256(archive.read_bytes()).hexdigest() +if actual != expected: + raise SystemExit( + f"baseline archive checksum mismatch: expected {expected}, got {actual}" + ) +PY + +tar -xzf "$ARCHIVE" -C "$TEMP_ROOT" + +# The published 5.2.4 manifest used the compatible range `1.4.1`, but a later +# incompatible a3s-search release now satisfies that range. Pin the dependency +# version used by 5.2.4 so its unchanged public API can still be documented. +if [[ "$BASELINE_VERSION" == "5.2.4" ]]; then + python3 - "$SOURCE_ROOT/Cargo.toml" <<'PY' +import pathlib +import sys + +manifest = pathlib.Path(sys.argv[1]) +contents = manifest.read_text() +original = '[dependencies.a3s-search]\nversion = "1.4.1"' +replacement = '[dependencies.a3s-search]\nversion = "=1.4.1"' +if contents.count(original) != 1: + raise SystemExit(f"unexpected baseline a3s-search declaration in {manifest}") +manifest.write_text(contents.replace(original, replacement)) +PY +fi + +cargo semver-checks check-release \ + --package "$PACKAGE" \ + --baseline-root "$SOURCE_ROOT" diff --git a/sdk/node/Cargo.lock b/sdk/node/Cargo.lock index 838b5f52..6716fc6f 100644 --- a/sdk/node/Cargo.lock +++ b/sdk/node/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.2.8" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-node" -version = "5.2.8" +version = "5.3.0" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index 4f80a282..96cbc75f 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "5.2.8" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.2.8", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.0", path = "../../core", features = ["s3", "serve"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index b9b3de35..2e2f5801 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "5.2.8", + "version": "5.3.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.8", - "@a3s-lab/code-linux-arm64-gnu": "5.2.8", - "@a3s-lab/code-linux-arm64-musl": "5.2.8", - "@a3s-lab/code-linux-x64-gnu": "5.2.8", - "@a3s-lab/code-linux-x64-musl": "5.2.8", - "@a3s-lab/code-win32-x64-msvc": "5.2.8" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index c1953ed4..70ea4cf2 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "5.2.8", + "version": "5.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "5.2.8", + "version": "5.3.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.8", - "@a3s-lab/code-linux-arm64-gnu": "5.2.8", - "@a3s-lab/code-linux-arm64-musl": "5.2.8", - "@a3s-lab/code-linux-x64-gnu": "5.2.8", - "@a3s-lab/code-linux-x64-musl": "5.2.8", - "@a3s-lab/code-win32-x64-msvc": "5.2.8" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index 184293ea..11367020 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "5.2.8", + "version": "5.3.0", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -44,11 +44,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.2.8", - "@a3s-lab/code-linux-x64-gnu": "5.2.8", - "@a3s-lab/code-linux-x64-musl": "5.2.8", - "@a3s-lab/code-linux-arm64-gnu": "5.2.8", - "@a3s-lab/code-linux-arm64-musl": "5.2.8", - "@a3s-lab/code-win32-x64-msvc": "5.2.8" + "@a3s-lab/code-darwin-arm64": "5.3.0", + "@a3s-lab/code-linux-x64-gnu": "5.3.0", + "@a3s-lab/code-linux-x64-musl": "5.3.0", + "@a3s-lab/code-linux-arm64-gnu": "5.3.0", + "@a3s-lab/code-linux-arm64-musl": "5.3.0", + "@a3s-lab/code-win32-x64-msvc": "5.3.0" } } diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml index f8a471b0..5c15fac1 100644 --- a/sdk/python-bootstrap/pyproject.toml +++ b/sdk/python-bootstrap/pyproject.toml @@ -7,7 +7,7 @@ name = "a3s-code" # Keep in sync with crates/code core release. The bootstrap loader fetches # the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v` # at import time. -version = "5.2.8" +version = "5.3.0" description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py index d58a3330..a5e1075b 100644 --- a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py +++ b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py @@ -31,7 +31,7 @@ # Version is the bootstrap's own version, which equals the matching native # wheel version on GH Releases. Bumped by the release workflow. -__version__ = "5.2.8" +__version__ = "5.3.0" _DEFAULT_BASE_URL = "https://github.com/A3S-Lab/Code/releases/download" _REQUEST_TIMEOUT_S = 120 diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 59fdec1f..36ba4ff2 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the A3S Code Python SDK will be documented in this file. ## [Unreleased] +## [5.3.0] - 2026-07-15 + +### Added + +- Added `Session.cancel_and_settle(...)` parity for bounded cooperative + cancellation and streaming-worker cleanup before session reuse. + ## [5.2.4] - 2026-07-14 ### Fixed diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index 91b3bdc9..8e86a43f 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.2.8" +version = "5.3.0" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-py" -version = "5.2.8" +version = "5.3.0" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index cc040c34..c91117a7 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "5.2.8" +version = "5.3.0" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.2.8", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.0", path = "../../core", features = ["s3", "serve"] } pyo3 = { version = "0.23", features = ["multiple-pymethods"] } tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 6a25fd53..8e578b5f 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "5.2.8" +version = "5.3.0" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}