feat(intents): 删除 work 改为「停止+记录原因」,不再销毁数据
删除不再删意图与其产出,而是:停到 stopped、要求填写删除原因、把原因作为 事实挂到该意图上、并写进意图 payload,规划者收到 cancelled 触发(意图内容+原因)。 - db: StopIntentWithReason —— 标 stopped + 建原因事实 + 连边 + 合并 payload (cancelled_by_user/cancel_reason),单事务;旧 CancelIntent 保留不再被调用 - agent/server: renderTriggers 加 cancelled 分支;Task.NotifyCancelled; controlIntent/applyIntentControl 接收并要求 reason - web: 删除弹窗改为必填原因的 Textarea;列表用红色垃圾桶+删除线+「已删除」徽标 区分;选中会话顶部横幅展示删除原因;controlIntent 传 reason Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
This commit is contained in:
1 parent
3297ca1821
commit
025e1ce445
7 files changed
+182
-39
No files matched your search
@@ -109,6 +109,8 @@ func renderPlannerTodos(items []actool.Todo) string {
|
||||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||||
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
|
||||
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
|
||||
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
|
||||
// stopped (not deleted) and the reason is attached to it as a fact.
|
||||
type TriggerEvent struct {
|
||||
Kind string
|
||||
IntentID int64
|
||||
@@ -136,6 +138,8 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
|
||||
}
|
||||
case "finding":
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||||
case "cancelled":
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已停止(不再执行),其原因已作为事实挂在该意图上;请据此重新规划。", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||||
default: // "done"
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID)))
|
||||
}
|
||||
|
||||
@@ -306,6 +306,67 @@ type IntentCleanup struct {
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// StopIntentWithReason marks a running/paused intent as 'stopped' WITHOUT deleting
|
||||
// it or any of its yielded facts/findings/activities, and records the user's reason
|
||||
// as a fact node hanging off that intent (intent --yields--> fact). Returns the new
|
||||
// fact node id. The caller must first stop a running worker to prevent late writes.
|
||||
// Used by the "delete work" action, which no longer destroys data — it stops the
|
||||
// intent and attaches why, so the planner can account for it.
|
||||
func (s *ExplorationStore) StopIntentWithReason(id int64, reason, origin string) (int64, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var state string
|
||||
var rawPayload []byte
|
||||
if err := tx.QueryRow(`SELECT state, payload FROM exploration_nodes
|
||||
WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state, &rawPayload); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, fmt.Errorf("intent not found")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if state != "running" && state != "paused" {
|
||||
return 0, fmt.Errorf("%w: intent state %s cannot be cancelled", ErrIntentStateConflict, state)
|
||||
}
|
||||
// Merge the delete marker into the intent's own payload so it travels with the
|
||||
// intent everywhere (session list badge, detail banner) without a schema change.
|
||||
ip := map[string]any{}
|
||||
_ = json.Unmarshal(rawPayload, &ip)
|
||||
ip["cancelled_by_user"] = true
|
||||
ip["cancel_reason"] = reason
|
||||
newPayload, _ := json.Marshal(ip)
|
||||
if _, err := tx.Exec(`UPDATE exploration_nodes SET state='stopped', payload=$3, blocked_reason=NULL
|
||||
WHERE id=$1 AND exploration_id=$2`, id, s.expID, string(newPayload)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if origin == "" {
|
||||
origin = "user"
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"summary": "用户删除了该意图",
|
||||
"detail": "删除原因:" + reason,
|
||||
"confidence": "observed",
|
||||
"user_cancel": true,
|
||||
})
|
||||
var factID int64
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin)
|
||||
VALUES ($1, 'fact', $2, 5, 'confirmed', $3) RETURNING id`,
|
||||
s.expID, string(raw), origin).Scan(&factID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO exploration_edges(exploration_id, src_id, rel, dst_id) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT DO NOTHING`, s.expID, id, RelYields, factID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return factID, tx.Commit()
|
||||
}
|
||||
|
||||
// CancelIntent removes one local intent and the fact/finding nodes it directly
|
||||
// yielded. The standalone finding row must be deleted before its node (whose FK
|
||||
// otherwise uses ON DELETE SET NULL), so the entire cleanup is kept in one DB
|
||||
|
||||
@@ -1488,6 +1488,18 @@ func (t *Task) NotifyGoal(texts []string) {
|
||||
t.Notify()
|
||||
}
|
||||
|
||||
// NotifyCancelled records that the human deleted intentID (reason = 删除原因), then
|
||||
// wakes the planner so the next round spells out which intent was removed and why.
|
||||
// The intent is stopped (not deleted); the reason is attached to it as a fact.
|
||||
func (t *Task) NotifyCancelled(intentID int64, reason string) {
|
||||
if intentID > 0 {
|
||||
t.trigMu.Lock()
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "cancelled", IntentID: intentID, Detail: reason})
|
||||
t.trigMu.Unlock()
|
||||
}
|
||||
t.Notify()
|
||||
}
|
||||
|
||||
// drainTriggers returns and clears the trigger events accumulated since the last round.
|
||||
func (t *Task) drainTriggers() []agent.TriggerEvent {
|
||||
t.trigMu.Lock()
|
||||
|
||||
+2
-1
@@ -1055,6 +1055,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"` // cancel(删除)时必填:删除原因,挂为事实并告知 planner
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "bad json: "+err.Error())
|
||||
@@ -1065,7 +1066,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.applyIntentControl(r.Context(), t, iid, req.Action)
|
||||
result, err := s.applyIntentControl(r.Context(), t, iid, req.Action, req.Reason)
|
||||
if err != nil {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
|
||||
+11
-6
@@ -115,7 +115,7 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, action string) (intentControlResult, error) {
|
||||
func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, action, reason string) (intentControlResult, error) {
|
||||
out := intentControlResult{ID: iid}
|
||||
node, err := t.Store.GetNode(iid)
|
||||
if err != nil {
|
||||
@@ -153,20 +153,25 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act
|
||||
t.Notify()
|
||||
out.State = "open"
|
||||
case "cancel":
|
||||
// 「删除」新语义:不销毁意图与产出,而是把意图停到 stopped、把用户填写的删除原因
|
||||
// 作为一条事实挂到该意图上,并用 cancelled 触发告知 planner(意图内容 + 删除原因)。
|
||||
if node.State != "running" && node.State != "paused" {
|
||||
return out, fmt.Errorf("仅运行中或已暂停的意图可以取消")
|
||||
return out, fmt.Errorf("仅运行中或已暂停的意图可以删除")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return out, fmt.Errorf("请填写删除原因")
|
||||
}
|
||||
if node.State == "running" {
|
||||
if err := s.engine.ControlWork(ctx, iid, "cancel"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
deleted, err := t.Store.CancelIntent(iid)
|
||||
if err != nil {
|
||||
if _, err := t.Store.StopIntentWithReason(iid, reason, "user"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
t.Notify()
|
||||
out.State, out.Deleted = "cancelled", &deleted
|
||||
t.NotifyCancelled(iid, reason)
|
||||
out.State = "stopped"
|
||||
default:
|
||||
return out, fmt.Errorf("action must be pause|resume|cancel")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
@@ -313,6 +314,7 @@ function SessionItem({
|
||||
onResume,
|
||||
onCancel,
|
||||
controlling,
|
||||
deleted,
|
||||
}: {
|
||||
s: Session;
|
||||
active: boolean;
|
||||
@@ -324,13 +326,15 @@ function SessionItem({
|
||||
onResume?: () => void;
|
||||
onCancel?: () => void;
|
||||
controlling?: boolean;
|
||||
deleted?: boolean;
|
||||
}) {
|
||||
const icon =
|
||||
s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
) : s.live ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
const icon = deleted ? (
|
||||
<Trash2Icon className="size-3.5 text-destructive" />
|
||||
) : s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
) : s.live ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
|
||||
) : null;
|
||||
|
||||
const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
|
||||
return (
|
||||
@@ -356,7 +360,14 @@ function SessionItem({
|
||||
来源 #{s.source_task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{displayTitle}</span>
|
||||
<span className={cn("min-w-0 flex-1 truncate text-sm font-medium", deleted && "text-muted-foreground line-through")}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
{deleted && (
|
||||
<Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive">
|
||||
已删除
|
||||
</Badge>
|
||||
)}
|
||||
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
|
||||
{!active && unread ? (
|
||||
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
|
||||
@@ -403,8 +414,8 @@ function SessionItem({
|
||||
size="icon-xs"
|
||||
onClick={onCancel}
|
||||
disabled={controlling}
|
||||
title="取消并清理 Worker 数据"
|
||||
aria-label="取消并清理 Worker 数据"
|
||||
title="删除该意图(需填写原因,保留数据)"
|
||||
aria-label="删除该意图(需填写原因,保留数据)"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
@@ -432,6 +443,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [cancelReason, setCancelReason] = React.useState("");
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
@@ -461,11 +473,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}, []);
|
||||
|
||||
const controlWorker = React.useCallback(
|
||||
async (session: Session, action: "pause" | "resume" | "cancel") => {
|
||||
async (session: Session, action: "pause" | "resume" | "cancel", reason?: string) => {
|
||||
if (!session.intent_id || session.inherited || controllingIntent) return;
|
||||
if (action === "cancel" && !reason?.trim()) {
|
||||
toast.error("请填写删除原因");
|
||||
return;
|
||||
}
|
||||
setControllingIntent(session.intent_id);
|
||||
try {
|
||||
const result = await api.controlIntent(taskId, session.intent_id, action);
|
||||
await api.controlIntent(taskId, session.intent_id, action, reason);
|
||||
if (action === "pause") {
|
||||
patchIntentState(session.intent_id, "paused");
|
||||
toast.success(`Worker #${session.intent_id} 已暂停`);
|
||||
@@ -473,14 +489,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
patchIntentState(session.intent_id, "open");
|
||||
toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
|
||||
} else {
|
||||
patchIntentState(session.intent_id);
|
||||
if (activeId === session.id) setActiveId(MAIN_ID);
|
||||
const deleted = result.deleted;
|
||||
toast.success(
|
||||
deleted
|
||||
? `Worker #${session.intent_id} 已取消,清理 ${deleted.facts} 条事实、${deleted.findings} 个漏洞`
|
||||
: `Worker #${session.intent_id} 已取消`,
|
||||
);
|
||||
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。
|
||||
patchIntentState(session.intent_id, "stopped");
|
||||
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
|
||||
setCancelReason("");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Worker 操作失败:${(error as Error).message}`);
|
||||
@@ -489,7 +501,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
setCancelIntent(null);
|
||||
}
|
||||
},
|
||||
[activeId, controllingIntent, patchIntentState, taskId],
|
||||
[controllingIntent, patchIntentState, taskId],
|
||||
);
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
@@ -964,21 +976,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// For each worker session (intent), derive the display title from the intent
|
||||
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
|
||||
const sessionMeta = React.useMemo(() => {
|
||||
const map = new Map<string, { title: string; json: unknown }>();
|
||||
const map = new Map<string, { title: string; json: unknown; deleted: boolean; deleteReason: string }>();
|
||||
for (const node of allIntents) {
|
||||
let title = `Intent ${node.id}`;
|
||||
let parsedPayload: unknown = node.payload;
|
||||
let deleted = false;
|
||||
let deleteReason = "";
|
||||
if (node.payload) {
|
||||
try {
|
||||
const p = JSON.parse(node.payload);
|
||||
parsedPayload = p;
|
||||
if (p?.summary) title = String(p.summary);
|
||||
if (p?.cancelled_by_user) {
|
||||
deleted = true;
|
||||
deleteReason = String(p.cancel_reason ?? "");
|
||||
}
|
||||
} catch {
|
||||
title = node.payload.trim() || title;
|
||||
}
|
||||
}
|
||||
const json = { ...node, payload: parsedPayload };
|
||||
map.set(node.id, { title, json });
|
||||
map.set(node.id, { title, json, deleted, deleteReason });
|
||||
}
|
||||
return map;
|
||||
}, [allIntents]);
|
||||
@@ -1327,6 +1345,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
unread={store[keyForSession(s)]?.unread}
|
||||
onClick={() => setActiveId(s.id)}
|
||||
controlling={controllingIntent === s.intent_id}
|
||||
deleted={meta?.deleted}
|
||||
onPause={() => void controlWorker(s, "pause")}
|
||||
onResume={() => void controlWorker(s, "resume")}
|
||||
onCancel={() => {
|
||||
@@ -1440,6 +1459,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
{isMain && <span>可交互</span>}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
|
||||
if (!dm?.deleted) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs">
|
||||
<Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-destructive">此意图已被用户删除</span>
|
||||
<span className="text-muted-foreground">
|
||||
(已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史)
|
||||
</span>
|
||||
{dm.deleteReason && (
|
||||
<p className="mt-1 break-words text-foreground">
|
||||
<span className="text-muted-foreground">删除原因:</span>
|
||||
{dm.deleteReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
|
||||
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
|
||||
out the width and defeat the truncation below — the transcript wraps to
|
||||
@@ -1569,24 +1609,44 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialog open={cancelIntent !== null} onOpenChange={(open) => !open && setCancelIntent(null)}>
|
||||
<AlertDialog
|
||||
open={cancelIntent !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCancelIntent(null);
|
||||
setCancelReason("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>取消 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
|
||||
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words whitespace-normal">
|
||||
取消后将删除该意图、执行记录,以及该意图直接登记的事实和漏洞;全局资产、流量和其他 Planner
|
||||
意图不会删除。此操作不可撤销。
|
||||
删除会<strong>停止该意图</strong>(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="grid gap-2 py-1">
|
||||
<label htmlFor="cancel-reason" className="text-sm font-medium">
|
||||
删除原因(必填)
|
||||
</label>
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>返回</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={!cancelIntent || controllingIntent !== null}
|
||||
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel")}
|
||||
disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()}
|
||||
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason)}
|
||||
>
|
||||
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
|
||||
确认取消并清理
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
+3
-3
@@ -227,12 +227,12 @@ export const api = {
|
||||
post<{ id: string; paused: boolean; queued: boolean; status: string }>(`/tasks/${id}/control`, { action }),
|
||||
controlTasksBatch: (taskIds: string[], action: "pause" | "resume") =>
|
||||
post<{ items: BatchControlItem[] }>("/tasks/control/batch", { task_ids: taskIds, action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) =>
|
||||
post<{
|
||||
id: number;
|
||||
state: "paused" | "open" | "cancelled";
|
||||
state: "paused" | "open" | "stopped";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
|
||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
|
||||
Reference in new issue
Block a user