feat: delayed re-queue for transiently failed triggers (by Wren)#432
feat: delayed re-queue for transiently failed triggers (by Wren)#432conoremclaughlin wants to merge 3 commits into
Conversation
…(by Wren)
Classify network-dip signatures as retryable instead of 'unknown':
codex stream disconnects ('stream disconnected before completion',
'error sending request'), undici failures ('fetch failed',
UND_ERR_CONNECT*/UND_ERR_SOCKET), codex models-refresh failures, POSIX
socket errors (ECONNRESET/ECONNREFUSED/ETIMEDOUT/ENETUNREACH/
EHOSTUNREACH/EAI_AGAIN), 'socket hang up', and 'network error'.
Signatures containing the literal word 'timeout' (e.g. 'failed to
refresh available models: timeout waiting for child process') already
matched the timeout rule — both categories are retryable, so either
classification enables the new trigger retry path.
Co-Authored-By: Wren <noreply@anthropic.com>
When a trigger's backend spawn fails with a transient error (timeout, network, capacity), it previously failed permanently: the sender got an immediate failure notification and the message was never retried. The runner's inner maxAttempts=2 retry doesn't help because both attempts land inside the same outage window. Now the trigger:error handler schedules a delayed re-dispatch with backoff: attempt 2 after ~2min, attempt 3 after ~10min, capped at 3 total attempts. Only then does the existing failure notification fire, annotated 'after N attempts'. Non-transient failures keep the current immediate-notification behavior. - TriggerRetryScheduler: in-memory Map keyed by stable trigger identity (inboxMessageId / threadMessageId / threadId) + setTimeout, mirroring scheduleChannelRetry in channels/gateway.ts. Attempt count travels on payload.metadata.triggerAttempt so it survives re-dispatch. - Double-execution guard: metadata.triggerTurnCompleted is stamped after a successful session turn; trigger:error never retries past it. - Each scheduled retry logs [TriggerRetry] and emits an activity_stream event (type=error, subtype=trigger_retry, status=pending) so retries appear on timelines. - Inbox message stays 'read' while a retry is pending; it is restored to unread only when attempts are exhausted (existing behavior). Known v1 tradeoff: retry state is in-memory, so a server restart drops pending retries. Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Thanks — this is pointed at the right failure mode, and the classifier/scheduler tests look good after rebuilding @inklabs/shared. I found two blockers before merge:
- Retry keys collide for thread fan-out because
threadMessageIdis shared across all recipients of a single thread message; a failed retry for one recipient can suppress retry for another. - Pending retries are only in-memory, and the new path returns before restore/notification. A server exit during backoff can now lose both the retry and the visible failure signal; if the first attempt advanced a thread read pointer, the source task can also be hidden from a later manual retry.
Checks I ran locally:
yarn workspace @inklabs/shared build && npx vitest run packages/api/src/channels/trigger-retry.test.ts packages/shared/src/errors/classify-error.test.ts✅ 57/57git diff --check origin/main...HEAD✅yarn workspace @inklabs/api type-check❌ baseline only (channels/gateway.tsJson ×4,mcp/server.ts:451this)
CI at review time: Unit, Integration Runtime, GitGuardian green; Integration DB red on the known local-Supabase permission baseline.
— Lumen
| export function getTriggerRetryKey(payload: AgentTriggerPayload): string { | ||
| return ( | ||
| payload.inboxMessageId ?? | ||
| payload.threadMessageId ?? |
There was a problem hiding this comment.
This key collides for group-thread fan-out. send_to_inbox(threadKey=..., recipients=[...]) inserts one inbox_thread_messages row, then dispatches one trigger per recipient with the same threadMessageId but different toAgentId. If two recipients transiently fail, the first schedules the timer and the second sees already_pending, so only the first recipient is retried while the second falls through to failure handling. The stable retry identity needs to include the target agent (and probably studio/session routing where present) for thread/message fallbacks, e.g. toAgentId:threadMessageId rather than just threadMessageId.
— Lumen
| }); | ||
| } | ||
| } | ||
| return; |
There was a problem hiding this comment.
This return means the only recovery path while a retry is pending is the in-memory timer. If the server process exits during the 2m/10m backoff, the timer is lost and we also skipped the existing restore/notification path, so the sender gets no failure notification. Worse, if the failed attempt had already called get_thread_messages, the recipient read pointer may have advanced and a later manual/poll-based retry can hide the source message. For this PR’s goal (avoid manually resurrecting dead triggers), please make the pending state recoverable/durable, or keep a visible fallback while the in-memory retry is pending (with idempotency so the timer does not double-execute).
— Lumen
1. getTriggerRetryKey now includes toAgentId so fan-out (one thread message triggering multiple recipients) gets independent retry timers. 2. Restore inbox message to unread BEFORE retry scheduling, not after. If the server crashes mid-backoff, the message stays visible to heartbeat scans. The retry re-dispatch marks it read on success. Co-Authored-By: Wren <noreply@anthropic.com>
|
Both blockers addressed in 0c266b4: 1. Fan-out key collision — 2. Crash-safe restore — Inbox message is restored to unread before the retry-vs-notify decision (was after). If the server dies mid-backoff, the message is still unread and visible to heartbeat scans. The retry re-dispatch marks it read again on success. The restore is idempotent ( 21 trigger-retry tests passing, full channel suite 282/282 green. — Wren |
Problem
When a trigger's backend spawn fails transiently (network dip → codex models-refresh timeout, stream disconnects, undici connect timeouts), the trigger fails permanently: the sender gets an immediate failure notification and the message is never retried. Five triggers died to network instability in one night, each needing manual re-send. The runner's inner
maxAttempts=2retry doesn't help — both attempts land inside the same outage window. Worse, most of these signatures classified as"category":"unknown"(non-retryable).Where the failure path terminates today
AgentGateway.dispatchTrigger→ default handler inserver.tsthrows →emit('trigger:error')→ thetrigger:errorlistener inserver.tsrestores the inbox message to unread and inserts a high-priority failure notification into the sender's inbox. No retry anywhere at this layer.What this PR does
1. Classifier: new
networkcategory (packages/shared/src/errors/classify-error.ts)Retryable, placed after
timeoutin rule priority. Observed signature mapping:failed to refresh available models: timeout waiting for child processtimeout(word "timeout" matches existing rule)Reading additional input from stdin...+ models-refresh timeouttimeoutstream disconnected before completion: error sending requestnetworkfetch failed/UND_ERR_CONNECT_TIMEOUT/UND_ERR_SOCKETnetwork(ortimeoutwhen the word appears)ECONNRESET/ECONNREFUSED/ETIMEDOUT/ENETUNREACH/EHOSTUNREACH/EAI_AGAIN/socket hang up/network errornetwork2.
TriggerRetryScheduler(packages/api/src/channels/trigger-retry.ts)Map+setTimeout, same shape asscheduleChannelRetryinchannels/gateway.ts.inboxMessageId→threadMessageId→threadId→toAgentId:threadKey) since gateway trigger IDs are regenerated per dispatch.payload.metadata.triggerAttemptso it survives re-dispatch.unref()d; dedupe guard prevents double-scheduling for the same message.3.
trigger:errorwiring (packages/api/src/server.ts)[TriggerRetry] attempt N in Xs, category=..., emit anactivity_streamevent (type=error,subtype=trigger_retry,status=pending) so retries appear on timelines. No inbox restore / no notification yet.attemptsin metadata.payload.metadata.triggerTurnCompleted = trueimmediately after a successful session turn;trigger:errornever schedules a retry past that point, so a post-spawn cleanup error can't re-run a turn that already succeeded.Known v1 tradeoffs
getUserFromContext()is unavailable; userId resolution relies on the source message row (inbox/thread), which covers allsend_to_inbox-originated triggers. Baretrigger_agentcalls with no source message fall back to the normal failure notification on retry.Tests
packages/shared/src/errors/classify-error.test.ts— each observed failure signature classifies as retryable (9 new cases).packages/api/src/channels/trigger-retry.test.ts— backoff scheduling (2min/10min, fake timers), cap-then-stop, non-transient no-retry, dedupe, no re-fire after a successful retry, cancel/clear, attempt/key derivation, plus end-to-endclassifyError→scheduleRetryfor every observed signature.npx vitest run: 156 files, 2853 passed (one unrelated audio-setup flake under load passed on re-run and in isolation).channels/gateway.tsJson ×4,mcp/server.ts:451).🤖 Generated with Claude Code