[Feat] Durable retry scheduling: parked Fast turns resume at their retry time - #2040
Merged
Conversation
…process A replay-safe Fast turn that hits a retryable inference failure now parks itself: the owner records retry_at and the consumed retry count on its durable row, releases its claim, and the parent-event queue re-runs the same turn at the scheduled time on whichever process is alive then. The resumed run inherits the retry budget and the active retry notice, so the answer edits the notice in place and the per-turn cap holds across handoffs. Turns that already performed a non-replayable action keep their in-process backoff. The expired-lease reconciler leaves conversations with a scheduled retry alone. Kill switch: R_FAST_DURABLE_RETRY_DISABLED.
Contributor
OpenCode owns provider retries while a prompt is active, so the wait a restart interrupts most often is OpenCode's internal backoff, not Roomote's outer loop. When OpenCode reports a scheduled retry with its next attempt time, a replay-safe durable turn now records the notice, parks the row at that time, and aborts the prompt with the park as its reason; the queue re-prompts on whichever process is alive then. The per-turn retry cap counts these handoffs too, after which OpenCode's in-process retries resume as before.
… itself A resumed inline turn settles its own row, so the drain no longer stamps delivered over a row the run already withdrew from replay (which erased the recorded reason). A run that parks itself for a scheduled retry ends the drain cleanly instead of failing the BullMQ job and overwriting the row's last error. Slack logs a parked turn at info level without the failure prefix.
The parent-turn adapters the durable queue runs resumed turns through had no replaceReply, so a resumed run answered beside the visible retry notice instead of editing it. Extract the per-surface edit-in-place replacers (Slack, Discord, Teams, Telegram) into one module, use them for the queue-side adapters, and dedupe the shared surface-reply builder onto the same helpers (which also gives its Discord adapter a replacer).
Each resumed run re-persisted the retry marker with the current clock, so the 30-second silent window restarted on every handoff and a turn could spend minutes in short parks without ever surfacing a notice. The marker now carries the episode's first-failure time, which the resumed run inherits, so the notice appears once the cumulative wait crosses the window regardless of how many processes it spanned.
…rd replies The parent-turn adapters posted replies without returning the message handle, so a retry notice posted by a resumed run was recorded without its platform message id and neither that run nor the next could edit it; the answer then landed beside a stale notice. Return the handle like the webhook handlers and the Teams and Telegram adapters already do.
The per-retry marker write replaces the notice row's metadata, which dropped the platform message id a previous run had recorded; the next resumed run then could not find the message to edit and posted a second notice. The marker write now carries the id and visibility of the notice this run already holds, whether it posted it or inherited it.
…t blip in place Parks were bounded by the 12-retry per-turn cap, which gave up on a real provider outage after about two minutes, and every retry parked, so a one-off blip cost a full re-prompt instead of OpenCode's cheap in-place retry. Now the first short in-process retry stays where it is (unless its wait is already long), parks start from the second attempt, park delays back off across handoffs up to a minute, and the episode is bounded by a 15-minute horizon from the first failure plus a safety cap of 30 handoffs. The in-process retry budget is no longer seeded from the row, so an exhausted horizon falls back to the normal in-process retries and their honest terminal failure.
Discord, the Slack reaction path, and the shared surface-reply continuation (Teams and Telegram default Fast replies) still logged a successfully parked durable retry as a failed response. Handle the park sentinel the way the Slack and web entry points already do.
daniel-lxs
marked this pull request as ready for review
September 2, 2026 14:22
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 of the durable Fast-turn execution work (Phase 1 was #2016): inference retry backoff no longer lives in a
setTimeoutowned by the API process.When a replay-safe Fast turn hits a retryable inference failure, the owning process now:
retry_at= the backoff deadline,inference_retries= retries consumed so far, claim released,retry_atand exits without an outcome.The parent-event queue re-runs the same turn at
retry_aton whichever process is alive then. The resumed run continues the same bounded retry budget (per-turn cap holds across handoffs) and inherits the active retry notice for its turn, so the eventual answer edits that notice in place instead of reconciling it into an interruption and posting beside it. A deploy or crash during backoff therefore needs nothing special: the row waits, the recovery sweep re-adds the delayed wakeup if Redis lost it, and the next owner picks it up.Turns that already performed a non-replayable action (task launch, message, memory write, mutating MCP call, closeout) keep their in-process backoff exactly as before; they cannot be re-run elsewhere until the Phase 3 side-effect journal exists.
Changes
fast_agent_parent_events: nullableretry_at,inference_retries(default 0). Migration 0072. The previous release ignores both columns; a rollback would run a parked retry immediately rather than at its time, which is safe (N-1).scheduleFastAgentDurableTurnRetry(pending-row guarded),findFastAgentActiveInferenceRetryNotice(for the resumed run), and the expired-lease reconciler skips conversations whose inline row has a futureretry_ator a live claim.retry_at;wakeFastAgentParentEventAtadds a delayed job keyed by event key + time; the recovery sweep re-adds delayed wakeups for scheduled rows; the drain passesresumedAfterInferenceRetry, the consumed retry count, and both wake hooks to the resumed run (this also closes a Phase 1 gap: worker-side resumed runs had no wake hook).deferRetrystep in the retry loop after the notice is recorded;FastAgentDurableRetryScheduledErrorends the execution without settling the row, the notice, or the responding lease; resumed runs seed the retry budget and adopt the predecessor's notice; new<resumed_turn>marker text for the retry case.replaceReplyand their Slack/DiscordpostReplyreturned no handle. Both are fixed; the per-surface edit-in-place replacers now live infast-agent-reply-replacement.tsand are shared with the surface-reply builder (which gains a Discord replacer). The webhook handlers keep their inline versions.requestDurableRetrynext torequestDurableResume; Slack and web log the parked turn at info level instead of as a failure.R_FAST_DURABLE_RETRY_DISABLED(implied byR_FAST_DURABLE_ADMISSION_DISABLED).Tests
retry_at, recovery re-adds delayed wakeups.Smoke (local, per the PR smoke-testing skill)
Setup: PM2 stack from this branch at
6b45d46d(watchman rebuilds verified by PID changes), mock Slack harness, and a local fault proxy in front of OpenRouter (provider.openrouter.options.baseURLviaOPENCODE_CONFIG_CONTENT) that injects 503, 429 withRetry-After: 45, or socket resets on chat completions from a mode file. Evidence layers: mock Slack thread state,fast_agent_parent_eventsrow,fast_agent_messagesrows, API and worker logs.Baseline (pass-through): two provider calls through the proxy,
pongin the thread, row settled.Park and resume under persistent 503: the API parked on the first OpenCode retry event (claim released,
retry_atabout 2s out,inference_retries1); the worker's delayed job resumed the turn on time and parked again with the count climbing 2, 3, 4, 5 (each visible asParked the turnin the worker log); after lifting the fault at 5 the resumed run answeredpong, the hidden marker was rewritten as the answer, row delivered. One visible transcript row.Restart during backoff (429, 45s): visible notice posted at once; both the API and worker runtimes were SIGKILLed 1s after the park and restarted; the resumed run started at exactly
retry_at(derived from its own duration fields), the notice was edited in place topong(same Slack ts), one visible transcript row, row settled.Budget exhaustion (503 never lifted): 12 parks at the per-turn cap, then OpenCode's in-process retries ran and the honest terminal failure replaced the marker; no interruption text, no loop.
Notice continuity across parks (503, lifted at 6): hidden marker for parks 1 to 3, one visible notice at park 4 once the cumulative wait passed 30s, its message id preserved through parks 5 and 6, and the answer edited it in place; thread ends with a single
pong, one visible transcript row. (Two earlier runs of this scenario found and fixed the missing adapter handle and the metadata wipe described above.)Final policy run (503, lifted at park 4): first retry absorbed in process with no park, parks 1 to 4 scheduled 2s, 4s, 8s, 16s apart, one visible notice once the cumulative wait passed 30s, the resumed run after the fault lifted answered by editing that notice, single transcript row, row settled.
Not exercised live: the non-replayable branch (a turn that already launched or messaged keeps its in-process backoff) and Discord, Teams, Telegram, web surfaces; those are covered by unit tests only. No browser pass (dev login is not enabled on this checkout); transcript visibility was verified from the canonical rows.
Kill switches
R_FAST_DURABLE_RETRY_DISABLED=truekeeps backoff in process (parks stop, everything else unchanged).R_FAST_DURABLE_ADMISSION_DISABLED=trueimplies it.