feat(intent): 意图删除支持假删除/真删除两种模式
删除入口区分两种模式(待领/运行中/已暂停的意图均可删除): - 假删除(默认):意图置 state='deleted'、删除原因记入新字段 delete_reason, 保留节点与全部产出/血缘,不再在图上另挂 fact。 - 真删除:物理删除意图及「仅由它支撑」的独占子孙(沿 yields/derived_from 级联到叶子),避免孤立数据;共享节点、目标、任务根事实保留。被删意图先做 token rollup 并清理 activity/副会话,被删 finding 先删 findings 行。 两种模式都照常用 cancelled 触发通知 planner(带删除前捕获的意图摘要 + 原因), planner 文案不再声称原因挂为事实。 - DB: 新增 SoftDeleteIntent、重写 CancelIntent 为深度独占级联;schema 加 delete_reason 列与 intent 的 deleted 状态(含旧库迁移)。 - 前端: 删除对话框加模式切换与动态说明,状态徽章/图标新增「已删除」, 播报板展开展示删除原因。
This commit is contained in:
1 parent
6e3dbf6452
commit
4756bb1c45
17 files changed
+593
-238
No files matched your search
+9
-7
@@ -177,17 +177,19 @@ type TaskNodeDTO struct {
|
||||
TS string `json:"ts"`
|
||||
SourceTaskID string `json:"source_task_id,omitempty"`
|
||||
Inherited bool `json:"inherited,omitempty"`
|
||||
DeleteReason string `json:"delete_reason,omitempty"` // 意图假删除(state='deleted')时的删除原因
|
||||
}
|
||||
|
||||
func taskNodeDTO(n *db.Node) TaskNodeDTO {
|
||||
d := TaskNodeDTO{
|
||||
ID: i64s(n.ID),
|
||||
Type: n.Kind,
|
||||
Payload: rawString(n.Payload),
|
||||
Priority: n.Priority,
|
||||
State: n.State,
|
||||
Origin: n.Origin,
|
||||
TS: rfc3339(n.CreatedAt),
|
||||
ID: i64s(n.ID),
|
||||
Type: n.Kind,
|
||||
Payload: rawString(n.Payload),
|
||||
DeleteReason: n.DeleteReason,
|
||||
Priority: n.Priority,
|
||||
State: n.State,
|
||||
Origin: n.Origin,
|
||||
TS: rfc3339(n.CreatedAt),
|
||||
}
|
||||
if n.SourceTaskID > 0 {
|
||||
d.SourceTaskID = i64s(n.SourceTaskID)
|
||||
|
||||
+5
-3
@@ -1838,11 +1838,13 @@ func (t *Task) NotifyGoalEdited(oldText, newText string) {
|
||||
|
||||
// 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) {
|
||||
// summary is the intent's text captured before deletion — needed for hard delete,
|
||||
// where the node is gone by the time the planner reads the trigger. Applies to both
|
||||
// soft (state='deleted') and hard (physical cascade) delete.
|
||||
func (t *Task) NotifyCancelled(intentID int64, summary, reason string) {
|
||||
if intentID > 0 {
|
||||
t.trigMu.Lock()
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "cancelled", IntentID: intentID, Detail: reason})
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "cancelled", IntentID: intentID, Summary: summary, Detail: reason})
|
||||
t.trigMu.Unlock()
|
||||
}
|
||||
t.Notify()
|
||||
|
||||
+3
-2
@@ -1122,7 +1122,8 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason"` // cancel(删除)时必填:删除原因,挂为事实并告知 planner
|
||||
Reason string `json:"reason"` // cancel(删除)时必填:删除原因
|
||||
Mode string `json:"mode"` // cancel 专用:soft(默认,假删除)| hard(真删除,级联移除独占子孙)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "bad json: "+err.Error())
|
||||
@@ -1133,7 +1134,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.applyIntentControl(r.Context(), t, iid, req.Action, req.Reason)
|
||||
result, err := s.applyIntentControl(r.Context(), t, iid, req.Action, req.Reason, req.Mode)
|
||||
if err != nil {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
|
||||
@@ -344,7 +344,7 @@ func TestSideHTTPGlobalLimitTaskWorkerAndDeletion(t *testing.T) {
|
||||
waitSide(t, f.m.pg, e.ID, "completed")
|
||||
}
|
||||
f.call(t, "GET", fmt.Sprintf("/api/tasks/%s/intents/%d/side-questions", task.ID, iid+1000000), nil, 404)
|
||||
if _, err := f.s.applyIntentControl(t.Context(), task, iid, "cancel", "cleanup"); err != nil {
|
||||
if _, err := f.s.applyIntentControl(t.Context(), task, iid, "cancel", "cleanup", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap, err := f.m.pg.SideSnapshot(t.Context(), (sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: iid}).Key()); err != nil || snap != nil {
|
||||
|
||||
+40
-10
@@ -149,7 +149,21 @@ 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, reason string) (intentControlResult, error) {
|
||||
// intentSummaryOf 取意图 payload 里的 summary,供删除通知在意图节点消失(真删除)前留档。
|
||||
func intentSummaryOf(n *db.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var p map[string]any
|
||||
if json.Unmarshal(n.Payload, &p) == nil {
|
||||
if s, ok := p["summary"].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, action, reason, mode string) (intentControlResult, error) {
|
||||
out := intentControlResult{ID: iid}
|
||||
node, err := t.Store.GetNode(iid)
|
||||
if err != nil {
|
||||
@@ -187,10 +201,14 @@ 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("仅运行中或已暂停的意图可以删除")
|
||||
// 删除支持两种模式:
|
||||
// soft(默认,假删除):意图停到 state='deleted'、删除原因记入 delete_reason 字段,
|
||||
// 保留意图节点与全部产出/血缘,不再在图上另挂 fact。
|
||||
// hard(真删除):物理删除该意图及"仅由它支撑"的独占子孙节点(级联到叶子),避免留下
|
||||
// 孤立数据;共享节点、goal、任务根事实保留。
|
||||
// 两种模式都用 cancelled 触发告知 planner(意图内容 + 删除原因),让它据此重规划。
|
||||
if node.State != "running" && node.State != "paused" && node.State != "open" {
|
||||
return out, fmt.Errorf("仅待领/运行中/已暂停的意图可以删除")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
@@ -201,12 +219,24 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if _, err := t.Store.StopIntentWithReason(iid, reason, "user"); err != nil {
|
||||
return out, err
|
||||
summary := intentSummaryOf(node)
|
||||
if mode == "hard" {
|
||||
cleanup, err := t.Store.CancelIntent(iid)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
s.cancelWorkerSide(t.ID, t.ExpID, iid)
|
||||
t.NotifyCancelled(iid, summary, reason)
|
||||
out.Deleted = &cleanup
|
||||
out.State = "" // 节点已删除,前端据 Deleted 从列表移除
|
||||
} else {
|
||||
if _, err := t.Store.SoftDeleteIntent(iid, reason); err != nil {
|
||||
return out, err
|
||||
}
|
||||
s.cancelWorkerSide(t.ID, t.ExpID, iid)
|
||||
t.NotifyCancelled(iid, summary, reason)
|
||||
out.State = db.StateIntentDeleted
|
||||
}
|
||||
s.cancelWorkerSide(t.ID, t.ExpID, iid)
|
||||
t.NotifyCancelled(iid, reason)
|
||||
out.State = "stopped"
|
||||
default:
|
||||
return out, fmt.Errorf("action must be pause|resume|cancel")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user