Files
multica/server/internal/service/retry_deferred_test.go
Bohan Jiang 6dba74c3c1 fix(task): auto-retry transient "Connection closed mid-response" like chat (MUL-4910) (#5565)
* fix(task): auto-retry transient provider stream cut like chat (MUL-4910)

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>

* feat(task): defer provider_network's final retry ~5s — three-tier (MUL-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>

* fix(task): persist reason-aware retry budget; respect max_attempts=1 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>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 16:47:59 +08:00

170 lines
6.8 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestCreateRetryTaskFireAtControlsDeferral locks in the SQL half of the
// three-tier provider_network schedule (MUL-4910): CreateRetryTask inserts a
// 'deferred' child carrying fire_at when the fire_at param is set (the final,
// backed-off attempt) and an immediately-claimable 'queued' child when it is
// NULL (every other retry). Both continue the resume chain — force_fresh_session
// stays false for a provider_network parent.
func TestCreateRetryTaskFireAtControlsDeferral(t *testing.T) {
pool := newResolveOriginatorPool(t)
ctx := context.Background()
q := db.New(pool)
_, _, agentID, issueID := seedAttributionFixture(t, pool)
// agent_task_queue.runtime_id is NOT NULL; reuse the fixture agent's runtime.
var runtimeID string
if err := pool.QueryRow(ctx, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID); err != nil {
t.Fatalf("read agent runtime: %v", err)
}
// Parent: a provider_network failure on its second attempt — the point at
// which the schedule wants the next (final) retry deferred.
var parentID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, attempt, max_attempts, failure_reason, session_id, work_dir)
VALUES ($1, $2, $3, 'failed', 0, 2, 2, 'agent_error.provider_network', 'src-session', '/tmp/src-workdir')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&parentID); err != nil {
t.Fatalf("insert parent task: %v", err)
}
t.Cleanup(func() {
pool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE parent_task_id = $1 OR id = $1`, parentID)
})
cases := []struct {
name string
fireAt pgtype.Timestamptz
maxAttempts pgtype.Int4
wantStatus string
wantFireAt bool
wantMaxAttempts int32
}{
// Final tier: deferred, and the effective budget (3) written into the row
// so it self-describes as attempt=3/max_attempts=3, not attempt=3/max=2.
{"deferred final tier persists budget", pgtype.Timestamptz{Time: time.Now().Add(5 * time.Second), Valid: true}, pgtype.Int4{Int32: 3, Valid: true}, "deferred", true, 3},
// NULL max_attempts inherits the parent's column (COALESCE fallback).
{"queued immediate tier inherits budget", pgtype.Timestamptz{}, pgtype.Int4{}, "queued", false, 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
child, err := q.CreateRetryTask(ctx, db.CreateRetryTaskParams{ID: parentID, FireAt: tc.fireAt, MaxAttempts: tc.maxAttempts})
if err != nil {
t.Fatalf("CreateRetryTask: %v", err)
}
t.Cleanup(func() { pool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, child.ID) })
if child.Status != tc.wantStatus {
t.Errorf("status = %q, want %q", child.Status, tc.wantStatus)
}
if child.FireAt.Valid != tc.wantFireAt {
t.Errorf("fire_at valid = %v, want %v", child.FireAt.Valid, tc.wantFireAt)
}
if child.Attempt != 3 {
t.Errorf("attempt = %d, want 3 (parent attempt 2 + 1)", child.Attempt)
}
if child.MaxAttempts != tc.wantMaxAttempts {
t.Errorf("max_attempts = %d, want %d", child.MaxAttempts, tc.wantMaxAttempts)
}
// provider_network is resume-safe: the retry must continue the session.
if child.ForceFreshSession {
t.Errorf("force_fresh_session = true, want false (provider_network resumes) for %s", util.UUIDToString(child.ID))
}
})
}
}
// TestFailTaskProviderNetworkBudget is the end-to-end guard for Elon's must-fix
// (MUL-4910): FailTask must (1) grant provider_network its raised budget and
// persist a self-consistent child (attempt=3, max_attempts=3), and (2) still
// honour max_attempts=1 as "auto-retry disabled" — no child at all.
func TestFailTaskProviderNetworkBudget(t *testing.T) {
pool := newResolveOriginatorPool(t)
ctx := context.Background()
q := db.New(pool)
_, _, agentID, issueID := seedAttributionFixture(t, pool)
svc := &TaskService{Queries: q, TxStarter: pool, Bus: events.New()}
var runtimeID string
if err := pool.QueryRow(ctx, `SELECT runtime_id::text FROM agent WHERE id = $1`, agentID).Scan(&runtimeID); err != nil {
t.Fatalf("read agent runtime: %v", err)
}
cases := []struct {
name string
attempt int32
maxAttempts int32
wantChild bool
wantAttempt int32
wantMax int32
wantDeferred bool
}{
// Default budget, failing on the 2nd attempt → deferred final tier that
// records attempt=3 AND max_attempts=3 (no contradictory row).
{"final tier persists raised budget", 2, 2, true, 3, 3, true},
// Default budget, failing on the 1st attempt → immediate 2nd tier.
{"first retry is immediate", 1, 2, true, 2, 3, false},
// max_attempts=1 disables auto-retry — even provider_network gets none.
{"disabled budget is never revived", 1, 1, false, 0, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var parentID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, attempt, max_attempts, session_id, work_dir)
VALUES ($1, $2, $3, 'running', 0, $4, $5, 'src-session', '/tmp/src-workdir')
RETURNING id
`, agentID, runtimeID, issueID, tc.attempt, tc.maxAttempts).Scan(&parentID); err != nil {
t.Fatalf("insert parent task: %v", err)
}
t.Cleanup(func() {
pool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE parent_task_id = $1 OR id = $1`, parentID)
})
if _, err := svc.FailTask(ctx, parentID, "API Error: Connection closed mid-response.", "src-session", "/tmp/src-workdir", "agent_error.provider_network"); err != nil {
t.Fatalf("FailTask: %v", err)
}
var (
childAttempt, childMax int32
childStatus string
n int
)
row := pool.QueryRow(ctx, `SELECT count(*), coalesce(max(attempt),0), coalesce(max(max_attempts),0), coalesce(max(status),'') FROM agent_task_queue WHERE parent_task_id = $1`, parentID)
if err := row.Scan(&n, &childAttempt, &childMax, &childStatus); err != nil {
t.Fatalf("read child: %v", err)
}
if !tc.wantChild {
if n != 0 {
t.Fatalf("expected no retry child, got %d", n)
}
return
}
if n != 1 {
t.Fatalf("expected exactly one retry child, got %d", n)
}
if childAttempt != tc.wantAttempt {
t.Errorf("child attempt = %d, want %d", childAttempt, tc.wantAttempt)
}
if childMax != tc.wantMax {
t.Errorf("child max_attempts = %d, want %d (self-consistent budget)", childMax, tc.wantMax)
}
gotDeferred := childStatus == "deferred"
if gotDeferred != tc.wantDeferred {
t.Errorf("child status = %q, want deferred=%v", childStatus, tc.wantDeferred)
}
})
}
}