mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* 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>
327 lines
11 KiB
Go
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)
|
|
}
|
|
}
|