diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index 74f285be3..19c2ba1d0 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -10,11 +10,11 @@ name: Package and publish on: workflow_dispatch: - push: - branches: - - develop + # push: + # branches: + # - develop workflow_run: - workflows: ['Version Bump'] + workflows: ["Version Bump"] types: - completed branches: @@ -170,15 +170,15 @@ jobs: fail-fast: false matrix: settings: - - platform: 'macos-latest' - args: '--target aarch64-apple-darwin' - target: 'aarch64-apple-darwin' - - platform: 'macos-latest' - args: '--target x86_64-apple-darwin' - target: 'x86_64-apple-darwin' - - platform: 'ubuntu-22.04' - args: '' - target: '' + - platform: "macos-latest" + args: "--target aarch64-apple-darwin" + target: "aarch64-apple-darwin" + - platform: "macos-latest" + args: "--target x86_64-apple-darwin" + target: "x86_64-apple-darwin" + - platform: "ubuntu-22.04" + args: "" + target: "" runs-on: ${{ matrix.settings.platform }} steps: - name: Checkout @@ -199,7 +199,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: 'yarn' + cache: "yarn" - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -322,7 +322,7 @@ jobs: # macOS 10.15+ required for std::filesystem used by llama.cpp MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} with: - args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}' + args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}" includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }} includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }} # TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks diff --git a/skills b/skills index 640518e8c..dd49a534a 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 640518e8cc3792c8f0a5565294ff791d75f375d5 +Subproject commit dd49a534a7389411da7cbeda246bf33f35f3f4a1 diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs index fb63646db..9eaf4ecd8 100644 --- a/src-tauri/src/runtime/ping_scheduler.rs +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -23,7 +23,7 @@ use crate::runtime::skill_registry::SkillRegistry; use crate::runtime::types::{events, SkillMessage, SkillStatus}; /// Interval between ping sweeps. -const PING_INTERVAL: Duration = Duration::from_secs(5); +const PING_INTERVAL: Duration = Duration::from_secs(60); /// Per-skill timeout when waiting for a ping reply. const PING_TIMEOUT: Duration = Duration::from_secs(30); @@ -109,11 +109,19 @@ impl PingScheduler { None => return, }; - // Collect running skill IDs + // Collect skill IDs that are running AND actively connected. + // Skills in setup mode or not yet connected are excluded to avoid + // interfering with the setup flow. let running: Vec = registry .list_skills() .into_iter() - .filter(|s| s.status == SkillStatus::Running) + .filter(|s| { + s.status == SkillStatus::Running + && s.state + .get("connection_status") + .and_then(|v| v.as_str()) + .is_some_and(|cs| cs == "connected") + }) .map(|s| s.skill_id) .collect(); diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index 15ec60382..4e4f0d751 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -312,7 +312,8 @@ impl RuntimeEngine { .error .clone() .unwrap_or_else(|| "Unknown initialization error".to_string()); - self.registry.unregister(&skill_id_owned); + // Don't unregister — keep the skill with Error status so the + // UI can see what happened and allow restart. self.emit_status_change(&skill_id_owned); return Err(format!( "Skill '{}' failed to start: {}", @@ -320,7 +321,9 @@ impl RuntimeEngine { )); } SkillStatus::Stopped => { - self.registry.unregister(&skill_id_owned); + // Don't unregister — keep the skill with Stopped status so the + // UI can still query it and allow restart. + self.emit_status_change(&skill_id_owned); return Err(format!( "Skill '{}' stopped unexpectedly during initialization", skill_id_owned diff --git a/src-tauri/src/runtime/skill_registry.rs b/src-tauri/src/runtime/skill_registry.rs index 7584a39d9..b62952219 100644 --- a/src-tauri/src/runtime/skill_registry.rs +++ b/src-tauri/src/runtime/skill_registry.rs @@ -118,6 +118,13 @@ impl SkillRegistry { let entry = skills .get(skill_id) .ok_or_else(|| format!("Skill '{}' not found", skill_id))?; + let status = entry.state.read().status; + if status != SkillStatus::Running { + return Err(format!( + "Skill '{}' is not running (status: {:?})", + skill_id, status + )); + } entry.sender.clone() }; @@ -198,8 +205,8 @@ impl SkillRegistry { // Wait for the skill to acknowledge stopping let _ = tokio::time::timeout(std::time::Duration::from_secs(5), reply_rx).await; - // Remove from registry - self.unregister(skill_id); + // Don't unregister — the skill stays in the registry with Stopped status + // so the UI can still query it and allow restart without full rediscovery. Ok(()) } @@ -235,6 +242,13 @@ impl SkillRegistry { let entry = skills .get(skill_id) .ok_or_else(|| format!("Skill '{}' not found", skill_id))?; + let status = entry.state.read().status; + if status != SkillStatus::Running { + return Err(format!( + "Skill '{}' is not running (status: {:?})", + skill_id, status + )); + } entry.sender.clone() }; diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs index 1edd72614..772f7d416 100644 --- a/src-tauri/src/services/tdlib/manager.rs +++ b/src-tauri/src/services/tdlib/manager.rs @@ -101,6 +101,8 @@ pub struct TdLibManager { request_tx: Arc>>>, /// Handle to the worker thread. worker_handle: RwLock>>, + /// True while a destroy is in progress (prevents concurrent create_client). + is_destroying: AtomicBool, } impl TdLibManager { @@ -112,12 +114,20 @@ impl TdLibManager { data_dir: RwLock::new(None), request_tx: Arc::new(RwLock::new(None)), worker_handle: RwLock::new(None), + is_destroying: AtomicBool::new(false), } } /// Create and start a TDLib client with the given data directory. /// Returns the client ID. If a client already exists, returns its ID. + /// Only one client can exist at a time; blocks if a destroy is in progress. pub fn create_client(&self, data_dir: PathBuf) -> Result { + // Block creation while a destroy is in progress to prevent + // creating a new C++ client before the old one releases its database lock. + if self.is_destroying.load(Ordering::SeqCst) { + return Err("TDLib client is currently being destroyed, please retry".to_string()); + } + // Check if already initialized - return existing client ID if let Some(existing_id) = *self.client_id.read() { log::info!("[tdlib] Client already exists with ID: {}, reusing", existing_id); @@ -574,15 +584,40 @@ impl TdLibManager { } /// Destroy the TDLib client and clean up resources. + /// Sends a `close` command to TDLib first to properly release the database lock, + /// then tears down the worker thread and clears state. pub async fn destroy(&self) -> Result<(), String> { log::info!("[tdlib] Destroying client"); + self.is_destroying.store(true, Ordering::SeqCst); + + // Close TDLib properly to release the td.binlog database lock. + // Without this, a subsequent create_client + setTdlibParameters will fail + // with "Can't lock file ... already in use by current program". + if self.client_id.read().is_some() { + log::info!("[tdlib] Sending close command to release database locks"); + match tokio::time::timeout( + Duration::from_secs(5), + self.send(serde_json::json!({ "@type": "close" })), + ) + .await + { + Ok(Ok(_)) => { + log::info!("[tdlib] TDLib close acknowledged, waiting for lock release"); + // Give TDLib time to fully close the database and release the file lock. + tokio::time::sleep(Duration::from_millis(500)).await; + } + Ok(Err(e)) => log::warn!("[tdlib] TDLib close command failed: {}", e), + Err(_) => log::warn!("[tdlib] TDLib close command timed out after 5s"), + } + } + // Get the request_tx without holding the lock across await let request_tx = { self.request_tx.read().clone() }; - // Send destroy request + // Send destroy request to stop the worker loop if let Some(request_tx) = request_tx { let (reply_tx, reply_rx) = oneshot::channel(); @@ -600,6 +635,7 @@ impl TdLibManager { let _ = handle.join(); } + self.is_destroying.store(false, Ordering::SeqCst); log::info!("[tdlib] Client destroyed"); Ok(()) } diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index ca49e03d8..19883d377 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -121,7 +121,7 @@ function deriveConnectionStatus( setupComplete: boolean | undefined, skillState: Record | undefined ): SkillConnectionStatus { - if (!lifecycleStatus || lifecycleStatus === 'installed') { + if (!lifecycleStatus || lifecycleStatus === 'installed' || lifecycleStatus === 'stopping') { return 'offline'; } if (lifecycleStatus === 'error') { diff --git a/src/lib/skills/hooks.ts b/src/lib/skills/hooks.ts index c87e7f6e1..f38c2ec9c 100644 --- a/src/lib/skills/hooks.ts +++ b/src/lib/skills/hooks.ts @@ -18,8 +18,8 @@ export function deriveConnectionStatus( setupComplete: boolean | undefined, skillState: Record | undefined, ): SkillConnectionStatus { - // Skill not registered or not started - if (!lifecycleStatus || lifecycleStatus === "installed") { + // Skill not registered, not started, or shutting down + if (!lifecycleStatus || lifecycleStatus === "installed" || lifecycleStatus === "stopping") { return "offline"; } diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index f9c1f5f80..602e7d9dd 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,7 +12,7 @@ import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { setSkillError, setSkillState } from '../store/skillsSlice'; +import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- @@ -100,6 +100,10 @@ export default function SkillProvider({ children }: { children: ReactNode }) { { skill_id, ...(name ? { skill_name: name } : {}) } ), }); + } else if (status === 'stopped' || status === 'pending') { + // Skill process has stopped — reset to "installed" so the UI + // shows the Enable button instead of staying in setup mode. + dispatch(setSkillStatus({ skillId: skill_id, status: 'installed' })); } } )