Files
multica/server/internal/service/rerun_workdir_reuse_test.go
Bohan Jiang ae68799c64 feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869) (#5525)
* 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>
2026-07-16 16:38:18 +08:00

85 lines
3.6 KiB
Go

package service
import (
"context"
"testing"
"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"
)
// TestRerunIssuePinsForceFreshSessionForRollbackSafety locks in the rollback-safe
// half of the MUL-4869 contract: RerunIssue ALWAYS persists
// force_fresh_session=true on the rerun row, no matter how the source task
// failed. The session-reuse decision is made later by the (new) claim handler
// from the source task, so an OLD claim handler picked up mid rolling-deploy —
// which gates the whole resume lookup on !force_fresh_session — degrades to a
// clean start instead of resuming a different execution via the
// (agent, issue) most-recent query. The claim-layer behaviour (workdir always
// reused, session gated by source failure class) is asserted in
// TestClaimTask_ManualRetryReusesWorkdir.
func TestRerunIssuePinsForceFreshSessionForRollbackSafety(t *testing.T) {
pool := newResolveOriginatorPool(t)
ctx := context.Background()
q := db.New(pool)
workspaceID, creatorID, agentID, issueID := seedAttributionFixture(t, pool)
_ = workspaceID
svc := &TaskService{Queries: q, TxStarter: pool, Bus: events.New()}
// 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)
}
// The flag must be true across resume-safe, resume-poisoned, and cancelled
// source failures alike — the enqueue row never encodes the classification.
cases := []struct {
name string
status string
failureReason any // string, or nil for a NULL failure_reason
}{
{name: "transient_timeout", status: "failed", failureReason: "timeout"},
{name: "transient_provider_network", status: "failed", failureReason: "agent_error.provider_network"},
{name: "poisoned_context_overflow", status: "failed", failureReason: "agent_error.context_overflow"},
{name: "cancelled_no_reason", status: "cancelled", failureReason: nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var sourceID pgtype.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, failure_reason, session_id, work_dir)
VALUES ($1, $2, $3, $4, 0, $5, 'src-session', '/tmp/src-workdir')
RETURNING id
`, agentID, runtimeID, issueID, tc.status, tc.failureReason).Scan(&sourceID); err != nil {
t.Fatalf("insert source task: %v", err)
}
t.Cleanup(func() { pool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, sourceID) })
task, err := svc.RerunIssue(ctx, util.MustParseUUID(issueID), sourceID, pgtype.UUID{}, util.MustParseUUID(creatorID), nil)
if err != nil {
t.Fatalf("RerunIssue: %v", err)
}
t.Cleanup(func() { pool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, task.ID) })
var forceFresh bool
var rerunOf pgtype.UUID
if err := pool.QueryRow(ctx, `
SELECT force_fresh_session, rerun_of_task_id FROM agent_task_queue WHERE id = $1
`, task.ID).Scan(&forceFresh, &rerunOf); err != nil {
t.Fatalf("read rerun task: %v", err)
}
if !forceFresh {
t.Errorf("force_fresh_session = false, want true for rollback safety (source failure_reason=%v)", tc.failureReason)
}
if !rerunOf.Valid || rerunOf.Bytes != sourceID.Bytes {
t.Errorf("rerun_of_task_id = %s, want source %s", util.UUIDToString(rerunOf), util.UUIDToString(sourceID))
}
})
}
}