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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@ import {
const mockPipelineStatus = vi.fn();
const mockSetEnabled = vi.fn();
const mockSyncStatusList = vi.fn();
const mockRetryFailed = vi.fn();
// #5324: the panel now navigates (budget CTA) and dispatches (escalating the
// blocking cause to the shell-mounted UserErrorCenter). Stub both so the
// suite keeps rendering the panel bare, without a Router or a Redux store.
const mockNavigate = vi.fn();
const mockDispatch = vi.fn();
// Analytics is a consent-gated side effect that reaches into the core-state
// snapshot; stub it so the panel renders bare and the retry-success path can be
// asserted without a real analytics pipeline.
const mockTrackAnalyticsEvent = vi.fn();

vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));
vi.mock('../analytics', () => ({
trackAnalyticsEvent: (...args: unknown[]) => mockTrackAnalyticsEvent(...args),
}));

vi.mock('../../utils/tauriCommands', async importOriginal => {
// Inherit everything else (types, sibling wrappers) verbatim so the panel
Expand All @@ -41,6 +49,7 @@ vi.mock('../../utils/tauriCommands', async importOriginal => {
memoryTreePipelineStatus: (...args: unknown[]) => mockPipelineStatus(...args),
memoryTreeSetEnabled: (...args: unknown[]) => mockSetEnabled(...args),
memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args),
memoryTreeRetryFailed: (...args: unknown[]) => mockRetryFailed(...args),
};
});

Expand Down Expand Up @@ -73,6 +82,8 @@ describe('<MemoryTreeStatusPanel />', () => {
mockPipelineStatus.mockReset();
mockSetEnabled.mockReset();
mockSyncStatusList.mockReset();
mockRetryFailed.mockReset();
mockTrackAnalyticsEvent.mockReset();
mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests
});

Expand Down Expand Up @@ -550,6 +561,125 @@ describe('<MemoryTreeStatusPanel />', () => {
expect(mockDispatch).toHaveBeenCalled();
});
});

// ── Retry-failed affordance ─────────────────────────────────────────────

it('offers a retry when jobs are parked in failed', async () => {
mockPipelineStatus.mockResolvedValue(
payload({
status: 'error',
reason: '29 unrecoverable failure(s) need action',
pipeline_jobs: { ready: 0, running: 0, failed: 29 },
})
);
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument();
});
});

/**
* The affordance keys off the failed-job counter, not off the blocking-cause
* banner. A failure the pipeline has already worked past no longer surfaces a
* remediation (the core withholds a superseded cause), but its rows still sit
* in `failed` and still need clearing — so the button must be reachable with
* no banner on screen. Without this the user is left in a permanent `error`
* state with no way out, which is the bug.
*/
it('offers the retry even when no blocking cause is surfaced', async () => {
mockPipelineStatus.mockResolvedValue(
payload({
status: 'error',
reason: '29 unrecoverable failure(s) need action',
pipeline_jobs: { ready: 0, running: 0, failed: 29 },
first_blocking_cause: null,
})
);
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument();
});
expect(screen.queryByTestId('memory-tree-blocking-cause')).not.toBeInTheDocument();
});

it('hides the retry when nothing has failed', async () => {
mockPipelineStatus.mockResolvedValue(payload({ status: 'running' }));
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/running/i);
});
expect(screen.queryByTestId('memory-tree-retry-failed')).not.toBeInTheDocument();
});

it('requeues the failed jobs, reports the count, and re-fetches', async () => {
mockPipelineStatus.mockResolvedValue(
payload({
status: 'error',
reason: '29 unrecoverable failure(s) need action',
pipeline_jobs: { ready: 0, running: 0, failed: 29 },
})
);
mockRetryFailed.mockResolvedValue({ requeued: 29 });
const onToast = vi.fn();
render(<MemoryTreeStatusPanel onToast={onToast} />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument();
});
const callsBefore = mockPipelineStatus.mock.calls.length;
await act(async () => {
fireEvent.click(screen.getByTestId('memory-tree-retry-failed'));
});

expect(mockRetryFailed).toHaveBeenCalledTimes(1);
// Successful domain outcome is tracked with the privacy-safe count only.
expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith('memory_tree_retry_succeeded', {
count: 29,
});
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success', message: expect.stringContaining('29') })
);
});
// Successful domain outcome is tracked with a privacy-safe count only.
expect(mockTrackAnalyticsEvent).toHaveBeenCalledWith('memory_tree_retry_succeeded', {
count: 29,
});
await waitFor(() => {
expect(mockPipelineStatus.mock.calls.length).toBeGreaterThan(callsBefore);
});
});

it('surfaces an error toast when the requeue fails', async () => {
mockPipelineStatus.mockResolvedValue(
payload({
status: 'error',
reason: '29 unrecoverable failure(s) need action',
pipeline_jobs: { ready: 0, running: 0, failed: 29 },
})
);
mockRetryFailed.mockRejectedValue(new Error('UNIQUE constraint failed'));
const onToast = vi.fn();
render(<MemoryTreeStatusPanel onToast={onToast} />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-retry-failed')).toBeInTheDocument();
});
await act(async () => {
fireEvent.click(screen.getByTestId('memory-tree-retry-failed'));
});

await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'UNIQUE constraint failed' })
);
});
// The button must stay usable so a transient failure is not a dead end.
expect(screen.getByTestId('memory-tree-retry-failed')).not.toBeDisabled();
});
});

describe('integration health helpers', () => {
Expand Down
66 changes: 66 additions & 0 deletions app/src/components/intelligence/MemoryTreeStatusPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import {
type MemorySyncStatusRow,
memoryTreePipelineStatus,
type MemoryTreePipelineStatus,
memoryTreeRetryFailed,
memoryTreeSetEnabled,
} from '../../utils/tauriCommands';
import { trackAnalyticsEvent } from '../analytics';
import Button from '../ui/Button';

/** Translator function shape exposed by `useT()`. */
Expand Down Expand Up @@ -338,6 +340,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
const dispatch = useAppDispatch();
const { status, integrations, loading, error, refresh } = useMemoryTreeStatus();
const [toggleBusy, setToggleBusy] = useState(false);
const [retryBusy, setRetryBusy] = useState(false);

// #002 (FR-004): the single first blocking cause. Prefer the explicit
// `first_blocking_cause`; fall back to the active degradation cause so older
Expand Down Expand Up @@ -376,6 +379,46 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
}
}, [status, toggleBusy, refresh, onToast, t]);

/**
* Requeue every terminally-failed job.
*
* An unrecoverable failure (auth, budget, dimension mismatch) is terminal by
* design — the worker never retries it — so a batch that failed under a
* since-fixed config stays parked forever and pins this panel on `error`.
* The `memory_tree_retry_failed` RPC existed for exactly this, but had no
* caller anywhere in the app, leaving the user with a permanent error state
* and no way to clear it.
*/
const handleRetryFailed = useCallback(async () => {
if (retryBusy) {
console.debug('[ui-flow][memory-tree-status] retryFailed: skipped busy=true');
return;
}
console.debug('[ui-flow][memory-tree-status] retryFailed: entry');
setRetryBusy(true);
console.debug('[ui-flow][memory-tree-status] retryFailed: busy=true rpc:start');
try {
const { requeued } = await memoryTreeRetryFailed();
console.debug('[ui-flow][memory-tree-status] retryFailed: rpc:ok requeued=%d', requeued);
// Record the successful domain outcome (not just the click). Privacy-safe:
// a non-identifying count only, no ids or user text.
trackAnalyticsEvent('memory_tree_retry_succeeded', { count: requeued });
onToast?.({
type: 'success',
title: t('memoryTree.status.retryFailedDone'),
message: t('memoryTree.status.retryFailedCount').replace('{count}', String(requeued)),
});
await refresh();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[ui-flow][memory-tree-status] retryFailed: error %s', message);
onToast?.({ type: 'error', title: t('memoryTree.status.retryFailedError'), message });
} finally {
console.debug('[ui-flow][memory-tree-status] retryFailed: busy=false exit');
setRetryBusy(false);
}
}, [retryBusy, refresh, onToast, t]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const statusKind = status?.status ?? 'idle';
// #5324: "Error — 936 unrecoverable failures need action" told the user
// nothing they could act on. When the blocking cause is a spent embedding
Expand Down Expand Up @@ -413,6 +456,12 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
// with a localized remediation.
const degraded = status?.degraded;

// Parked failures are the one panel state the user can act on directly, and
// the affordance is keyed off the counter rather than off the blocking-cause
// banner: a failure the pipeline has already worked past no longer surfaces a
// remediation, but its rows still sit in `failed` and still need clearing.
const failedJobs = status?.pipeline_jobs.failed ?? 0;

const checked = !(status?.is_paused ?? false);

const tileClass =
Expand Down Expand Up @@ -513,6 +562,23 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
{status.reason ? (
<div className="mt-0.5 text-[11px] text-content-muted">{status.reason}</div>
) : null}
{failedJobs > 0 ? (
<div className="mt-2">
<Button
variant="secondary"
size="xs"
disabled={retryBusy}
data-testid="memory-tree-retry-failed"
analyticsId="memory-tree-retry-failed-jobs"
onClick={() => {
void handleRetryFailed();
}}>
{retryBusy
? t('memoryTree.status.retryFailedBusy')
: t('memoryTree.status.retryFailed')}
</Button>
</div>
) : null}
</>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'أبداً',
'memoryTree.status.fetchError': 'لم أستطع الحصول على وضعية شجرة الذاكرة',
'memoryTree.status.retry': 'Retry',
'memoryTree.status.retryFailed': 'إعادة تشغيل المهام الفاشلة',
'memoryTree.status.retryFailedBusy': 'جارٍ إعادة المحاولة...',
'memoryTree.status.retryFailedDone': 'تمت إعادة إدراج المهام الفاشلة',
'memoryTree.status.retryFailedCount': 'تمت جدولة {count} مهمة للتشغيل من جديد.',
'memoryTree.status.retryFailedError': 'تعذّرت إعادة إدراج المهام الفاشلة',
'memoryTree.status.toggleFailed': 'لا يمكن أن نهز السيرة الذاتية',
'memoryTree.status.justNow': 'الآن',
'memoryTree.status.secondsAgo': 'اكساكسوكس قبل',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'কখনো নয়',
'memoryTree.status.fetchError': 'মেমরি প্রাপ্ত করতে ব্যর্থ',
'memoryTree.status.retry': 'পুনরায় চেষ্টা করুন',
'memoryTree.status.retryFailed': 'ব্যর্থ কাজগুলো আবার চালান',
'memoryTree.status.retryFailedBusy': 'আবার চেষ্টা করা হচ্ছে...',
'memoryTree.status.retryFailedDone': 'ব্যর্থ কাজগুলো আবার সারিতে দেওয়া হয়েছে',
'memoryTree.status.retryFailedCount': '{count}টি কাজ আবার চালানোর জন্য সারিতে রাখা হয়েছে।',
'memoryTree.status.retryFailedError': 'ব্যর্থ কাজগুলো আবার সারিতে দেওয়া যায়নি',
'memoryTree.status.toggleFailed': 'স্বয়ংক্রিয়ভাবে সনাক্ত করা সম্ভব হয়নি',
'memoryTree.status.justNow': 'এখন',
'memoryTree.status.secondsAgo': 'xqxqx পূর্বে',
Expand Down
6 changes: 6 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,12 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'Nie',
'memoryTree.status.fetchError': 'Speicherbaum-Status konnte nicht abgerufen werden',
'memoryTree.status.retry': 'Wiederholen',
'memoryTree.status.retryFailed': 'Fehlgeschlagene Jobs erneut ausführen',
'memoryTree.status.retryFailedBusy': 'Wird wiederholt...',
'memoryTree.status.retryFailedDone': 'Fehlgeschlagene Jobs neu eingereiht',
'memoryTree.status.retryFailedCount': 'Erneut eingereihte Jobs: {count}.',
'memoryTree.status.retryFailedError':
'Die fehlgeschlagenen Jobs konnten nicht neu eingereiht werden',
'memoryTree.status.toggleFailed':
'Automatische Synchronisierung konnte nicht umgeschaltet werden',
'memoryTree.status.justNow': 'gerade eben',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,11 @@ const en: TranslationMap = {
'Memory processing encountered an issue. Check Connections → API keys for configuration.',
'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status",
'memoryTree.status.retry': 'Retry',
'memoryTree.status.retryFailed': 'Retry failed jobs',
'memoryTree.status.retryFailedBusy': 'Retrying...',
'memoryTree.status.retryFailedDone': 'Failed jobs requeued',
'memoryTree.status.retryFailedCount': 'Jobs queued to run again: {count}.',
'memoryTree.status.retryFailedError': 'Could not requeue the failed jobs',
'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync",
// Relative-time buckets surfaced by the last-sync tile. `{count}` is
// replaced client-side at the call site (the runtime `t()` does not
Expand Down
6 changes: 6 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,12 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'Nunca',
'memoryTree.status.fetchError': 'No se pudo obtener el estado del Árbol de Memoria',
'memoryTree.status.retry': 'Rever',
'memoryTree.status.retryFailed': 'Reintentar los trabajos fallidos',
'memoryTree.status.retryFailedBusy': 'Reintentando...',
'memoryTree.status.retryFailedDone': 'Trabajos fallidos añadidos de nuevo a la cola',
'memoryTree.status.retryFailedCount': 'Trabajos en cola para ejecutarse de nuevo: {count}.',
'memoryTree.status.retryFailedError':
'No se pudieron volver a poner en cola los trabajos fallidos',
'memoryTree.status.toggleFailed': 'No se pudo activar la sincronización automática',
'memoryTree.status.justNow': 'justo ahora',
'memoryTree.status.secondsAgo': '{count}s hace',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'Jamais',
'memoryTree.status.fetchError': "Impossible de récupérer l'état de l'arborescence de mémoire",
'memoryTree.status.retry': 'Réessayer',
'memoryTree.status.retryFailed': 'Relancer les tâches en échec',
'memoryTree.status.retryFailedBusy': 'Nouvelle tentative...',
'memoryTree.status.retryFailedDone': 'Tâches en échec remises en file',
'memoryTree.status.retryFailedCount': "Tâches remises en file d'attente : {count}.",
'memoryTree.status.retryFailedError': 'Impossible de remettre en file les tâches en échec',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'memoryTree.status.toggleFailed':
"Impossible d'activer/désactiver la synchronisation automatique",
'memoryTree.status.justNow': "à l'instant",
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'कभी नहीं',
'memoryTree.status.fetchError': 'स्मृति वृक्ष की स्थिति नहीं मिला',
'memoryTree.status.retry': 'रेस्त्री',
'memoryTree.status.retryFailed': 'विफल कार्य दोबारा चलाएँ',
'memoryTree.status.retryFailedBusy': 'दोबारा चलाया जा रहा है...',
'memoryTree.status.retryFailedDone': 'विफल कार्य फिर से कतार में डाले गए',
'memoryTree.status.retryFailedCount': '{count} कार्य दोबारा चलने के लिए कतार में हैं।',
'memoryTree.status.retryFailedError': 'विफल कार्यों को फिर से कतार में नहीं डाला जा सका',
'memoryTree.status.toggleFailed': 'ऑटो सिंक को टॉगल नहीं कर सका',
'memoryTree.status.justNow': 'अभी',
'memoryTree.status.secondsAgo': '{count} पहले',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'Tidak pernah',
'memoryTree.status.fetchError': 'Gagal mengambil status Pohon Memori',
'memoryTree.status.retry': 'Coba lagi',
'memoryTree.status.retryFailed': 'Jalankan ulang tugas yang gagal',
'memoryTree.status.retryFailedBusy': 'Mencoba lagi...',
'memoryTree.status.retryFailedDone': 'Tugas yang gagal masuk antrean lagi',
'memoryTree.status.retryFailedCount': '{count} tugas diantrekan untuk dijalankan ulang.',
'memoryTree.status.retryFailedError': 'Tidak dapat mengantrekan ulang tugas yang gagal',
'memoryTree.status.toggleFailed': 'Gagal mengalihkan sinkronisasi otomatis',
'memoryTree.status.justNow': 'baru saja',
'memoryTree.status.secondsAgo': '{count} dtk lalu',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,11 @@ const messages: TranslationMap = {
'memoryTree.status.never': 'Mai',
'memoryTree.status.fetchError': "Impossibile recuperare lo stato dell'Albero della Memoria",
'memoryTree.status.retry': 'Riprova',
'memoryTree.status.retryFailed': 'Riprova i lavori non riusciti',
'memoryTree.status.retryFailedBusy': 'Nuovo tentativo...',
'memoryTree.status.retryFailedDone': 'Lavori non riusciti rimessi in coda',
'memoryTree.status.retryFailedCount': 'Lavori in coda per una nuova esecuzione: {count}.',
'memoryTree.status.retryFailedError': 'Impossibile rimettere in coda i lavori non riusciti',
'memoryTree.status.toggleFailed':
'Impossibile attivare/disattivare la sincronizzazione automatica',
'memoryTree.status.justNow': 'proprio adesso',
Expand Down
Loading
Loading