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')}
+
void handleRepairClick()}
+ disabled={repairing}
+ analyticsId="memory-sources-repair"
+ data-testid="repair-memories-button"
+ title={t('memorySources.repair.title')}>
+ {t('memorySources.repair.button')}
+
setAllInModalOpen(false)} />
)}
+
+ {repairModalOpen && (
+ {
+ setRepairModalOpen(false);
+ setRepairPreview(null);
+ }}
+ />
+ )}
);
}
diff --git a/app/src/components/intelligence/SourceSettingsPanel.tsx b/app/src/components/intelligence/SourceSettingsPanel.tsx
index 8bc3adf82c..cfd3c745ea 100644
--- a/app/src/components/intelligence/SourceSettingsPanel.tsx
+++ b/app/src/components/intelligence/SourceSettingsPanel.tsx
@@ -52,27 +52,13 @@ type LimitFields = Pick<
// Item-count caps where a "Maxed" badge is meaningful (synced count vs cap).
// Time-window (sync_depth_days/since_days) and budget (tokens/cost) caps don't
// map to a chunk count, so they never show "Maxed".
-const COUNT_FIELDS = new Set([
- 'max_items',
- 'max_prs',
- 'max_issues',
- 'max_commits',
-]);
-
interface SourceSettingsPanelProps {
source: MemorySourceEntry;
- /** Chunks already synced for this source — drives the "Maxed" badge. */
- syncedCount?: number;
onSaved: (updated: MemorySourceEntry) => void;
onToast?: (toast: { type: 'success' | 'error'; title: string; message?: string }) => void;
}
-export function SourceSettingsPanel({
- source,
- syncedCount,
- onSaved,
- onToast,
-}: SourceSettingsPanelProps) {
+export function SourceSettingsPanel({ source, onSaved, onToast }: SourceSettingsPanelProps) {
const { t } = useT();
const fields = KIND_FIELDS[source.kind] ?? [];
@@ -146,25 +132,17 @@ export function SourceSettingsPanel({
{fields.map(field => {
- const cap = Number(values[field]);
const isUnlimited = (values[field] ?? '') === '';
- const isMaxed =
- COUNT_FIELDS.has(field) &&
- !isUnlimited &&
- Number.isFinite(cap) &&
- typeof syncedCount === 'number' &&
- syncedCount >= cap;
+ // No "Maxed" badge. The only count a row has is *chunks*, and a cap
+ // is *items* (emails, issues, commits); one email is several
+ // chunks, so the badge lit long before the cap was reached. It
+ // comes back when the status carries an item count (openhuman#6012).
return (
{t(FIELD_LABEL_KEYS[field])}
- {isMaxed && (
-
- {t('memorySources.settings.maxed')}
-
- )}
{isUnlimited && (
({
// ── tauriCommands mock ────────────────────────────────────────────────────────
// memoryTreePipelineStatus is polled for downstream pipeline health (GH-4690);
// default to a healthy running snapshot so rows keep their clean synced state.
+const mockTrackAnalyticsEvent = vi.fn();
+vi.mock('../../analytics', () => ({
+ trackAnalyticsEvent: (...args: unknown[]) => mockTrackAnalyticsEvent(...args),
+}));
+
vi.mock('../../../utils/tauriCommands/memoryTree', () => ({
+ memoryTreeBackfillConnectorTrees: vi.fn(),
memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }),
memoryTreePipelineStatus: vi
.fn()
@@ -135,6 +142,32 @@ describe('parseIngestedCount', () => {
});
});
+// ── parseSyncNote unit tests ──────────────────────────────────────────────────
+
+describe('parseSyncNote', () => {
+ it('reads "more pending" from the remainder after the count', () => {
+ expect(parseSyncNote('ingested 100 item(s), more pending — Sync again to continue')).toBe(
+ 'more_pending'
+ );
+ });
+
+ it('reads a spent budget, and prefers it when both appear', () => {
+ expect(parseSyncNote("ingested 0 item(s); today's provider request budget is spent")).toBe(
+ 'budget_spent'
+ );
+ expect(
+ parseSyncNote(
+ "ingested 0 item(s), more pending — Sync again to continue; today's provider request budget is spent"
+ )
+ ).toBe('budget_spent');
+ });
+
+ it('returns null for a plain count or no detail', () => {
+ expect(parseSyncNote('ingested 5 item(s)')).toBeNull();
+ expect(parseSyncNote(null)).toBeNull();
+ });
+});
+
// ── MemorySourcesRegistry integration tests ───────────────────────────────────
describe('MemorySourcesRegistry', () => {
@@ -485,4 +518,208 @@ describe('MemorySourcesRegistry', () => {
// The optimistic syncing state is cleared after the RPC rejection.
expect(screen.queryByText('sync.syncing')).not.toBeInTheDocument();
});
+ it('says the budget is spent instead of "Up to date" when zero items arrive for that reason', async () => {
+ // The core writes why a run stopped after the count; a spent budget with
+ // zero new items used to read as "Up to date" — the opposite of what
+ // happened (openhuman#6012 follow-up).
+ const sources = [makeSource('src-budget')];
+ listMemorySources.mockResolvedValue(sources);
+ memorySourcesStatusList.mockResolvedValue([]);
+ const onToast = vi.fn();
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-budget')).toBeInTheDocument());
+
+ act(() => {
+ window.dispatchEvent(
+ makeSyncStageEvent({
+ stage: 'completed',
+ source_id: 'src-budget',
+ detail:
+ "ingested 0 item(s), more pending — Sync again to continue; today's provider request budget is spent",
+ })
+ );
+ });
+
+ await waitFor(() => {
+ const chip = screen.getByTestId('memory-source-result-src-budget');
+ expect(chip).toHaveTextContent('memorySources.sync.budgetSpent');
+ expect(chip).not.toHaveTextContent('memorySources.sync.upToDate');
+ });
+ expect(onToast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'warning', message: 'memorySources.sync.budgetSpent' })
+ );
+ });
+
+ it('keeps the budget note beside a nonzero count in the toast', async () => {
+ // A pass that filed some mail and then ran out for the day is the common
+ // partial case; "N items synced" alone read as a finished sync.
+ const sources = [makeSource('src-partial')];
+ listMemorySources.mockResolvedValue(sources);
+ memorySourcesStatusList.mockResolvedValue([]);
+ const onToast = vi.fn();
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-partial')).toBeInTheDocument());
+
+ act(() => {
+ window.dispatchEvent(
+ makeSyncStageEvent({
+ stage: 'completed',
+ source_id: 'src-partial',
+ detail: "ingested 3 item(s); today's provider request budget is spent",
+ })
+ );
+ });
+
+ await waitFor(() =>
+ expect(onToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'warning',
+ message: '3 memorySources.sync.itemsSynced — memorySources.sync.budgetSpent',
+ })
+ )
+ );
+ });
+
+ it('shows the "more to sync" note beside a count when the run stopped at its cap', async () => {
+ const sources = [makeSource('src-more')];
+ listMemorySources.mockResolvedValue(sources);
+ memorySourcesStatusList.mockResolvedValue([]);
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-more')).toBeInTheDocument());
+
+ act(() => {
+ window.dispatchEvent(
+ makeSyncStageEvent({
+ stage: 'completed',
+ source_id: 'src-more',
+ detail: 'ingested 100 item(s), more pending — Sync again to continue',
+ })
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('memory-source-result-src-more')).toHaveTextContent(
+ '100 memorySources.sync.itemsSynced'
+ );
+ expect(screen.getByTestId('memory-source-note-src-more')).toHaveTextContent(
+ 'memorySources.sync.morePending'
+ );
+ });
+ });
+
+ it('repairs older memories only after a preview and a confirmation', async () => {
+ // Backfill has no other entry point in the app (openhuman#6012). The real
+ // pass embeds every document it files, so the button previews (dry run)
+ // and asks before it writes anything.
+ const memoryTree = await import('../../../utils/tauriCommands/memoryTree');
+ const backfill = memoryTree.memoryTreeBackfillConnectorTrees as ReturnType;
+ backfill
+ .mockResolvedValueOnce({
+ executed: false,
+ scanned: 42,
+ ingested: 0,
+ already_present: 0,
+ skipped: 0,
+ more_pending: false,
+ notes: [],
+ })
+ .mockResolvedValueOnce({
+ executed: true,
+ scanned: 42,
+ ingested: 40,
+ already_present: 1,
+ skipped: 1,
+ more_pending: false,
+ notes: [],
+ });
+ listMemorySources.mockResolvedValue([makeSource('src-a')]);
+ memorySourcesStatusList.mockResolvedValue([]);
+ const onToast = vi.fn();
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-a')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByTestId('repair-memories-button'));
+ await waitFor(() => expect(backfill).toHaveBeenCalledWith({ dryRun: true }));
+ // The confirmation names the preview count and nothing has been written.
+ await waitFor(() =>
+ expect(screen.getByText('memorySources.repair.confirm')).toBeInTheDocument()
+ );
+ expect(backfill).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByText('memorySources.repair.confirm'));
+ await waitFor(() => expect(backfill).toHaveBeenCalledWith({ dryRun: false }));
+ // The successful domain outcome is tracked with the privacy-safe count only.
+ await waitFor(() =>
+ expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith('memory_repair_succeeded', { count: 40 })
+ );
+ // The i18n mock returns keys, so the placeholders in the summary are
+ // not substituted here; the counts are covered by the wrapper's tests.
+ await waitFor(() =>
+ expect(onToast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'success', title: 'memorySources.repair.success' })
+ )
+ );
+ });
+
+ it('does not ask when the preview finds nothing to repair', async () => {
+ const memoryTree = await import('../../../utils/tauriCommands/memoryTree');
+ const backfill = memoryTree.memoryTreeBackfillConnectorTrees as ReturnType;
+ backfill.mockReset();
+ backfill.mockResolvedValueOnce({
+ executed: false,
+ scanned: 0,
+ ingested: 0,
+ already_present: 0,
+ skipped: 0,
+ more_pending: false,
+ notes: [],
+ });
+ listMemorySources.mockResolvedValue([makeSource('src-b')]);
+ memorySourcesStatusList.mockResolvedValue([]);
+ const onToast = vi.fn();
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-b')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByTestId('repair-memories-button'));
+ await waitFor(() =>
+ expect(onToast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'memorySources.repair.nothing' })
+ )
+ );
+ expect(screen.queryByText('memorySources.repair.confirm')).not.toBeInTheDocument();
+ expect(backfill).toHaveBeenCalledTimes(1);
+ });
+ it('says "more to sync" — not "Up to date" — when zero items arrived because the run stopped at its cap', async () => {
+ // A capped run can write nothing (everything on the page was already
+ // ingested) and still have more to read. Zero + more-pending used to
+ // render as "Up to date" beside a "more to sync" note — contradictory.
+ const sources = [makeSource('src-zero-more')];
+ listMemorySources.mockResolvedValue(sources);
+ memorySourcesStatusList.mockResolvedValue([]);
+
+ renderWithProviders( );
+ await waitFor(() => expect(screen.getByText('Source src-zero-more')).toBeInTheDocument());
+
+ act(() => {
+ window.dispatchEvent(
+ makeSyncStageEvent({
+ stage: 'completed',
+ source_id: 'src-zero-more',
+ detail: 'ingested 0 item(s), more pending — Sync again to continue',
+ })
+ );
+ });
+
+ await waitFor(() => {
+ const chip = screen.getByTestId('memory-source-result-src-zero-more');
+ expect(chip).toHaveTextContent('memorySources.sync.morePending');
+ expect(chip).not.toHaveTextContent('memorySources.sync.upToDate');
+ });
+ expect(screen.queryByTestId('memory-source-note-src-zero-more')).not.toBeInTheDocument();
+ });
});
diff --git a/app/src/components/intelligence/memorySourcesSyncTypes.ts b/app/src/components/intelligence/memorySourcesSyncTypes.ts
index 6a611babda..1d3206aeba 100644
--- a/app/src/components/intelligence/memorySourcesSyncTypes.ts
+++ b/app/src/components/intelligence/memorySourcesSyncTypes.ts
@@ -17,12 +17,24 @@ export interface SyncProgress {
* so a no-op ("0 new items") or failed sync leaves visible confirmation
* instead of the indicator silently vanishing.
*/
+/**
+ * Why a completed run stopped short, parsed from the remainder the core writes
+ * after the item count. `more_pending`: the per-run cap or the day's budget
+ * left more to read — click Sync again. `budget_spent`: the day's provider
+ * request budget is gone, so nothing more arrives until tomorrow; with a zero
+ * count that is the opposite of "Up to date", which is what the row used to
+ * say.
+ */
+export type SyncNote = 'more_pending' | 'budget_spent';
+
export interface SyncResult {
kind: 'success' | 'failed';
/** New items ingested (success only); null when the count is unknown. */
items: number | null;
/** Human-readable failure reason (failed only). */
reason: string | null;
+ /** Why the run stopped short (success only); null when it did not. */
+ note: SyncNote | null;
}
/**
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 7ea57cd1ed..fe1c7ff736 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -2576,6 +2576,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'عناصر تمت مزامنتها',
'memorySources.sync.upToDate': 'محدّث',
'memorySources.sync.failedLabel': 'فشل',
+ 'memorySources.sync.morePending': 'يوجد المزيد للمزامنة. انقر على «مزامنة» مرة أخرى',
+ 'memorySources.sync.budgetSpent': 'استُنفدت ميزانية الطلبات لليوم. حاول مرة أخرى غدًا',
'time.justNow': 'للتو',
'time.secondsAgoSuffix': 'ثانية مضت',
'time.minutesAgoSuffix': 'دقيقة مضت',
@@ -2617,6 +2619,17 @@ const messages: TranslationMap = {
'memorySources.allIn.failed': 'تعذّر تطبيق خيار الكل. يرجى المحاولة مرة أخرى.',
'memorySources.allIn.allFailed': 'تعذّر بدء أي مزامنة. تحقق من كل مصدر لمعرفة السبب.',
'memorySources.allIn.partial': 'المزامنات التي بدأت: {triggered}. التي تعذّر بدؤها: {failed}.',
+ 'memorySources.repair.button': 'إصلاح الذكريات الأقدم',
+ 'memorySources.repair.title': 'إصلاح الذكريات الأقدم؟',
+ 'memorySources.repair.message':
+ 'حُفظ ما يصل إلى {scanned} مستندًا متزامنًا قبل إصلاح الأرشفة في شجرة الذاكرة، وهي غير مرئية في رسم الذاكرة البياني. تستهلك أرشفتها رصيد التضمين. تُتخطى المستندات الموجودة أصلًا في الشجرة.',
+ 'memorySources.repair.confirm': 'إصلاح',
+ 'memorySources.repair.cancel': 'ليس الآن',
+ 'memorySources.repair.nothing': 'لا شيء لإصلاحه. لا توجد مستندات متزامنة بانتظار الأرشفة.',
+ 'memorySources.repair.success':
+ 'تمت أرشفة {ingested} في شجرة الذاكرة ({already} موجودة مسبقًا، {skipped} تم تخطيها).',
+ 'memorySources.repair.morePending': 'بقي المزيد. انقر على «إصلاح الذكريات الأقدم» مرة أخرى.',
+ 'memorySources.repair.failed': 'تعذّر إصلاح الذكريات الأقدم.',
'memorySources.settings.button': 'الإعدادات',
'memorySources.settings.title': 'إعدادات المزامنة',
'memorySources.settings.maxPrs': 'أقصى عدد لطلبات السحب',
@@ -2630,7 +2643,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'غير محدود',
'memorySources.settings.unlimitedTooltip':
'لقد اخترت مزامنة الحد الأقصى لـ {toolkit}. يمكنك تغيير الحدود من هنا.',
- 'memorySources.settings.maxed': 'مكتمل',
'memorySources.settings.save': 'حفظ',
'memorySources.settings.saving': 'جارٍ الحفظ…',
'memorySources.settings.saved': 'تم حفظ الإعدادات',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 7f543f6d25..6ae2a65470 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -2638,6 +2638,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'আইটেম সিঙ্ক হয়েছে',
'memorySources.sync.upToDate': 'হালনাগাদ আছে',
'memorySources.sync.failedLabel': 'ব্যর্থ',
+ 'memorySources.sync.morePending': 'আরও সিঙ্ক বাকি আছে। আবার Sync-এ ক্লিক করুন',
+ 'memorySources.sync.budgetSpent': 'আজকের অনুরোধের বাজেট শেষ। আগামীকাল আবার চেষ্টা করুন',
'time.justNow': 'এইমাত্র',
'time.secondsAgoSuffix': 'সেকেন্ড আগে',
'time.minutesAgoSuffix': 'মিনিট আগে',
@@ -2679,6 +2681,18 @@ const messages: TranslationMap = {
'memorySources.allIn.failed': 'সব চালু করা সম্ভব হয়নি। আবার চেষ্টা করুন।',
'memorySources.allIn.allFailed': 'কোনো সিঙ্ক শুরু করা যায়নি। কারণ জানতে প্রতিটি উৎস দেখুন।',
'memorySources.allIn.partial': 'শুরু হওয়া সিঙ্ক: {triggered}। শুরু করা যায়নি: {failed}।',
+ 'memorySources.repair.button': 'পুরোনো স্মৃতি মেরামত করুন',
+ 'memorySources.repair.title': 'পুরোনো স্মৃতি মেরামত করবেন?',
+ 'memorySources.repair.message':
+ 'মেমোরি ট্রিতে ফাইলিং ঠিক করার আগে সংরক্ষিত সর্বোচ্চ {scanned}টি সিঙ্ক করা নথি মেমোরি গ্রাফে দেখা যায় না। সেগুলি ফাইল করতে এমবেডিং ক্রেডিট খরচ হয়। যেসব নথি ইতিমধ্যে ট্রিতে আছে সেগুলি বাদ দেওয়া হয়।',
+ 'memorySources.repair.confirm': 'মেরামত করুন',
+ 'memorySources.repair.cancel': 'এখন নয়',
+ 'memorySources.repair.nothing':
+ 'মেরামতের কিছু নেই। কোনো সিঙ্ক করা নথি ফাইল হওয়ার অপেক্ষায় নেই।',
+ 'memorySources.repair.success':
+ '{ingested}টি মেমোরি ট্রিতে ফাইল করা হয়েছে ({already}টি আগে থেকেই ছিল, {skipped}টি বাদ দেওয়া হয়েছে)।',
+ 'memorySources.repair.morePending': 'আরও বাকি আছে। আবার পুরোনো স্মৃতি মেরামত করুন-এ ক্লিক করুন।',
+ 'memorySources.repair.failed': 'পুরোনো স্মৃতি মেরামত করা যায়নি।',
'memorySources.settings.button': 'সেটিংস',
'memorySources.settings.title': 'সিঙ্ক সেটিংস',
'memorySources.settings.maxPrs': 'সর্বোচ্চ পুল রিকোয়েস্ট',
@@ -2692,7 +2706,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'সীমাহীন',
'memorySources.settings.unlimitedTooltip':
'আপনি {toolkit}-এর জন্য সর্বাধিক সিঙ্ক করার অপশন বেছে নিয়েছেন। আপনি এখানে সীমা পরিবর্তন করতে পারেন।',
- 'memorySources.settings.maxed': 'পূর্ণ',
'memorySources.settings.save': 'সংরক্ষণ',
'memorySources.settings.saving': 'সংরক্ষণ হচ্ছে…',
'memorySources.settings.saved': 'সেটিংস সংরক্ষিত হয়েছে',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 79fc44eaa3..8f5fafdc15 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -2713,6 +2713,9 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'Elemente synchronisiert',
'memorySources.sync.upToDate': 'Aktuell',
'memorySources.sync.failedLabel': 'Fehlgeschlagen',
+ 'memorySources.sync.morePending': 'Mehr zu synchronisieren. Erneut auf Synchronisieren klicken',
+ 'memorySources.sync.budgetSpent':
+ 'Das heutige Anfragebudget ist aufgebraucht. Morgen erneut versuchen',
'time.justNow': 'gerade eben',
'time.secondsAgoSuffix': 'vor {count} Sek.',
'time.minutesAgoSuffix': 'vor {count} Min.',
@@ -2756,6 +2759,19 @@ const messages: TranslationMap = {
'Keine Synchronisierung konnte gestartet werden. Prüfe jede Quelle, um den Grund zu sehen.',
'memorySources.allIn.partial':
'Gestartete Synchronisierungen: {triggered}. Nicht startbar: {failed}.',
+ 'memorySources.repair.button': 'Ältere Erinnerungen reparieren',
+ 'memorySources.repair.title': 'Ältere Erinnerungen reparieren?',
+ 'memorySources.repair.message':
+ 'Bis zu {scanned} synchronisierte Dokumente wurden gespeichert, bevor die Ablage im Gedächtnisbaum korrigiert wurde, und sind im Gedächtnisgraphen unsichtbar. Das Ablegen verbraucht Embedding-Guthaben. Bereits abgelegte Dokumente werden übersprungen.',
+ 'memorySources.repair.confirm': 'Reparieren',
+ 'memorySources.repair.cancel': 'Nicht jetzt',
+ 'memorySources.repair.nothing':
+ 'Nichts zu reparieren. Keine synchronisierten Dokumente warten auf die Ablage.',
+ 'memorySources.repair.success':
+ '{ingested} im Gedächtnisbaum abgelegt ({already} bereits vorhanden, {skipped} übersprungen).',
+ 'memorySources.repair.morePending':
+ 'Es sind noch weitere übrig. Erneut auf „Ältere Erinnerungen reparieren“ klicken.',
+ 'memorySources.repair.failed': 'Ältere Erinnerungen konnten nicht repariert werden.',
'memorySources.settings.button': 'Einstellungen',
'memorySources.settings.title': 'Synchronisierungseinstellungen',
'memorySources.settings.maxPrs': 'Maximale Pull-Requests',
@@ -2769,7 +2785,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Unbegrenzt',
'memorySources.settings.unlimitedTooltip':
'Du hast dich entschieden, das Maximum für {toolkit} zu synchronisieren. Du kannst die Limits hier ändern.',
- 'memorySources.settings.maxed': 'Voll',
'memorySources.settings.save': 'Speichern',
'memorySources.settings.saving': 'Speichern…',
'memorySources.settings.saved': 'Einstellungen gespeichert',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index a7085881ef..8e816cab95 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -3005,6 +3005,8 @@ const en: TranslationMap = {
'memorySources.sync.itemsSynced': 'items synced',
'memorySources.sync.upToDate': 'Up to date',
'memorySources.sync.failedLabel': 'Failed',
+ 'memorySources.sync.morePending': 'More to sync. Click Sync again',
+ 'memorySources.sync.budgetSpent': "Today's request budget is spent. Try again tomorrow",
'time.justNow': 'just now',
'time.secondsAgoSuffix': 's ago',
'time.minutesAgoSuffix': 'm ago',
@@ -3046,6 +3048,17 @@ const en: TranslationMap = {
'memorySources.allIn.failed': 'Could not apply All In. Please try again.',
'memorySources.allIn.allFailed': 'No sync could start. Check each source for the reason.',
'memorySources.allIn.partial': 'Syncs started: {triggered}. Could not start: {failed}.',
+ 'memorySources.repair.button': 'Repair older memories',
+ 'memorySources.repair.title': 'Repair older memories?',
+ 'memorySources.repair.message':
+ 'Up to {scanned} synced documents were stored before memory-tree filing was fixed and are invisible to the memory graph. Filing them uses embedding credits. Documents already in the tree are skipped.',
+ 'memorySources.repair.confirm': 'Repair',
+ 'memorySources.repair.cancel': 'Not now',
+ 'memorySources.repair.nothing': 'Nothing to repair. No synced documents are waiting to be filed.',
+ 'memorySources.repair.success':
+ 'Filed {ingested} into the memory tree ({already} already there, {skipped} skipped).',
+ 'memorySources.repair.morePending': 'More remain. Click Repair older memories again.',
+ 'memorySources.repair.failed': 'Could not repair older memories.',
'memorySources.settings.button': 'Settings',
'memorySources.settings.title': 'Sync settings',
'memorySources.settings.maxPrs': 'Max pull requests',
@@ -3059,7 +3072,6 @@ const en: TranslationMap = {
'memorySources.settings.unlimited': 'Unlimited',
'memorySources.settings.unlimitedTooltip':
"You've opted in to sync the maximum for {toolkit}. You can change the caps here.",
- 'memorySources.settings.maxed': 'Maxed',
'memorySources.settings.save': 'Save',
'memorySources.settings.saving': 'Saving…',
'memorySources.settings.saved': 'Settings saved',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index ddab97025f..13a9bb57c8 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -2690,6 +2690,9 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'elementos sincronizados',
'memorySources.sync.upToDate': 'Actualizado',
'memorySources.sync.failedLabel': 'Fallido',
+ 'memorySources.sync.morePending': 'Queda más por sincronizar. Pulsa Sincronizar de nuevo',
+ 'memorySources.sync.budgetSpent':
+ 'El presupuesto de solicitudes de hoy se ha agotado. Inténtalo mañana',
'time.justNow': 'ahora mismo',
'time.secondsAgoSuffix': 's',
'time.minutesAgoSuffix': 'min',
@@ -2734,6 +2737,18 @@ const messages: TranslationMap = {
'No se pudo iniciar ninguna sincronización. Revisa cada fuente para ver el motivo.',
'memorySources.allIn.partial':
'Sincronizaciones iniciadas: {triggered}. No se pudieron iniciar: {failed}.',
+ 'memorySources.repair.button': 'Reparar recuerdos antiguos',
+ 'memorySources.repair.title': '¿Reparar recuerdos antiguos?',
+ 'memorySources.repair.message':
+ 'Hasta {scanned} documentos sincronizados se guardaron antes de corregir el archivado en el árbol de memoria y no aparecen en el grafo de memoria. Archivarlos consume créditos de embeddings. Los documentos que ya están en el árbol se omiten.',
+ 'memorySources.repair.confirm': 'Reparar',
+ 'memorySources.repair.cancel': 'Ahora no',
+ 'memorySources.repair.nothing':
+ 'Nada que reparar. Ningún documento sincronizado está pendiente de archivar.',
+ 'memorySources.repair.success':
+ '{ingested} archivados en el árbol de memoria ({already} ya estaban, {skipped} omitidos).',
+ 'memorySources.repair.morePending': 'Quedan más. Pulsa Reparar recuerdos antiguos de nuevo.',
+ 'memorySources.repair.failed': 'No se pudieron reparar los recuerdos antiguos.',
'memorySources.settings.button': 'Configuración',
'memorySources.settings.title': 'Configuración de sincronización',
'memorySources.settings.maxPrs': 'Máximo de pull requests',
@@ -2747,7 +2762,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Sin límite',
'memorySources.settings.unlimitedTooltip':
'Has optado por sincronizar el máximo para {toolkit}. Puedes cambiar los límites aquí.',
- 'memorySources.settings.maxed': 'Lleno',
'memorySources.settings.save': 'Guardar',
'memorySources.settings.saving': 'Guardando…',
'memorySources.settings.saved': 'Configuración guardada',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index c1f5afc126..557ff3bdbe 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -2712,6 +2712,9 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'éléments synchronisés',
'memorySources.sync.upToDate': 'À jour',
'memorySources.sync.failedLabel': 'Échec',
+ 'memorySources.sync.morePending':
+ 'Il reste des éléments à synchroniser. Cliquez à nouveau sur Synchroniser',
+ 'memorySources.sync.budgetSpent': 'Le budget de requêtes du jour est épuisé. Réessayez demain',
'time.justNow': "à l'instant",
'time.secondsAgoSuffix': 's',
'time.minutesAgoSuffix': 'min',
@@ -2756,6 +2759,19 @@ const messages: TranslationMap = {
'Aucune synchronisation n’a pu démarrer. Vérifiez chaque source pour connaître la raison.',
'memorySources.allIn.partial':
'Synchronisations lancées : {triggered}. Impossibles à lancer : {failed}.',
+ 'memorySources.repair.button': 'Réparer les souvenirs anciens',
+ 'memorySources.repair.title': 'Réparer les souvenirs anciens ?',
+ 'memorySources.repair.message':
+ "Jusqu'à {scanned} documents synchronisés ont été enregistrés avant la correction du classement dans l'arbre de mémoire et sont invisibles dans le graphe de mémoire. Les classer consomme des crédits d'embedding. Les documents déjà dans l'arbre sont ignorés.",
+ 'memorySources.repair.confirm': 'Réparer',
+ 'memorySources.repair.cancel': 'Pas maintenant',
+ 'memorySources.repair.nothing':
+ "Rien à réparer. Aucun document synchronisé n'attend d'être classé.",
+ 'memorySources.repair.success':
+ "{ingested} classés dans l'arbre de mémoire ({already} déjà présents, {skipped} ignorés).",
+ 'memorySources.repair.morePending':
+ 'Il en reste. Cliquez à nouveau sur Réparer les souvenirs anciens.',
+ 'memorySources.repair.failed': 'Impossible de réparer les souvenirs anciens.',
'memorySources.settings.title': 'Paramètres de synchronisation',
'memorySources.settings.maxPrs': 'Nombre maximal de pull requests',
'memorySources.settings.maxIssues': "Nombre maximal d'issues",
@@ -2768,7 +2784,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Illimité',
'memorySources.settings.unlimitedTooltip':
'Vous avez choisi de synchroniser le maximum pour {toolkit}. Vous pouvez modifier les limites ici.',
- 'memorySources.settings.maxed': 'Plein',
'memorySources.settings.save': 'Enregistrer',
'memorySources.settings.saving': 'Enregistrement…',
'memorySources.settings.saved': 'Paramètres enregistrés',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index e359da3270..f2cb6c40f2 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -2638,6 +2638,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'आइटम सिंक हुए',
'memorySources.sync.upToDate': 'अद्यतित',
'memorySources.sync.failedLabel': 'विफल',
+ 'memorySources.sync.morePending': 'और सिंक बाकी है। फिर से Sync पर क्लिक करें',
+ 'memorySources.sync.budgetSpent': 'आज का अनुरोध बजट खत्म हो गया है। कल फिर कोशिश करें',
'time.justNow': 'अभी',
'time.secondsAgoSuffix': 'सेकंड पहले',
'time.minutesAgoSuffix': 'मिनट पहले',
@@ -2680,6 +2682,18 @@ const messages: TranslationMap = {
'memorySources.allIn.allFailed':
'कोई भी सिंक शुरू नहीं हो सका। कारण जानने के लिए हर स्रोत को जाँचें।',
'memorySources.allIn.partial': 'शुरू हुए सिंक: {triggered}। शुरू नहीं हो सके: {failed}।',
+ 'memorySources.repair.button': 'पुरानी यादें सुधारें',
+ 'memorySources.repair.title': 'पुरानी यादें सुधारें?',
+ 'memorySources.repair.message':
+ 'मेमोरी ट्री में फाइलिंग ठीक होने से पहले सहेजे गए अधिकतम {scanned} सिंक किए गए दस्तावेज़ मेमोरी ग्राफ़ में दिखाई नहीं देते। उन्हें फाइल करने में एम्बेडिंग क्रेडिट लगते हैं। जो दस्तावेज़ पहले से ट्री में हैं, उन्हें छोड़ दिया जाता है।',
+ 'memorySources.repair.confirm': 'सुधारें',
+ 'memorySources.repair.cancel': 'अभी नहीं',
+ 'memorySources.repair.nothing':
+ 'सुधारने के लिए कुछ नहीं। कोई सिंक किया गया दस्तावेज़ फाइल होने की प्रतीक्षा में नहीं है।',
+ 'memorySources.repair.success':
+ '{ingested} मेमोरी ट्री में फाइल किए गए ({already} पहले से थे, {skipped} छोड़े गए)।',
+ 'memorySources.repair.morePending': 'और बाकी हैं। पुरानी यादें सुधारें पर फिर से क्लिक करें।',
+ 'memorySources.repair.failed': 'पुरानी यादें सुधारी नहीं जा सकीं।',
'memorySources.settings.button': 'सेटिंग',
'memorySources.settings.title': 'सिंक सेटिंग',
'memorySources.settings.maxPrs': 'अधिकतम पुल रिक्वेस्ट',
@@ -2693,7 +2707,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'असीमित',
'memorySources.settings.unlimitedTooltip':
'आपने {toolkit} के लिए अधिकतम सिंक करना चुना है। आप यहाँ सीमाएँ बदल सकते हैं।',
- 'memorySources.settings.maxed': 'पूर्ण',
'memorySources.settings.save': 'सहेजें',
'memorySources.settings.saving': 'सहेजा जा रहा है…',
'memorySources.settings.saved': 'सेटिंग सहेजी गई',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 1ee21f220e..6a29e24434 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -2650,6 +2650,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'item tersinkron',
'memorySources.sync.upToDate': 'Sudah terbaru',
'memorySources.sync.failedLabel': 'Gagal',
+ 'memorySources.sync.morePending': 'Masih ada yang perlu disinkronkan. Klik Sinkronkan lagi',
+ 'memorySources.sync.budgetSpent': 'Anggaran permintaan hari ini sudah habis. Coba lagi besok',
'time.justNow': 'baru saja',
'time.secondsAgoSuffix': 'd lalu',
'time.minutesAgoSuffix': 'm lalu',
@@ -2692,6 +2694,18 @@ const messages: TranslationMap = {
'memorySources.allIn.allFailed':
'Tidak ada sinkronisasi yang bisa dimulai. Periksa setiap sumber untuk melihat alasannya.',
'memorySources.allIn.partial': 'Sinkronisasi dimulai: {triggered}. Tidak bisa dimulai: {failed}.',
+ 'memorySources.repair.button': 'Perbaiki memori lama',
+ 'memorySources.repair.title': 'Perbaiki memori lama?',
+ 'memorySources.repair.message':
+ 'Hingga {scanned} dokumen yang disinkronkan disimpan sebelum pengarsipan pohon memori diperbaiki dan tidak terlihat di grafik memori. Mengarsipkannya menggunakan kredit embedding. Dokumen yang sudah ada di pohon dilewati.',
+ 'memorySources.repair.confirm': 'Perbaiki',
+ 'memorySources.repair.cancel': 'Nanti saja',
+ 'memorySources.repair.nothing':
+ 'Tidak ada yang perlu diperbaiki. Tidak ada dokumen tersinkron yang menunggu diarsipkan.',
+ 'memorySources.repair.success':
+ '{ingested} diarsipkan ke pohon memori ({already} sudah ada, {skipped} dilewati).',
+ 'memorySources.repair.morePending': 'Masih ada sisa. Klik Perbaiki memori lama lagi.',
+ 'memorySources.repair.failed': 'Tidak dapat memperbaiki memori lama.',
'memorySources.settings.button': 'Pengaturan',
'memorySources.settings.title': 'Pengaturan sinkronisasi',
'memorySources.settings.maxPrs': 'Maksimal pull request',
@@ -2705,7 +2719,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Tanpa batas',
'memorySources.settings.unlimitedTooltip':
'Anda memilih menyinkronkan maksimum untuk {toolkit}. Anda dapat mengubah batas di sini.',
- 'memorySources.settings.maxed': 'Penuh',
'memorySources.settings.save': 'Simpan',
'memorySources.settings.saving': 'Menyimpan…',
'memorySources.settings.saved': 'Pengaturan tersimpan',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 2d00f123bb..3f9a9e9487 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -2688,6 +2688,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'elementi sincronizzati',
'memorySources.sync.upToDate': 'Aggiornato',
'memorySources.sync.failedLabel': 'Non riuscito',
+ 'memorySources.sync.morePending': 'Altro da sincronizzare. Fai di nuovo clic su Sincronizza',
+ 'memorySources.sync.budgetSpent': 'Il budget di richieste di oggi è esaurito. Riprova domani',
'time.justNow': 'proprio ora',
'time.secondsAgoSuffix': 's fa',
'time.minutesAgoSuffix': 'min fa',
@@ -2731,6 +2733,19 @@ const messages: TranslationMap = {
'memorySources.allIn.allFailed':
'Nessuna sincronizzazione è potuta partire. Controlla ogni fonte per vedere il motivo.',
'memorySources.allIn.partial': 'Sincronizzazioni avviate: {triggered}. Non avviabili: {failed}.',
+ 'memorySources.repair.button': 'Ripara i ricordi meno recenti',
+ 'memorySources.repair.title': 'Riparare i ricordi meno recenti?',
+ 'memorySources.repair.message':
+ "Fino a {scanned} documenti sincronizzati sono stati salvati prima della correzione dell'archiviazione nell'albero della memoria e non compaiono nel grafo della memoria. Archiviarli consuma crediti di embedding. I documenti già nell'albero vengono saltati.",
+ 'memorySources.repair.confirm': 'Ripara',
+ 'memorySources.repair.cancel': 'Non ora',
+ 'memorySources.repair.nothing':
+ 'Niente da riparare. Nessun documento sincronizzato è in attesa di archiviazione.',
+ 'memorySources.repair.success':
+ "{ingested} archiviati nell'albero della memoria ({already} già presenti, {skipped} saltati).",
+ 'memorySources.repair.morePending':
+ 'Ne restano altri. Fai di nuovo clic su Ripara i ricordi meno recenti.',
+ 'memorySources.repair.failed': 'Impossibile riparare i ricordi meno recenti.',
'memorySources.settings.button': 'Impostazioni',
'memorySources.settings.title': 'Impostazioni di sincronizzazione',
'memorySources.settings.maxPrs': 'Numero massimo di pull request',
@@ -2744,7 +2759,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Illimitato',
'memorySources.settings.unlimitedTooltip':
'Hai scelto di sincronizzare il massimo per {toolkit}. Puoi modificare i limiti qui.',
- 'memorySources.settings.maxed': 'Pieno',
'memorySources.settings.save': 'Salva',
'memorySources.settings.saving': 'Salvataggio…',
'memorySources.settings.saved': 'Impostazioni salvate',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 11d204369e..2a8e01ce65 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -2605,6 +2605,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': '항목 동기화됨',
'memorySources.sync.upToDate': '최신 상태',
'memorySources.sync.failedLabel': '실패',
+ 'memorySources.sync.morePending': '동기화할 항목이 더 있습니다. 동기화를 다시 클릭하세요',
+ 'memorySources.sync.budgetSpent': '오늘의 요청 한도를 모두 사용했습니다. 내일 다시 시도하세요',
'time.justNow': '방금 전',
'time.secondsAgoSuffix': '초 전',
'time.minutesAgoSuffix': '분 전',
@@ -2647,6 +2649,18 @@ const messages: TranslationMap = {
'memorySources.allIn.allFailed':
'동기화를 하나도 시작할 수 없습니다. 각 소스에서 원인을 확인하세요.',
'memorySources.allIn.partial': '시작된 동기화: {triggered}. 시작하지 못함: {failed}.',
+ 'memorySources.repair.button': '이전 기억 복구',
+ 'memorySources.repair.title': '이전 기억을 복구할까요?',
+ 'memorySources.repair.message':
+ '최대 {scanned}개의 동기화된 문서가 메모리 트리 정리 수정 이전에 저장되어 메모리 그래프에 표시되지 않습니다. 정리하려면 임베딩 크레딧이 사용됩니다. 이미 트리에 있는 문서는 건너뜁니다.',
+ 'memorySources.repair.confirm': '복구',
+ 'memorySources.repair.cancel': '나중에',
+ 'memorySources.repair.nothing':
+ '복구할 항목이 없습니다. 정리를 기다리는 동기화된 문서가 없습니다.',
+ 'memorySources.repair.success':
+ '{ingested}개를 메모리 트리에 정리했습니다 ({already}개 이미 있음, {skipped}개 건너뜀).',
+ 'memorySources.repair.morePending': '남은 항목이 있습니다. 이전 기억 복구를 다시 클릭하세요.',
+ 'memorySources.repair.failed': '이전 기억을 복구하지 못했습니다.',
'memorySources.settings.button': '설정',
'memorySources.settings.title': '동기화 설정',
'memorySources.settings.maxPrs': '최대 풀 리퀘스트 수',
@@ -2660,7 +2674,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': '무제한',
'memorySources.settings.unlimitedTooltip':
'{toolkit}에 대해 최대로 동기화하도록 선택했습니다. 여기에서 한도를 변경할 수 있습니다.',
- 'memorySources.settings.maxed': '최대',
'memorySources.settings.save': '저장',
'memorySources.settings.saving': '저장 중…',
'memorySources.settings.saved': '설정이 저장되었습니다',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index a37d0d6def..9dfb955250 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -2669,6 +2669,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'zsynchronizowanych elementów',
'memorySources.sync.upToDate': 'Aktualne',
'memorySources.sync.failedLabel': 'Niepowodzenie',
+ 'memorySources.sync.morePending': 'Jest więcej do synchronizacji. Kliknij Synchronizuj ponownie',
+ 'memorySources.sync.budgetSpent': 'Dzisiejszy limit zapytań został wyczerpany. Spróbuj jutro',
'time.justNow': 'przed chwilą',
'time.secondsAgoSuffix': 's temu',
'time.minutesAgoSuffix': 'min temu',
@@ -2713,6 +2715,19 @@ const messages: TranslationMap = {
'Nie udało się uruchomić żadnej synchronizacji. Sprawdź każde źródło, aby poznać przyczynę.',
'memorySources.allIn.partial':
'Uruchomione synchronizacje: {triggered}. Nie udało się uruchomić: {failed}.',
+ 'memorySources.repair.button': 'Napraw starsze wspomnienia',
+ 'memorySources.repair.title': 'Naprawić starsze wspomnienia?',
+ 'memorySources.repair.message':
+ 'Do {scanned} zsynchronizowanych dokumentów zapisano przed naprawą katalogowania w drzewie pamięci i nie są widoczne w grafie pamięci. Katalogowanie zużywa kredyty embeddingów. Dokumenty już obecne w drzewie są pomijane.',
+ 'memorySources.repair.confirm': 'Napraw',
+ 'memorySources.repair.cancel': 'Nie teraz',
+ 'memorySources.repair.nothing':
+ 'Nie ma nic do naprawy. Żadne zsynchronizowane dokumenty nie czekają na skatalogowanie.',
+ 'memorySources.repair.success':
+ 'Skatalogowano {ingested} w drzewie pamięci ({already} już było, {skipped} pominięto).',
+ 'memorySources.repair.morePending':
+ 'Zostało więcej. Kliknij ponownie Napraw starsze wspomnienia.',
+ 'memorySources.repair.failed': 'Nie udało się naprawić starszych wspomnień.',
'memorySources.settings.button': 'Ustawienia',
'memorySources.settings.title': 'Ustawienia synchronizacji',
'memorySources.settings.maxPrs': 'Maksymalna liczba pull requestów',
@@ -2726,7 +2741,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Bez limitu',
'memorySources.settings.unlimitedTooltip':
'Wybrano synchronizację maksimum dla {toolkit}. Limity możesz zmienić tutaj.',
- 'memorySources.settings.maxed': 'Pełny',
'memorySources.settings.save': 'Zapisz',
'memorySources.settings.saving': 'Zapisywanie…',
'memorySources.settings.saved': 'Ustawienia zapisane',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index a743dc50a3..968177ecbc 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -2686,6 +2686,9 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'itens sincronizados',
'memorySources.sync.upToDate': 'Atualizado',
'memorySources.sync.failedLabel': 'Falhou',
+ 'memorySources.sync.morePending': 'Há mais para sincronizar. Clique em Sincronizar novamente',
+ 'memorySources.sync.budgetSpent':
+ 'O orçamento de solicitações de hoje acabou. Tente novamente amanhã',
'time.justNow': 'agora mesmo',
'time.secondsAgoSuffix': 's atrás',
'time.minutesAgoSuffix': 'min atrás',
@@ -2729,6 +2732,19 @@ const messages: TranslationMap = {
'Nenhuma sincronização pôde começar. Verifique cada fonte para ver o motivo.',
'memorySources.allIn.partial':
'Sincronizações iniciadas: {triggered}. Não foi possível iniciar: {failed}.',
+ 'memorySources.repair.button': 'Reparar memórias antigas',
+ 'memorySources.repair.title': 'Reparar memórias antigas?',
+ 'memorySources.repair.message':
+ 'Até {scanned} documentos sincronizados foram salvos antes da correção do arquivamento na árvore de memória e não aparecem no grafo de memória. Arquivá-los consome créditos de embedding. Documentos já na árvore são ignorados.',
+ 'memorySources.repair.confirm': 'Reparar',
+ 'memorySources.repair.cancel': 'Agora não',
+ 'memorySources.repair.nothing':
+ 'Nada a reparar. Nenhum documento sincronizado aguarda arquivamento.',
+ 'memorySources.repair.success':
+ '{ingested} arquivados na árvore de memória ({already} já existentes, {skipped} ignorados).',
+ 'memorySources.repair.morePending':
+ 'Ainda restam mais. Clique em Reparar memórias antigas novamente.',
+ 'memorySources.repair.failed': 'Não foi possível reparar as memórias antigas.',
'memorySources.settings.button': 'Configurações',
'memorySources.settings.title': 'Configurações de sincronização',
'memorySources.settings.maxPrs': 'Máximo de pull requests',
@@ -2742,7 +2758,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Ilimitado',
'memorySources.settings.unlimitedTooltip':
'Você optou por sincronizar o máximo para {toolkit}. Você pode alterar os limites aqui.',
- 'memorySources.settings.maxed': 'Cheio',
'memorySources.settings.save': 'Salvar',
'memorySources.settings.saving': 'Salvando…',
'memorySources.settings.saved': 'Configurações salvas',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index c0a305111d..a93515fe9b 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -2657,6 +2657,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': 'элементов синхронизировано',
'memorySources.sync.upToDate': 'Актуально',
'memorySources.sync.failedLabel': 'Не удалось',
+ 'memorySources.sync.morePending': 'Есть что синхронизировать. Нажмите «Синхронизировать» ещё раз',
+ 'memorySources.sync.budgetSpent': 'Дневной лимит запросов исчерпан. Попробуйте завтра',
'time.justNow': 'только что',
'time.secondsAgoSuffix': 'с назад',
'time.minutesAgoSuffix': 'мин назад',
@@ -2702,6 +2704,19 @@ const messages: TranslationMap = {
'Ни одну синхронизацию не удалось запустить. Проверьте каждый источник, чтобы узнать причину.',
'memorySources.allIn.partial':
'Запущено синхронизаций: {triggered}. Не удалось запустить: {failed}.',
+ 'memorySources.repair.button': 'Восстановить старые воспоминания',
+ 'memorySources.repair.title': 'Восстановить старые воспоминания?',
+ 'memorySources.repair.message':
+ 'До {scanned} синхронизированных документов были сохранены до исправления размещения в дереве памяти и не видны в графе памяти. Их размещение расходует кредиты на эмбеддинги. Документы, уже находящиеся в дереве, пропускаются.',
+ 'memorySources.repair.confirm': 'Восстановить',
+ 'memorySources.repair.cancel': 'Не сейчас',
+ 'memorySources.repair.nothing':
+ 'Нечего восстанавливать. Нет синхронизированных документов, ожидающих размещения.',
+ 'memorySources.repair.success':
+ 'Размещено в дереве памяти: {ingested} ({already} уже было, {skipped} пропущено).',
+ 'memorySources.repair.morePending':
+ 'Остались ещё. Нажмите «Восстановить старые воспоминания» снова.',
+ 'memorySources.repair.failed': 'Не удалось восстановить старые воспоминания.',
'memorySources.settings.button': 'Настройки',
'memorySources.settings.title': 'Настройки синхронизации',
'memorySources.settings.maxPrs': 'Максимум pull request',
@@ -2715,7 +2730,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': 'Без лимита',
'memorySources.settings.unlimitedTooltip':
'Вы выбрали синхронизацию максимума для {toolkit}. Лимиты можно изменить здесь.',
- 'memorySources.settings.maxed': 'Заполнено',
'memorySources.settings.save': 'Сохранить',
'memorySources.settings.saving': 'Сохранение…',
'memorySources.settings.saved': 'Настройки сохранены',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 4d79ef7874..ed2129d007 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -2488,6 +2488,8 @@ const messages: TranslationMap = {
'memorySources.sync.itemsSynced': '项已同步',
'memorySources.sync.upToDate': '已是最新',
'memorySources.sync.failedLabel': '失败',
+ 'memorySources.sync.morePending': '还有更多待同步。请再次点击“同步”',
+ 'memorySources.sync.budgetSpent': '今日请求额度已用完。请明天再试',
'time.justNow': '刚刚',
'time.secondsAgoSuffix': '秒前',
'time.minutesAgoSuffix': '分钟前',
@@ -2529,6 +2531,17 @@ const messages: TranslationMap = {
'memorySources.allIn.failed': '无法应用全部启用。请重试。',
'memorySources.allIn.allFailed': '没有任何同步能够开始。请检查每个来源以了解原因。',
'memorySources.allIn.partial': '已开始同步:{triggered}。无法开始:{failed}。',
+ 'memorySources.repair.button': '修复较早的记忆',
+ 'memorySources.repair.title': '要修复较早的记忆吗?',
+ 'memorySources.repair.message':
+ '最多 {scanned} 个已同步的文档在记忆树摄取修复之前保存,因此不会显示在记忆图谱中。摄取它们会消耗嵌入额度。已在记忆树中的文档会被跳过。',
+ 'memorySources.repair.confirm': '修复',
+ 'memorySources.repair.cancel': '暂不',
+ 'memorySources.repair.nothing': '无需修复。没有等待摄取的已同步文档。',
+ 'memorySources.repair.success':
+ '已摄取 {ingested} 个到记忆树({already} 个已存在,{skipped} 个已跳过)。',
+ 'memorySources.repair.morePending': '还有剩余。请再次点击“修复较早的记忆”。',
+ 'memorySources.repair.failed': '无法修复较早的记忆。',
'memorySources.settings.button': '设置',
'memorySources.settings.title': '同步设置',
'memorySources.settings.maxPrs': '最大拉取请求数',
@@ -2542,7 +2555,6 @@ const messages: TranslationMap = {
'memorySources.settings.unlimited': '无限制',
'memorySources.settings.unlimitedTooltip':
'您已选择为 {toolkit} 同步最大数量。您可以在此处更改上限。',
- 'memorySources.settings.maxed': '已满',
'memorySources.settings.save': '保存',
'memorySources.settings.saving': '保存中…',
'memorySources.settings.saved': '设置已保存',
diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts
index c675a07ca5..b00c9b1c0d 100644
--- a/app/src/services/analytics.ts
+++ b/app/src/services/analytics.ts
@@ -104,6 +104,7 @@ const ALLOWED_EVENT_NAMES = [
'automation_run_started',
'automation_run_resumed',
'automation_run_cancelled',
+ 'memory_repair_succeeded',
'memory_tree_retry_succeeded',
'skill_install',
'skill_uninstall',
diff --git a/app/src/utils/tauriCommands/memoryTree.test.ts b/app/src/utils/tauriCommands/memoryTree.test.ts
index 6b67b74228..a7b6a0300c 100644
--- a/app/src/utils/tauriCommands/memoryTree.test.ts
+++ b/app/src/utils/tauriCommands/memoryTree.test.ts
@@ -10,6 +10,7 @@ import { callCoreRpc } from '../../services/coreRpcClient';
import {
memoryNamespaceSummaries,
memorySyncStatusList,
+ memoryTreeBackfillConnectorTrees,
memoryTreeBackfillStatus,
memoryTreeChunkScore,
memoryTreeDeleteChunk,
@@ -525,3 +526,51 @@ describe('memoryNamespaceSummaries', () => {
expect(out).toEqual({ namespaces: [], total_documents: 0 });
});
});
+
+describe('memoryTreeBackfillConnectorTrees', () => {
+ test('sends dry_run and omits limit when none is given', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({
+ result: {
+ executed: false,
+ scanned: 12,
+ ingested: 0,
+ already_present: 0,
+ skipped: 0,
+ more_pending: false,
+ notes: [],
+ },
+ logs: ['stub'],
+ });
+
+ const out = await memoryTreeBackfillConnectorTrees({ dryRun: true });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'openhuman.memory_tree_backfill_connector_trees',
+ params: { dry_run: true },
+ })
+ );
+ expect(out.executed).toBe(false);
+ expect(out.scanned).toBe(12);
+ });
+
+ test('forwards a limit and unwraps a bare (non-envelope) reply', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({
+ executed: true,
+ scanned: 5,
+ ingested: 4,
+ already_present: 1,
+ skipped: 0,
+ more_pending: true,
+ notes: [],
+ });
+
+ const out = await memoryTreeBackfillConnectorTrees({ dryRun: false, limit: 5 });
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith(
+ expect.objectContaining({ params: { dry_run: false, limit: 5 } })
+ );
+ expect(out.ingested).toBe(4);
+ expect(out.more_pending).toBe(true);
+ });
+});
diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts
index 118fbf15a8..3393468e31 100644
--- a/app/src/utils/tauriCommands/memoryTree.ts
+++ b/app/src/utils/tauriCommands/memoryTree.ts
@@ -555,6 +555,68 @@ export async function memoryTreeResetTree(): Promise {
return out;
}
+/** Response shape for `memory_tree_backfill_connector_trees`. */
+export interface BackfillConnectorTreesResponse {
+ /** False for the dry-run preview: it counted and wrote nothing. */
+ executed: boolean;
+ /** Documents examined. */
+ scanned: number;
+ /** Documents that produced new memory-tree rows. */
+ ingested: number;
+ /** Documents the tree already held. */
+ already_present: number;
+ /** Documents left alone rather than filed under a guess. */
+ skipped: number;
+ /** The pass stopped on its limit with documents unexamined — run again. */
+ more_pending: boolean;
+ /** Bounded, human-readable reasons behind `skipped`. */
+ notes: string[];
+}
+
+/**
+ * A real pass reads and embeds up to the driver's per-call limit of documents
+ * (500) before it answers; the budget has to cover the whole pass.
+ */
+const BACKFILL_RPC_TIMEOUT_MS = 10 * 60 * 1_000;
+
+/**
+ * Re-file connector documents stored before the memory-tree routing fix
+ * (openhuman#6007) into the tree. Backed by
+ * `openhuman.memory_tree_backfill_connector_trees` (openhuman#6012).
+ *
+ * `dryRun: true` counts what a pass would examine and writes nothing; the real
+ * pass embeds every document it files, which spends credits, so the UI always
+ * previews first and asks. Idempotent: documents already in the tree come
+ * back as `already_present`, never filed twice.
+ */
+export async function memoryTreeBackfillConnectorTrees(opts: {
+ dryRun: boolean;
+ limit?: number;
+}): Promise {
+ console.debug(
+ '[memory-tree-rpc] memoryTreeBackfillConnectorTrees: entry dry_run=%s',
+ opts.dryRun
+ );
+ const resp = await callCoreRpc<
+ BackfillConnectorTreesResponse | ResultEnvelope
+ >({
+ method: 'openhuman.memory_tree_backfill_connector_trees',
+ params: { dry_run: opts.dryRun, ...(opts.limit ? { limit: opts.limit } : {}) },
+ timeoutMs: BACKFILL_RPC_TIMEOUT_MS,
+ });
+ const out = unwrapResult(resp);
+ console.debug(
+ '[memory-tree-rpc] memoryTreeBackfillConnectorTrees: exit executed=%s scanned=%d ingested=%d already=%d skipped=%d more=%s',
+ out.executed,
+ out.scanned,
+ out.ingested,
+ out.already_present,
+ out.skipped,
+ out.more_pending
+ );
+ return out;
+}
+
/** Response shape for `memory_tree_flush_now`. */
export interface FlushNowResponse {
enqueued: boolean;
diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs
index f69a0cbcb0..ce8341e972 100644
--- a/src/core/runtime/builder.rs
+++ b/src/core/runtime/builder.rs
@@ -841,7 +841,16 @@ impl CoreRuntime {
});
}
- if let Some(shutdown_token) = shutdown_token {
+ // Arms memory's exit gate for the eventual exit (and clears one a
+ // previous server in this process may have left): from here on a
+ // memory binding built during exit is refused rather than missed.
+ crate::openhuman::memory::exit::server_starting();
+
+ // The serve result is held, not propagated, until the exit work below
+ // has run. A `?` here on a server error would skip the memory teardown
+ // on exactly the exits where a wedged store is likeliest, and the
+ // callers only forward the error — nobody else runs the cleanup.
+ let served = if let Some(shutdown_token) = shutdown_token {
log::info!(
"[core] embedded server waiting on cancellation token for graceful shutdown"
);
@@ -849,13 +858,26 @@ impl CoreRuntime {
.with_graceful_shutdown(async move {
shutdown_token.cancelled().await;
})
- .await?;
+ .await
} else {
axum::serve(listener, app)
.with_graceful_shutdown(crate::core::shutdown::signal())
- .await?;
+ .await
+ };
+ if let Err(error) = &served {
+ log::warn!(
+ "[core] embedded server ended with an error; running exit cleanup before \
+ reporting it: {error}"
+ );
}
+ // Memory first. The engine's queue worker holds leases on in-flight
+ // jobs, and releasing them is a write to the store, so it has to happen
+ // while the store is still open and before anything else on the way
+ // out (tinymemory#133). Bounded inside, on one shared deadline: a
+ // wedged store costs at most that budget, never the exit.
+ crate::openhuman::memory::exit::shutdown_for_exit().await;
+
// Server has stopped accepting and in-flight requests drained. Kill any
// `ollama serve` openhuman itself spawned (no-op when externally
// managed) so the next launch doesn't try to reclaim a dead daemon.
@@ -876,6 +898,7 @@ impl CoreRuntime {
}
}
+ served?;
Ok(())
}
diff --git a/src/core/shutdown.rs b/src/core/shutdown.rs
index 4d569f4d74..aa1e14a438 100644
--- a/src/core/shutdown.rs
+++ b/src/core/shutdown.rs
@@ -52,6 +52,27 @@ async fn run_hooks() {
}
}
+/// Run every registered hook now, once, without waiting for a signal.
+///
+/// For the embedded server, whose graceful path is a cancellation token rather
+/// than SIGTERM: [`signal`] never resolves there, so the hooks it would have
+/// run — the memory engine releasing its queue leases, above all — never ran
+/// on a normal quit. Drains the registry, so a later call, or a signal landing
+/// mid-teardown, finds nothing to run twice.
+///
+/// Side by side, unlike the signal path. The caller runs this under a
+/// deadline, and in sequence one hook that never answers would keep every hook
+/// registered after it — the lease release among them — from so much as
+/// starting before the deadline dropped the lot. Run together, a hanging hook
+/// costs only itself.
+pub async fn run_hooks_now() {
+ let hooks: Vec = {
+ let mut guard = HOOKS.lock().expect("shutdown hooks poisoned");
+ std::mem::take(&mut *guard)
+ };
+ futures::future::join_all(hooks.iter().map(|hook| hook())).await;
+}
+
/// Returns a future that resolves when the process receives a termination
/// signal (SIGINT on all platforms, plus SIGTERM on Unix), then runs all
/// registered shutdown hooks.
diff --git a/src/openhuman/integrations/composio/ops/mod.rs b/src/openhuman/integrations/composio/ops/mod.rs
index a91dc8f23d..cb16fcf708 100644
--- a/src/openhuman/integrations/composio/ops/mod.rs
+++ b/src/openhuman/integrations/composio/ops/mod.rs
@@ -43,6 +43,7 @@ pub use execute::composio_execute;
#[cfg(test)]
pub(crate) use providers_ops::{
completed_sync_detail, completed_sync_detail_for_test, next_pass_budget,
+ pick_source_sync_depth_days,
};
pub use providers_ops::{
composio_get_user_profile, composio_refresh_all_identities, composio_sync,
diff --git a/src/openhuman/integrations/composio/ops/providers_ops.rs b/src/openhuman/integrations/composio/ops/providers_ops.rs
index b4394d1a2e..fa38c0d5fe 100644
--- a/src/openhuman/integrations/composio/ops/providers_ops.rs
+++ b/src/openhuman/integrations/composio/ops/providers_ops.rs
@@ -71,12 +71,27 @@ pub(crate) fn next_pass_budget(source_max_items: Option, total_written: u64
/// A parse contract, not prose: the Sources UI extracts the count with
/// `/ingested\s+(\d+)\s+item/i` and falls back to a generic "up to date"
/// when it cannot (#3295). Pinned by a unit test against that exact pattern.
-pub(crate) fn completed_sync_detail(total_written: u64, more_pending: bool) -> String {
- if more_pending {
+///
+/// `note` is what the module said about a run that stopped short — today's
+/// request budget being spent, above all. It rides *after* the count, never
+/// inside it, so the regex keeps matching and everything past the count is
+/// free text the UI can show. Without it a spent budget wrote zero items and
+/// read back as "Up to date", the opposite of what happened.
+pub(crate) fn completed_sync_detail(
+ total_written: u64,
+ more_pending: bool,
+ note: Option<&str>,
+) -> String {
+ let mut detail = if more_pending {
format!("ingested {total_written} item(s), more pending — Sync again to continue")
} else {
format!("ingested {total_written} item(s)")
+ };
+ if let Some(note) = note.map(str::trim).filter(|note| !note.is_empty()) {
+ detail.push_str("; ");
+ detail.push_str(note);
}
+ detail
}
/// Aggregate result of [`composio_refresh_all_identities`].
@@ -325,6 +340,11 @@ pub async fn composio_sync_budgeted(
let mut total_written: u64 = 0;
let mut passes = 0usize;
let mut more_pending = false;
+ // The module's own account of a pass that stopped short (the day's
+ // request budget, typically). The last pass's word wins, present or
+ // absent: it describes the state the run ended in, and a pass that
+ // completes cleanly must not inherit the note of an earlier one.
+ let mut stop_note: Option = None;
let outcome = loop {
passes += 1;
// The source's configured per-run cap wins over the pass ceiling:
@@ -346,6 +366,12 @@ pub async fn composio_sync_budgeted(
Ok(pass) => {
total_written = total_written.saturating_add(u64::from(pass.written));
more_pending = pass.more_pending;
+ stop_note = pass
+ .message
+ .as_deref()
+ .map(str::trim)
+ .filter(|message| !message.is_empty())
+ .map(str::to_string);
tracing::info!(
toolkit = %toolkit_for_log,
connection_id = %connection_for_log,
@@ -378,7 +404,11 @@ pub async fn composio_sync_budgeted(
// falls back to a generic "up to date" when it cannot (#3295).
publish_stage(
"completed",
- Some(completed_sync_detail(total_written, more_pending)),
+ Some(completed_sync_detail(
+ total_written,
+ more_pending,
+ stop_note.as_deref(),
+ )),
);
}
Err(error) => {
@@ -427,6 +457,10 @@ pub(crate) struct SyncPassOutcome {
/// Whether the module has more to read — the caller decides whether to
/// call again.
pub more_pending: bool,
+ /// What the module said about a run that stopped short — today's request
+ /// budget being spent, above all. Carried so the completed-stage detail
+ /// can say *why* zero items arrived instead of reading as "up to date".
+ pub message: Option,
}
/// Read one connection through the module and ingest what it returns.
@@ -450,6 +484,13 @@ pub(crate) async fn run_sync_pass(
) -> Result {
// Sync pages the whole account inside the call; the default 30s bus
// deadline reported failure on runs the module then finished successfully.
+ // The per-source "Sync depth (days)" cap. Until contract 1.8 gave the
+ // request a field for it, the setting reached the log and nothing else;
+ // the module now turns it into Gmail's own `after:` search term, so a
+ // bounded first sync costs one page of recent mail rather than a walk
+ // through the years. `None` reads unbounded, as every earlier release did.
+ let depth_days = source_sync_depth_days(config, toolkit, connection_id);
+
let response = connectors::call_slow::<_, ConnectorSyncResponse>(
config,
methods::SYNC,
@@ -462,6 +503,7 @@ pub(crate) async fn run_sync_pass(
// pass loop bounds nothing (review finding). complete=false at the
// budget → more_pending → the loop (or the next click) resumes.
max_items: Some(pass_budget),
+ depth_days,
..ConnectorSyncRequest::default()
},
)
@@ -474,6 +516,7 @@ pub(crate) async fn run_sync_pass(
written: 0,
already_ingested: false,
more_pending: !response.batch.complete,
+ message: response.message,
});
}
@@ -593,9 +636,80 @@ pub(crate) async fn run_sync_pass(
written: outcome.written,
already_ingested: outcome.already_ingested,
more_pending: !response.batch.complete,
+ message: response.message,
})
}
+/// The per-source "Sync depth (days)" cap for one connection, from the
+/// memory-sources registry the pass's own `config` names.
+///
+/// Resolved here rather than threaded through every caller because there are
+/// five of them (the row button, All In, the periodic loop, the connection
+/// bootstrap, Slack's own RPC), and `max_items` already showed what happens
+/// when each open-codes the same rule: two of the five disagreed
+/// (openhuman#6007). Read through `config`, not the process environment: a
+/// pass is bound to one workspace, and the global registry path would answer a
+/// caller bound to workspace B with workspace A's rows — the cross-workspace
+/// leak the registry's `_in` variants exist to prevent. `None` when the row is
+/// missing, carries no cap, or the registry cannot be read — each means "no
+/// lower bound", which is what every release before the field existed did, so
+/// a registry hiccup degrades to the old behaviour rather than to a failed
+/// sync.
+fn source_sync_depth_days(config: &Config, toolkit: &str, connection_id: &str) -> Option {
+ let sources = match crate::openhuman::memory::sources::registry::list_sources_in(config) {
+ Ok(sources) => sources,
+ Err(error) => {
+ tracing::warn!(
+ toolkit = %toolkit,
+ connection_id = %connection_id,
+ error = %error,
+ "[composio] memory-sources registry unreadable for the sync depth; \
+ syncing without a lower bound"
+ );
+ return None;
+ }
+ };
+ pick_source_sync_depth_days(
+ sources
+ .iter()
+ .filter(|source| source.kind == crate::openhuman::memory::sources::SourceKind::Composio)
+ .map(|source| {
+ (
+ source.toolkit.as_deref(),
+ source.connection_id.as_deref(),
+ source.sync_depth_days,
+ )
+ }),
+ toolkit,
+ connection_id,
+ )
+}
+
+/// The cap of the row matching `toolkit` and `connection_id`, if any.
+///
+/// Matched the way the engine keys the rows — toolkit case-insensitively and
+/// trimmed, connection trimmed — and a cap of zero reads as none: the settings
+/// field stores "unlimited" as an empty value, and a zero typed by hand would
+/// otherwise ask Gmail for mail newer than today.
+pub(crate) fn pick_source_sync_depth_days<'a>(
+ rows: impl IntoIterator- , Option<&'a str>, Option
)>,
+ toolkit: &str,
+ connection_id: &str,
+) -> Option {
+ let toolkit = toolkit.trim();
+ let connection_id = connection_id.trim();
+ rows.into_iter()
+ .find_map(|(row_toolkit, row_connection, depth)| {
+ let same_toolkit =
+ row_toolkit.is_some_and(|slug| slug.trim().eq_ignore_ascii_case(toolkit));
+ let same_connection = row_connection.is_some_and(|id| id.trim() == connection_id);
+ (same_toolkit && same_connection)
+ .then_some(depth)
+ .flatten()
+ .filter(|days| *days > 0)
+ })
+}
+
/// Parse the optional `reason` parameter into a [`SyncReason`].
///
/// `None` and the explicit `"manual"` value both map to
@@ -617,5 +731,5 @@ pub(crate) fn parse_sync_reason(raw: Option<&str>) -> OpResult {
/// name; this avoids re-plumbing the cfg(test) re-export).
#[cfg(test)]
pub(crate) fn completed_sync_detail_for_test(total: u64, more: bool) -> String {
- completed_sync_detail(total, more)
+ completed_sync_detail(total, more, None)
}
diff --git a/src/openhuman/integrations/composio/ops_tests_part_03_tests.rs b/src/openhuman/integrations/composio/ops_tests_part_03_tests.rs
index a31901326e..80236334f9 100644
--- a/src/openhuman/integrations/composio/ops_tests_part_03_tests.rs
+++ b/src/openhuman/integrations/composio/ops_tests_part_03_tests.rs
@@ -648,8 +648,9 @@ async fn composio_list_connections_returns_empty_when_direct_mode_no_key() {
fn completed_sync_detail_matches_the_ui_parse_contract() {
let re = regex::Regex::new(r"(?i)ingested\s+(\d+)\s+item").expect("ui parse regex");
for count in [0u64, 1, 200, 25_000] {
- let detail =
- crate::openhuman::integrations::composio::ops::completed_sync_detail(count, false);
+ let detail = crate::openhuman::integrations::composio::ops::completed_sync_detail(
+ count, false, None,
+ );
let caps = re
.captures(&detail)
.unwrap_or_else(|| panic!("detail must parse: {detail}"));
diff --git a/src/openhuman/integrations/composio/ops_tests_part_04_tests.rs b/src/openhuman/integrations/composio/ops_tests_part_04_tests.rs
index 805182af9d..5790018ac8 100644
--- a/src/openhuman/integrations/composio/ops_tests_part_04_tests.rs
+++ b/src/openhuman/integrations/composio/ops_tests_part_04_tests.rs
@@ -643,3 +643,55 @@ async fn enrich_leaves_unmatched_connection_unchanged() {
"connection with no cached profile must remain unenriched"
);
}
+
+/// A run that wrote nothing because the day's request budget was spent must
+/// say so: the UI shows "Up to date" for a zero count, and a spent budget is
+/// the opposite. The note rides after the count so the parse contract holds,
+/// and a blank note is no separator with nothing behind it.
+#[test]
+fn completed_detail_carries_the_module_note_after_the_count() {
+ let re = regex::Regex::new(r"(?i)ingested\s+(\d+)\s+item").expect("ui parse regex");
+ let detail = crate::openhuman::integrations::composio::ops::completed_sync_detail(
+ 0,
+ true,
+ Some("today's provider request budget is spent"),
+ );
+ let caps = re.captures(&detail).expect("detail still parses");
+ assert_eq!(&caps[1], "0");
+ assert!(
+ detail.ends_with("; today's provider request budget is spent"),
+ "{detail}"
+ );
+ let bare =
+ crate::openhuman::integrations::composio::ops::completed_sync_detail(3, false, Some(" "));
+ assert_eq!(bare, "ingested 3 item(s)");
+}
+
+/// The per-source depth cap is matched the way the engine keys the rows and a
+/// zero reads as "no cap": the settings field stores unlimited as empty, and a
+/// zero typed by hand must not ask for mail newer than today.
+#[test]
+fn source_depth_matches_the_row_and_treats_zero_as_unbounded() {
+ use crate::openhuman::integrations::composio::ops::pick_source_sync_depth_days;
+ let rows = [
+ (Some("gmail"), Some("conn-1"), Some(30)),
+ (Some("gmail"), Some("conn-2"), Some(0)),
+ (Some("notion"), Some("conn-3"), Some(14)),
+ (Some("gmail"), None, Some(7)),
+ ];
+ assert_eq!(
+ pick_source_sync_depth_days(rows, "gmail", "conn-1"),
+ Some(30)
+ );
+ assert_eq!(
+ pick_source_sync_depth_days(rows, " GMAIL ", "conn-1 "),
+ Some(30)
+ );
+ assert_eq!(pick_source_sync_depth_days(rows, "gmail", "conn-2"), None);
+ assert_eq!(pick_source_sync_depth_days(rows, "gmail", "conn-9"), None);
+ assert_eq!(
+ pick_source_sync_depth_days(rows, "notion", "conn-3"),
+ Some(14)
+ );
+ assert_eq!(pick_source_sync_depth_days([], "gmail", "conn-1"), None);
+}
diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs
index fd7353216a..1601763ddd 100644
--- a/src/openhuman/memory/binding.rs
+++ b/src/openhuman/memory/binding.rs
@@ -467,6 +467,46 @@ pub(crate) fn bind_provider_for_test(
type BindingCacheKey = (PathBuf, String, MemorySubsystemConfig);
static BINDINGS: OnceLock>>> = OnceLock::new();
+/// Every binding this process has built so far, for the exit path.
+///
+/// A snapshot rather than a handle to the map: exit runs while other tasks may
+/// still be resolving bindings, and holding the lock across a driver's
+/// `shutdown` would queue them behind it.
+pub(crate) fn cached_bindings() -> Vec> {
+ let Some(cache) = BINDINGS.get() else {
+ return Vec::new();
+ };
+ match cache.read() {
+ Ok(map) => map.values().cloned().collect(),
+ Err(poisoned) => poisoned.into_inner().values().cloned().collect(),
+ }
+}
+
+/// Drop `shut_down` from the cache, so a server that starts again in this
+/// process binds fresh drivers instead of the ones exit has already torn down.
+///
+/// The shell restarts the embedded server in place (a permission refresh, an
+/// app update), and exit runs every cached driver's `shutdown` on the way out.
+/// Left in the cache, those drivers would be handed straight back to the next
+/// server — their workers stopped and their hooks already drained — and memory
+/// would stay dark until the whole desktop process restarted. Matched by
+/// identity, not by key: only what exit actually asked to shut down leaves.
+/// With the exit gate up nothing else can be in the cache by then; without it
+/// (a bare call, in tests) a binding built beside the exit stays.
+pub(crate) fn evict_bindings(shut_down: &[Arc]) {
+ if shut_down.is_empty() {
+ return;
+ }
+ let Some(cache) = BINDINGS.get() else {
+ return;
+ };
+ let mut map = match cache.write() {
+ Ok(map) => map,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ map.retain(|_, binding| !shut_down.iter().any(|gone| Arc::ptr_eq(gone, binding)));
+}
+
/// The bound memory driver for `workspace_dir`, constructing it on first use.
///
/// The same workspace always resolves to the same cached `Arc` (so
@@ -666,6 +706,16 @@ pub fn for_subtree(
let mut guard = cache
.write()
.map_err(|e| format!("[memory:binding] cache write lock poisoned: {e}"))?;
+ // Under the same lock the exit snapshot is taken under: once memory is on
+ // its way out, a driver bound now would never be asked to shut down, so
+ // it is not bound at all. The check sits inside the lock on purpose — a
+ // builder that passed it inserted before the snapshot, and one that did
+ // not is refused; there is no third case (memory/exit.rs).
+ if crate::openhuman::memory::exit::exiting() {
+ return Err(
+ "[memory:binding] memory is shutting down; not binding a new driver".to_string(),
+ );
+ }
// Re-check under the write lock: a racing caller may have bound the same
// workspace while we were building. Reuse theirs so one workspace never has
// two live drivers (kernel.md §3.1) and `capabilities()` stays asked once.
diff --git a/src/openhuman/memory/exit.rs b/src/openhuman/memory/exit.rs
new file mode 100644
index 0000000000..d2de4622b6
--- /dev/null
+++ b/src/openhuman/memory/exit.rs
@@ -0,0 +1,127 @@
+//! What memory does on the way out of the process.
+//!
+//! The engine registers exactly one shutdown hook — its queue worker releasing
+//! the leases on in-flight jobs, so the next launch re-claims that work instead
+//! of waiting the leases out (tinymemory#133). In module mode the engine banks
+//! that hook and drains it when the host calls `Shutdown` (tinymemory#137).
+//! The host never did. The embedded server's graceful path is a cancellation
+//! token, not SIGTERM, so [`crate::core::shutdown::signal`] never resolved
+//! there, and nothing else ever called the bound provider's `shutdown`: every
+//! normal quit left the leases held, and every next launch took the slow path.
+//!
+//! This is the other half. It runs from the server's post-drain block, and it
+//! is bounded, because a quit that hangs on a wedged store is worse than a
+//! lease that expires on its own.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::Duration;
+
+use futures::future::join_all;
+use tokio::time::Instant;
+
+/// The whole of memory's exit work — every bound driver's `shutdown`, then
+/// the host's hook registry — must fit in this, together.
+///
+/// One deadline rather than one per driver. The Tauri shell gives the server
+/// a bounded moment to drain before it aborts the task, and a per-driver
+/// budget multiplied by the number of bindings could outrun that moment with
+/// a hook still pending. Releasing a lease is one write per in-flight job;
+/// anything slower is a store that is not going to answer, and its leases
+/// expire by themselves. The shell sizes its drain budget from this constant
+/// plus the ollama cleanup that follows it in `serve_http`.
+pub const EXIT_BUDGET: Duration = Duration::from_secs(2);
+
+/// The least the hook registry gets even when the drivers spent the budget.
+/// In-process engines (dev runs, tests) release their leases through a hook,
+/// not a driver, so the hooks are never skipped outright.
+const HOOKS_FLOOR: Duration = Duration::from_millis(250);
+
+/// Whether a real server has started in this process.
+///
+/// The exit gate below engages only then. Unit tests call
+/// [`shutdown_for_exit`] without ever serving, and a process-wide refusal to
+/// bind memory would reach every other test in the same process.
+static SERVING: AtomicBool = AtomicBool::new(false);
+
+/// Set when memory's exit work begins, cleared when a server starts.
+///
+/// Read by the binding cache *inside its write lock* before every insert. That
+/// ordering is what makes the exit snapshot complete: the flag goes up, then
+/// the snapshot is taken under the same lock, so a builder either inserted
+/// before the snapshot (and is in it) or sees the flag and is refused. No
+/// number of re-snapshots could say that.
+static EXITING: AtomicBool = AtomicBool::new(false);
+
+/// Called by the embedded server as it starts serving. Arms the exit gate for
+/// the eventual exit and clears one left by a previous server in this process
+/// (the shell can respawn the core task without restarting the process).
+pub fn server_starting() {
+ EXITING.store(false, Ordering::SeqCst);
+ SERVING.store(true, Ordering::SeqCst);
+}
+
+/// Whether memory is on its way out: new bindings are refused.
+pub(crate) fn exiting() -> bool {
+ EXITING.load(Ordering::SeqCst)
+}
+
+/// Test seam: put both flags back so one test's exit cannot leak into another.
+#[cfg(test)]
+pub(crate) fn reset_gate_for_tests() {
+ EXITING.store(false, Ordering::SeqCst);
+ SERVING.store(false, Ordering::SeqCst);
+}
+
+/// Ask every bound memory driver to shut down, then run the hooks the
+/// in-process engine registered with the host.
+///
+/// Providers first, concurrently and on the shared deadline: a module drains
+/// its own banked hook inside `Shutdown`, and the host's registry is where the
+/// in-process engine registers instead. The snapshot of the binding cache is
+/// complete rather than merely recent: the exit gate goes up first, and the
+/// cache checks it under its write lock before every insert, so nothing can
+/// appear after the snapshot. Both halves are idempotent — a provider's second
+/// `shutdown` is a no-op and the registry drains — so a signal landing
+/// mid-teardown, or the app-update restart path calling this twice, repeats
+/// nothing. The drivers it shut down leave the cache afterwards, so the
+/// server the shell starts next in this same process binds anew instead of
+/// reusing a driver whose workers are stopped.
+pub async fn shutdown_for_exit() {
+ if SERVING.load(Ordering::SeqCst) {
+ EXITING.store(true, Ordering::SeqCst);
+ }
+ let deadline = Instant::now() + EXIT_BUDGET;
+
+ let bindings = crate::openhuman::memory::binding::cached_bindings();
+ if !bindings.is_empty() {
+ let shutdowns = join_all(bindings.iter().map(|binding| async move {
+ let driver = binding.driver_id().to_string();
+ match binding.provider().shutdown().await {
+ Ok(()) => log::debug!("[memory:exit] driver '{driver}' shut down"),
+ Err(error) => {
+ log::warn!("[memory:exit] driver '{driver}' shutdown failed: {error}");
+ }
+ }
+ }));
+ if tokio::time::timeout_at(deadline, shutdowns).await.is_err() {
+ log::warn!(
+ "[memory:exit] driver shutdown exceeded the {EXIT_BUDGET:?} exit budget; \
+ proceeding with exit"
+ );
+ }
+ // Out of the cache either way — answered or timed out, these drivers
+ // have been told to stop. The shell restarts the embedded server in
+ // place; a server starting again in this process must bind fresh
+ // drivers, never be handed back ones whose workers exit already
+ // stopped.
+ crate::openhuman::memory::binding::evict_bindings(&bindings);
+ }
+
+ let hooks_deadline = deadline.max(Instant::now() + HOOKS_FLOOR);
+ if tokio::time::timeout_at(hooks_deadline, crate::core::shutdown::run_hooks_now())
+ .await
+ .is_err()
+ {
+ log::warn!("[memory:exit] shutdown hooks exceeded the exit budget; proceeding with exit");
+ }
+}
diff --git a/src/openhuman/memory/exit_tests.rs b/src/openhuman/memory/exit_tests.rs
new file mode 100644
index 0000000000..74172637d9
--- /dev/null
+++ b/src/openhuman/memory/exit_tests.rs
@@ -0,0 +1,120 @@
+//! Tests for the memory exit path.
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use super::binding::{cached_bindings, for_subtree};
+use super::exit::{exiting, reset_gate_for_tests, server_starting, shutdown_for_exit, EXIT_BUDGET};
+use crate::openhuman::config::schema::MemorySubsystemConfig;
+
+/// The hook registry is process-global, so the two tests below must not see
+/// each other's hooks: each registers and drains under this lock.
+static REGISTRY: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+
+/// With no binding ever built, the exit path is the hook registry alone — and
+/// the registry drains, so the hooks run once however many times exit is asked
+/// for (the app-update restart path asks twice).
+#[tokio::test]
+async fn exit_runs_registered_hooks_once() {
+ let _serial = REGISTRY.lock().await;
+ let runs = Arc::new(AtomicUsize::new(0));
+ let counter = Arc::clone(&runs);
+ crate::core::shutdown::register(move || {
+ let counter = Arc::clone(&counter);
+ async move {
+ counter.fetch_add(1, Ordering::SeqCst);
+ }
+ });
+
+ shutdown_for_exit().await;
+ shutdown_for_exit().await;
+
+ assert_eq!(
+ runs.load(Ordering::SeqCst),
+ 1,
+ "the registry drains on the first exit; the second finds nothing"
+ );
+}
+
+/// The gate that refuses new bindings goes up only once a real server has
+/// started — a bare exit (every unit test in this process) must never leave
+/// memory refusing to bind — and a server starting again takes it down.
+#[tokio::test]
+async fn the_exit_gate_engages_only_behind_a_server() {
+ let _serial = REGISTRY.lock().await;
+ reset_gate_for_tests();
+
+ shutdown_for_exit().await;
+ assert!(
+ !exiting(),
+ "no server ever started, so exit must not gate bindings"
+ );
+
+ server_starting();
+ shutdown_for_exit().await;
+ assert!(exiting(), "behind a server, exit refuses new bindings");
+
+ server_starting();
+ assert!(!exiting(), "a server starting again takes the gate down");
+
+ reset_gate_for_tests();
+}
+
+/// A hook that never answers must not hold the quit: exit returns within its
+/// budget (plus the hooks' floor) and the process goes on without it.
+#[tokio::test]
+async fn exit_is_bounded_even_when_a_hook_hangs() {
+ let _serial = REGISTRY.lock().await;
+ crate::core::shutdown::register(|| async {
+ tokio::time::sleep(Duration::from_secs(30)).await;
+ });
+
+ let started = std::time::Instant::now();
+ shutdown_for_exit().await;
+
+ assert!(
+ started.elapsed() < EXIT_BUDGET + Duration::from_secs(2),
+ "exit took {:?}; the hanging hook held it past the budget",
+ started.elapsed()
+ );
+}
+
+/// The shell restarts the embedded server in place (a permission refresh, an
+/// app update). Exit shuts every cached driver down, so the server that starts
+/// next must not be handed those drivers back: exit evicts them, and the next
+/// bind builds anew.
+#[tokio::test]
+async fn exit_evicts_the_drivers_it_shut_down() {
+ let _serial = REGISTRY.lock().await;
+ reset_gate_for_tests();
+ let workspace = tempfile::tempdir().expect("tempdir");
+ let cfg = MemorySubsystemConfig {
+ driver: "null".into(),
+ ..Default::default()
+ };
+ let before = for_subtree(workspace.path(), "memory", &cfg).expect("binds the null driver");
+ assert!(
+ cached_bindings()
+ .iter()
+ .any(|cached| Arc::ptr_eq(cached, &before)),
+ "the binding is cached before exit"
+ );
+
+ server_starting();
+ shutdown_for_exit().await;
+ assert!(
+ !cached_bindings()
+ .iter()
+ .any(|cached| Arc::ptr_eq(cached, &before)),
+ "exit must drop the driver it shut down"
+ );
+
+ server_starting();
+ let after = for_subtree(workspace.path(), "memory", &cfg).expect("binds again after a restart");
+ assert!(
+ !Arc::ptr_eq(&before, &after),
+ "a restarted server must not be handed back a driver exit already stopped"
+ );
+ reset_gate_for_tests();
+}
diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs
index c1a68508a7..00d43ee661 100644
--- a/src/openhuman/memory/mod.rs
+++ b/src/openhuman/memory/mod.rs
@@ -36,6 +36,7 @@ pub mod agent;
pub mod api;
pub mod binding;
pub mod driver;
+pub mod exit;
pub mod guard;
pub mod host;
/// Host implementations of the seam traits the ENGINE declares.
@@ -100,6 +101,8 @@ mod bypass_allowlist_tests;
#[cfg(test)]
mod direct_engine_refs_tests;
#[cfg(test)]
+mod exit_tests;
+#[cfg(test)]
mod profile_conn_guard_tests;
#[cfg(test)]
mod seam_integration_tests;
diff --git a/src/openhuman/memory/sources/status.rs b/src/openhuman/memory/sources/status.rs
index 3563927c3a..37a3d508bf 100644
--- a/src/openhuman/memory/sources/status.rs
+++ b/src/openhuman/memory/sources/status.rs
@@ -126,7 +126,15 @@ pub(crate) fn source_id_prefix(source: &MemorySourceEntry) -> String {
match source.kind {
SourceKind::Composio => {
match (source.toolkit.as_deref(), source.connection_id.as_deref()) {
- (Some(toolkit), Some(connection_id)) => format!("{toolkit}:{connection_id}:"),
+ // Normalised exactly as the engine keys the rows
+ // (`ingest_connector_item_into_tree`: toolkit trimmed and
+ // lower-cased, connection trimmed), so a slug that ever arrives
+ // in another case still counts its own chunks instead of none.
+ (Some(toolkit), Some(connection_id)) => format!(
+ "{}:{}:",
+ toolkit.trim().to_ascii_lowercase(),
+ connection_id.trim()
+ ),
// A connection-less entry gets an unmatchable prefix, not the
// bare `{toolkit}:` — that widened prefix matched *every*
// connection of the toolkit, so a malformed or legacy Gmail
diff --git a/src/openhuman/modules/registry_part_02.rs b/src/openhuman/modules/registry_part_02.rs
index 31f347a748..66c64d0db1 100644
--- a/src/openhuman/modules/registry_part_02.rs
+++ b/src/openhuman/modules/registry_part_02.rs
@@ -91,63 +91,63 @@ const TINYCONNECTORS: ModuleRecord = ModuleRecord {
description: "OAuth connector integrations: accounts, actions, triggers, and record sync",
bus_name: "ai.tinyhumans.connectors.Composio",
object_path: "/ai/tinyhumans/connectors/Composio",
- version: "0.7.1",
- release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.7.1",
+ version: "0.8.0",
+ release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.8.0",
assets: &[
PlatformAsset {
host_key: "ubuntu-24.04-x86_64",
- archive: "tinyconnectors-0.7.1-ubuntu-24.04-x86_64.tar.gz",
- sha256: "31f0cfb402b0788b59d35a9f40d8a1ed514f9da0084259f61a58c03e4fe0d8ff",
+ archive: "tinyconnectors-0.8.0-ubuntu-24.04-x86_64.tar.gz",
+ sha256: "3cdd2c4b119b2da3ce0082bada68eeff89ee8554b2f1959065ca97d0eb6f1596",
},
PlatformAsset {
host_key: "ubuntu-24.04-arm64",
- archive: "tinyconnectors-0.7.1-ubuntu-24.04-arm64.tar.gz",
- sha256: "137e37be064e764585750f90dc310ea1c374aa454f46db73546a14355fc0cc97",
+ archive: "tinyconnectors-0.8.0-ubuntu-24.04-arm64.tar.gz",
+ sha256: "f2e63c9042ea75e11134b06a9ddff0a5f5d8edf24a6a300a723957cd5765c027",
},
PlatformAsset {
host_key: "ubuntu-22.04-x86_64",
- archive: "tinyconnectors-0.7.1-ubuntu-22.04-x86_64.tar.gz",
- sha256: "ca18131395fa146dc30cd662dee62b986c2a4b4bbdaa99ba17c9f62e628102cb",
+ archive: "tinyconnectors-0.8.0-ubuntu-22.04-x86_64.tar.gz",
+ sha256: "a29759a86d76b788ca58ff1a24f47de54a4fe82402e3c51161ed333a63932231",
},
PlatformAsset {
host_key: "ubuntu-22.04-arm64",
- archive: "tinyconnectors-0.7.1-ubuntu-22.04-arm64.tar.gz",
- sha256: "828878f7012897ac85b35a6caaabb5ad867aaca267afb7d15c2bd17d4a4fd907",
+ archive: "tinyconnectors-0.8.0-ubuntu-22.04-arm64.tar.gz",
+ sha256: "0cd3fd23cbf62a0ba9f4932c7d94f4cf3553e6d137b2ba3600c234486b0c18d5",
},
PlatformAsset {
host_key: "macos-26-arm64",
- archive: "tinyconnectors-0.7.1-macos-26-arm64.tar.gz",
- sha256: "64d057a49178863a2e4289b890117bd921dc915d6044e69786c90aca19878287",
+ archive: "tinyconnectors-0.8.0-macos-26-arm64.tar.gz",
+ sha256: "e9a97f70620b811ee63d458f0f72f06eb57cc3efa94bbd656088a2f961555f6a",
},
PlatformAsset {
host_key: "macos-26-x86_64",
- archive: "tinyconnectors-0.7.1-macos-26-x86_64.tar.gz",
- sha256: "4a908f1b598634bca38adaba93272ae1bd4b2b30b6472cf0f551bb36fd0637d9",
+ archive: "tinyconnectors-0.8.0-macos-26-x86_64.tar.gz",
+ sha256: "e27f2ac2f34f943dc3d3ae9fdfd3ae8b1742e94569bad6cdbfce54a52186c1d9",
},
PlatformAsset {
host_key: "macos-15-arm64",
- archive: "tinyconnectors-0.7.1-macos-15-arm64.tar.gz",
- sha256: "6c1b8ed910fd1d14bb570c4df6dbe6976605aa9c74b4551e867ace87e605520e",
+ archive: "tinyconnectors-0.8.0-macos-15-arm64.tar.gz",
+ sha256: "19c6fbc6dc5b9504424b35dd8600be7d80071bbeb5172b99588edce74d45ef5f",
},
PlatformAsset {
host_key: "macos-15-x86_64",
- archive: "tinyconnectors-0.7.1-macos-15-x86_64.tar.gz",
- sha256: "f0a13813fe460e6a8cc359b0cee6b8a854bb03c9fe0acc96c9d190a64035a5c2",
+ archive: "tinyconnectors-0.8.0-macos-15-x86_64.tar.gz",
+ sha256: "d6022e8d32834162ed269230eb50ea1e7263b6dd41d333b83f017eee17cc0bc3",
},
PlatformAsset {
host_key: "windows-2025-x86_64",
- archive: "tinyconnectors-0.7.1-windows-2025-x86_64.zip",
- sha256: "71356fb76f975736e2b7a74961eef4a2ad59842265575bfbfdb7e576836ce9df",
+ archive: "tinyconnectors-0.8.0-windows-2025-x86_64.zip",
+ sha256: "ace5366027828436ae2d3d78fe45dc4907fd4385fcc7b351e6f0cceb9dc58cdd",
},
PlatformAsset {
host_key: "windows-2022-x86_64",
- archive: "tinyconnectors-0.7.1-windows-2022-x86_64.zip",
- sha256: "5740c96c0a546f133e48ef3409550d17aee2fb4877bbd799ba1954867c29ba6a",
+ archive: "tinyconnectors-0.8.0-windows-2022-x86_64.zip",
+ sha256: "a2db1ca277c2287d26fcb0cb99ad2e2c5000cbe3992adf801dc33de7ec78d291",
},
PlatformAsset {
host_key: "windows-11-arm64",
- archive: "tinyconnectors-0.7.1-windows-11-arm64.zip",
- sha256: "f63002ffeaa7dd6c7b0fa096ab2e175bfba2d26d23eccef8b0bbd54e00171b4e",
+ archive: "tinyconnectors-0.8.0-windows-11-arm64.zip",
+ sha256: "f10a348ec43beea290ec42a836a49575cdcb970b7355712286464e90fa40f9c4",
},
],
// Lazy: a user with no connected accounts should not pay to load it, and
diff --git a/vendor/tinyconnectors b/vendor/tinyconnectors
index 402fc649ec..8a5045df3a 160000
--- a/vendor/tinyconnectors
+++ b/vendor/tinyconnectors
@@ -1 +1 @@
-Subproject commit 402fc649ecbaa78878b0dc88ea8960969d12e1bb
+Subproject commit 8a5045df3adb816fac7f0487eaacc3c84ca58fc2