diff --git a/Cargo.lock b/Cargo.lock index 875cacd590..952176c8be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde", "serde_json", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 76f3f90e57..7344c24ce9 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -7180,7 +7180,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde", "serde_json", diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 76638f1d3a..c6bd66e0d3 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -683,14 +683,48 @@ impl CoreProcessHandle { /// Synchronous-friendly shutdown for `RunEvent::ExitRequested`. /// - /// Aborts the embedded server task so any background tokio tasks the - /// server spawned stop driving I/O before CEF's teardown runs. Cheap - /// and non-blocking on the UI thread — `JoinHandle::abort` returns - /// immediately. + /// Cancels the embedded server's token, gives it a bounded moment to + /// drain, then aborts whatever is left so any background tokio tasks the + /// server spawned stop driving I/O before CEF's teardown runs. + /// + /// The moment is what makes the server's post-drain teardown real. The + /// memory engine releases its job leases there, and an immediate abort + /// skipped it on every normal quit, so every next launch waited the + /// leases out (tinymemory#133). The wait is the same shape as the gateway + /// shutdown beside it: short, bounded, and worth the last moment of the + /// UI thread. A server that does not finish in time is aborted as before. pub async fn send_terminate_signal(&self) { self.cancel_shutdown_token(" on app shutdown").await; + self.drain_task_briefly().await; self.abort_task(" on app shutdown").await; } + + /// Wait a bounded moment for the server task to finish on its own after + /// its token was cancelled, so the teardown inside it runs. + /// + /// The moment is sized from what that teardown is allowed to take, so the + /// abort below never lands in the middle of it: the memory exit budget + /// (`EXIT_BUDGET`, every driver and the hook registry on one deadline), + /// the ollama cleanup after it in `serve_http` (2 s), and half a second + /// for the drain itself. Typical quits finish in milliseconds; the budget + /// is only what a wedged store or daemon may cost. + async fn drain_task_briefly(&self) { + const AFTER_MEMORY: Duration = Duration::from_millis(2_500); + let budget = openhuman_core::openhuman::memory::exit::EXIT_BUDGET + AFTER_MEMORY; + let mut task_guard = self.task.lock().await; + let Some(task) = task_guard.as_mut() else { + return; + }; + match timeout(budget, task).await { + Ok(_) => { + task_guard.take(); + log::info!("[core] embedded core server task drained on app shutdown"); + } + Err(_) => log::warn!( + "[core] embedded core server task did not drain within {budget:?}; aborting" + ), + } + } } /// A non-OpenHuman process holding the core RPC port. Surfaced to the frontend diff --git a/app/src/components/intelligence/MemorySourceRow.tsx b/app/src/components/intelligence/MemorySourceRow.tsx index e348c004c6..b06ba5d3da 100644 --- a/app/src/components/intelligence/MemorySourceRow.tsx +++ b/app/src/components/intelligence/MemorySourceRow.tsx @@ -66,6 +66,18 @@ interface SourceRowProps { onSignIn: () => void; } +/** + * The line shown beside a successful count when the run stopped short. With a + * zero count the chip itself says why the run stopped (there is no count to + * show instead), so the note is never repeated beside it. + */ +function syncNoteKey(result: SyncResult): string | null { + if (!(result.items && result.items > 0)) return null; + if (result.note === 'more_pending') return 'memorySources.sync.morePending'; + if (result.note === 'budget_spent') return 'memorySources.sync.budgetSpent'; + return null; +} + export function MemorySourceRow({ source, status, @@ -97,6 +109,7 @@ export function MemorySourceRow({ // renders live progress / a terminal chip instead, and `chunks_pending` is // legitimately transient, so we suppress the warning until things settle. const settled = !progress && !result; + const noteKey = result ? syncNoteKey(result) : null; const health: SourcePipelineHealth = settled ? deriveSourcePipelineHealth(status, pipeline) : { state: 'none', issues: [], authRelated: false }; @@ -164,12 +177,25 @@ export function MemorySourceRow({ {!progress && result && (
{result.kind === 'success' ? ( - - - {result.items && result.items > 0 - ? `${result.items.toLocaleString()} ${t('memorySources.sync.itemsSynced')}` - : t('memorySources.sync.upToDate')} - + <> + + + {result.items && result.items > 0 + ? `${result.items.toLocaleString()} ${t('memorySources.sync.itemsSynced')}` + : result.note === 'budget_spent' + ? t('memorySources.sync.budgetSpent') + : result.note === 'more_pending' + ? t('memorySources.sync.morePending') + : t('memorySources.sync.upToDate')} + + {noteKey && ( + + {t(noteKey)} + + )} + ) : (
- + diff --git a/app/src/components/intelligence/MemorySourcesRegistry.tsx b/app/src/components/intelligence/MemorySourcesRegistry.tsx index 6d7906690d..0bd76163ff 100644 --- a/app/src/components/intelligence/MemorySourcesRegistry.tsx +++ b/app/src/components/intelligence/MemorySourcesRegistry.tsx @@ -28,10 +28,13 @@ import type { ToastNotification, } from '../../types/intelligence'; import { + type BackfillConnectorTreesResponse, + memoryTreeBackfillConnectorTrees, memoryTreeFlushSource, memoryTreePipelineStatus, type MemoryTreePipelineStatus, } from '../../utils/tauriCommands/memoryTree'; +import { trackAnalyticsEvent } from '../analytics'; import { Card } from '../ui'; import Button from '../ui/Button'; import { AddMemorySourceDialog } from './AddMemorySourceDialog'; @@ -41,6 +44,7 @@ import { AllInIcon, PlusIcon } from './memorySourcesIcons'; import { sourceTreeScope } from './memorySourcesRowHelpers'; import { STAGE_FALLBACK_PERCENT, + type SyncNote, type SyncProgress, type SyncResult, } from './memorySourcesSyncTypes'; @@ -89,6 +93,23 @@ export function parseIngestedCount(detail: string | null): number | null { return null; } +/** + * Why a completed run stopped short, from the remainder the core writes after + * the count: `", more pending — Sync again to continue"` when the per-run cap + * left more to read, `"; today's provider request budget is spent"` when the + * day's budget did. The number alone made both read as a finished sync, and a + * spent budget with zero new items read as "Up to date" — the opposite of what + * happened. The budget wins when both appear: it is the reason nothing more + * will arrive today. + */ +export function parseSyncNote(detail: string | null): SyncNote | null { + if (!detail) return null; + const lower = detail.toLowerCase(); + if (lower.includes('budget')) return 'budget_spent'; + if (lower.includes('more pending')) return 'more_pending'; + return null; +} + export function MemorySourcesRegistry({ onToast, pollIntervalMs = 5000, @@ -120,6 +141,14 @@ export function MemorySourcesRegistry({ const [allInModalOpen, setAllInModalOpen] = useState(false); const [applyingAllIn, setApplyingAllIn] = useState(false); const allInInFlightRef = useRef(false); + // "Repair older memories" (openhuman#6012): the backfill RPC has no other + // entry point in the app. Two steps — a dry run that only counts, then the + // real pass behind a confirmation — because the real pass embeds every + // document it files, and that spends credits. + const [repairModalOpen, setRepairModalOpen] = useState(false); + const [repairPreview, setRepairPreview] = useState(null); + const [repairing, setRepairing] = useState(false); + const repairInFlightRef = useRef(false); const [expandedSettingsId, setExpandedSettingsId] = useState(null); // Refs let the (intentionally dep-free) sync-stage listener fire accurate @@ -177,18 +206,36 @@ export function MemorySourcesRegistry({ // Success: record + toast the item count parsed from the detail // ("ingested N item(s)"). 0 new items → "up to date" (#3295). const items = parseIngestedCount(data?.detail ?? null); + const note = parseSyncNote(data?.detail ?? null); setSyncResults(prev => { const next = new Map(prev); - next.set(rowId, { kind: 'success', items, reason: null }); + next.set(rowId, { kind: 'success', items, reason: null, note }); return next; }); + // A zero count is not "up to date" when the run stopped short: the + // reason it stopped is the whole message then, not a suffix. + const hasItems = Boolean(items && items > 0); + const counted = hasItems + ? `${items} ${tt('memorySources.sync.itemsSynced')}` + : note === 'budget_spent' + ? tt('memorySources.sync.budgetSpent') + : note === 'more_pending' + ? tt('memorySources.sync.morePending') + : tt('memorySources.sync.upToDate'); + // Beside a count, the note says why the run stopped short — the + // budget as much as the cap. A pass that filed some mail and then + // ran out for the day is the common partial case this exists to + // explain; "N items synced" alone would read as a finished sync. + const noteKey = + note === 'budget_spent' + ? 'memorySources.sync.budgetSpent' + : note === 'more_pending' + ? 'memorySources.sync.morePending' + : null; onToastRef.current?.({ - type: 'success', + type: note === 'budget_spent' ? 'warning' : 'success', title: `${tt('memorySources.sync.completeTitle')} ${label}`, - message: - items && items > 0 - ? `${items} ${tt('memorySources.sync.itemsSynced')}` - : tt('memorySources.sync.upToDate'), + message: hasItems && noteKey ? `${counted} — ${tt(noteKey)}` : counted, }); } else { // Failure: surface the reason on the row + a toast. The core already @@ -196,7 +243,7 @@ export function MemorySourcesRegistry({ const reason = data?.detail ?? null; setSyncResults(prev => { const next = new Map(prev); - next.set(rowId, { kind: 'failed', items: null, reason }); + next.set(rowId, { kind: 'failed', items: null, reason, note: null }); return next; }); onToastRef.current?.({ @@ -381,7 +428,7 @@ export function MemorySourcesRegistry({ }); setSyncResults(prev => { const next = new Map(prev); - next.set(source.id, { kind: 'failed', items: null, reason }); + next.set(source.id, { kind: 'failed', items: null, reason, note: null }); return next; }); onToast?.({ @@ -473,6 +520,73 @@ export function MemorySourcesRegistry({ } }, [onToast, t]); + const handleRepairClick = useCallback(async () => { + if (repairInFlightRef.current) return; + repairInFlightRef.current = true; + setRepairing(true); + try { + // Preview first: the dry run counts what a pass would examine and + // writes nothing, so the confirmation can name a number before any + // credit is spent. + const preview = await memoryTreeBackfillConnectorTrees({ dryRun: true }); + setRepairPreview(preview); + if (preview.scanned === 0) { + onToast?.({ type: 'success', title: t('memorySources.repair.nothing') }); + return; + } + setRepairModalOpen(true); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySources.repair.failed'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + repairInFlightRef.current = false; + setRepairing(false); + } + }, [onToast, t]); + + const handleConfirmRepair = useCallback(async () => { + if (repairInFlightRef.current) return; + repairInFlightRef.current = true; + setRepairing(true); + setRepairModalOpen(false); + try { + const result = await memoryTreeBackfillConnectorTrees({ dryRun: false }); + // The successful domain outcome, not the click: a privacy-safe count + // only — no ids, no user text. + trackAnalyticsEvent('memory_repair_succeeded', { count: result.ingested }); + const summary = t('memorySources.repair.success') + .replace('{ingested}', String(result.ingested)) + .replace('{already}', String(result.already_present)) + .replace('{skipped}', String(result.skipped)); + // The driver files up to its per-call limit and says when documents + // remain; the pass is idempotent, so "run it again" is the whole + // resume story. + if (result.more_pending) { + onToast?.({ + type: 'warning', + title: summary, + message: t('memorySources.repair.morePending'), + }); + } else { + onToast?.({ type: 'success', title: summary }); + } + void refresh(); + } catch (err) { + onToast?.({ + type: 'error', + title: t('memorySources.repair.failed'), + message: err instanceof Error ? err.message : String(err), + }); + } finally { + repairInFlightRef.current = false; + setRepairing(false); + setRepairPreview(null); + } + }, [onToast, refresh, t]); + const handleSettingsSaved = useCallback((updated: MemorySourceEntry) => { setSources(prev => prev.map(s => (s.id === updated.id ? updated : s))); }, []); @@ -496,11 +610,40 @@ export function MemorySourcesRegistry({ }, }; + const repairModal: ConfirmationModalType = { + isOpen: repairModalOpen, + title: t('memorySources.repair.title'), + message: t('memorySources.repair.message').replace( + '{scanned}', + String(repairPreview?.scanned ?? 0) + ), + confirmText: t('memorySources.repair.confirm'), + cancelText: t('memorySources.repair.cancel'), + destructive: false, + onConfirm: () => { + void handleConfirmRepair(); + }, + onCancel: () => { + setRepairModalOpen(false); + setRepairPreview(null); + }, + }; + return (

{t('memorySources.title')}

+