feat: 出错(blocked)意图可一键重跑 + 概览页按钮

worker 因 LLM 网络/连接等错误 model_error 收场、耗尽引擎层 2 次自动重试后意图标
blocked,此前无手动恢复入口。现支持一键重跑:置回 open→worker 重新认领、从头再跑
(已写回图谱的 fact/finding/asset 保留);任务若已终态/暂停会自动复活。

- db: ReopenIntent(blocked/exhausted/stopped→open)、ReopenBlockedIntents(批量 blocked→open)。
- server: resumeTaskForGoal 泛化为 reviveTask(set_goals 与重跑复用同一复活逻辑);
  新增 POST /tasks/{id}/intents/{iid}/rerun 与 /tasks/{id}/intents/rerun-blocked,
  reopen 后 reviveTask 确保 worker 循环存活并重认领。
- web: api.rerunIntent/rerunBlocked;概览页新增「被拦/出错意图」卡片,每条「重跑」+
  顶部「全部重跑」,乐观更新 + 3s 轮询兜底。
This commit is contained in:
Autumn-27 committed 2026-08-17 09:56:59 -04:00
1 parent d9f28de704
commit 05eb7d2174
5 files changed
+186 -10

No files matched your search

+9 -9
View File
@@ -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)
}
}
}
+51 -1
View File
@@ -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() + ")"})
}