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:
Autumn-27 committed 2026-08-23 11:14:04 -04:00
1 parent 3297ca1821
commit 025e1ce445
7 files changed
+182 -39

No files matched your search

+4
View File
@@ -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)))
}
+61
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) =>