mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
316 lines
12 KiB
Go
316 lines
12 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.TaskService.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 preserves the issue-scoped API used by installed clients. A
|
|
// task_id repeats that run; an empty body targets the current assignee.
|
|
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
|
|
}
|
|
h.rerunIssue(w, r, issue, sourceTaskID, false)
|
|
}
|
|
|
|
// RerunTaskByUser repeats an issue-linked run without requiring its issue ID.
|
|
func (h *Handler) RerunTaskByUser(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := requireUserID(w, r); !ok {
|
|
return
|
|
}
|
|
workspaceID, ok := parseUUIDOrBadRequest(w, ctxWorkspaceID(r.Context()), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
taskID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "taskId"), "task id")
|
|
if !ok {
|
|
return
|
|
}
|
|
source, err := h.Queries.GetAgentTaskInWorkspace(r.Context(), db.GetAgentTaskInWorkspaceParams{
|
|
ID: taskID, WorkspaceID: workspaceID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "task not found")
|
|
return
|
|
}
|
|
if !source.IssueID.Valid {
|
|
writeError(w, http.StatusBadRequest, "only issue-linked runs can be rerun with this endpoint")
|
|
return
|
|
}
|
|
issue, ok := h.loadIssueForUser(w, r, uuidToString(source.IssueID))
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Fresh bool `json:"fresh"`
|
|
}
|
|
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
|
|
}
|
|
}
|
|
h.rerunIssue(w, r, issue, source.ID, req.Fresh)
|
|
}
|
|
|
|
func (h *Handler) rerunIssue(w http.ResponseWriter, r *http.Request, issue db.Issue, sourceTaskID pgtype.UUID, fresh bool) {
|
|
// 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, fresh, canInvoke)
|
|
if errors.Is(err, service.ErrRerunInvokeNotAllowed) {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonInvocationNotAllowed)
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrIssueInTriage) {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonIssueInTriage)
|
|
return
|
|
}
|
|
// Not a dispatch refusal: the issue may well be runnable, and only the named
|
|
// source is ineligible. It falls through to the 400 below with the
|
|
// sentinel's own sentence, like the sibling "does not belong to this issue".
|
|
if err != nil {
|
|
slog.Warn("issue rerun failed", "issue_id", uuidToString(issue.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)
|
|
}
|
|
|
|
// RetrySourceContextQuickCreate manually re-enqueues a failed issue-less
|
|
// quick-create while atomically moving its pending immutable source context to
|
|
// the new task. The workspace middleware supplies tenancy; the service also
|
|
// requires the original requester and the normal private-agent invoke gate.
|
|
func (h *Handler) RetrySourceContextQuickCreate(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID, ok := parseUUIDOrBadRequest(w, ctxWorkspaceID(r.Context()), "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
requesterID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
taskID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "taskId"), "task id")
|
|
if !ok {
|
|
return
|
|
}
|
|
canInvoke := func(agent db.Agent) bool {
|
|
return h.canInvokeAgent(r.Context(), agent, "member", userID, userID, uuidToString(workspaceID))
|
|
}
|
|
task, err := h.TaskService.RetrySourceContextQuickCreate(r.Context(), workspaceID, requesterID, taskID, canInvoke)
|
|
if writeIssueLimitReached(w, err) {
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrRerunInvokeNotAllowed) {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonInvocationNotAllowed)
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrSourceContextRetryUnavailable) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"code": "source_context_retry_unavailable",
|
|
"error": "This context can no longer be retried. Start again from the branch point.",
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Warn("source context quick-create retry failed", "task_id", uuidToString(taskID), "error", err)
|
|
writeError(w, http.StatusInternalServerError, "retry source context quick create")
|
|
return
|
|
}
|
|
resp := taskToResponse(*task, uuidToString(workspaceID))
|
|
h.hydrateTaskAttributions(r.Context(), []*TaskAttribution{resp.Attribution})
|
|
writeJSON(w, http.StatusAccepted, resp)
|
|
}
|