From b3d39b937048819b192c16102e14b8cbcaa2d20e Mon Sep 17 00:00:00 2001 From: Moltar Date: Wed, 15 Apr 2026 09:13:34 -0400 Subject: [PATCH 1/3] feat(monitor): live-refresh session details while panel is open --- web/src/components/SessionDetailPanel.jsx | 267 ++++++++++++++-------- 1 file changed, 176 insertions(+), 91 deletions(-) diff --git a/web/src/components/SessionDetailPanel.jsx b/web/src/components/SessionDetailPanel.jsx index 3a6f76b..3f3ccf7 100644 --- a/web/src/components/SessionDetailPanel.jsx +++ b/web/src/components/SessionDetailPanel.jsx @@ -1,4 +1,4 @@ -import { Fragment, useState, useEffect, useRef } from 'react'; +import { Fragment, useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, Transition } from '@headlessui/react'; import { XMarkIcon, @@ -16,6 +16,8 @@ import logger from '../utils/logger'; // Gap threshold for detecting a new isolated run boundary const RUN_GAP_MS = 5 * 60 * 1000; +const SESSION_REFRESH_INTERVAL_MS = 2000; +const CRON_REFRESH_INTERVAL_MS = 5000; /** Short hash display with copy-on-click for UUIDs/IDs. */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -108,6 +110,19 @@ function groupMessagesIntoRuns(messages) { return runs; } +function getMessageFingerprint(list) { + if (!Array.isArray(list) || list.length === 0) return '0'; + const last = list[list.length - 1]; + const contentLength = + typeof last?.content === 'string' + ? last.content.length + : Array.isArray(last?.content) + ? last.content.length + : 0; + const blocksLength = Array.isArray(last?.blocks) ? last.blocks.length : 0; + return `${list.length}|${last?.timestamp || ''}|${last?.role || ''}|${contentLength}|${blocksLength}`; +} + /** Helper to format tool call summary for display. */ function toolCallSummary(tc) { const args = tc.arguments || {}; @@ -306,6 +321,8 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun const [error, setError] = useState(null); const [latestRun, setLatestRun] = useState(null); const scrollContainerRef = useRef(null); + const refreshTimerRef = useRef(null); + const refreshInFlightRef = useRef(false); const getAgentById = useAgentStore((state) => state.getAgentById); const agent = session?.agent ? getAgentById(session.agent) : null; @@ -318,110 +335,178 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun // latestRunOnly: only show the last run's messages (used by Task Manager) const runs = latestRunOnly && allRuns ? allRuns.slice(-1) : allRuns; - // For cron sessions, fetch the latest run history first, then load its messages - useEffect(() => { - if (isOpen && session?.kind === 'cron') { - loadCronRunHistory(); - } else if (isOpen && session?.key) { - loadMessages(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- loadMessages/loadCronRunHistory depend on session, which is in deps - }, [isOpen, session?.key, session?.kind]); - - // Scroll to bottom when messages finish loading - useEffect(() => { - if (!isLoading && !error && messages.length > 0 && scrollContainerRef.current) { - const el = scrollContainerRef.current; - requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight; - }); - } - }, [isLoading, error, messages.length]); - // Load cron run history and fetch messages from the latest run - const loadCronRunHistory = async () => { - setIsLoading(true); - setError(null); - setSessionNotLoaded(false); - setLatestRun(null); - setMessages([]); + const loadCronRunHistory = useCallback( + async ({ silent = false } = {}) => { + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; - try { - const jobId = session?.jobId || getCronJobIdFromKey(session?.key); - if (!jobId) { - setError('Could not determine cron job ID from session.'); - setIsLoading(false); - return; + if (!silent) { + setIsLoading(true); + setError(null); + setSessionNotLoaded(false); + setLatestRun(null); + setMessages([]); } - logger.info('Fetching cron run history', { jobId }); - const runsData = await getCronJobRuns(jobId, { limit: 25 }); - const runs = runsData.runs || []; + try { + const jobId = session?.jobId || getCronJobIdFromKey(session?.key); + if (!jobId) { + setError('Could not determine cron job ID from session.'); + return; + } - if (runs.length === 0) { - setError('No run history found for this cron job.'); - setIsLoading(false); - return; - } + logger.info('Fetching cron run history', { jobId, silent }); + const runsData = await getCronJobRuns(jobId, { limit: 25 }); + const cronRuns = runsData.runs || []; - // Get the latest run (last in array, as runs are in chronological order) - const latest = runs[runs.length - 1]; - setLatestRun(latest); + if (cronRuns.length === 0) { + setLatestRun(null); + setMessages([]); + setError('No run history found for this cron job.'); + return; + } - // If the latest run has a sessionKey, fetch its messages - if (latest.sessionKey) { - logger.info('Fetching latest run messages', { sessionKey: latest.sessionKey }); - const messagesData = await getSessionMessages(latest.sessionKey, { - limit: 200, - includeTools: true, - }); - setMessages(messagesData.messages || []); - logger.info('Latest run messages loaded', { - messageCount: messagesData.messages?.length || 0, + // Get the latest run (last in array, as runs are in chronological order) + const latest = cronRuns[cronRuns.length - 1]; + setLatestRun((prev) => { + if ( + prev?.runId === latest?.runId && + prev?.updatedAt === latest?.updatedAt && + prev?.status === latest?.status && + prev?.summary === latest?.summary && + prev?.error === latest?.error + ) { + return prev; + } + return latest; }); - } else { - setMessages([]); - logger.warn('Latest run has no sessionKey', { run: latest }); + + // If the latest run has a sessionKey, fetch its messages + if (latest.sessionKey) { + logger.info('Fetching latest run messages', { sessionKey: latest.sessionKey, silent }); + const messagesData = await getSessionMessages(latest.sessionKey, { + limit: 200, + includeTools: true, + }); + + const nextMessages = messagesData.messages || []; + setMessages((prev) => + getMessageFingerprint(prev) === getMessageFingerprint(nextMessages) ? prev : nextMessages, + ); + + setError(null); + logger.info('Latest run messages loaded', { + messageCount: nextMessages.length, + silent, + }); + } else { + setMessages([]); + setError(null); + logger.warn('Latest run has no sessionKey', { run: latest }); + } + } catch (err) { + logger.error('Failed to load cron run history', err); + setError('Failed to load cron run history. Please try again.'); + } finally { + refreshInFlightRef.current = false; + if (!silent) { + setIsLoading(false); + } } - } catch (err) { - logger.error('Failed to load cron run history', err); - setError('Failed to load cron run history. Please try again.'); - } finally { - setIsLoading(false); - } - }; + }, + [session?.jobId, session?.key], + ); - const loadMessages = async () => { - setIsLoading(true); - setError(null); - setSessionNotLoaded(false); + const loadMessages = useCallback( + async ({ silent = false } = {}) => { + if (!session?.key || refreshInFlightRef.current) return; + refreshInFlightRef.current = true; - try { - logger.info('Fetching session messages', { sessionKey: session.key }); - const data = await getSessionMessages(session.key, { limit: 100, includeTools: true }); - setMessages(data.messages || []); - setSessionMetadata(data.session || null); - setSessionNotLoaded(data.sessionNotLoaded === true); - logger.info('Session messages loaded', { messageCount: data.messages?.length || 0 }); - } catch (err) { - logger.error('Failed to load session messages', err); - - // Check for agent-to-agent access error - if ( - err.response?.status === 403 && - err.response?.data?.error?.code === 'AGENT_TO_AGENT_DISABLED' - ) { - setError( - 'Agent session history is not accessible. Agent-to-agent access is disabled in OpenClaw Gateway. ' + - 'Contact your administrator to enable this feature.', + if (!silent) { + setIsLoading(true); + setError(null); + setSessionNotLoaded(false); + } + + try { + logger.info('Fetching session messages', { sessionKey: session.key, silent }); + const data = await getSessionMessages(session.key, { limit: 100, includeTools: true }); + const nextMessages = data.messages || []; + + setMessages((prev) => + getMessageFingerprint(prev) === getMessageFingerprint(nextMessages) ? prev : nextMessages, ); - } else { - setError('Failed to load session messages. Please try again.'); + setSessionMetadata(data.session || null); + setSessionNotLoaded(data.sessionNotLoaded === true); + setError(null); + + logger.info('Session messages loaded', { messageCount: nextMessages.length, silent }); + } catch (err) { + logger.error('Failed to load session messages', err); + + // Check for agent-to-agent access error + if ( + err.response?.status === 403 && + err.response?.data?.error?.code === 'AGENT_TO_AGENT_DISABLED' + ) { + setError( + 'Agent session history is not accessible. Agent-to-agent access is disabled in OpenClaw Gateway. ' + + 'Contact your administrator to enable this feature.', + ); + } else { + setError('Failed to load session messages. Please try again.'); + } + } finally { + refreshInFlightRef.current = false; + if (!silent) { + setIsLoading(false); + } } - } finally { - setIsLoading(false); + }, + [session?.key], + ); + + // Initial load + live refresh while the drawer is open. + useEffect(() => { + if (!isOpen) return undefined; + + const load = async (silent = false) => { + if (session?.kind === 'cron') { + await loadCronRunHistory({ silent }); + } else if (session?.key) { + await loadMessages({ silent }); + } + }; + + load(false); + + const hasLiveStreamTarget = session?.kind === 'cron' || Boolean(session?.key); + if (hasLiveStreamTarget) { + const intervalMs = session?.kind === 'cron' ? CRON_REFRESH_INTERVAL_MS : SESSION_REFRESH_INTERVAL_MS; + refreshTimerRef.current = setInterval(() => { + load(true); + }, intervalMs); } - }; + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + refreshInFlightRef.current = false; + }; + }, [isOpen, session?.key, session?.kind, loadMessages, loadCronRunHistory]); + + // Scroll to bottom when messages finish loading + useEffect(() => { + if (!isLoading && !error && messages.length > 0 && scrollContainerRef.current) { + const el = scrollContainerRef.current; + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }, [isLoading, error, messages.length]); const getStatusColor = (status) => { switch (status) { From a31c76c10bb7dfbd33c3428c617ef70b0cbe2e5d Mon Sep 17 00:00:00 2001 From: Moltar Date: Wed, 15 Apr 2026 10:03:30 -0400 Subject: [PATCH 2/3] fix(monitor): harden live polling against stale session updates --- web/src/components/SessionDetailPanel.jsx | 82 ++++-- .../components/SessionDetailPanel.test.jsx | 248 ++++++++++++++++++ 2 files changed, 315 insertions(+), 15 deletions(-) create mode 100644 web/src/components/SessionDetailPanel.test.jsx diff --git a/web/src/components/SessionDetailPanel.jsx b/web/src/components/SessionDetailPanel.jsx index 3f3ccf7..54e4de1 100644 --- a/web/src/components/SessionDetailPanel.jsx +++ b/web/src/components/SessionDetailPanel.jsx @@ -322,7 +322,9 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun const [latestRun, setLatestRun] = useState(null); const scrollContainerRef = useRef(null); const refreshTimerRef = useRef(null); - const refreshInFlightRef = useRef(false); + const refreshInFlightByIdentityRef = useRef(new Map()); + const activeSessionIdentityRef = useRef(''); + const latestRequestIdRef = useRef(0); const getAgentById = useAgentStore((state) => state.getAgentById); const agent = session?.agent ? getAgentById(session.agent) : null; @@ -335,11 +337,28 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun // latestRunOnly: only show the last run's messages (used by Task Manager) const runs = latestRunOnly && allRuns ? allRuns.slice(-1) : allRuns; + const sessionIdentity = + session?.kind === 'cron' + ? `cron:${session?.jobId || getCronJobIdFromKey(session?.key) || ''}` + : `session:${session?.key || ''}`; + + const isRequestStale = useCallback((requestId, identity) => { + return requestId !== latestRequestIdRef.current || identity !== activeSessionIdentityRef.current; + }, []); + // Load cron run history and fetch messages from the latest run const loadCronRunHistory = useCallback( async ({ silent = false } = {}) => { - if (refreshInFlightRef.current) return; - refreshInFlightRef.current = true; + const identity = + session?.kind === 'cron' + ? `cron:${session?.jobId || getCronJobIdFromKey(session?.key) || ''}` + : `session:${session?.key || ''}`; + + if (!identity || identity === 'cron:' || identity === 'session:') return; + if (refreshInFlightByIdentityRef.current.get(identity)) return; + + const requestId = ++latestRequestIdRef.current; + refreshInFlightByIdentityRef.current.set(identity, true); if (!silent) { setIsLoading(true); @@ -352,7 +371,9 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun try { const jobId = session?.jobId || getCronJobIdFromKey(session?.key); if (!jobId) { - setError('Could not determine cron job ID from session.'); + if (!isRequestStale(requestId, identity)) { + setError('Could not determine cron job ID from session.'); + } return; } @@ -360,6 +381,8 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun const runsData = await getCronJobRuns(jobId, { limit: 25 }); const cronRuns = runsData.runs || []; + if (isRequestStale(requestId, identity)) return; + if (cronRuns.length === 0) { setLatestRun(null); setMessages([]); @@ -390,6 +413,8 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun includeTools: true, }); + if (isRequestStale(requestId, identity)) return; + const nextMessages = messagesData.messages || []; setMessages((prev) => getMessageFingerprint(prev) === getMessageFingerprint(nextMessages) ? prev : nextMessages, @@ -406,22 +431,27 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun logger.warn('Latest run has no sessionKey', { run: latest }); } } catch (err) { + if (isRequestStale(requestId, identity)) return; logger.error('Failed to load cron run history', err); setError('Failed to load cron run history. Please try again.'); } finally { - refreshInFlightRef.current = false; - if (!silent) { + refreshInFlightByIdentityRef.current.delete(identity); + if (!silent && !isRequestStale(requestId, identity)) { setIsLoading(false); } } }, - [session?.jobId, session?.key], + [isRequestStale, session?.jobId, session?.key, session?.kind], ); const loadMessages = useCallback( async ({ silent = false } = {}) => { - if (!session?.key || refreshInFlightRef.current) return; - refreshInFlightRef.current = true; + const identity = `session:${session?.key || ''}`; + if (!session?.key || identity === 'session:') return; + if (refreshInFlightByIdentityRef.current.get(identity)) return; + + const requestId = ++latestRequestIdRef.current; + refreshInFlightByIdentityRef.current.set(identity, true); if (!silent) { setIsLoading(true); @@ -432,6 +462,7 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun try { logger.info('Fetching session messages', { sessionKey: session.key, silent }); const data = await getSessionMessages(session.key, { limit: 100, includeTools: true }); + if (isRequestStale(requestId, identity)) return; const nextMessages = data.messages || []; setMessages((prev) => @@ -443,6 +474,7 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun logger.info('Session messages loaded', { messageCount: nextMessages.length, silent }); } catch (err) { + if (isRequestStale(requestId, identity)) return; logger.error('Failed to load session messages', err); // Check for agent-to-agent access error @@ -458,18 +490,20 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun setError('Failed to load session messages. Please try again.'); } } finally { - refreshInFlightRef.current = false; - if (!silent) { + refreshInFlightByIdentityRef.current.delete(identity); + if (!silent && !isRequestStale(requestId, identity)) { setIsLoading(false); } } }, - [session?.key], + [isRequestStale, session?.key], ); // Initial load + live refresh while the drawer is open. useEffect(() => { if (!isOpen) return undefined; + activeSessionIdentityRef.current = sessionIdentity; + const inFlightMap = refreshInFlightByIdentityRef.current; const load = async (silent = false) => { if (session?.kind === 'cron') { @@ -483,10 +517,28 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun const hasLiveStreamTarget = session?.kind === 'cron' || Boolean(session?.key); if (hasLiveStreamTarget) { - const intervalMs = session?.kind === 'cron' ? CRON_REFRESH_INTERVAL_MS : SESSION_REFRESH_INTERVAL_MS; + const intervalMs = + session?.kind === 'cron' ? CRON_REFRESH_INTERVAL_MS : SESSION_REFRESH_INTERVAL_MS; refreshTimerRef.current = setInterval(() => { + if (document.visibilityState !== 'visible') return; load(true); }, intervalMs); + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + load(true); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + document.removeEventListener('visibilitychange', onVisibilityChange); + inFlightMap.clear(); + }; } return () => { @@ -494,9 +546,9 @@ export default function SessionDetailPanel({ isOpen, onClose, session, latestRun clearInterval(refreshTimerRef.current); refreshTimerRef.current = null; } - refreshInFlightRef.current = false; + inFlightMap.clear(); }; - }, [isOpen, session?.key, session?.kind, loadMessages, loadCronRunHistory]); + }, [isOpen, sessionIdentity, session?.key, session?.kind, loadMessages, loadCronRunHistory]); // Scroll to bottom when messages finish loading useEffect(() => { diff --git a/web/src/components/SessionDetailPanel.test.jsx b/web/src/components/SessionDetailPanel.test.jsx new file mode 100644 index 0000000..b49fa3a --- /dev/null +++ b/web/src/components/SessionDetailPanel.test.jsx @@ -0,0 +1,248 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import SessionDetailPanel from './SessionDetailPanel'; +import { getSessionMessages, getCronJobRuns } from '../api/client'; +import { useAgentStore } from '../stores/agentStore'; + +vi.mock('../api/client', () => ({ + getSessionMessages: vi.fn(), + getCronJobRuns: vi.fn(), +})); + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: vi.fn(), +})); + +vi.mock('./MarkdownRenderer', () => ({ + default: ({ content }) =>
{content}
, +})); + +vi.mock('@headlessui/react', () => { + function Dialog({ children }) { + return
{children}
; + } + + function DialogPanel({ children }) { + return
{children}
; + } + + function DialogTitle({ children }) { + return
{children}
; + } + + Dialog.Panel = DialogPanel; + Dialog.Title = DialogTitle; + + return { + Dialog, + Transition: { + Root: ({ show, children }) => (show ? <>{children} : null), + Child: ({ children }) => <>{children}, + }, + }; +}); + +function setVisibility(state) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: state, + }); +} + +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe('SessionDetailPanel live refresh', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + setVisibility('visible'); + + useAgentStore.mockImplementation((selector) => + selector({ + getAgentById: () => null, + }), + ); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('loads immediately and refreshes regular sessions every 2s, then stops when closed', async () => { + getSessionMessages.mockResolvedValue({ + messages: [{ role: 'assistant', content: 'hello', timestamp: '2026-01-01T00:00:00.000Z' }], + session: null, + sessionNotLoaded: false, + }); + + const { rerender } = render( + {}} + session={{ key: 'agent:main:main', kind: 'main', label: 'Main Session' }} + />, + ); + + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1999); + }); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(2); + + rerender( + {}} + session={{ key: 'agent:main:main', kind: 'main', label: 'Main Session' }} + />, + ); + + await act(async () => { + vi.advanceTimersByTime(4000); + }); + expect(getSessionMessages).toHaveBeenCalledTimes(2); + }); + + it('refreshes cron sessions every 5s while open', async () => { + getCronJobRuns.mockResolvedValue({ + runs: [ + { + runId: 'run-1', + sessionKey: 'agent:coo:cron:job-1:run:run-1', + status: 'ok', + }, + ], + }); + + getSessionMessages.mockResolvedValue({ + messages: [{ role: 'assistant', content: 'cron message', timestamp: '2026-01-01T00:00:00.000Z' }], + session: null, + sessionNotLoaded: false, + }); + + render( + {}} + session={{ key: 'agent:coo:cron:job-1', kind: 'cron', label: 'Cron Job', jobId: 'job-1' }} + />, + ); + + await flush(); + expect(getCronJobRuns).toHaveBeenCalledTimes(1); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(4999); + }); + expect(getCronJobRuns).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + await flush(); + expect(getCronJobRuns).toHaveBeenCalledTimes(2); + expect(getSessionMessages).toHaveBeenCalledTimes(2); + }); + + it('pauses polling when tab is hidden and refreshes once when visible again', async () => { + getSessionMessages.mockResolvedValue({ + messages: [{ role: 'assistant', content: 'hello', timestamp: '2026-01-01T00:00:00.000Z' }], + session: null, + sessionNotLoaded: false, + }); + + render( + {}} + session={{ key: 'agent:main:main', kind: 'main', label: 'Main Session' }} + />, + ); + + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + setVisibility('hidden'); + await act(async () => { + vi.advanceTimersByTime(6000); + }); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + setVisibility('visible'); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + }); + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(2); + }); + + it('does not let stale response overwrite messages after switching sessions', async () => { + const pendingA = deferred(); + + getSessionMessages + .mockImplementationOnce(() => pendingA.promise) + .mockResolvedValueOnce({ + messages: [ + { role: 'assistant', content: 'B fresh', timestamp: '2026-01-01T00:00:02.000Z' }, + ], + session: null, + sessionNotLoaded: false, + }); + + const { rerender } = render( + {}} + session={{ key: 'agent:a:main', kind: 'main', label: 'Session A' }} + />, + ); + + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(1); + + rerender( + {}} + session={{ key: 'agent:b:main', kind: 'main', label: 'Session B' }} + />, + ); + + await flush(); + expect(getSessionMessages).toHaveBeenCalledTimes(2); + + await act(async () => { + pendingA.resolve({ + messages: [{ role: 'assistant', content: 'A stale', timestamp: '2026-01-01T00:00:01.000Z' }], + session: null, + sessionNotLoaded: false, + }); + }); + + await flush(); + expect(screen.getByText('B fresh')).toBeInTheDocument(); + expect(screen.queryByText('A stale')).not.toBeInTheDocument(); + }); +}); From 557263531d57f79d3099e2f6f5df91bd38450d5b Mon Sep 17 00:00:00 2001 From: Moltar Date: Wed, 15 Apr 2026 10:11:54 -0400 Subject: [PATCH 3/3] ci(coverage): don't fail finalize step on Coveralls outages --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66675dc..29d451d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,3 +186,4 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true + fail-on-error: false