mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
170 lines
6.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|