Skip to content

fix(task): auto-retry transient "Connection closed mid-response" like chat (MUL-4910)#5565

Merged
Bohan-J merged 3 commits into
mainfrom
agent/j/c1ca7e71
Jul 17, 2026
Merged

fix(task): auto-retry transient "Connection closed mid-response" like chat (MUL-4910)#5565
Bohan-J merged 3 commits into
mainfrom
agent/j/c1ca7e71

Conversation

@Bohan-J

@Bohan-J Bohan-J commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Problem (MUL-4910)

Claude Code agents running issue tasks frequently terminate mid-run with:

API Error: Connection closed mid-response. The response above may be incomplete.

In chat the same transient error is invisible — it "retries once and recovers." There is no chat-specific retry in Multica: chat and issue tasks share the same finalizeStreamResult → taskfailure.Classify → retryableReasons pipeline. The string is emitted by the Claude Code CLI, which has its own in-process retry; chat just happens to recover before the error surfaces, while longer unattended issue runs surface it and — because it classified to a non-retryable agent_error.* — terminated with no platform retry.

What this PR does

Makes the platform recover from the transient cut itself, on a three-tier schedule:

  1. first run
  2. immediate retry (resume the session)
  3. retry deferred ~5s (resume the session)

A 4th failure stops and reports normally. All retries resume the session, so the truncated conversation continues rather than restarting.

Implementation

  • Classify (taskfailure/classify.go): route connection closed / mid-response into the provider-network bucket → agent_error.provider_network, before the exit status rule so the exit-status variant lands there too.
  • Retryable (service/task.go): add agent_error.provider_network to retryableReasons. It is resume-safe, so CreateRetryTask inherits the session and continues the conversation.
  • Backoff via the existing deferred primitive — no new infra: CreateRetryTask gains an optional fire_at; when set, the child is inserted deferred and the existing PromoteDueDeferredTasksForRuntime sweeper (already run promote-first on every claim poll) flips it to queued at fire time. No migration, no claim-query change, no daemon change.
  • Reason-aware budget, persisted (retryAttemptCeiling): provider_network's ceiling is 3 (max(col, 3) — a higher configured budget is kept), and the effective ceiling is written into the child's max_attempts so the chain self-describes (attempt=3, max_attempts=3), never a contradictory attempt=3, max_attempts=2. max_attempts=1 still means "auto-retry disabled" and is never revived.

Behaviour notes for the merger

  • Timing: the deferred child fires on the first claim poll at/after fire_at, so it is ≥5s; on an otherwise-idle runtime it can stretch toward the poll interval — the same behaviour deferred escalations already have. A tighter (scheduled-wake) variant is a possible follow-up.
  • Scope: this makes the whole agent_error.provider_network bucket three-tier (existing dial tcp / DNS / connection refused members too, not only the newly-added connection closed / mid-response). This is consistent with the "transient provider network error is recoverable" boundary, but is a deliberately wider blast radius than the literal string.
  • Intentionally out of scope: 5xx / 429 (provider_server_error / capacity_or_rate_limit) — retrying rate-limits needs backoff/Retry-After semantics; left as a follow-up.

Tests

  • pkg/taskfailure/classify_test.go: plain + exit status-wrapped variants classify to provider_network.
  • service/task_complete_race_test.go (TestProviderNetworkRetrySchedule): the tier/delay/eligibility schedule, incl. the ceiling widening to 3, keeping a higher budget, and never reviving a max_attempts=1 task.
  • service/retry_deferred_test.go: DB tests — CreateRetryTask deferred-vs-queued + persisted budget, and an end-to-end FailTask test asserting default budget → deferred attempt=3/max_attempts=3, first failure → immediate child, and max_attempts=1 → no child.

Verified locally (build/vet + service & taskfailure suites against a clean migrated DB); GitHub CI green.

Follow-up noted separately

Mirror the connection closed / mid-response substrings into the MUL-1949 offline backfill SQL so historical rows share the taxonomy (flagged in a code comment + commit).

Claude Code's "API Error: Connection closed mid-response" is a transient
network cut. In unattended issue runs it fell through to
agent_error.unknown / process_failure — neither in retryableReasons — so
the task terminated. Interactive chat only appeared resilient because the
CLI's own in-process retry usually recovers first; there is no
chat-specific retry in Multica. Both paths share the same
finalizeStreamResult -> Classify -> retryableReasons pipeline.

- classify: route "connection closed" / "mid-response" to
  agent_error.provider_network, before the exit-status rule so the
  "exited with error: exit status N" variant also lands here.
- retry: add provider_network to retryableReasons. It is resume-safe, so
  the retry child inherits the session and continues the truncated
  conversation instead of restarting.

Tests cover the new classification (incl. exit-status variant) and the
retry/resume flags. Note: mirror the substrings into the MUL-1949 offline
backfill SQL to keep in-flight and historical taxonomies aligned.

Co-authored-by: multica-agent <github@multica.ai>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multica-docs Ready Ready Preview, Comment Jul 17, 2026 8:44am

Request Review

…L-4910)

Follow-up to the immediate-retry fix: make the connection-closed retry a
three-tier schedule — first run + immediate retry + one retry deferred ~5s —
so a blip that survives the immediate retry gets a short cooldown before the
final attempt instead of firing back-to-back.

Reuses the existing deferred/fire_at primitive (the comment-routing escalation
mechanism) rather than adding new infrastructure: CreateRetryTask gains an
optional fire_at; when set, the child is inserted 'deferred' and the existing
PromoteDueDeferredTasksForRuntime sweeper — already run promote-first on every
claim poll — flips it to 'queued' at fire_at. No migration, no claim-query
change, no daemon change.

- retryAttemptCeiling: raise provider_network's ceiling to 3 (other reasons
  keep max_attempts=2); applied in both retryEligible and
  MaybeRetryFailedTask's budget pre-check so the primary and sweeper paths agree.
- retryDelayForAttempt: only provider_network's final attempt is deferred (5s);
  every other retry — including provider_network's first — stays immediate.
- FailTask + MaybeRetryFailedTask pass fire_at and skip the queued
  broadcast/notify for a deferred child (promotion emits them at fire time).

Timing: the deferred child fires on the first claim poll at/after fire_at, so
>= 5s; on an otherwise-idle runtime it can stretch to the poll interval — the
same behaviour deferred escalations already have.

Tests: pure schedule/eligibility coverage (TestProviderNetworkRetrySchedule)
plus a DB test asserting fire_at controls deferred-vs-queued, attempt=3, and
resume-safety.

Co-authored-by: multica-agent <github@multica.ai>
…disable (MUL-4910)

Addresses the pre-merge review must-fix: retryAttemptCeiling raised
provider_network to 3 unconditionally, which (1) overrode the
max_attempts=1 "auto-retry disabled" contract and (2) persisted a
self-contradictory child (attempt=3, max_attempts=2) that leaks to the
task API, so a naive attempt < max_attempts consumer would misjudge the
budget.

- retryAttemptCeiling now returns taskMaxAttempts unchanged when it is <= 1
  (disabled stays disabled) and only ever WIDENS otherwise (max(col, 3) for
  provider_network) — a higher configured budget is kept.
- CreateRetryTask takes an optional max_attempts; FailTask and
  MaybeRetryFailedTask write the reason-aware effective ceiling into the
  child so the whole retry chain self-describes (attempt=3, max_attempts=3).
  NULL inherits the parent column, so non-provider_network reasons are
  unchanged.

Tests:
- pure: ceiling widens to 3, keeps a higher budget, and never revives a
  disabled (max_attempts=1) task; eligibility rejects the disabled case.
- DB (end-to-end FailTask): default budget → deferred final child at
  attempt=3/max_attempts=3; first failure → immediate child; max_attempts=1
  → no child. Plus CreateRetryTask persists the passed budget / inherits on NULL.

Co-authored-by: multica-agent <github@multica.ai>
@Bohan-J
Bohan-J merged commit 6dba74c into main Jul 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant