Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions app/src-tauri/src/core_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 33 additions & 12 deletions app/src/components/intelligence/MemorySourceRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function MemorySourceRow({
source,
status,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -164,12 +177,25 @@ export function MemorySourceRow({
{!progress && result && (
<div className="mt-2 pl-7" data-testid={`memory-source-result-${source.id}`}>
{result.kind === 'success' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-sage-100 px-2 py-0.5 text-xs font-medium text-sage-700 dark:bg-sage-500/20 dark:text-sage-300">
<CheckIcon />
{result.items && result.items > 0
? `${result.items.toLocaleString()} ${t('memorySources.sync.itemsSynced')}`
: t('memorySources.sync.upToDate')}
</span>
<>
<span className="inline-flex items-center gap-1 rounded-md bg-sage-100 px-2 py-0.5 text-xs font-medium text-sage-700 dark:bg-sage-500/20 dark:text-sage-300">
<CheckIcon />
{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')}
</span>
{noteKey && (
<span
className="ml-2 text-xs text-content-muted"
data-testid={`memory-source-note-${source.id}`}>
{t(noteKey)}
</span>
)}
</>
) : (
<span
className="inline-flex items-start gap-1 rounded-md bg-coral-50 px-2 py-0.5 text-xs font-medium text-coral-700 dark:bg-coral-500/10 dark:text-coral-300"
Expand Down Expand Up @@ -294,12 +320,7 @@ export function MemorySourceRow({
</div>
</div>
<CollapsibleContent>
<SourceSettingsPanel
source={source}
syncedCount={status?.chunks_synced}
onSaved={onSettingsSaved}
onToast={onToast}
/>
<SourceSettingsPanel source={source} onSaved={onSettingsSaved} onToast={onToast} />
</CollapsibleContent>
</CollapsibleRoot>
</li>
Expand Down
169 changes: 161 additions & 8 deletions app/src/components/intelligence/MemorySourcesRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<BackfillConnectorTreesResponse | null>(null);
const [repairing, setRepairing] = useState(false);
const repairInFlightRef = useRef(false);
const [expandedSettingsId, setExpandedSettingsId] = useState<string | null>(null);

// Refs let the (intentionally dep-free) sync-stage listener fire accurate
Expand Down Expand Up @@ -177,26 +206,44 @@ 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
// reported internal bugs to Sentry via report_error_or_expected.
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?.({
Expand Down Expand Up @@ -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?.({
Expand Down Expand Up @@ -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)));
}, []);
Expand All @@ -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 (
<Card padded divided={false} data-testid="memory-sources">
<header className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-content-secondary">{t('memorySources.title')}</h3>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => void handleRepairClick()}
disabled={repairing}
analyticsId="memory-sources-repair"
data-testid="repair-memories-button"
title={t('memorySources.repair.title')}>
Comment thread
YellowSnnowmann marked this conversation as resolved.
{t('memorySources.repair.button')}
</Button>
<Button
variant="secondary"
size="sm"
Expand Down Expand Up @@ -573,6 +716,16 @@ export function MemorySourcesRegistry({
{allInModalOpen && (
<ConfirmationModal modal={allInModal} onClose={() => setAllInModalOpen(false)} />
)}

{repairModalOpen && (
<ConfirmationModal
modal={repairModal}
onClose={() => {
setRepairModalOpen(false);
setRepairPreview(null);
}}
/>
)}
</Card>
);
}
Loading
Loading