mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-30 10:03:50 +08:00
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:
1 parent
d9f28de704
commit
05eb7d2174
5 files changed
+186
-10
No files matched your search
@@ -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"
|
||||
|
||||
+9
-9
@@ -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
@@ -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() + ")"})
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(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 }) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Blocked intents — 出错/被拦(如 LLM 网络问题)的意图,可一键重跑:置回 open,
|
||||
worker 会重新认领、从头再跑(已写回图谱的数据保留);任务若已终态/暂停会自动复活。 */}
|
||||
{blocked.length > 0 && (
|
||||
<Card className="border-red-500/30">
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
(共 {blocked.length} 条,可重跑)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rerunning.has("__all__")}
|
||||
onClick={() => void rerunAll()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
|
||||
/>
|
||||
全部重跑
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{blocked.slice(0, 20).map((i) => (
|
||||
<div key={i.id} className="flex items-center gap-2 text-sm">
|
||||
<StatusBadge domain="intent" value={i.state} />
|
||||
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={rerunning.has(i.id)}
|
||||
onClick={() => void rerunOne(i.id)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
|
||||
/>
|
||||
重跑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{blocked.length > 20 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
仅显示前 20 条,点「全部重跑」处理剩余 {blocked.length - 20} 条。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
|
||||
@@ -120,6 +120,10 @@ export const api = {
|
||||
}),
|
||||
deleteTask: (id: string) => 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>(`/stats${tq(task)}`),
|
||||
|
||||
Reference in new issue
Block a user