diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index f3bcffe8..f08b4a60 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -125,6 +125,7 @@ export class CallbackNotificationService { sessionId, messageId, success, + ...(error != null ? { error } : {}), timestamp, context, }; diff --git a/packages/control-plane/src/session/message-queue.ts b/packages/control-plane/src/session/message-queue.ts index 1bbf2105..713559d1 100644 --- a/packages/control-plane/src/session/message-queue.ts +++ b/packages/control-plane/src/session/message-queue.ts @@ -232,10 +232,12 @@ export class SessionMessageQueue { message_id: processingMessage.id, }); + const stopError = "Execution was stopped"; const syntheticExecutionComplete: Extract = { type: "execution_complete", messageId: processingMessage.id, success: false, + error: stopError, sandboxId: "", timestamp: now / 1000, }; @@ -251,7 +253,7 @@ export class SessionMessageQueue { }); this.deps.ctx.waitUntil( - this.deps.callbackService.notifyComplete(processingMessage.id, false) + this.deps.callbackService.notifyComplete(processingMessage.id, false, stopError) ); if (!options.suppressStatusReconcile) { @@ -281,17 +283,21 @@ export class SessionMessageQueue { this.deps.repository.updateMessageCompletion(processingMessage.id, "failed", now); + const stuckError = "Execution timed out (stuck processing)"; const syntheticEvent: Extract = { type: "execution_complete", messageId: processingMessage.id, success: false, + error: stuckError, sandboxId: "", timestamp: now / 1000, }; this.deps.repository.upsertExecutionCompleteEvent(processingMessage.id, syntheticEvent, now); this.deps.broadcast({ type: "sandbox_event", event: syntheticEvent }); this.deps.broadcast({ type: "processing_status", isProcessing: false }); - this.deps.ctx.waitUntil(this.deps.callbackService.notifyComplete(processingMessage.id, false)); + this.deps.ctx.waitUntil( + this.deps.callbackService.notifyComplete(processingMessage.id, false, stuckError) + ); await this.deps.reconcileSessionStatusAfterExecution(false); } diff --git a/packages/control-plane/src/session/sandbox-events.ts b/packages/control-plane/src/session/sandbox-events.ts index 995544d9..efd84d44 100644 --- a/packages/control-plane/src/session/sandbox-events.ts +++ b/packages/control-plane/src/session/sandbox-events.ts @@ -201,7 +201,7 @@ export class SessionSandboxEventProcessor { isProcessing: this.deps.getIsProcessing(), }); this.deps.ctx.waitUntil( - this.deps.callbackService.notifyComplete(completionMessageId, event.success) + this.deps.callbackService.notifyComplete(completionMessageId, event.success, event.error) ); await this.deps.reconcileSessionStatusAfterExecution(event.success); diff --git a/packages/linear-bot/src/types.ts b/packages/linear-bot/src/types.ts index 52ac36f8..044f5584 100644 --- a/packages/linear-bot/src/types.ts +++ b/packages/linear-bot/src/types.ts @@ -119,6 +119,7 @@ export interface CompletionCallback { sessionId: string; messageId: string; success: boolean; + error?: string; timestamp: number; signature: string; context: LinearCallbackContext; diff --git a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py index 8590f285..84646cc3 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py @@ -604,19 +604,19 @@ async def _handle_prompt(self, cmd: dict[str, Any]) -> None: reasoning_effort=reasoning_effort, ) - scm_name = author_data.get("scmName") - scm_email = author_data.get("scmEmail") - await self._configure_git_identity( - GitUser( - name=scm_name or FALLBACK_GIT_USER.name, - email=scm_email or FALLBACK_GIT_USER.email, + try: + scm_name = author_data.get("scmName") + scm_email = author_data.get("scmEmail") + await self._configure_git_identity( + GitUser( + name=scm_name or FALLBACK_GIT_USER.name, + email=scm_email or FALLBACK_GIT_USER.email, + ) ) - ) - if not self.opencode_session_id: - await self._create_opencode_session() + if not self.opencode_session_id: + await self._create_opencode_session() - try: had_error = False error_message = None async for event in self._stream_opencode_response_sse( diff --git a/packages/shared/src/completion/extractor.ts b/packages/shared/src/completion/extractor.ts index 13c57600..88da2720 100644 --- a/packages/shared/src/completion/extractor.ts +++ b/packages/shared/src/completion/extractor.ts @@ -137,8 +137,13 @@ export async function extractAgentResponse( const artifacts = await fetchSessionArtifacts(deps, sessionId, headers, base); const finalArtifacts = artifacts.length > 0 ? artifacts : eventArtifacts; - // Check for completion event to get success status + // Check for completion event to get success status and error message. + // The error may be on execution_complete itself, or on a separate "error" event. const completionEvent = allEvents.find((e) => e.type === "execution_complete"); + const errorEvent = allEvents.find((e) => e.type === "error"); + const errorMessage = + (completionEvent?.data.error != null ? String(completionEvent.data.error) : undefined) ?? + (errorEvent?.data.error != null ? String(errorEvent.data.error) : undefined); log.info("control_plane.fetch_events", { ...base, @@ -147,6 +152,7 @@ export async function extractAgentResponse( tool_call_count: toolCalls.length, artifact_count: finalArtifacts.length, has_text: Boolean(textContent), + has_error: Boolean(errorMessage), duration_ms: Date.now() - startTime, }); @@ -155,6 +161,7 @@ export async function extractAgentResponse( toolCalls, artifacts: finalArtifacts, success: Boolean(completionEvent?.data.success), + error: errorMessage, }; } catch (error) { log.error("control_plane.fetch_events", { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 32ff32ce..7dbed42b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -468,6 +468,7 @@ export interface AgentResponse { toolCalls: ToolCallSummary[]; artifacts: ArtifactInfo[]; success: boolean; + error?: string; } export interface UserPreferences { diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index 1f45549d..c5bcefbb 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -6,7 +6,7 @@ import { computeHmacHex, timingSafeEqual } from "@open-inspect/shared"; import { Hono } from "hono"; import type { Env, CompletionCallback } from "./types"; import { extractAgentResponse } from "./completion/extractor"; -import { buildCompletionBlocks, getFallbackText } from "./completion/blocks"; +import { buildCompletionBlocks, getFallbackText, truncateError } from "./completion/blocks"; import { postMessage, removeReaction } from "./utils/slack-client"; import { createLogger } from "./logger"; @@ -159,42 +159,43 @@ async function handleCompletionCallback( // Fetch events to build response (filtered by messageId directly) const agentResponse = await extractAgentResponse(env, sessionId, payload.messageId, traceId); + // Fall back to the callback payload's error if the extractor didn't find one. + agentResponse.error = agentResponse.error || payload.error; + const errorMessage = agentResponse.error; + // Check if extraction succeeded (has content or was explicitly successful) if (!agentResponse.textContent && agentResponse.toolCalls.length === 0 && !payload.success) { + const displayError = truncateError(errorMessage || "Unknown error", 2000); log.error("callback.complete", { ...base, outcome: "error", error_message: "empty_agent_response", + agent_error: errorMessage || "Unknown error", duration_ms: Date.now() - startTime, }); - await postMessage( - env.SLACK_BOT_TOKEN, - context.channel, - "The agent completed but I couldn't retrieve the response. Please check the web UI for details.", - { - thread_ts: context.threadTs, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: ":warning: The agent completed but I couldn't retrieve the response.", - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "View Session" }, - url: `${env.WEB_APP_URL}/session/${sessionId}`, - action_id: "view_session", - }, - ], + await postMessage(env.SLACK_BOT_TOKEN, context.channel, `The agent failed: ${displayError}`, { + thread_ts: context.threadTs, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:x: *Agent failed:* ${displayError}`, }, - ], - } - ); + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "View Session" }, + url: `${env.WEB_APP_URL}/session/${sessionId}`, + action_id: "view_session", + }, + ], + }, + ], + }); if (context.reactionMessageTs) { await clearThinkingReaction(env, context.channel, context.reactionMessageTs, traceId); diff --git a/packages/slack-bot/src/completion/blocks.ts b/packages/slack-bot/src/completion/blocks.ts index d5d1ef02..80e6557d 100644 --- a/packages/slack-bot/src/completion/blocks.ts +++ b/packages/slack-bot/src/completion/blocks.ts @@ -27,6 +27,7 @@ const STATUS_EMOJI = { */ const TRUNCATE_LIMIT = 2000; const FALLBACK_TEXT_LIMIT = 150; +const ERROR_FOOTER_LIMIT = 200; /** * Build Slack blocks for completion message. @@ -71,7 +72,11 @@ export function buildCompletionBlocks( // 4. Status footer const emoji = response.success ? STATUS_EMOJI.success : STATUS_EMOJI.warning; - const status = response.success ? "Done" : "Completed with issues"; + const status = response.success + ? "Done" + : response.error + ? `Failed: ${truncateError(response.error, ERROR_FOOTER_LIMIT)}` + : "Completed with issues"; const effortSuffix = context.reasoningEffort ? ` (${context.reasoningEffort})` : ""; blocks.push({ type: "context", @@ -137,6 +142,15 @@ function truncateForSlack(text: string, maxLen: number): string { return truncated + "...\n\n_...truncated_"; } +/** + * Truncate an error string for Slack display, collapsing whitespace. + */ +export function truncateError(text: string, maxLen: number): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLen) return normalized; + return normalized.slice(0, maxLen - 1) + "…"; +} + function getManualCreatePrUrl(artifacts: AgentResponse["artifacts"]): string | null { const manualBranchArtifact = artifacts.find((artifact) => { if (artifact.type !== "branch") { diff --git a/packages/slack-bot/src/types/index.ts b/packages/slack-bot/src/types/index.ts index 5877b7d3..6630158a 100644 --- a/packages/slack-bot/src/types/index.ts +++ b/packages/slack-bot/src/types/index.ts @@ -153,6 +153,7 @@ export interface CompletionCallback { sessionId: string; messageId: string; success: boolean; + error?: string; timestamp: number; signature: string; context: SlackCallbackContext;