Files
multica/server/internal/service/task_complete_race_test.go
Bohan Jiang 30318b79bc MUL-5426: fix(daemon): retire sessions whose history the provider refuses to replay (#6083)
* fix(daemon): retire sessions whose history the provider refuses to replay

A run killed mid-reply (machine shutdown, force-quit, SIGKILL) can leave an
empty assistant message in the agent CLI's transcript. Every later resume
replays it, the provider rejects the request, and the (agent, issue) pair is
bricked with no self-healing and no user-facing recovery.

Multica already has the mechanism for this — poisoned-session classification —
but its detector paired "400" with "invalid_request_error", which is the
Anthropic wire shape. The same defect reported by any other provider carried
neither token, so it classified as agent_error.unknown: resume-safe by
omission. GetLastTaskSession kept handing back the dead session on every
follow-up, manual Rerun resolved it through the same predicate, and the
in-turn fresh-session retry never fired because ResumeRejected is false here
(nothing rejected the resume — the transcript loaded and the provider refused
to replay it).

Add taskfailure.UnresumableHistory, which recognises the defect by what the
provider says is wrong — some content is empty, and here is which message in
the history — rather than by status code or provider name. Both signals are
required, so a tool reporting "field must not be empty" does not match.

Wire it into the four places that decide whether a session survives:

- classifyPoisonedError, so the task is written as api_invalid_request
- shouldRetryWithFreshSession, so the turn recovers on all 17 backends
  instead of the subset whose adapter learned to detect it; the tools == 0
  gate is unchanged, so a run that already used a tool is never re-run
- ResumeUnsafeFailure, covering the manual-Rerun path
- both resume queries, as defense-in-depth for hosts whose daemon predates
  this (self-host daemons upgrade on their own cadence)

Fixes #6066. Also covers the daemon half of #5760.

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

* fix(session): close the Chat and fresh-retry paths that resurrect a poisoned session

Review found the previous commit stopped short in two places, both of which
put the dead transcript back in play.

Chat never consulted the guarded query. The claim handler reads
chat_session.session_id first and only falls back to GetLastChatTaskSession
when it is empty, so a poisoned pointer there bypasses every filter that query
applies. The fail path merely declined to OVERWRITE the pointer, leaving it in
place. It now clears it in the same transaction, matched on session and
runtime so a concurrent turn's newer pointer survives. The promote guard moves
to ResumeUnsafeFailure as well — the reason-only check passed an un-upgraded
daemon's agent_error.unknown row and re-pinned what the clear had just removed.

GetLastChatTaskSession also kept the row-level filter the issue query dropped
in GH #5975: it discarded the newest poisoned row and fell back to an older
completed row carrying the same dead session. It now judges each session by
its latest terminal state, matching GetLastTaskSession.

A recovered turn could not retire anything. A terminal report carried one
session_id, and an empty one meant both "nothing to report" and "forget the
old session", so a fresh-session retry that SUCCEEDED left the id it retried
away from selectable — through an older completed row on the issue, or through
the chat pointer. agent_task_queue.retired_session_id records the abandonment
itself, reported on every terminal path including completed, and both resume
lookups exclude it. This is the contract gap the previous PR deferred; the
fresh-retry path now runs on all backends, so deferring it is not safe.

Also narrows what the cross-backend test claims: it pins the shared decision,
not that all 17 adapters surface the error into Result.Error (#5760 is the
counter-example), and says so.

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

* test(session): require pgx.ErrNoRows in the resume-exclusion assertions

The `if err == nil && prior.SessionID.Valid` form these tests shared is
false-green: any real fault — undefined column, syntax error, dead connection
— makes err non-nil, so the condition is false and the test passes. Run
against a database missing this branch's new column, the exclusion tests
reported PASS on a SQLSTATE 42703, meaning they could not have caught a broken
query.

requireSessionExcluded demands pgx.ErrNoRows specifically and fails loudly on
anything else, so a green run now means the filter worked rather than the
query never ran.

Applied to all nine sites, not just the four this branch added: the other five
guard the same GetLastTaskSession exclusion behaviour that this branch
changes, so leaving them false-green would leave the change under-tested. All
nine pass on a correctly migrated database.

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-29 15:54:51 +08:00

327 lines
11 KiB
Go

package service
import (
"context"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/taskfailure"
)
// mockRow implements pgx.Row, returning either a scanned task or pgx.ErrNoRows.
type mockRow struct {
task *db.AgentTaskQueue
err error
}
func (r *mockRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
t := r.task
ptrs := []any{
&t.ID, &t.AgentID, &t.IssueID, &t.Status, &t.Priority,
&t.DispatchedAt, &t.StartedAt, &t.CompletedAt, &t.Result,
&t.Error, &t.CreatedAt, &t.Context, &t.RuntimeID,
&t.SessionID, &t.WorkDir, &t.TriggerCommentID,
&t.ChatSessionID, &t.AutopilotRunID,
}
for i, p := range ptrs {
if i >= len(dest) {
break
}
// Copy value from source to dest by assigning through the pointer.
switch d := dest[i].(type) {
case *pgtype.UUID:
*d = *(p.(*pgtype.UUID))
case *string:
*d = *(p.(*string))
case *int32:
*d = *(p.(*int32))
case *pgtype.Timestamptz:
*d = *(p.(*pgtype.Timestamptz))
case *[]byte:
*d = *(p.(*[]byte))
case *pgtype.Text:
*d = *(p.(*pgtype.Text))
}
}
return nil
}
// mockDBTX routes QueryRow calls: complete/fail queries return ErrNoRows,
// getAgentTask returns the stored task.
type mockDBTX struct {
task db.AgentTaskQueue
}
func (m *mockDBTX) Exec(_ context.Context, _ string, _ ...interface{}) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag(""), nil
}
func (m *mockDBTX) Query(_ context.Context, _ string, _ ...interface{}) (pgx.Rows, error) {
return nil, pgx.ErrNoRows
}
func (m *mockDBTX) QueryRow(_ context.Context, sql string, _ ...interface{}) pgx.Row {
// CompleteAgentTask and FailAgentTask SQL contain "SET status ="
if strings.Contains(sql, "SET status =") {
return &mockRow{err: pgx.ErrNoRows}
}
// GetAgentTask — return the existing task
return &mockRow{task: &m.task}
}
func testUUID(b byte) pgtype.UUID {
var u pgtype.UUID
u.Valid = true
u.Bytes[0] = b
return u
}
func TestCompleteTask_AlreadyFinalized(t *testing.T) {
taskID := testUUID(1)
agentID := testUUID(2)
tests := []struct {
name string
status string
}{
{"already completed", "completed"},
{"already cancelled", "cancelled"},
{"already failed", "failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockDBTX{task: db.AgentTaskQueue{
ID: taskID,
AgentID: agentID,
Status: tt.status,
}}
svc := &TaskService{
Queries: db.New(mock),
Bus: events.New(),
}
got, err := svc.CompleteTask(context.Background(), taskID, nil, "", "", false, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got == nil {
t.Fatal("expected task, got nil")
}
if got.Status != tt.status {
t.Errorf("expected status %q, got %q", tt.status, got.Status)
}
if got.ID != taskID {
t.Error("returned task ID doesn't match")
}
})
}
}
func TestFailTask_AlreadyFinalized(t *testing.T) {
taskID := testUUID(1)
agentID := testUUID(2)
tests := []struct {
name string
status string
}{
{"already completed", "completed"},
{"already cancelled", "cancelled"},
{"already failed", "failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockDBTX{task: db.AgentTaskQueue{
ID: taskID,
AgentID: agentID,
Status: tt.status,
}}
svc := &TaskService{
Queries: db.New(mock),
Bus: events.New(),
}
got, err := svc.FailTask(context.Background(), taskID, "agent crashed", "", "", "", false, "")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got == nil {
t.Fatal("expected task, got nil")
}
if got.Status != tt.status {
t.Errorf("expected status %q, got %q", tt.status, got.Status)
}
if got.ID != taskID {
t.Error("returned task ID doesn't match")
}
})
}
}
// TestProviderNetworkRetrySchedule locks in the three-tier schedule for a
// transient provider stream cut (MUL-4910): first run + immediate retry + one
// retry deferred ~5s, and only for provider_network — other retryable reasons
// keep their generic max_attempts=2 (single, immediate retry).
func TestProviderNetworkRetrySchedule(t *testing.T) {
const provNet = "agent_error.provider_network"
// Attempt ceiling: provider_network is raised to 3, but only ever WIDENS the
// budget and never overrides the max_attempts<=1 "retry disabled" contract.
ceilingCases := []struct {
reason string
max int32
want int32
}{
{provNet, 2, providerNetworkMaxAttempts}, // default budget → raised to 3
{provNet, 1, 1}, // disabled → stays disabled, not revived
{provNet, 5, 5}, // higher configured budget → kept (widen-only)
{"timeout", 2, 2}, // unrelated reason → column value untouched
{"timeout", 1, 1}, // unrelated + disabled → untouched
}
for _, tc := range ceilingCases {
if got := retryAttemptCeiling(tc.reason, tc.max); got != tc.want {
t.Errorf("ceiling(%q, %d) = %d, want %d", tc.reason, tc.max, got, tc.want)
}
}
// Backoff: only provider_network's final attempt (after the 2nd failure) is
// deferred; its first retry and every other reason are immediate.
delayCases := []struct {
reason string
failedAttempt int32
want time.Duration
}{
{provNet, 1, 0}, // first failure → immediate retry
{provNet, 2, providerNetworkFinalRetryWait}, // second failure → 5s-deferred retry
{"timeout", 2, 0}, // unrelated reason → never deferred
}
for _, tc := range delayCases {
if got := retryDelayForAttempt(tc.reason, tc.failedAttempt); got != tc.want {
t.Errorf("retryDelayForAttempt(%q, %d) = %s, want %s", tc.reason, tc.failedAttempt, got, tc.want)
}
}
// Eligibility across the whole chain. mkTask has an issue link and no
// autopilot run so only the reason/attempt/ceiling gate is exercised.
mkTask := func(attempt, max int32) db.AgentTaskQueue {
return db.AgentTaskQueue{
Attempt: attempt,
MaxAttempts: max,
IssueID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true},
}
}
eligCases := []struct {
name string
reason string
attempt int32
max int32
want bool
}{
{"provider_network first run retries", provNet, 1, 2, true},
{"provider_network second run still retries (deferred tier)", provNet, 2, 2, true},
{"provider_network third run is the ceiling", provNet, 3, 2, false},
{"provider_network with retry disabled (max_attempts=1) never retries", provNet, 1, 1, false},
{"timeout keeps single immediate retry", "timeout", 1, 2, true},
{"timeout exhausts at attempt 2", "timeout", 2, 2, false},
{"non-retryable reason never retries", "agent_error.unknown", 1, 2, false},
}
for _, tc := range eligCases {
if got := retryEligible(tc.reason, mkTask(tc.attempt, tc.max)); got != tc.want {
t.Errorf("%s: retryEligible(%q, attempt=%d/max=%d) = %v, want %v", tc.name, tc.reason, tc.attempt, tc.max, got, tc.want)
}
}
}
func TestTaskFailureClassifiers(t *testing.T) {
cases := []struct {
reason string
wantType string
wantResumeOK bool
wantRetry bool
}{
{reason: "timeout", wantType: "timeout", wantResumeOK: true, wantRetry: true},
{reason: "codex_semantic_inactivity", wantType: "timeout", wantResumeOK: false, wantRetry: true},
// Transient mid-stream provider disconnect (MUL-4910): retryable, and
// resume-safe so the retry continues the truncated conversation.
{reason: "agent_error.provider_network", wantType: "agent_error", wantResumeOK: true, wantRetry: true},
{reason: "runtime_recovery", wantType: "runtime", wantResumeOK: true, wantRetry: true},
{reason: "iteration_limit", wantType: "agent_output", wantResumeOK: false, wantRetry: false},
{reason: "api_invalid_request", wantType: "agent_error", wantResumeOK: false, wantRetry: false},
{reason: "agent_error.context_overflow", wantType: "agent_error", wantResumeOK: false, wantRetry: false},
{reason: "agent_error", wantType: "agent_error", wantResumeOK: true, wantRetry: false},
// Missing terminal result errors classify to agent_error.unknown. Keep
// that deterministic upstream failure outside the auto-retry allowlist.
{reason: "agent_error.unknown", wantType: "agent_error", wantResumeOK: true, wantRetry: false},
}
for _, tc := range cases {
t.Run(tc.reason, func(t *testing.T) {
if got := taskErrorType(tc.reason); got != tc.wantType {
t.Fatalf("taskErrorType(%q) = %q, want %q", tc.reason, got, tc.wantType)
}
if got := !resumeUnsafeFailureReason(tc.reason); got != tc.wantResumeOK {
t.Fatalf("resume-safe(%q) = %v, want %v", tc.reason, got, tc.wantResumeOK)
}
if got := retryableReasons[tc.reason]; got != tc.wantRetry {
t.Fatalf("retryableReasons[%q] = %v, want %v", tc.reason, got, tc.wantRetry)
}
})
}
}
// TestSkillBundleFailureFromLegacyDaemonRetries is the mixed-version
// regression for MUL-5370. It walks the exact chain FailTask runs for a task
// an un-upgraded daemon just failed, and asserts the user-visible outcome:
// the run is retried instead of dying.
//
// The trap this guards: the daemon-side fix labels the failure structurally,
// but an old daemon reports a NON-EMPTY catchall, so FailTask's "classify only
// when the caller gave us nothing" branch leaves it alone. Without
// NormalizeDaemonReason the reason stays agent_error.unknown, which is not on
// retryableReasons — meaning the fix would reach only hosts that happened to
// update, while the un-upgraded hosts most likely to be hitting the bug keep
// failing terminally.
func TestSkillBundleFailureFromLegacyDaemonRetries(t *testing.T) {
const legacyErr = "resolve skill bundles: context deadline exceeded"
task := db.AgentTaskQueue{
Attempt: 1,
MaxAttempts: 2,
IssueID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true},
}
// What an old daemon puts on the wire, and what FailTask does with it.
legacyReason := taskfailure.ReasonAgentUnknown.String()
if retryEligible(legacyReason, task) {
t.Fatal("precondition: the raw catchall must not be retryable, or this test proves nothing")
}
normalized := taskfailure.NormalizeDaemonReason(legacyReason, legacyErr).String()
if normalized != taskfailure.ReasonSkillBundleUnavailable.String() {
t.Fatalf("normalized reason = %q, want %q", normalized, taskfailure.ReasonSkillBundleUnavailable)
}
if !retryEligible(normalized, task) {
t.Errorf("a skill-bundle failure reported by an old daemon must still be retried; got reason %q", normalized)
}
// A current daemon supplies the reason itself and must reach the same
// outcome — the two versions converge rather than diverging by client.
current := taskfailure.NormalizeDaemonReason(
taskfailure.ReasonSkillBundleUnavailable.String(),
`skill bundle unavailable: skill "x" (id=1, 10 bytes) after 30s: context deadline exceeded`,
).String()
if !retryEligible(current, task) {
t.Errorf("a skill-bundle failure reported by a current daemon must be retried; got reason %q", current)
}
}