Skip to content

Commit ba78829

Browse files
committed
Improve Copilot error handling and logging
1 parent 4ad9f17 commit ba78829

16 files changed

Lines changed: 289 additions & 19 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ const RECONNECT_TAIL_ERROR =
228228
const MAX_RECONNECT_ATTEMPTS = 10
229229
const RECONNECT_BASE_DELAY_MS = 1000
230230
const RECONNECT_MAX_DELAY_MS = 30_000
231+
const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000
231232
const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000
232233
const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000
233234
const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000
@@ -1470,6 +1471,10 @@ export function useChat(
14701471
() => {}
14711472
)
14721473
const recoveringQueuedSendHandoffRef = useRef<ActiveQueuedSendHandoffRecovery | null>(null)
1474+
const recoverActiveStreamRef = useRef<
1475+
(reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck') => Promise<void>
1476+
>(async () => {})
1477+
const reconnectExhaustedRecheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
14731478

14741479
const abortControllerRef = useRef<AbortController | null>(null)
14751480
const detachedChatResolutionControllersRef = useRef<Set<AbortController>>(new Set())
@@ -3242,7 +3247,29 @@ export function useChat(
32423247
maxAttempts: MAX_RECONNECT_ATTEMPTS,
32433248
})
32443249
if (streamGenRef.current === gen) {
3250+
/**
3251+
* Never give up silently: surface the failure so the pane shows why
3252+
* the live stream stopped instead of a torn-down transcript. Callers
3253+
* own the finalize on a false return (every call site finalizes with
3254+
* error: true), which refetches the persisted transcript; if the
3255+
* server turn is still running, the visibility/online recovery path
3256+
* re-attaches on the next pageshow/visible/online event.
3257+
*/
32453258
setIsReconnecting(false)
3259+
setError(RECONNECT_TAIL_ERROR)
3260+
/**
3261+
* The tab may stay visible (no pageshow/visible/online event will ever
3262+
* fire) while the server turn keeps running detached. One bounded
3263+
* recheck re-enters recovery once the transient network condition has
3264+
* had time to clear; recovery itself no-ops when nothing is active.
3265+
*/
3266+
if (reconnectExhaustedRecheckTimerRef.current) {
3267+
clearTimeout(reconnectExhaustedRecheckTimerRef.current)
3268+
}
3269+
reconnectExhaustedRecheckTimerRef.current = setTimeout(() => {
3270+
reconnectExhaustedRecheckTimerRef.current = null
3271+
void recoverActiveStreamRef.current('exhausted_recheck')
3272+
}, RECONNECT_EXHAUSTED_RECHECK_MS)
32463273
}
32473274
return false
32483275
},
@@ -3251,7 +3278,7 @@ export function useChat(
32513278
retryReconnectRef.current = retryReconnect
32523279

32533280
const recoverActiveStreamFromRedis = useCallback(
3254-
async (reason: 'pageshow' | 'visible' | 'online'): Promise<void> => {
3281+
async (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck'): Promise<void> => {
32553282
const startingChatId = chatIdRef.current
32563283
const startingSelectedChatId = selectedChatIdRef.current
32573284
const chatId = startingChatId ?? startingSelectedChatId
@@ -3386,6 +3413,7 @@ export function useChat(
33863413
},
33873414
[getActiveStreamIdForChat, queryClient, resumeOrFinalize, setTransportReconnecting]
33883415
)
3416+
recoverActiveStreamRef.current = recoverActiveStreamFromRedis
33893417

33903418
useEffect(() => {
33913419
if (typeof window === 'undefined' || typeof document === 'undefined') return
@@ -3417,6 +3445,10 @@ export function useChat(
34173445
document.removeEventListener('visibilitychange', handleVisibilityChange)
34183446
window.removeEventListener('pageshow', handlePageShow)
34193447
window.removeEventListener('online', handleOnline)
3448+
if (reconnectExhaustedRecheckTimerRef.current) {
3449+
clearTimeout(reconnectExhaustedRecheckTimerRef.current)
3450+
reconnectExhaustedRecheckTimerRef.current = null
3451+
}
34203452
}
34213453
}, [recoverActiveStreamFromRedis])
34223454

apps/sim/instrumentation-node.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,23 @@ function normalizeOtlpMetricsUrl(url: string): string {
8383
}
8484
}
8585

86+
// Logs counterpart to `normalizeOtlpMetricsUrl` — same parsed-pathname
87+
// handling, targeting the /v1/logs signal path.
88+
function normalizeOtlpLogsUrl(url: string): string {
89+
if (!url) return url
90+
try {
91+
const u = new URL(url)
92+
const path = u.pathname.replace(/\/$/, '')
93+
if (path.endsWith('/v1/logs')) return url
94+
u.pathname = path.endsWith('/v1/traces')
95+
? path.replace(/\/v1\/traces$/, '/v1/logs')
96+
: `${path}/v1/logs`
97+
return u.toString()
98+
} catch {
99+
return url
100+
}
101+
}
102+
86103
// deployment.environment in the GO value space (dev | staging | prod) without
87104
// any new infra env var. Every deployed Sim tier already gets
88105
// APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we
@@ -177,6 +194,8 @@ async function initializeOpenTelemetry() {
177194
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
178195
const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http')
179196
const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics')
197+
const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-http')
198+
const { BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs')
180199
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
181200
const { TraceIdRatioBasedSampler, SamplingDecision } = await import(
182201
'@opentelemetry/sdk-trace-base'
@@ -271,6 +290,19 @@ async function initializeOpenTelemetry() {
271290
exportIntervalMillis: 60000,
272291
})
273292

293+
// Logs share the trace endpoint and headers as well (signal path
294+
// /v1/logs). Every @sim/logger line fans out through the global Logs API
295+
// (see packages/logger), which the NodeSDK wires to this processor — the
296+
// stdout JSON lines continue to CloudWatch unchanged.
297+
const logRecordProcessor = new BatchLogRecordProcessor(
298+
new OTLPLogExporter({
299+
url: normalizeOtlpLogsUrl(telemetryConfig.endpoint),
300+
headers: otlpHeaders,
301+
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
302+
keepAlive: false,
303+
})
304+
)
305+
274306
// Must be unique per process: replicas sharing one instance id collapse
275307
// into a single Prometheus series, so their independent cumulative
276308
// counters interleave and corrupt rate()/increase(). The slug keeps Sim
@@ -320,6 +352,7 @@ async function initializeOpenTelemetry() {
320352
spanProcessors,
321353
sampler,
322354
metricReader,
355+
logRecordProcessors: [logRecordProcessor],
323356
})
324357

325358
sdk.start()
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1+
import { trace } from '@opentelemetry/api'
2+
import { toError } from '@sim/utils/errors'
13
import { asOrchestrationError } from '@/lib/core/orchestration/types'
24

35
export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE =
46
'The operation failed due to a system error. Please retry.'
57

6-
/** Projects only caller-actionable application failures into Copilot-visible content. */
8+
/**
9+
* Projects only caller-actionable application failures into Copilot-visible
10+
* content. Whenever the real cause is swallowed by the generic fallback, it is
11+
* recorded on the active span first — otherwise these failures are
12+
* undiagnosable from telemetry (the cause otherwise lives only in stdout logs
13+
* that do not ship anywhere queryable).
14+
*/
715
export function messageForCopilotApplicationError(
816
error: unknown,
917
fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE
1018
): string {
1119
const classified = asOrchestrationError(error)
12-
return classified && classified.code !== 'internal' ? classified.message : fallback
20+
if (classified && classified.code !== 'internal') {
21+
return classified.message
22+
}
23+
trace.getActiveSpan()?.recordException(toError(error))
24+
return fallback
1325
}

apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,38 @@ describe('executeBrowserToolOnClient', () => {
436436
})
437437
})
438438
})
439+
440+
describe('pre-dispatch drops still resolve the waiter', () => {
441+
beforeEach(() => {
442+
vi.clearAllMocks()
443+
mockReportCompletion.mockResolvedValue(undefined)
444+
})
445+
446+
it('reports an error confirmation for a stale event instead of hanging the turn', async () => {
447+
const staleTs = new Date(Date.now() - 10 * 60 * 1000).toISOString()
448+
executeBrowserToolOnClient('stale-call-1', 'browser_list_sessions', {}, 'chat-scope-1', staleTs)
449+
await sleep(0)
450+
451+
expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
452+
expect(mockReportCompletion).toHaveBeenCalledWith(
453+
'stale-call-1',
454+
'error',
455+
expect.stringContaining('too late'),
456+
expect.objectContaining({ staleEvent: true })
457+
)
458+
})
459+
460+
it('reports an error confirmation when no chat scope exists', async () => {
461+
useBrowserSessionStore.setState({ activeScopeId: null })
462+
executeBrowserToolOnClient('no-scope-1', 'browser_list_sessions', {}, undefined)
463+
await sleep(0)
464+
465+
expect(mockExecuteBrowserTool).not.toHaveBeenCalled()
466+
expect(mockReportCompletion).toHaveBeenCalledWith(
467+
'no-scope-1',
468+
'error',
469+
expect.stringContaining('no active browser session'),
470+
expect.anything()
471+
)
472+
})
473+
})

apps/sim/lib/copilot/tools/client/browser-tool-execution.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,45 @@ export function executeBrowserToolOnClient(
175175
): void {
176176
if (!scopeId) {
177177
logger.error('Cannot execute browser tool without a chat scope', { toolCallId, toolName })
178+
// Tell the waiter, or the turn hangs forever on a tool that never ran.
179+
const message = 'This browser action could not run: no active browser session for this chat.'
180+
void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, {
181+
error: message,
182+
}).catch((reportErr) => {
183+
logger.error('Failed to report missing-scope browser tool error', {
184+
toolCallId,
185+
error: toError(reportErr).message,
186+
})
187+
})
178188
return
179189
}
180190
if (hasAlreadyExecuted(toolCallId)) {
191+
// Same-page re-delivery: the original dispatch is in flight (or done) and
192+
// owns the result. Reporting here would race it — the server claims each
193+
// resume exactly once, so an error now would discard the genuine result.
181194
logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName })
182195
return
183196
}
184197
const age = eventAgeMs(eventTs)
185198
if (age !== null && age > MAX_EVENT_AGE_MS) {
186199
logger.info('Skipping stale browser tool event', { toolCallId, toolName, age })
200+
// Usually a replay of an action that already ran and resumed in a previous
201+
// page lifetime — the server claims each resume exactly once, so this
202+
// duplicate confirmation is simply discarded. When it is NOT a replay
203+
// (the event was delivered late, e.g. a backgrounded tab with throttled
204+
// timers), this error unblocks the turn instead of leaving it hanging
205+
// forever on a tool that will never execute.
206+
const message =
207+
'This browser action was delivered too late to run safely. Ask again to retry it.'
208+
void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, {
209+
error: message,
210+
staleEvent: true,
211+
}).catch((reportErr) => {
212+
logger.error('Failed to report stale browser tool error', {
213+
toolCallId,
214+
error: toError(reportErr).message,
215+
})
216+
})
187217
return
188218
}
189219
markExecuted(toolCallId)

apps/sim/lib/copilot/tools/client/completion.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
44
import { isRecordLike } from '@sim/utils/object'
5+
import { backoffWithJitter } from '@sim/utils/retry'
56
import type {
67
AsyncCompletionData,
78
AsyncConfirmationStatus,
@@ -48,7 +49,13 @@ export async function reportClientToolCompletion(
4849
const bodySize = new Blob([body]).size
4950
let lastError: Error | null = null
5051

51-
for (let attempt = 1; attempt <= 2; attempt++) {
52+
// A lost confirmation strands the server-side waiter forever (the turn shows
53+
// the tool as running indefinitely), so ride out multi-second network blips:
54+
// 5 attempts with jittered exponential backoff (~15s total) instead of a
55+
// sub-second give-up. The confirm endpoint claims each resume exactly once,
56+
// so duplicate deliveries from retries are discarded server-side.
57+
const maxAttempts = 5
58+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5259
try {
5360
const response = await send(body)
5461
if (response.ok) return
@@ -78,8 +85,8 @@ export async function reportClientToolCompletion(
7885
lastError = toError(error)
7986
}
8087

81-
if (attempt < 2) {
82-
await sleep(250)
88+
if (attempt < maxAttempts) {
89+
await sleep(backoffWithJitter(attempt, null))
8390
}
8491
}
8592

apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ const setCurrentExecutionId = vi.fn()
5757
const getCurrentExecutionId = vi.fn()
5858
const getWorkflowExecution = vi.fn(() => ({ isExecuting: false }))
5959

60+
// Neutralize the confirm-retry backoff so exhaustion tests stay fast.
61+
vi.mock('@sim/utils/retry', () => ({
62+
backoffWithJitter: () => 0,
63+
}))
64+
6065
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({
6166
executeWorkflowWithFullLogging,
6267
}))
@@ -265,6 +270,9 @@ describe('run tool execution cancellation', () => {
265270
})
266271
.mockResolvedValueOnce({ ok: false, status: 503 })
267272
.mockResolvedValueOnce({ ok: false, status: 503 })
273+
.mockResolvedValueOnce({ ok: false, status: 503 })
274+
.mockResolvedValueOnce({ ok: false, status: 503 })
275+
.mockResolvedValueOnce({ ok: false, status: 503 })
268276
.mockResolvedValue({ ok: true })
269277
vi.stubGlobal('fetch', fetchMock)
270278

@@ -273,7 +281,7 @@ describe('run tool execution cancellation', () => {
273281
async: true,
274282
})
275283

276-
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
284+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6))
277285
await vi.waitFor(() => expect(isRunToolActiveForId('tool-recover-async')).toBe(false))
278286
loadExecutionPointer.mockResolvedValueOnce({
279287
workflowId: 'wf-1',
@@ -283,10 +291,10 @@ describe('run tool execution cancellation', () => {
283291

284292
await expect(bindRunToolToExecution('tool-recover-async', 'wf-1')).resolves.toBe(true)
285293

286-
expect(fetchMock).toHaveBeenCalledTimes(4)
287-
expect(fetchMock.mock.calls[3][0]).toBe('/api/copilot/confirm')
288-
expect(fetchMock.mock.calls[3][1]?.body).toContain('"status":"background"')
289-
expect(fetchMock.mock.calls[3][1]?.body).toContain('"executionId":"exec-recover-async"')
294+
expect(fetchMock).toHaveBeenCalledTimes(7)
295+
expect(fetchMock.mock.calls[6][0]).toBe('/api/copilot/confirm')
296+
expect(fetchMock.mock.calls[6][1]?.body).toContain('"status":"background"')
297+
expect(fetchMock.mock.calls[6][1]?.body).toContain('"executionId":"exec-recover-async"')
290298
expect(
291299
fetchMock.mock.calls.filter(([url]) => url === '/api/workflows/wf-1/execute')
292300
).toHaveLength(1)

apps/sim/lib/copilot/tools/handlers/function-execute.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
247247
)
248248

249249
expect(mockExecuteTool).toHaveBeenCalledWith(
250-
'run_function',
250+
'function_execute',
251251
expect.objectContaining({
252252
envVars: { API_KEY: 'secret-value' },
253253
secretScope: 'selected',
@@ -272,7 +272,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
272272

273273
expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled()
274274
expect(mockExecuteTool).toHaveBeenCalledWith(
275-
'run_function',
275+
'function_execute',
276276
expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }),
277277
{ resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) }
278278
)
@@ -305,7 +305,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
305305
requestedNames: names,
306306
})
307307
expect(mockExecuteTool).toHaveBeenCalledWith(
308-
'run_function',
308+
'function_execute',
309309
expect.objectContaining({ code, language, mountedSecrets: names }),
310310
{ resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) }
311311
)
@@ -332,7 +332,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
332332
requestedNames: ['CLI_TOKEN'],
333333
})
334334
expect(mockExecuteTool).toHaveBeenCalledWith(
335-
'run_function',
335+
'function_execute',
336336
expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }),
337337
{
338338
resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry),
@@ -353,7 +353,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
353353
)
354354

355355
expect(mockExecuteTool).toHaveBeenCalledWith(
356-
'run_function',
356+
'function_execute',
357357
expect.objectContaining({
358358
_context: expect.not.objectContaining({ sandboxProfile: expect.anything() }),
359359
}),
@@ -373,7 +373,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
373373

374374
expect(mockHasWorkspaceSandboxAccess).toHaveBeenCalledWith('ws_1')
375375
expect(mockExecuteTool).toHaveBeenCalledWith(
376-
'run_function',
376+
'function_execute',
377377
expect.objectContaining({ sandboxId: 'sandbox-1' }),
378378
expect.objectContaining({ internalSandboxProfile: 'mothership' })
379379
)

0 commit comments

Comments
 (0)