diff --git a/db/exploration.go b/db/exploration.go index ee8c902..94e16bc 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -220,6 +220,35 @@ WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID) return n, nil } +// ReopenIntent flips ONE not-successfully-finished intent (blocked/exhausted/stopped) +// back to 'open' so a worker re-claims and re-runs it from scratch (graph writes it +// already produced stay). done/open/running are left untouched. Returns whether a row +// changed (false = intent absent or not in a rerunnable state). Used by the "重跑" button. +func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) { + res, err := s.db.Exec(`UPDATE exploration_nodes +SET state='open', completed_at=NULL +WHERE id=$1 AND exploration_id=$2 AND kind='intent' + AND state IN ('blocked','exhausted','stopped')`, id, s.expID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// ReopenBlockedIntents flips EVERY 'blocked' intent in this exploration back to 'open' +// (batch rerun after e.g. an LLM/network outage that blocked many at once). Returns the +// number reopened. Workers re-run each from scratch; kept graph writes remain. +func (s *ExplorationStore) ReopenBlockedIntents() (int64, error) { + res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL +WHERE exploration_id=$1 AND kind='intent' AND state='blocked'`, s.expID) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return n, nil +} + func (s *ExplorationStore) SetIntentState(id int64, state string) error { // terminal states stamp completed_at; reopening (back to open/running) clears it. terminal := state == "done" || state == "blocked" || state == "exhausted" || state == "stopped" diff --git a/server/goals.go b/server/goals.go index 279a2e9..09e23b1 100644 --- a/server/goals.go +++ b/server/goals.go @@ -46,21 +46,21 @@ func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) { }() } -// resumeTaskForGoal 让「主 agent 通过 set_goals 新增目标」的任务能继续跑:把终态 -// (done/failed/timeout)拉回 running、解除暂停,并(重)启动引擎循环 + 唤醒 planner。 -// 已在 running 且未暂停的任务:只剩 Run 里的一次 Notify,近乎无副作用。 +// reviveTask 让一个已停下的任务重新跑起来:把终态(done/failed/timeout)拉回 running、 +// 解除暂停,并(重)启动引擎循环 + 唤醒。已在 running 且未暂停的任务:只剩 Run 里的一次 +// Notify,近乎无副作用。用于「主 agent set_goals 新增目标」和「重跑 blocked 意图」两处。 // -// 为什么必须显式复活:planner 循环的终态门(engine.go)会吞掉普通 notify——光新增 goal -// 节点 + Notify 唤不醒已判完成的任务;重启后终态任务的 goroutine 也可能已不在,故还要 Run。 -func (s *Server) resumeTaskForGoal(t *Task) { +// 为什么必须显式复活:planner/worker 循环的终态门(engine.go)会吞掉普通 notify——光改 +// 图 + Notify 唤不醒已判完成的任务;重启后终态任务的 goroutine 也可能已不在,故还要 Run。 +func (s *Server) reviveTask(t *Task) { if t == nil { return } // 终态 → 拉回 running(SetTaskStatus 会清 completed_at 并同步内存 handle 的 Status, - // 使 planner 循环的终态门放行)。 + // 使 planner/worker 循环的终态门放行)。 if isTerminalStatus(t.Status) { if err := s.m.SetTaskStatus(t.ID, "running"); err != nil { - log.Printf("[chat] task %s set_goals 复活置 running 失败: %v", t.ID, err) + log.Printf("[revive] task %s 置 running 失败: %v", t.ID, err) } } // 确保引擎循环存活:已 started 只会 Notify;未 started(重启后未恢复的终态任务)则重起循环。 @@ -70,7 +70,7 @@ func (s *Server) resumeTaskForGoal(t *Task) { s.engine.Resume(t) t.Paused = false if err := s.m.SetTaskPaused(t.ID, false); err != nil { - log.Printf("[chat] task %s set_goals 复活解除暂停失败: %v", t.ID, err) + log.Printf("[revive] task %s 解除暂停失败: %v", t.ID, err) } } } diff --git a/server/server.go b/server/server.go index f12185c..f00eef8 100644 --- a/server/server.go +++ b/server/server.go @@ -577,6 +577,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) + mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图 + mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图 mux.HandleFunc("POST /api/active", s.setActive) mux.HandleFunc("GET /api/llm", s.getLLM) @@ -907,6 +909,54 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"id": t.ID, "paused": paused}) } +// rerunIntent 重跑一条没跑成功的意图(blocked/exhausted/stopped):把它置回 open,worker +// 会重新认领、从头再跑(已写回图谱的 fact/finding/asset 保留);若任务已终态/暂停则顺带复活。 +// 用于「出错的 work 点击继续运行」——网络/LLM 抖动导致 blocked 后可一键重试。 +func (s *Server) rerunIntent(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64) + if err != nil { + writeErr(w, 400, "bad intent id") + return + } + reopened, err := t.Store.ReopenIntent(iid) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if !reopened { + writeErr(w, 409, "该意图不是可重跑状态(仅 blocked/exhausted/stopped 可重跑)") + return + } + s.reviveTask(t) // 终态/暂停 → 拉回 running,确保 worker 循环存活并重新认领 + log.Printf("[task] #%s 意图 #%d 已重开(重跑)", t.ID, iid) + writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": iid}) +} + +// rerunBlocked 批量重跑本任务全部 blocked 意图(适合一次网络/LLM 断连导致多条 blocked 后 +// 一键全部重试),置回 open 并复活任务;返回重开的条数。 +func (s *Server) rerunBlocked(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + n, err := t.Store.ReopenBlockedIntents() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n > 0 { + s.reviveTask(t) + log.Printf("[task] #%s 批量重开 %d 条 blocked 意图", t.ID, n) + } + writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": n}) +} + // getLLM returns the current LLM config (key never exposed). func (s *Server) getLLM(w http.ResponseWriter, r *http.Request) { s.cfgMu.Lock() @@ -2052,7 +2102,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { // is the captured "result" step — no separate reply emit (would duplicate). emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) } maTaskID, _ := strconv.ParseInt(t.ID, 10, 64) - resume := func() { s.resumeTaskForGoal(t) } // set_goals 新增目标 → 把任务拉回 running + resume := func() { s.reviveTask(t) } // set_goals 新增目标 → 把任务拉回 running if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil { s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"}) } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 0644996..0555d7a 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -6,6 +6,7 @@ import { AlertTriangleIcon, BugIcon, ClockIcon, + RefreshCwIcon, ShieldCheckIcon, TargetIcon, } from "lucide-react"; @@ -16,6 +17,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { StatusBadge } from "@/components/status-badge"; import { api } from "@/lib/api"; @@ -59,6 +61,46 @@ export function OverviewTab({ taskId }: { taskId: string }) { pct: number | null; by_type: { type: string; total: number; tested: number }[]; } | null>(null); + // 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。 + const [rerunning, setRerunning] = React.useState>(new Set()); + + const markRerun = (key: string, on: boolean) => + setRerunning((prev) => { + const next = new Set(prev); + if (on) next.add(key); + else next.delete(key); + return next; + }); + + // 重跑单条:置回 open(乐观更新本地 state,3s 轮询兜底),worker 会重新认领、从头再跑。 + const rerunOne = async (id: string) => { + markRerun(id, true); + try { + await api.rerunIntent(taskId, id); + setIntents((prev) => + prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)), + ); + } catch { + // 失败忽略:下次轮询仍显示 blocked,用户可再点 + } finally { + markRerun(id, false); + } + }; + + // 批量重跑本任务全部 blocked。 + const rerunAll = async () => { + markRerun("__all__", true); + try { + await api.rerunBlocked(taskId); + setIntents((prev) => + prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)), + ); + } catch { + // ignore + } finally { + markRerun("__all__", false); + } + }; React.useEffect(() => { let cancelled = false; @@ -266,6 +308,57 @@ export function OverviewTab({ taskId }: { taskId: string }) { + {/* Blocked intents — 出错/被拦(如 LLM 网络问题)的意图,可一键重跑:置回 open, + worker 会重新认领、从头再跑(已写回图谱的数据保留);任务若已终态/暂停会自动复活。 */} + {blocked.length > 0 && ( + + + + 被拦/出错意图 + + (共 {blocked.length} 条,可重跑) + + + + + + {blocked.slice(0, 20).map((i) => ( +
+ + {i.payload} + +
+ ))} + {blocked.length > 20 && ( +

+ 仅显示前 20 条,点「全部重跑」处理剩余 {blocked.length - 20} 条。 +

+ )} +
+
+ )} + {/* Stat cards */}
del<{ deleted: number }>(`/tasks/${id}`), controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), + // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 + rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), + // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 + rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), setActive: (id: string) => post<{ active: string }>("/active", { id }), // ---- stats ---- stats: (task?: string) => get(`/stats${tq(task)}`),