From 687c814222ee8995447adc4abd533146c8c8e088 Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Wed, 19 Aug 2026 10:27:33 +0200 Subject: [PATCH] fix(homeassistant): report webhook delivery failures Webhook sends discarded every error, so a notification that never arrived looked identical to one that did. The poll then ran normally and timed out with zero failed reads, which pointed at the response helper when the real fault was delivery. Failures now reach the log with the URL and reason, covering non-2xx statuses, timeouts and transport errors. Waiting-state failures add that no notification was delivered, since that is the case where the agent stays blocked with nothing on the phone. A waiting state with no configured URL is reported too, rather than silently doing nothing. Each URL is reported separately, so a working status webhook no longer masks a failing notification webhook. The per-request timeout moves from 2s to 5s. Two seconds is tight for a remote Home Assistant behind TLS, and a timeout meant a silently lost notification. The dispose drain grows to 6s so it cannot cut off a request that is still within its own timeout. --- packages/homeassistant/src/index.ts | 59 +++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/homeassistant/src/index.ts b/packages/homeassistant/src/index.ts index 67a736b..6edc790 100644 --- a/packages/homeassistant/src/index.ts +++ b/packages/homeassistant/src/index.ts @@ -31,6 +31,11 @@ interface HaEntityState { attributes: Record; } +interface WebhookFailure { + url: string; + reason: string; +} + type PollResult = | { kind: 'permission'; response: 'allow' | 'deny' } | { kind: 'question'; optionIndex: number } @@ -49,7 +54,8 @@ const DEFAULT_RESPONSE_ENTITY = 'input_text.opencode_permission_response'; const POLL_INTERVAL_MS = 2000; const STALE_SESSION_TIMEOUT_MS = 10 * 60 * 1000; const STALE_SESSION_SWEEP_INTERVAL_MS = 60 * 1000; -const WEBHOOK_DRAIN_TIMEOUT_MS = 3000; +const WEBHOOK_TIMEOUT_MS = 5000; +const WEBHOOK_DRAIN_TIMEOUT_MS = 6000; function loadConfig(): Config { const configPath = @@ -79,17 +85,28 @@ function resolveWebhookUrls(config: Config, state: AgentState): string[] { return []; } -function sendWebhook(urls: string[], payload: WebhookPayload): Promise { +function sendWebhook(urls: string[], payload: WebhookPayload): Promise { return Promise.all( - urls.map((url) => - fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(2000), - }).catch(() => {}), - ), - ).then(() => {}); + urls.map(async (url): Promise => { + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS), + }); + if (!resp.ok) return { url, reason: `HTTP ${resp.status}` }; + return undefined; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + const timedOut = error instanceof Error && error.name === 'TimeoutError'; + return { + url, + reason: timedOut ? `timed out after ${WEBHOOK_TIMEOUT_MS}ms` : detail, + }; + } + }), + ).then((results) => results.filter((r): r is WebhookFailure => r !== undefined)); } function sleep(ms: number): Promise { @@ -192,7 +209,15 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { extra?: { durationMs?: number; waiting?: WaitingDetail }, ): Promise { const urls = resolveWebhookUrls(config, state); - if (urls.length === 0) return Promise.resolve(); + if (urls.length === 0) { + if (state === 'waiting') { + return report( + 'no webhook URL configured for the waiting state, so no notification was sent', + undefined, + ); + } + return Promise.resolve(); + } const payload: WebhookPayload = { state, hostname: host, project, sessionId }; if (extra?.durationMs !== undefined) payload.durationMs = extra.durationMs; if (extra?.waiting) payload.waiting = extra.waiting; @@ -201,6 +226,16 @@ export const HomeAssistantPlugin: Plugin = async ({ client, directory }) => { const previous = inflightWebhooks.get(key) ?? Promise.resolve(); const promise = previous .then(() => sendWebhook(urls, payload)) + .then(async (failures) => { + for (const failure of failures) { + await report( + `${state} webhook to ${failure.url} failed: ${failure.reason}${ + state === 'waiting' ? ' (no notification was delivered)' : '' + }`, + undefined, + ); + } + }) .finally(() => { if (inflightWebhooks.get(key) === promise) inflightWebhooks.delete(key); });