mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)
A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.
New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.
- RerunIssue derives force_fresh_session from the source task's failure_reason
via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
context would immediately overflow again.
Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.
Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)
Addresses code review on #5525.
- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
source failure. Rerun rows are pinned to force_fresh_session=true again, so an
OLD claim handler picked up mid rolling-deploy degrades to a clean start
instead of falling back to the (agent, issue) most-recent lookup and resuming
a *different* execution. The new claim handler ignores the flag for reruns and
computes session reuse from the exact source task (rerun_of_task_id).
- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
errorText), which combines the failure_reason poison set with the same
400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
claim handler's exact-source path uses it, so legacy 'agent_error' /
deploy-window rows carrying a 400 marker are no longer resumed.
- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
force_fresh_session=true). Service test now asserts that rollback-safe
invariant across failure classes; the claim test drives session gating from
the source task and adds a legacy-400 case.
- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
distinguish execution-log per-row retry (task_id: reuse workdir, conditional
session) from CLI/API rerun (no task_id: fresh session + fresh workdir).
- Clarify cross-runtime workdir is best-effort: the daemon offers the source
workdir regardless of runtime (a shared mount may resolve it) and only the
per-cwd session is runtime-gated.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
203 lines
5.5 KiB
Go
203 lines
5.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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, "", "")
|
|
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", "", "", "")
|
|
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 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},
|
|
{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)
|
|
}
|
|
})
|
|
}
|
|
}
|