From 4756bb1c45f0687a3440b782acf272f84a4e68a3 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:52:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(intent):=20=E6=84=8F=E5=9B=BE=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=94=AF=E6=8C=81=E5=81=87=E5=88=A0=E9=99=A4/?= =?UTF-8?q?=E7=9C=9F=E5=88=A0=E9=99=A4=E4=B8=A4=E7=A7=8D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除入口区分两种模式(待领/运行中/已暂停的意图均可删除): - 假删除(默认):意图置 state='deleted'、删除原因记入新字段 delete_reason, 保留节点与全部产出/血缘,不再在图上另挂 fact。 - 真删除:物理删除意图及「仅由它支撑」的独占子孙(沿 yields/derived_from 级联到叶子),避免孤立数据;共享节点、目标、任务根事实保留。被删意图先做 token rollup 并清理 activity/副会话,被删 finding 先删 findings 行。 两种模式都照常用 cancelled 触发通知 planner(带删除前捕获的意图摘要 + 原因), planner 文案不再声称原因挂为事实。 - DB: 新增 SoftDeleteIntent、重写 CancelIntent 为深度独占级联;schema 加 delete_reason 列与 intent 的 deleted 状态(含旧库迁移)。 - 前端: 删除对话框加模式切换与动态说明,状态徽章/图标新增「已删除」, 播报板展开展示删除原因。 --- agent/cancelcause.go | 4 +- agent/planner.go | 8 +- db/constants.go | 5 + db/exploration.go | 292 ++++++++++-------- db/intent_delete_test.go | 170 ++++++++++ db/schema.sql | 13 +- server/dto.go | 16 +- server/manager.go | 8 +- server/server.go | 5 +- server/side_questions_test.go | 2 +- server/task_control.go | 50 ++- .../tasks/detail/_tabs/broadcast-tab.tsx | 6 + .../tasks/detail/_tabs/sessions-tab.tsx | 171 +++++++--- web/src/lib/api.ts | 13 +- web/src/lib/mock/handler.ts | 36 ++- web/src/lib/status.ts | 29 +- web/src/lib/types.ts | 3 +- 17 files changed, 593 insertions(+), 238 deletions(-) create mode 100644 db/intent_delete_test.go diff --git a/agent/cancelcause.go b/agent/cancelcause.go index f4705bc..0e6b4e8 100644 --- a/agent/cancelcause.go +++ b/agent/cancelcause.go @@ -45,8 +45,8 @@ var ( "规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取") AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图", "用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行") - AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户取消了这条 Worker 意图", - "用户取消了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端会事务性删除该意图及其直接产生的事实、漏洞和执行记录") + AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户删除了这条 Worker 意图", + "用户删除了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端按用户选择的删除模式处理该意图——假删除仅标记为已删除并保留全部产出,真删除会级联移除该意图及仅由它支撑的下游节点") AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context", "Worker 已正常结束,引擎在 detachWork 中释放其 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态") AbortPausedRaceGuard = cause("paused_race_guard", "任务暂停期间拒绝启动新运行", diff --git a/agent/planner.go b/agent/planner.go index 12c26f0..36ed693 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -159,6 +159,7 @@ type TriggerEvent struct { Kind string IntentID int64 Detail string + Summary string // Kind=="cancelled" 专用:删除前捕获的意图摘要(真删除后节点已不存在,无法再查) Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条) OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本 NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本 @@ -196,7 +197,12 @@ 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)) + // 意图内容优先用删除时捕获的 Summary(真删除后节点已不存在,intentSummary 查不到)。 + sm := ev.Summary + if sm == "" { + sm = intentSummary(ts, ev.IntentID) + } + b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已删除(不再执行);请据此重新规划。", ev.IntentID, sm, ev.Detail)) default: // "done" b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID))) if fids := factIDsYielded(ts, ev.IntentID); fids != "" { diff --git a/db/constants.go b/db/constants.go index 901cc5e..ff42a5c 100644 --- a/db/constants.go +++ b/db/constants.go @@ -25,6 +25,11 @@ const ( // facts use state 'confirmed', so this never collides. const StateOrigin = "origin" +// StateIntentDeleted marks an intent the user假删除(soft delete): it drops out of +// the frontier and graph_overview like other terminal states, but keeps its node +// and full lineage. The delete reason lives in exploration_nodes.delete_reason. +const StateIntentDeleted = "deleted" + // Exploration edge relations (exploration_edges.rel). const ( RelSpawns = "spawns" diff --git a/db/exploration.go b/db/exploration.go index c7f4228..039ab3a 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -75,6 +75,7 @@ type Node struct { Origin string `json:"origin,omitempty"` Owner string `json:"owner,omitempty"` BlockedReason string `json:"blocked_reason,omitempty"` + DeleteReason string `json:"delete_reason,omitempty"` // 仅意图假删除(state='deleted')时非空 Anchors []int64 `json:"anchors,omitempty"` CreatedAt time.Time `json:"created_at"` SourceTaskID int64 `json:"source_task_id,omitempty"` @@ -380,16 +381,14 @@ 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) { +// SoftDeleteIntent 假删除一个待领/运行中/已暂停的意图:置 state='deleted' 并把用户填写的 +// 删除原因记入 delete_reason 字段,保留意图节点及其全部产出/血缘(不再像旧实现那样在图上 +// 另挂一条 fact)。返回删除前意图的 summary,供 planner 通知使用。副会话随删除态一并清理。 +// 调用方须先停掉运行中的 worker,避免其后续写入。 +func (s *ExplorationStore) SoftDeleteIntent(id int64, reason string) (string, error) { tx, err := s.db.Begin() if err != nil { - return 0, err + return "", err } defer tx.Rollback() @@ -398,58 +397,39 @@ func (s *ExplorationStore) StopIntentWithReason(id int64, reason, origin string) 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 "", fmt.Errorf("intent not found") } - return 0, err + return "", err } - if state != "running" && state != "paused" { - return 0, fmt.Errorf("%w: intent state %s cannot be cancelled", ErrIntentStateConflict, state) + if state != "running" && state != "paused" && state != "open" { + return "", fmt.Errorf("%w: intent state %s cannot be deleted", 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 _, err := tx.Exec(`UPDATE exploration_nodes + SET state='deleted', delete_reason=$3, blocked_reason=NULL, + content_version=content_version+1, completed_at=now() + WHERE id=$1 AND exploration_id=$2`, id, s.expID, reason); err != nil { + return "", err } - // Soft deletion retains the main intent audit trail, but side conversations - // are removed atomically with the stopped state. Delayed snapshots reject it. + // 主意图审计轨迹保留,但副问答会话随删除态原子清理(延迟快照会拒绝它)。 if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE intent_id=$1`, id); err != nil { - return 0, err + return "", err } - - if origin == "" { - origin = "user" + var summary string + var p map[string]any + if json.Unmarshal(rawPayload, &p) == nil { + if sm, ok := p["summary"].(string); ok { + summary = sm + } } - 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() + return summary, 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 -// transaction. The caller must first stop a running worker to prevent late writes. +// CancelIntent 物理删除一个意图,以及"仅由该意图支撑"的全部独占子孙节点——从该意图沿 +// yields/derived_from 向下可达、且所有父节点(所有指向它的边的源)都落在删除集内的节点。 +// goal 与 origin fact 永不删除;还被删除集之外的意图/digest 引用的共享节点也保留,以免破坏 +// 其它分支、产生断链。被删的每个 intent 先做 token rollup(保留不可逆计量)并清理其 activity +// 与副会话;被删的 finding 先删 findings 表行(node_id FK 为 ON DELETE SET NULL,否则留孤儿)。 +// 边随节点 CASCADE 清理,整个清理保持在一个事务内。调用方须先停掉运行中的 worker 防止其后续写入。 func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) { var out IntentCleanup tx, err := s.db.Begin() @@ -458,96 +438,160 @@ func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) { } defer tx.Rollback() - var state string - if err := tx.QueryRow(`SELECT state FROM exploration_nodes - WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state); err != nil { + // 锁定意图行确认存在(幂等:已删则 not found)。状态不校验——真删除对任何状态成立, + // running 的 worker 由调用方先停。 + if err := tx.QueryRow(`SELECT 1 FROM exploration_nodes + WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(new(int)); err != nil { if err == sql.ErrNoRows { return out, fmt.Errorf("intent not found") } return out, err } - if state != "running" && state != "paused" { - return out, fmt.Errorf("%w: intent state %s cannot be cancelled", ErrIntentStateConflict, state) - } - if err := tx.QueryRow(`SELECT - COUNT(*) FILTER (WHERE n.kind='fact'), - COUNT(*) FILTER (WHERE n.kind='finding') - FROM exploration_edges e - JOIN exploration_nodes n ON n.id=e.dst_id AND n.exploration_id=e.exploration_id - WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3 - AND n.kind IN ('fact','finding') - AND NOT EXISTS ( - SELECT 1 FROM exploration_edges other - WHERE other.exploration_id=e.exploration_id AND other.dst_id=e.dst_id - AND other.src_id<>$2 - )`, s.expID, id, RelYields).Scan(&out.Facts, &out.Findings); err != nil { - return out, err - } - - if _, err := tx.Exec(`DELETE FROM findings f USING exploration_edges e, exploration_nodes n - WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3 - AND n.id=e.dst_id AND n.exploration_id=e.exploration_id - AND n.kind='finding' AND f.node_id=n.id - AND NOT EXISTS ( - SELECT 1 FROM exploration_edges other - WHERE other.exploration_id=e.exploration_id AND other.dst_id=e.dst_id - AND other.src_id<>$2 - )`, s.expID, id, RelYields); err != nil { - return out, err - } - // Activity rows are part of the blackboard cleanup, but their token usage is - // irreversible metering data. Fold completed runs plus the latest unfinished - // usage frame into detached daily result rows before deleting the conversation. - // The rows are excluded from per-intent sessions while whole-task totals and - // the original UTC reporting dates remain accurate. - tokenBuckets, err := intentTokenRollup(tx, s.expID, id) + // 载入全图节点(判 protected)与边(算向下可达 + 父集)。图规模对真实任务很小。 + kind := map[int64]string{} + protected := map[int64]bool{} + nrows, err := tx.Query(`SELECT id, kind, state FROM exploration_nodes WHERE exploration_id=$1`, s.expID) if err != nil { return out, err } - res, err := tx.Exec(`DELETE FROM activity WHERE exploration_id=$1 AND node_id=$2`, s.expID, id) + for nrows.Next() { + var nid int64 + var k, st string + if err := nrows.Scan(&nid, &k, &st); err != nil { + nrows.Close() + return out, err + } + kind[nid] = k + if k == KindGoal || (k == KindFact && st == StateOrigin) { + protected[nid] = true // 目标与任务根事实永不随意图删除 + } + } + nrows.Close() + if err := nrows.Err(); err != nil { + return out, err + } + + parentsOf := map[int64][]int64{} // dst -> 所有指向它的边的 src(任意 rel,含 covers:被 digest 覆盖的成员因此有 digest 父而被保留) + downOf := map[int64][]int64{} // src -> 沿 yields/derived_from 的向下邻居 + erows, err := tx.Query(`SELECT src_id, rel, dst_id FROM exploration_edges WHERE exploration_id=$1`, s.expID) if err != nil { return out, err } - out.Activities, _ = res.RowsAffected() - for _, bucket := range tokenBuckets { - metadata, _ := json.Marshal(map[string]any{ - "cancelled_intent_id": id, - "token_day": bucket.Day.Format(time.DateOnly), - "token_rollup": true, - }) - if _, err := tx.Exec(`INSERT INTO activity( - exploration_id, worker, kind, summary, metadata, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at) - VALUES ($1,'token-ledger','result',$2,$3,$4,$5,$6,$7,$8)`, - s.expID, fmt.Sprintf("已取消意图 #%d 的 Token 计量", id), metadata, - bucket.Usage.InputTokens, bucket.Usage.OutputTokens, - bucket.Usage.CacheReadTokens, bucket.Usage.CacheWriteTokens, bucket.Day); err != nil { + for erows.Next() { + var src, dst int64 + var rel string + if err := erows.Scan(&src, &rel, &dst); err != nil { + erows.Close() + return out, err + } + parentsOf[dst] = append(parentsOf[dst], src) + if rel == RelYields || rel == RelDerivedFrom { + downOf[src] = append(downOf[src], dst) + } + } + erows.Close() + if err := erows.Err(); err != nil { + return out, err + } + + // 独占级联:从意图向下扩展,一个节点入删除集当且仅当它未被保护、且它的每个父都已在集内 + // (即除了经过被删节点外再无来路)。迭代到不动点。 + del := map[int64]bool{id: true} + for changed := true; changed; { + changed = false + for src := range del { + for _, dst := range downOf[src] { + if del[dst] || protected[dst] { + continue + } + exclusive := true + for _, p := range parentsOf[dst] { + if !del[p] { + exclusive = false + break + } + } + if exclusive { + del[dst] = true + changed = true + } + } + } + } + + // 按类型分桶。 + var ids, intentIDs, findingIDs []int64 + for nid := range del { + ids = append(ids, nid) + switch kind[nid] { + case KindIntent: + intentIDs = append(intentIDs, nid) + out.Intents++ + case KindFact: + out.Facts++ + case KindFinding: + findingIDs = append(findingIDs, nid) + out.Findings++ + } + } + + // 每个被删意图:token rollup(保留不可逆计量)后清 activity。rollup 行 node_id 为 NULL, + // 不会被下面按 node_id 的删除命中。 + for _, iid := range intentIDs { + tokenBuckets, err := intentTokenRollup(tx, s.expID, iid) + if err != nil { + return out, err + } + res, err := tx.Exec(`DELETE FROM activity WHERE exploration_id=$1 AND node_id=$2`, s.expID, iid) + if err != nil { + return out, err + } + if n, _ := res.RowsAffected(); n > 0 { + out.Activities += n + } + for _, bucket := range tokenBuckets { + metadata, _ := json.Marshal(map[string]any{ + "cancelled_intent_id": iid, + "token_day": bucket.Day.Format(time.DateOnly), + "token_rollup": true, + }) + if _, err := tx.Exec(`INSERT INTO activity( + exploration_id, worker, kind, summary, metadata, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at) + VALUES ($1,'token-ledger','result',$2,$3,$4,$5,$6,$7,$8)`, + s.expID, fmt.Sprintf("已取消意图 #%d 的 Token 计量", iid), metadata, + bucket.Usage.InputTokens, bucket.Usage.OutputTokens, + bucket.Usage.CacheReadTokens, bucket.Usage.CacheWriteTokens, bucket.Day); err != nil { + return out, err + } + } + if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE intent_id=$1`, iid); err != nil { return out, err } } - res, err = tx.Exec(`DELETE FROM exploration_nodes - WHERE exploration_id=$1 AND ( - id=$2 OR id IN ( - SELECT e.dst_id FROM exploration_edges e - JOIN exploration_nodes n ON n.id=e.dst_id AND n.exploration_id=e.exploration_id - WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3 - AND n.kind IN ('fact','finding') - AND NOT EXISTS ( - SELECT 1 FROM exploration_edges other - WHERE other.exploration_id=e.exploration_id AND other.dst_id=e.dst_id - AND other.src_id<>$2 - ) - ) - )`, s.expID, id, RelYields) - if err != nil { - return out, err + // finding 节点删除前先删 findings 表行(否则留 node_id=NULL 的孤儿)。 + for _, fid := range findingIDs { + if _, err := tx.Exec(`DELETE FROM findings WHERE node_id=$1`, fid); err != nil { + return out, err + } } - if removed, _ := res.RowsAffected(); removed != 1+out.Facts+out.Findings { + + // 删节点(边随 CASCADE 清理)。逐个删并核对行数,防并发改动。 + var removed int64 + for _, nid := range ids { + res, err := tx.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND exploration_id=$2`, nid, s.expID) + if err != nil { + return out, err + } + if n, _ := res.RowsAffected(); n > 0 { + removed += n + } + } + if removed != int64(len(ids)) { return out, fmt.Errorf("intent cleanup changed concurrently") } - out.Intents = 1 return out, tx.Commit() } @@ -653,12 +697,12 @@ func (u *TokenUsage) add(other TokenUsage) { u.CacheWriteTokens += other.CacheWriteTokens } -const nodeCols = `id, kind, payload, priority, state, COALESCE(origin,''), COALESCE(owner,''), COALESCE(blocked_reason,''), created_at` +const nodeCols = `id, kind, payload, priority, state, COALESCE(origin,''), COALESCE(owner,''), COALESCE(blocked_reason,''), COALESCE(delete_reason,''), created_at` func scanNode(sc interface{ Scan(...any) error }) (*Node, error) { var n Node var payload []byte - if err := sc.Scan(&n.ID, &n.Kind, &payload, &n.Priority, &n.State, &n.Origin, &n.Owner, &n.BlockedReason, &n.CreatedAt); err != nil { + if err := sc.Scan(&n.ID, &n.Kind, &payload, &n.Priority, &n.State, &n.Origin, &n.Owner, &n.BlockedReason, &n.DeleteReason, &n.CreatedAt); err != nil { return nil, err } n.Payload = json.RawMessage(payload) diff --git a/db/intent_delete_test.go b/db/intent_delete_test.go new file mode 100644 index 0000000..627aacd --- /dev/null +++ b/db/intent_delete_test.go @@ -0,0 +1,170 @@ +package db + +import "testing" + +// mustIntent / mustNode / mustLink are terse builders for the delete-cascade tests. +func mustIntent(t *testing.T, es *ExplorationStore, summary string) int64 { + t.Helper() + id, err := es.AddIntent(map[string]any{"summary": summary}, 1, nil, "planner") + if err != nil { + t.Fatal(err) + } + return id +} + +func mustNode(t *testing.T, es *ExplorationStore, kind, summary string) int64 { + t.Helper() + id, err := es.AddNode(kind, map[string]any{"summary": summary}, 1, "confirmed", "worker", nil) + if err != nil { + t.Fatal(err) + } + return id +} + +func mustLink(t *testing.T, es *ExplorationStore, from int64, rel string, to int64) { + t.Helper() + if err := es.Link(from, rel, to); err != nil { + t.Fatal(err) + } +} + +func gone(t *testing.T, es *ExplorationStore, id int64) bool { + t.Helper() + n, err := es.GetNode(id) + if err != nil { + t.Fatal(err) + } + return n == nil +} + +// TestSoftDeleteIntent 假删除置 deleted + delete_reason,保留节点。 +func TestSoftDeleteIntent(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + expID, err := d.CreateExploration("soft delete", "假删除") + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) + es := d.Exploration(expID) + + intent := mustIntent(t, es, "待删意图") + if err := es.SetIntentState(intent, "paused"); err != nil { + t.Fatal(err) + } + summary, err := es.SoftDeleteIntent(intent, "方向判断错误") + if err != nil { + t.Fatal(err) + } + if summary != "待删意图" { + t.Fatalf("summary=%q, want 待删意图", summary) + } + n, err := es.GetNode(intent) + if err != nil || n == nil { + t.Fatalf("intent removed by soft delete: n=%+v err=%v", n, err) + } + if n.State != StateIntentDeleted || n.DeleteReason != "方向判断错误" { + t.Fatalf("state=%q delete_reason=%q, want deleted/方向判断错误", n.State, n.DeleteReason) + } + // 待领(open)意图也允许假删除。 + openIntent := mustIntent(t, es, "待领意图") + if _, err := es.SoftDeleteIntent(openIntent, "方向不需要了"); err != nil { + t.Fatalf("soft delete open intent: %v", err) + } + if n, err := es.GetNode(openIntent); err != nil || n == nil || n.State != StateIntentDeleted { + t.Fatalf("open intent not soft-deleted: n=%+v err=%v", n, err) + } + + // 已删除(deleted)等其它状态不能再假删除。 + if _, err := es.SoftDeleteIntent(intent, "再删"); err == nil { + t.Fatal("soft-deleting an already-deleted intent unexpectedly succeeded") + } +} + +// TestHardDeleteCascadesExclusiveDescendants 真删除沿链路级联删除独占子孙到叶子。 +func TestHardDeleteCascadesExclusiveDescendants(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + expID, err := d.CreateExploration("hard cascade", "级联删除") + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) + es := d.Exploration(expID) + + // intent1 --yields--> fact1 --derived_from--> intent2 --yields--> fact2(叶子) + intent1 := mustIntent(t, es, "根意图") + fact1 := mustNode(t, es, KindFact, "事实1") + mustLink(t, es, intent1, RelYields, fact1) + intent2 := mustIntent(t, es, "衍生意图") + mustLink(t, es, fact1, RelDerivedFrom, intent2) + fact2 := mustNode(t, es, KindFact, "事实2") + mustLink(t, es, intent2, RelYields, fact2) + + cleanup, err := es.CancelIntent(intent1) + if err != nil { + t.Fatal(err) + } + if cleanup.Intents != 2 || cleanup.Facts != 2 { + t.Fatalf("cleanup=%+v, want 2 intents / 2 facts", cleanup) + } + for _, id := range []int64{intent1, fact1, intent2, fact2} { + if !gone(t, es, id) { + t.Fatalf("node %d survived cascade", id) + } + } +} + +// TestHardDeletePreservesSharedAndGoal 真删除保留共享子孙(还有其它父)与目标。 +func TestHardDeletePreservesSharedAndGoal(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + expID, err := d.CreateExploration("hard preserve", "保留共享/目标") + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) + es := d.Exploration(expID) + + goal, err := es.AddGoal(map[string]any{"text": "拿下后台"}, "human") + if err != nil { + t.Fatal(err) + } + // intent1 独占 finding(proves goal),intent1 与 intentX 共享 fact1(fact1 衍生出 intent2)。 + intent1 := mustIntent(t, es, "待删意图") + intentX := mustIntent(t, es, "旁路意图") + finding := mustNode(t, es, KindFinding, "漏洞") + mustLink(t, es, intent1, RelYields, finding) + mustLink(t, es, finding, RelProves, goal) + shared := mustNode(t, es, KindFact, "共享事实") + mustLink(t, es, intent1, RelYields, shared) + mustLink(t, es, intentX, RelYields, shared) + intent2 := mustIntent(t, es, "由共享事实衍生") + mustLink(t, es, shared, RelDerivedFrom, intent2) + + cleanup, err := es.CancelIntent(intent1) + if err != nil { + t.Fatal(err) + } + // 只删 intent1 与其独占的 finding;shared(有 intentX 父)及其下游 intent2、goal 全保留。 + if cleanup.Intents != 1 || cleanup.Findings != 1 || cleanup.Facts != 0 { + t.Fatalf("cleanup=%+v, want 1 intent / 1 finding / 0 fact", cleanup) + } + if !gone(t, es, intent1) || !gone(t, es, finding) { + t.Fatal("intent1/finding should be removed") + } + for _, id := range []int64{goal, intentX, shared, intent2} { + if gone(t, es, id) { + t.Fatalf("node %d was wrongly cascaded", id) + } + } +} diff --git a/db/schema.sql b/db/schema.sql index cf9460c..561e450 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -177,13 +177,14 @@ CREATE TABLE IF NOT EXISTS exploration_nodes ( origin TEXT, owner TEXT, blocked_reason TEXT, + delete_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')), CONSTRAINT ck_node_state CHECK ( (kind='begin' AND state IN ('open')) OR - (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR + (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR (kind='goal' AND state IN ('open','met','abandoned')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR @@ -192,6 +193,8 @@ CREATE TABLE IF NOT EXISTS exploration_nodes ( ) ); ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT; +-- 意图假删除(soft delete):state='deleted' 时,delete_reason 记用户填写的删除原因。 +ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS delete_reason TEXT; -- cold-digest (§2.3/§5.3): content_version bumps on any change that could alter a -- digest body (summary/state/confidence); cold_since_round stamps the planner round -- a node most recently went from "has a live downstream branch" to none (NULL = hot). @@ -211,7 +214,8 @@ BEGIN CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')); END IF; END $$; --- ck_node_state: recreate when it lacks the 'paused' (older) or 'digest' (this rev) branches. +-- ck_node_state: recreate when it lacks the 'paused' (older), 'superseded' (digest rev), +-- or 'deleted' (intent soft-delete rev) branches. DO $$ BEGIN IF EXISTS ( @@ -219,12 +223,13 @@ BEGIN WHERE conrelid='exploration_nodes'::regclass AND conname='ck_node_state' AND (pg_get_constraintdef(oid) NOT LIKE '%paused%' - OR pg_get_constraintdef(oid) NOT LIKE '%superseded%') + OR pg_get_constraintdef(oid) NOT LIKE '%superseded%' + OR pg_get_constraintdef(oid) NOT LIKE '%deleted%') ) THEN ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state; ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK ( (kind='begin' AND state IN ('open')) OR - (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR + (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR (kind='goal' AND state IN ('open','met','abandoned')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR diff --git a/server/dto.go b/server/dto.go index f9bc1c1..0e1ff46 100644 --- a/server/dto.go +++ b/server/dto.go @@ -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) diff --git a/server/manager.go b/server/manager.go index 2ee833a..6e6605c 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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() diff --git a/server/server.go b/server/server.go index 6043523..efb9c28 100644 --- a/server/server.go +++ b/server/server.go @@ -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 diff --git a/server/side_questions_test.go b/server/side_questions_test.go index d673c66..cbdad89 100644 --- a/server/side_questions_test.go +++ b/server/side_questions_test.go @@ -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 { diff --git a/server/task_control.go b/server/task_control.go index 795f89b..ea479e1 100644 --- a/server/task_control.go +++ b/server/task_control.go @@ -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") } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx index 8b9fd12..03a4406 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/broadcast-tab.tsx @@ -405,6 +405,12 @@ function BroadcastRow({ 来源 {node.origin || "system"} {Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")} + {node.state === "deleted" && node.delete_reason && ( +