mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +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>
85 lines
3.6 KiB
Go
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))
|
|
}
|
|
})
|
|
}
|
|
}
|