Files
multica/server/internal/handler/task_lifecycle.go
T
Bohan Jiangandmultica-agent eea687d461 MUL-5686: fix(chat): resume the cancelled turn's session instead of starting cold (#6352)
* fix(chat): resume the cancelled turn's session instead of starting cold

Stopping a chat turn the agent had already begun answering, then sending
the next message, produced a reply with no memory of the conversation.

The cancelled turn's provider session is real and recorded — the daemon
pins it onto the task row mid-flight and cancellation keeps it there —
but nothing could hand it back:

  - GetLastChatTaskSession / GetLastTaskSession only considered
    'completed' and 'failed' rows, so a cancelled row's session was
    invisible to resume resolution;
  - chat_session.session_id is written only by CompleteTask / FailTask,
    and a cancelled task reaches neither: the daemon discards its result
    and sends a cancel-ack. On a chat whose first turn was cancelled the
    pointer therefore stayed NULL and the next turn started cold, since
    buildChatPrompt injects only the current user message.

Let cancelled rows into both resume lookups, and advance the chat-level
pointer at cancel time so a provider that mints a new session id per
resume does not rewind past the cancelled exchange. The mid-flight pin
may now also fill an EMPTY session slot on a just-cancelled row, which
is what a Codex pin waiting on its rollout needs when the cancel wins
the race; occupied slots and completed/failed rows stay untouchable.

Retired sessions, poisoned-failure filters and the rollout-present guard
are unchanged. A transcript killed mid-tool-call that the provider later
refuses is still caught by taskfailure.UnresumableHistory, which retires
the session and starts the next turn fresh.

Fixes #6340

Co-authored-by: multica-agent <[email protected]>

* fix(chat): close the two cancel/pin races on the chat resume pointer

Review found the pointer advance had the same shape of hole it was meant
to fix, on both sides of the cancel.

1. The status flip and the pointer advance were separate statements. In
   the gap the row is already `cancelled` while the pointer still names
   the PREVIOUS turn, so a follow-up the user had queued could be claimed
   there and resume the older session — and a failed pointer write only
   logged, still reporting the cancel as successful. Both now commit in
   one transaction and the error propagates.

2. The mid-flight pin may land AFTER the cancel (for Codex it waits for
   the rollout), so the cancel transaction sees no session to publish and
   the pin only fills the task row. On a chat that already had history
   the stale pointer kept shadowing it, which is precisely the case the
   pin change was added for.

Both paths now run one guarded statement,
AdvanceCancelledChatSessionPointer. It reads the task row itself rather
than trusting an in-memory copy, ignores anything that is not a cancelled
chat task, and refuses to move the pointer when a NEWER task on the chat
already recorded a session — so a straggler pin cannot drag the
conversation back onto the interrupted turn.

Regression tests, both failing before this commit: a cancel whose pointer
write is blocked must not expose `cancelled` to another connection, and a
late pin on an already-cancelled row must reach the next real claim. The
newer-turn guard is covered too.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): make the pin atomic and restore the chat->task lock order

Second review round found the remaining half of the same class of bug.

1. PinTaskSession still committed the session onto the task row and
   advanced the chat pointer as two statements, and the pointer failure
   only logged while the endpoint still answered 204. A follow-up claimed
   in that window resumes the previous turn — the exact case the pin
   change was added for. Both writes now share one transaction and every
   failure is reported.

2. Adding the pointer write gave the cancel transaction two rows to hold,
   and it took them in the wrong order: agent_task_queue first, then
   chat_session. DeleteChatSession takes them the other way round, so the
   two could deadlock (40P01, and runInTx has no deadlock retry). The
   repo's documented order is chat_session -> agent_task_queue; both the
   cancel and the pin now open with LockChatSessionForTask, the same
   helper FinalizeDeferredCancelledChat uses. ErrNoRows there means a
   non-chat task or an already-deleted session — nothing to lock and
   nothing to advance.

Regression tests, all three failing before this commit (the concurrency
one with a real `deadlock detected`): the pin must not expose a session
on the task row while its pointer write is blocked; a cancel waiting on
the chat session must hold no lock on the task row (FOR UPDATE NOWAIT
probe); and cancel racing a chat delete must never come back with 40P01.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): put every chat-task terminal write behind the same lock order

The cancel and pin paths took chat_session before agent_task_queue; the
terminal reports still took them the other way round, so the two could
deadlock (40P01) — reproduced deterministically as cancel-vs-complete.
Choosing per-path was never going to work: either every writer that holds
both rows agrees on an order or none of them are safe.

CompleteTask, FailTask and the cancelled-chat finalize now open with the
same LockChatSessionForTask the cancel, pin, DeleteChatSession and
FinalizeDeferredCancelledChat paths use, via a shared helper that
documents the invariant in one place.

Also de-flakes the concurrency tests this PR added. They held a row lock
and then read other rows on a FRESH pooled connection; the suite shares
one database, so a sibling package's DDL could queue an ACCESS EXCLUSIVE
request in between, park our later ACCESS SHARE request behind it, and
wedge the package until the 10-minute timeout (observed in a parallel
`go test ./internal/...` run). Those transactions now take their table
locks up front and read on the connection that already holds them, and
every racing call is bounded so a stall fails loudly instead of hanging.

Regression tests, all failing before this commit: complete and fail must
hold no lock on the task row while waiting for the chat session (FOR
UPDATE NOWAIT probe), and cancel-vs-complete plus pin-vs-fail must never
come back with 40P01.

Co-authored-by: multica-agent <[email protected]>

* test(chat): fail the race tests on any unexpected error, bound every call

Review nits on the concurrency tests.

Matching only *pgconn.PgError(40P01) let the pin side through: a pin that
loses a deadlock is reported by the HTTP handler as a plain 500 with no
PgError to unwrap, so the check skipped exactly the failure the test
exists for. Both racers now fail on anything except the one benign
outcome — whoever finalized the task first leaves the loser matching no
row (pgx.ErrNoRows).

The remaining racing calls still used an unbounded context.Background(),
which this PR had already claimed were bounded. They now share the same
raceTimeout as the rest.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-04 16:47:17 +08:00

225 lines
9.2 KiB
Go

package handler
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// RecoverOrphanedTasks is called by the daemon at startup for each runtime
// it owns. It atomically fails any dispatched/running tasks the server still
// believes belong to that runtime — those are the tasks the previous daemon
// process was running when it died — and triggers MaybeRetryFailedTask for
// each so the user sees a fresh attempt instead of a permanently stuck row.
//
// This is the targeted fix for "issue stuck at in_progress when daemon
// restarts mid-task": the runtime heartbeat sweeper takes up to 75s + the
// in-process task timeout (2.5h) to notice such tasks; the daemon itself
// knows the moment it comes back up, so we let it report orphan recovery.
func (h *Handler) RecoverOrphanedTasks(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
if _, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID); !ok {
return
}
rows, err := h.Queries.RecoverOrphanedTasksForRuntime(r.Context(), parseUUID(runtimeID))
if err != nil {
slog.Warn("recover-orphans failed", "runtime_id", runtimeID, "error", err)
writeError(w, http.StatusInternalServerError, "recover orphans failed")
return
}
// Funnel through the shared post-failure pipeline so we get the same
// task:failed events, agent reconcile, issue rollback, and auto-retry
// behaviour as the runtime sweeper. This was previously a fast-path
// that bypassed those side effects, leaving the UI stale when no retry
// was created (max_attempts exhausted, autopilot, non-retryable reason).
retried := h.TaskService.HandleFailedTasks(r.Context(), rows)
if len(rows) > 0 {
slog.Info("recover-orphans completed",
"runtime_id", runtimeID,
"orphaned", len(rows),
"retried", retried,
)
}
writeJSON(w, http.StatusOK, map[string]any{
"orphaned": len(rows),
"retried": retried,
})
}
// PinTaskSession lets the daemon persist the agent's session_id and
// work_dir as soon as they're known — typically right after the agent
// emits its first system message — so a crash mid-run doesn't lose the
// resume pointer needed to continue the conversation on the next attempt.
type PinTaskSessionRequest struct {
SessionID string `json:"session_id,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
}
func (h *Handler) PinTaskSession(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if _, ok := h.requireDaemonTaskAccess(w, r, taskID); !ok {
return
}
var req PinTaskSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.SessionID == "" && req.WorkDir == "" {
writeError(w, http.StatusBadRequest, "session_id or work_dir required")
return
}
params := db.UpdateAgentTaskSessionParams{ID: parseUUID(taskID)}
if req.SessionID != "" {
params.SessionID = pgtype.Text{String: req.SessionID, Valid: true}
}
if req.WorkDir != "" {
params.WorkDir = pgtype.Text{String: req.WorkDir, Valid: true}
}
// The pin can arrive after the user has already cancelled the run — it is
// asynchronous, and for Codex it waits for the rollout to reach the store.
// The cancel transaction then found no session to publish, so the chat's
// resume pointer is still on the previous turn and would shadow the session
// this pin is about to record (the claim handler reads the pointer before
// the GetLastChatTaskSession fallback). Advancing it here closes that half
// of GH #6340.
//
// Both writes commit together. Landing the session on the task row first and
// the pointer second leaves the same window the cancel path had: the row
// already names the new session while the pointer still names the previous
// turn, and a follow-up claimed in between resumes the older one. The lock
// comes first for the same reason it does in CancelTaskWithResult —
// chat_session -> agent_task_queue is the global order, and ErrNoRows simply
// means there is no session to lock or advance.
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
slog.Warn("pin-session failed to start tx", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "pin session failed")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
if _, err := qtx.LockChatSessionForTask(r.Context(), params.ID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
slog.Warn("pin-session failed to lock chat session", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "pin session failed")
return
}
if err := qtx.UpdateAgentTaskSession(r.Context(), params); err != nil {
slog.Warn("pin-session failed", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "pin session failed")
return
}
// The statement re-reads the row, ignores anything that is not a cancelled
// chat task, and refuses to move the pointer when a newer turn already owns
// a session — so a straggler pin cannot drag the conversation backwards.
if err := qtx.AdvanceCancelledChatSessionPointer(r.Context(), params.ID); err != nil {
slog.Warn("advance cancelled chat session pointer failed", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "pin session failed")
return
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("pin-session commit failed", "task_id", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "pin session failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
// RerunIssueRequest is the optional body of POST /api/issues/{id}/rerun.
// All fields are optional; an empty body keeps the legacy "rerun the issue's
// current assignee" behaviour used by the CLI.
type RerunIssueRequest struct {
// TaskID identifies the execution-log row the user clicked retry on.
// When set, the rerun targets the agent that ran that specific task
// (and reuses its leader/worker role) rather than the issue's current
// assignee — so clicking retry on row that belonged to a now-displaced
// agent re-fires that same agent, not the new assignee.
TaskID string `json:"task_id,omitempty"`
}
// RerunIssue manually re-enqueues an agent run for the issue. By default it
// targets the issue's current assignee (agent or squad leader); if the
// request body carries task_id, the rerun targets the agent that ran that
// specific past task instead. The new task is flagged force_fresh_session=true:
// the daemon claim handler skips the (agent_id, issue_id) session-resume
// lookup so the agent starts a clean session. A user clicking rerun has just
// judged the prior output bad — replaying the same conversation would replay
// the same poisoned state. (Automatic retry, by contrast, intentionally
// inherits the session — that path handles infrastructure failures, not bad
// output.)
func (h *Handler) RerunIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
// Body is optional. A zero-length body or `{}` keeps the legacy
// assignee-driven rerun behaviour the CLI relies on.
var req RerunIssueRequest
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
var sourceTaskID pgtype.UUID
if req.TaskID != "" {
parsed, ok := parseUUIDOrBadRequest(w, req.TaskID, "task_id")
if !ok {
return
}
sourceTaskID = parsed
}
// A manual rerun is a direct human action: attribute the new run to the
// rerunning member (MUL-4302 §5). Resolve the actor the same way assign/promote
// does; an agent A2A actor is not a human and threads an invalid actor.
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := uuidToString(issue.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
actorUserID := memberActorUserID(actorType, actorID)
// Re-validate the operator's invoke permission on the resolved target agent
// before cancelling / creating anything (MUL-4525). Issue visibility does not
// grant the right to trigger a private agent — a task_id rerun must gate the
// historical agent, not the (possibly reassigned) current assignee.
originatorUserID := h.invokeOriginatorFromRequest(r, actorType, actorID)
canInvoke := func(agent db.Agent) bool {
return h.canInvokeAgent(r.Context(), agent, actorType, actorID, originatorUserID, workspaceID)
}
task, err := h.TaskService.RerunIssue(r.Context(), issue.ID, sourceTaskID, pgtype.UUID{}, actorUserID, canInvoke)
if errors.Is(err, service.ErrRerunInvokeNotAllowed) {
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonInvocationNotAllowed)
return
}
if err != nil {
slog.Warn("issue rerun failed", "issue_id", id, "error", err)
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := taskToResponse(*task, uuidToString(issue.WorkspaceID))
h.hydrateTaskAttributions(r.Context(), []*TaskAttribution{resp.Attribution})
writeJSON(w, http.StatusAccepted, resp)
}