Merge pull request #149 from Autumn-27/feat/intent-delete-modes

feat(intent): 意图删除支持假删除/真删除两种模式
This commit is contained in:
Autumn authored and GitHub committed 2026-09-18 01:05:03 +08:00
commit 8a3f40b6f8
17 files changed
+593 -238

No files matched your search

+2 -2
View File
@@ -45,8 +45,8 @@ var (
"规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取") "规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取")
AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图", AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图",
"用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行") "用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行")
AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户取消了这条 Worker 意图", AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户删除了这条 Worker 意图",
"用户取消了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端会事务性删除该意图及其直接产生的事实、漏洞和执行记录") "用户删除了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端按用户选择的删除模式处理该意图——假删除仅标记为已删除并保留全部产出,真删除会级联移除该意图及仅由它支撑的下游节点")
AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context", AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context",
"Worker 已正常结束,引擎在 detachWork 中释放其 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态") "Worker 已正常结束,引擎在 detachWork 中释放其 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
AbortPausedRaceGuard = cause("paused_race_guard", "任务暂停期间拒绝启动新运行", AbortPausedRaceGuard = cause("paused_race_guard", "任务暂停期间拒绝启动新运行",
+7 -1
View File
@@ -159,6 +159,7 @@ type TriggerEvent struct {
Kind string Kind string
IntentID int64 IntentID int64
Detail string Detail string
Summary string // Kind=="cancelled" 专用:删除前捕获的意图摘要(真删除后节点已不存在,无法再查)
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条) Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本 OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本 NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
@@ -196,7 +197,12 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
case "finding": case "finding":
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail)) b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
case "cancelled": 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" default: // "done"
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID))) 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 != "" { if fids := factIDsYielded(ts, ev.IntentID); fids != "" {
+5
View File
@@ -25,6 +25,11 @@ const (
// facts use state 'confirmed', so this never collides. // facts use state 'confirmed', so this never collides.
const StateOrigin = "origin" 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). // Exploration edge relations (exploration_edges.rel).
const ( const (
RelSpawns = "spawns" RelSpawns = "spawns"
+168 -124
View File
@@ -75,6 +75,7 @@ type Node struct {
Origin string `json:"origin,omitempty"` Origin string `json:"origin,omitempty"`
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
BlockedReason string `json:"blocked_reason,omitempty"` BlockedReason string `json:"blocked_reason,omitempty"`
DeleteReason string `json:"delete_reason,omitempty"` // 仅意图假删除(state='deleted')时非空
Anchors []int64 `json:"anchors,omitempty"` Anchors []int64 `json:"anchors,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
SourceTaskID int64 `json:"source_task_id,omitempty"` SourceTaskID int64 `json:"source_task_id,omitempty"`
@@ -380,16 +381,14 @@ type IntentCleanup struct {
Activities int64 `json:"activities"` Activities int64 `json:"activities"`
} }
// StopIntentWithReason marks a running/paused intent as 'stopped' WITHOUT deleting // SoftDeleteIntent 假删除一个待领/运行中/已暂停的意图:置 state='deleted' 并把用户填写的
// it or any of its yielded facts/findings/activities, and records the user's reason // 删除原因记入 delete_reason 字段,保留意图节点及其全部产出/血缘(不再像旧实现那样在图上
// as a fact node hanging off that intent (intent --yields--> fact). Returns the new // 另挂一条 fact)。返回删除前意图的 summary,供 planner 通知使用。副会话随删除态一并清理。
// fact node id. The caller must first stop a running worker to prevent late writes. // 调用方须先停掉运行中的 worker,避免其后续写入。
// Used by the "delete work" action, which no longer destroys data — it stops the func (s *ExplorationStore) SoftDeleteIntent(id int64, reason string) (string, error) {
// 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() tx, err := s.db.Begin()
if err != nil { if err != nil {
return 0, err return "", err
} }
defer tx.Rollback() 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 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 { WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state, &rawPayload); err != nil {
if err == sql.ErrNoRows { 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" { if state != "running" && state != "paused" && state != "open" {
return 0, fmt.Errorf("%w: intent state %s cannot be cancelled", ErrIntentStateConflict, state) 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 if _, err := tx.Exec(`UPDATE exploration_nodes
// intent everywhere (session list badge, detail banner) without a schema change. SET state='deleted', delete_reason=$3, blocked_reason=NULL,
ip := map[string]any{} content_version=content_version+1, completed_at=now()
_ = json.Unmarshal(rawPayload, &ip) WHERE id=$1 AND exploration_id=$2`, id, s.expID, reason); err != nil {
ip["cancelled_by_user"] = true return "", err
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
} }
// 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 { if _, err := tx.Exec(`DELETE FROM side_question_sessions WHERE intent_id=$1`, id); err != nil {
return 0, err return "", err
} }
var summary string
if origin == "" { var p map[string]any
origin = "user" if json.Unmarshal(rawPayload, &p) == nil {
if sm, ok := p["summary"].(string); ok {
summary = sm
}
} }
raw, _ := json.Marshal(map[string]any{ return summary, tx.Commit()
"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 // CancelIntent 物理删除一个意图,以及"仅由该意图支撑"的全部独占子孙节点——从该意图沿
// yielded. The standalone finding row must be deleted before its node (whose FK // yields/derived_from 向下可达、且所有父节点(所有指向它的边的源)都落在删除集内的节点。
// otherwise uses ON DELETE SET NULL), so the entire cleanup is kept in one DB // goal 与 origin fact 永不删除;还被删除集之外的意图/digest 引用的共享节点也保留,以免破坏
// transaction. The caller must first stop a running worker to prevent late writes. // 其它分支、产生断链。被删的每个 intent 先做 token rollup(保留不可逆计量)并清理其 activity
// 与副会话;被删的 finding 先删 findings 表行(node_id FK 为 ON DELETE SET NULL,否则留孤儿)。
// 边随节点 CASCADE 清理,整个清理保持在一个事务内。调用方须先停掉运行中的 worker 防止其后续写入。
func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) { func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) {
var out IntentCleanup var out IntentCleanup
tx, err := s.db.Begin() tx, err := s.db.Begin()
@@ -458,96 +438,160 @@ func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) {
} }
defer tx.Rollback() defer tx.Rollback()
var state string // 锁定意图行确认存在(幂等:已删则 not found)。状态不校验——真删除对任何状态成立,
if err := tx.QueryRow(`SELECT state FROM exploration_nodes // running 的 worker 由调用方先停。
WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state); err != nil { 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 { if err == sql.ErrNoRows {
return out, fmt.Errorf("intent not found") return out, fmt.Errorf("intent not found")
} }
return out, err 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 // 载入全图节点(判 protected)与边(算向下可达 + 父集)。图规模对真实任务很小。
COUNT(*) FILTER (WHERE n.kind='fact'), kind := map[int64]string{}
COUNT(*) FILTER (WHERE n.kind='finding') protected := map[int64]bool{}
FROM exploration_edges e nrows, err := tx.Query(`SELECT id, kind, state FROM exploration_nodes WHERE exploration_id=$1`, s.expID)
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)
if err != nil { if err != nil {
return out, err 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 { if err != nil {
return out, err return out, err
} }
out.Activities, _ = res.RowsAffected() for erows.Next() {
for _, bucket := range tokenBuckets { var src, dst int64
metadata, _ := json.Marshal(map[string]any{ var rel string
"cancelled_intent_id": id, if err := erows.Scan(&src, &rel, &dst); err != nil {
"token_day": bucket.Day.Format(time.DateOnly), erows.Close()
"token_rollup": true, return out, err
}) }
if _, err := tx.Exec(`INSERT INTO activity( parentsOf[dst] = append(parentsOf[dst], src)
exploration_id, worker, kind, summary, metadata, if rel == RelYields || rel == RelDerivedFrom {
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at) downOf[src] = append(downOf[src], dst)
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, erows.Close()
bucket.Usage.CacheReadTokens, bucket.Usage.CacheWriteTokens, bucket.Day); err != nil { 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 return out, err
} }
} }
res, err = tx.Exec(`DELETE FROM exploration_nodes // finding 节点删除前先删 findings 表行(否则留 node_id=NULL 的孤儿)。
WHERE exploration_id=$1 AND ( for _, fid := range findingIDs {
id=$2 OR id IN ( if _, err := tx.Exec(`DELETE FROM findings WHERE node_id=$1`, fid); err != nil {
SELECT e.dst_id FROM exploration_edges e return out, err
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
} }
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") return out, fmt.Errorf("intent cleanup changed concurrently")
} }
out.Intents = 1
return out, tx.Commit() return out, tx.Commit()
} }
@@ -653,12 +697,12 @@ func (u *TokenUsage) add(other TokenUsage) {
u.CacheWriteTokens += other.CacheWriteTokens 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) { func scanNode(sc interface{ Scan(...any) error }) (*Node, error) {
var n Node var n Node
var payload []byte 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 return nil, err
} }
n.Payload = json.RawMessage(payload) n.Payload = json.RawMessage(payload)
+170
View File
@@ -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)
}
}
}
+9 -4
View File
@@ -177,13 +177,14 @@ CREATE TABLE IF NOT EXISTS exploration_nodes (
origin TEXT, origin TEXT,
owner TEXT, owner TEXT,
blocked_reason TEXT, blocked_reason TEXT,
delete_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ, completed_at TIMESTAMPTZ,
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')), CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')),
CONSTRAINT ck_node_state CHECK ( CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR (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='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) 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; 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 -- 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 -- 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). -- 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')); CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest'));
END IF; END IF;
END $$; 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 $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -219,12 +223,13 @@ BEGIN
WHERE conrelid='exploration_nodes'::regclass WHERE conrelid='exploration_nodes'::regclass
AND conname='ck_node_state' AND conname='ck_node_state'
AND (pg_get_constraintdef(oid) NOT LIKE '%paused%' 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 ) THEN
ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state; ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state;
ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK ( ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR (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='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR
+9 -7
View File
@@ -177,17 +177,19 @@ type TaskNodeDTO struct {
TS string `json:"ts"` TS string `json:"ts"`
SourceTaskID string `json:"source_task_id,omitempty"` SourceTaskID string `json:"source_task_id,omitempty"`
Inherited bool `json:"inherited,omitempty"` Inherited bool `json:"inherited,omitempty"`
DeleteReason string `json:"delete_reason,omitempty"` // 意图假删除(state='deleted')时的删除原因
} }
func taskNodeDTO(n *db.Node) TaskNodeDTO { func taskNodeDTO(n *db.Node) TaskNodeDTO {
d := TaskNodeDTO{ d := TaskNodeDTO{
ID: i64s(n.ID), ID: i64s(n.ID),
Type: n.Kind, Type: n.Kind,
Payload: rawString(n.Payload), Payload: rawString(n.Payload),
Priority: n.Priority, DeleteReason: n.DeleteReason,
State: n.State, Priority: n.Priority,
Origin: n.Origin, State: n.State,
TS: rfc3339(n.CreatedAt), Origin: n.Origin,
TS: rfc3339(n.CreatedAt),
} }
if n.SourceTaskID > 0 { if n.SourceTaskID > 0 {
d.SourceTaskID = i64s(n.SourceTaskID) d.SourceTaskID = i64s(n.SourceTaskID)
+5 -3
View File
@@ -1838,11 +1838,13 @@ func (t *Task) NotifyGoalEdited(oldText, newText string) {
// NotifyCancelled records that the human deleted intentID (reason = 删除原因), then // NotifyCancelled records that the human deleted intentID (reason = 删除原因), then
// wakes the planner so the next round spells out which intent was removed and why. // 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. // summary is the intent's text captured before deletion — needed for hard delete,
func (t *Task) NotifyCancelled(intentID int64, reason string) { // 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 { if intentID > 0 {
t.trigMu.Lock() 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.trigMu.Unlock()
} }
t.Notify() t.Notify()
+3 -2
View File
@@ -1122,7 +1122,8 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
} }
var req struct { var req struct {
Action string `json:"action"` 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 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error()) writeErr(w, 400, "bad json: "+err.Error())
@@ -1133,7 +1134,7 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeErr(w, 409, err.Error()) writeErr(w, 409, err.Error())
return return
+1 -1
View File
@@ -344,7 +344,7 @@ func TestSideHTTPGlobalLimitTaskWorkerAndDeletion(t *testing.T) {
waitSide(t, f.m.pg, e.ID, "completed") 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) 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) 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 { 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
View File
@@ -149,7 +149,21 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
return out, nil 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} out := intentControlResult{ID: iid}
node, err := t.Store.GetNode(iid) node, err := t.Store.GetNode(iid)
if err != nil { if err != nil {
@@ -187,10 +201,14 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act
t.Notify() t.Notify()
out.State = "open" out.State = "open"
case "cancel": case "cancel":
// 「删除」新语义:不销毁意图与产出,而是把意图停到 stopped、把用户填写的删除原因 // 删除支持两种模式:
// 作为一条事实挂到该意图上,并用 cancelled 触发告知 planner(意图内容 + 删除原因)。 // soft(默认,假删除):意图停到 state='deleted'、删除原因记入 delete_reason 字段,
if node.State != "running" && node.State != "paused" { // 保留意图节点与全部产出/血缘,不再在图上另挂 fact。
return out, fmt.Errorf("仅运行中或已暂停的意图可以删除") // hard(真删除):物理删除该意图及"仅由它支撑"的独占子孙节点(级联到叶子),避免留下
// 孤立数据;共享节点、goal、任务根事实保留。
// 两种模式都用 cancelled 触发告知 planner(意图内容 + 删除原因),让它据此重规划。
if node.State != "running" && node.State != "paused" && node.State != "open" {
return out, fmt.Errorf("仅待领/运行中/已暂停的意图可以删除")
} }
reason = strings.TrimSpace(reason) reason = strings.TrimSpace(reason)
if reason == "" { if reason == "" {
@@ -201,12 +219,24 @@ func (s *Server) applyIntentControl(ctx context.Context, t *Task, iid int64, act
return out, err return out, err
} }
} }
if _, err := t.Store.StopIntentWithReason(iid, reason, "user"); err != nil { summary := intentSummaryOf(node)
return out, err 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: default:
return out, fmt.Errorf("action must be pause|resume|cancel") return out, fmt.Errorf("action must be pause|resume|cancel")
} }
@@ -405,6 +405,12 @@ function BroadcastRow({
<span>来源 {node.origin || "system"}</span> <span>来源 {node.origin || "system"}</span>
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span> <span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
</div> </div>
{node.state === "deleted" && node.delete_reason && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
<span className="font-medium text-destructive">删除原因</span>
<span className="ml-2 break-words text-muted-foreground">{node.delete_reason}</span>
</div>
)}
<AssetList assets={assets[node.id] ?? []} /> <AssetList assets={assets[node.id] ?? []} />
{(upstream.length > 0 || downstream.length > 0) && ( {(upstream.length > 0 || downstream.length > 0) && (
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
@@ -27,9 +27,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
import { MentionTextarea } from "@/components/mention-textarea";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace"; import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { TodoPopover } from "@/components/todo-popover"; import { TodoPopover } from "@/components/todo-popover";
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
import { Transcript } from "@/components/transcript"; import { Transcript } from "@/components/transcript";
import { import {
AlertDialog, AlertDialog,
@@ -42,7 +43,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { MentionTextarea } from "@/components/mention-textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -181,6 +181,8 @@ function statusIcon(status: SessionStatus) {
return <CircleXIcon className="size-3.5 text-red-500" />; return <CircleXIcon className="size-3.5 text-red-500" />;
case "exhausted": // 步数耗尽(撞 max_turns) case "exhausted": // 步数耗尽(撞 max_turns)
return <ZapOffIcon className="size-3.5 text-violet-500" />; return <ZapOffIcon className="size-3.5 text-violet-500" />;
case "deleted": // 用户假删除
return <CircleSlashIcon className="size-3.5 text-muted-foreground" />;
} }
} }
@@ -309,6 +311,8 @@ function intentStatus(state: string): SessionStatus {
return "paused"; return "paused";
case "open": // 待领取,区别于执行中 case "open": // 待领取,区别于执行中
return "pending"; return "pending";
case "deleted": // 用户假删除
return "deleted";
default: // running default: // running
return "running"; return "running";
} }
@@ -362,7 +366,11 @@ function SessionItem({
) : null; ) : null;
const cancellable = const cancellable =
s.role === "worker" && !s.inherited && !deleted && (s.status === "running" || s.status === "paused"); s.role === "worker" &&
!s.inherited &&
!deleted &&
// pending = 待领(open)意图;连同运行中/已暂停都允许删除。
(s.status === "running" || s.status === "paused" || s.status === "pending");
return ( return (
<div <div
className={cn( className={cn(
@@ -417,8 +425,8 @@ function SessionItem({
size="icon-xs" size="icon-xs"
onClick={onCancel} onClick={onCancel}
disabled={controlling} disabled={controlling}
title="删除该意图(需填写原因,保留数据)" title="删除该意图(需填写原因,可选假删除/真删除)"
aria-label="删除该意图(需填写原因,保留数据)" aria-label="删除该意图(需填写原因,可选假删除/真删除)"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
> >
<Trash2Icon /> <Trash2Icon />
@@ -528,6 +536,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null); const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null); const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [cancelReason, setCancelReason] = React.useState(""); const [cancelReason, setCancelReason] = React.useState("");
// 删除模式:soft=假删除(默认,置 deleted + 记原因,保留数据)| hard=真删除(级联移除独占子孙)。
const [deleteMode, setDeleteMode] = React.useState<"soft" | "hard">("soft");
const [workerMessage, setWorkerMessage] = React.useState(""); const [workerMessage, setWorkerMessage] = React.useState("");
const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState(""); const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState("");
const [workerMessageSending, setWorkerMessageSending] = React.useState(false); const [workerMessageSending, setWorkerMessageSending] = React.useState(false);
@@ -583,7 +593,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}, []); }, []);
const controlWorker = React.useCallback( const controlWorker = React.useCallback(
async (session: Session, action: "pause" | "resume" | "cancel", reason?: string) => { async (session: Session, action: "pause" | "resume" | "cancel", reason?: string, mode?: "soft" | "hard") => {
if (!session.intent_id || session.inherited || controllingIntent) return; if (!session.intent_id || session.inherited || controllingIntent) return;
if (action === "cancel" && !reason?.trim()) { if (action === "cancel" && !reason?.trim()) {
toast.error("请填写删除原因"); toast.error("请填写删除原因");
@@ -591,16 +601,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} }
setControllingIntent(session.intent_id); setControllingIntent(session.intent_id);
try { try {
await api.controlIntent(taskId, session.intent_id, action, reason); const res = await api.controlIntent(taskId, session.intent_id, action, reason, mode);
if (action === "pause") { if (action === "pause") {
patchIntentState(session.intent_id, "paused"); patchIntentState(session.intent_id, "paused");
toast.success(`Worker #${session.intent_id} 已暂停`); toast.success(`Worker #${session.intent_id} 已暂停`);
} else if (action === "resume") { } else if (action === "resume") {
patchIntentState(session.intent_id, "open"); patchIntentState(session.intent_id, "open");
toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`); toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
} else if (mode === "hard") {
// 真删除:意图及独占下游已物理移除,从列表剔除该行。
patchIntentState(session.intent_id);
const d = res.deleted;
const extra = d ? `(含 ${d.intents} 意图 / ${d.facts} 事实 / ${d.findings} 漏洞)` : "";
toast.success(`Worker #${session.intent_id} 及其独占下游已彻底删除${extra}`);
setCancelReason("");
} else { } else {
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。 // 假删除:意图置 deleted、记录删除原因,保留节点与产出。
patchIntentState(session.intent_id, "stopped"); patchIntentState(session.intent_id, "deleted");
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`); toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
setCancelReason(""); setCancelReason("");
} }
@@ -1139,17 +1156,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
for (const node of allIntents) { for (const node of allIntents) {
let title = `Intent ${node.id}`; let title = `Intent ${node.id}`;
let parsedPayload: unknown = node.payload; let parsedPayload: unknown = node.payload;
let deleted = false; // 假删除:意图 state='deleted',删除原因在独立字段 delete_reason 上。
let deleteReason = ""; const deleted = node.state === "deleted";
const deleteReason = node.delete_reason ?? "";
if (node.payload) { if (node.payload) {
try { try {
const p = JSON.parse(node.payload); const p = JSON.parse(node.payload);
parsedPayload = p; parsedPayload = p;
if (p?.summary) title = String(p.summary); if (p?.summary) title = String(p.summary);
if (p?.cancelled_by_user) {
deleted = true;
deleteReason = String(p.cancel_reason ?? "");
}
} catch { } catch {
title = node.payload.trim() || title; title = node.payload.trim() || title;
} }
@@ -1288,16 +1302,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}, [activeKey]); }, [activeKey]);
const focusKey = approvalFocus.state?.source?.session; const focusKey = approvalFocus.state?.source?.session;
const loadFocusPage = React.useCallback((before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE), [taskId, focusKey]); const loadFocusPage = React.useCallback(
const mergeFocusPage = React.useCallback((page: { items: Activity[]; hasMore: boolean }) => { (before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE),
if (!focusKey) return; [taskId, focusKey],
patchStore(focusKey, (s) => { );
const items = mergeBySeq(page.items, s.items); const mergeFocusPage = React.useCallback(
return { ...s, items, hasMore: page.hasMore, earliestSeq: items[0]?.seq ?? s.earliestSeq }; (page: { items: Activity[]; hasMore: boolean }) => {
}); if (!focusKey) return;
}, [focusKey, patchStore]); patchStore(focusKey, (s) => {
const focusHistory = useApprovalHistory(approvalFocus.state?.source, !!focusKey && !!store[focusKey]?.loaded, const items = mergeBySeq(page.items, s.items);
focusKey ? store[focusKey]?.items ?? [] : [], loadFocusPage, mergeFocusPage); return { ...s, items, hasMore: page.hasMore, earliestSeq: items[0]?.seq ?? s.earliestSeq };
});
},
[focusKey, patchStore],
);
const focusHistory = useApprovalHistory(
approvalFocus.state?.source,
!!focusKey && !!store[focusKey]?.loaded,
focusKey ? (store[focusKey]?.items ?? []) : [],
loadFocusPage,
mergeFocusPage,
);
React.useEffect(() => { React.useEffect(() => {
if (approvalFocus.state) atBottomRef.current = false; if (approvalFocus.state) atBottomRef.current = false;
}, [approvalFocus.state]); }, [approvalFocus.state]);
@@ -1828,10 +1853,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div> </div>
); );
})()} })()}
<ApprovalExecutionFocus focus={{ ...approvalFocus, close: () => { <ApprovalExecutionFocus
setActiveId(activeId); focus={{
approvalFocus.close(); ...approvalFocus,
} }} history={focusHistory} /> close: () => {
setActiveId(activeId);
approvalFocus.close();
},
}}
history={focusHistory}
/>
{/* Force Radix's internal viewport wrapper (display:table, sizes to content) {/* Force Radix's internal viewport wrapper (display:table, sizes to content)
to block so wide/unbreakable steps (long commands, code, URLs) can't blow 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 out the width and defeat the truncation below — the transcript wraps to
@@ -1863,7 +1894,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</Button> </Button>
</div> </div>
) : activity.length ? ( ) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined} /> <Transcript
activity={activity}
live={active.live}
taskId={taskId}
chat={isMain}
focusedSeq={focusHistory.ready ? approvalFocus.state?.source?.seq : undefined}
/>
) : ( ) : (
<div className="pl-9 text-xs text-muted-foreground"> <div className="pl-9 text-xs text-muted-foreground">
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
@@ -1908,7 +1945,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
inputGroup inputGroup
rows={1} rows={1}
aria-label="给主 Agent 发消息" aria-label="给主 Agent 发消息"
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"} placeholder={
mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"
}
value={input} value={input}
disabled={sending} disabled={sending}
onValueChange={setInput} onValueChange={setInput}
@@ -2069,6 +2108,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (!open) { if (!open) {
setCancelIntent(null); setCancelIntent(null);
setCancelReason(""); setCancelReason("");
setDeleteMode("soft");
} }
}} }}
> >
@@ -2076,33 +2116,68 @@ export function SessionsTab({ taskId }: { taskId: string }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle> <AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal"> <AlertDialogDescription className="break-words whitespace-normal">
删除会<strong>停止该意图</strong> {deleteMode === "hard" ? (
(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞 <>
<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。 <strong>真删除</strong>会物理移除该意图,以及<strong>仅由它支撑</strong>
的下游节点(级联到叶子,避免留下孤立数据);共享节点、目标和任务根事实会保留。
<strong>此操作不可恢复。</strong>规划者会收到删除通知并据此重新规划。
</>
) : (
<>
<strong>假删除</strong>会把该意图置为「已删除」并记录删除原因,意图节点、执行记录、
已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
</>
)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="grid gap-2 py-1"> <div className="grid gap-3 py-1">
<label htmlFor="cancel-reason" className="text-sm font-medium"> <div className="grid grid-cols-2 gap-2">
删除原因(必填) <button
</label> type="button"
<Textarea onClick={() => setDeleteMode("soft")}
id="cancel-reason" className={cn(
value={cancelReason} "rounded-md border px-3 py-2 text-left text-sm transition-colors",
onChange={(e) => setCancelReason(e.target.value)} deleteMode === "soft" ? "border-primary bg-primary/5" : "hover:bg-accent",
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…" )}
rows={3} >
autoFocus <div className="font-medium">假删除</div>
/> <div className="text-xs text-muted-foreground">保留数据,可追溯</div>
</button>
<button
type="button"
onClick={() => setDeleteMode("hard")}
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
deleteMode === "hard" ? "border-destructive bg-destructive/5" : "hover:bg-accent",
)}
>
<div className="font-medium">真删除</div>
<div className="text-xs text-muted-foreground">级联移除,不可恢复</div>
</button>
</div>
<div className="grid gap-2">
<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>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>返回</AlertDialogCancel> <AlertDialogCancel>返回</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant="destructive" variant="destructive"
disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()} disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason)} onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason, deleteMode)}
> >
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />} {controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
确认删除 {deleteMode === "hard" ? "彻底删除" : "确认删除"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+10 -3
View File
@@ -315,12 +315,19 @@ export const api = {
deleteTaskArchive: (id: number) => del<TaskArchive>(`/task-archives/${id}`), deleteTaskArchive: (id: number) => del<TaskArchive>(`/task-archives/${id}`),
deleteTaskArchives: (archiveIds: number[]) => deleteTaskArchives: (archiveIds: number[]) =>
post<{ items: ArchiveBatchItem[] }>("/task-archives/delete/batch", { archive_ids: archiveIds }), post<{ items: ArchiveBatchItem[] }>("/task-archives/delete/batch", { archive_ids: archiveIds }),
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel", reason?: string) => controlIntent: (
taskId: string,
intentId: string,
action: "pause" | "resume" | "cancel",
reason?: string,
// cancel 专用:soft(默认,假删除,意图置 deleted + 记删除原因)| hard(真删除,级联移除独占子孙)。
mode?: "soft" | "hard",
) =>
post<{ post<{
id: number; id: number;
state: "paused" | "open" | "stopped"; state: "paused" | "open" | "deleted" | "";
deleted?: { intents: number; facts: number; findings: number; activities: number }; deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }), }>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "", mode: mode ?? "soft" }),
sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) => sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) =>
post<{ post<{
id: number; id: number;
+24 -12
View File
@@ -1452,20 +1452,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (b.action === "cancel") { if (b.action === "cancel") {
const index = mockIntents.findIndex((item) => item.id === id); const index = mockIntents.findIndex((item) => item.id === id);
const intent = mockIntents[index]; const intent = mockIntents[index];
if (!intent || intent.inherited || (intent.state !== "running" && intent.state !== "paused")) { if (
!intent ||
intent.inherited ||
(intent.state !== "running" && intent.state !== "paused" && intent.state !== "open")
) {
throw new Error("Worker 状态已变化"); throw new Error("Worker 状态已变化");
} }
mockIntents.splice(index, 1); const numId = Number(id.replace(/\D/g, "")) || 0;
return { if (b.mode === "hard") {
id: Number(id.replace(/\D/g, "")) || 0, // 真删除:从列表移除,返回级联删除计数。
state: "cancelled", mockIntents.splice(index, 1);
deleted: { return {
intents: 1, id: numId,
facts: 1, state: "",
findings: 1, deleted: {
activities: mockActivity.filter((item) => item.intent_id === id).length, intents: 1,
}, facts: 1,
}; findings: 1,
activities: mockActivity.filter((item) => item.intent_id === id).length,
},
};
}
// 假删除(默认):置 deleted + 记删除原因,保留节点。
intent.state = "deleted";
intent.delete_reason = String(b.reason ?? "");
return { id: numId, state: "deleted" };
} }
const action = b.action === "resume" ? "resume" : "pause"; const action = b.action === "resume" ? "resume" : "pause";
const result = controlMockIntent(id, action); const result = controlMockIntent(id, action);
+9 -20
View File
@@ -1,31 +1,18 @@
// Centralised status → color/label semantics, reused across the whole app. // Centralised status → color/label semantics, reused across the whole app.
// Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set. // Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set.
export type Tone = export type Tone = "neutral" | "blue" | "green" | "amber" | "red" | "rose" | "violet" | "slate";
| "neutral"
| "blue"
| "green"
| "amber"
| "red"
| "rose"
| "violet"
| "slate";
export const toneClasses: Record<Tone, string> = { export const toneClasses: Record<Tone, string> = {
neutral: neutral: "bg-muted text-muted-foreground border-transparent",
"bg-muted text-muted-foreground border-transparent",
blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
green: green: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", amber: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
amber:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
// rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。 // rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。
rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white", rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white",
violet: violet: "bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
"bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20", slate: "bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
slate:
"bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/20",
}; };
export const toneDot: Record<Tone, string> = { export const toneDot: Record<Tone, string> = {
@@ -53,8 +40,10 @@ const intent: Record<string, StatusMeta> = {
blocked: { label: "执行出错", tone: "red" }, blocked: { label: "执行出错", tone: "red" },
// exhausted = 达到步数/时间预算被中途掐断、只写回部分结果(非方向已探尽)。 // exhausted = 达到步数/时间预算被中途掐断、只写回部分结果(非方向已探尽)。
exhausted: { label: "预算耗尽", tone: "violet" }, exhausted: { label: "预算耗尽", tone: "violet" },
// stopped = 用户手动删除/停止了该 work(保留已产出的事实)。 // stopped = 历史软删除状态(保留,历史数据)。
stopped: { label: "已停止", tone: "slate" }, stopped: { label: "已停止", tone: "slate" },
// deleted = 用户假删除了该意图(保留节点与血缘,删除原因见 delete_reason 字段)。
deleted: { label: "已删除", tone: "slate" },
}; };
const task: Record<string, StatusMeta> = { const task: Record<string, StatusMeta> = {
+2 -1
View File
@@ -418,6 +418,7 @@ export interface TaskNode {
ts: string; ts: string;
source_task_id?: string; source_task_id?: string;
inherited?: boolean; inherited?: boolean;
delete_reason?: string; // 意图假删除(state='deleted')时的删除原因
} }
// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引), // 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引),
@@ -754,7 +755,7 @@ export interface LogLine {
} }
export type SessionRole = "mainagent" | "planner" | "worker" | "system"; export type SessionRole = "mainagent" | "planner" | "worker" | "system";
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped"; export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped" | "deleted";
// Daily token aggregate bucket (GET /api/tokens/daily). // Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket { export interface DailyTokenBucket {