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
@@ -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", "任务暂停期间拒绝启动新运行",
|
||||
|
||||
+7
-1
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+168
-124
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+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")
|
||||
}
|
||||
|
||||
@@ -405,6 +405,12 @@ function BroadcastRow({
|
||||
<span>来源 {node.origin || "system"}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("zh-CN")}</span>
|
||||
</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] ?? []} />
|
||||
{(upstream.length > 0 || downstream.length > 0) && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
} from "lucide-react";
|
||||
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 { TodoPopover } from "@/components/todo-popover";
|
||||
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MentionTextarea } from "@/components/mention-textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -181,6 +181,8 @@ function statusIcon(status: SessionStatus) {
|
||||
return <CircleXIcon className="size-3.5 text-red-500" />;
|
||||
case "exhausted": // 步数耗尽(撞 max_turns)
|
||||
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";
|
||||
case "open": // 待领取,区别于执行中
|
||||
return "pending";
|
||||
case "deleted": // 用户假删除
|
||||
return "deleted";
|
||||
default: // running
|
||||
return "running";
|
||||
}
|
||||
@@ -362,7 +366,11 @@ function SessionItem({
|
||||
) : null;
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -417,8 +425,8 @@ function SessionItem({
|
||||
size="icon-xs"
|
||||
onClick={onCancel}
|
||||
disabled={controlling}
|
||||
title="删除该意图(需填写原因,保留数据)"
|
||||
aria-label="删除该意图(需填写原因,保留数据)"
|
||||
title="删除该意图(需填写原因,可选假删除/真删除)"
|
||||
aria-label="删除该意图(需填写原因,可选假删除/真删除)"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon />
|
||||
@@ -528,6 +536,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [cancelReason, setCancelReason] = React.useState("");
|
||||
// 删除模式:soft=假删除(默认,置 deleted + 记原因,保留数据)| hard=真删除(级联移除独占子孙)。
|
||||
const [deleteMode, setDeleteMode] = React.useState<"soft" | "hard">("soft");
|
||||
const [workerMessage, setWorkerMessage] = React.useState("");
|
||||
const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState("");
|
||||
const [workerMessageSending, setWorkerMessageSending] = React.useState(false);
|
||||
@@ -583,7 +593,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}, []);
|
||||
|
||||
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 (action === "cancel" && !reason?.trim()) {
|
||||
toast.error("请填写删除原因");
|
||||
@@ -591,16 +601,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}
|
||||
setControllingIntent(session.intent_id);
|
||||
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") {
|
||||
patchIntentState(session.intent_id, "paused");
|
||||
toast.success(`Worker #${session.intent_id} 已暂停`);
|
||||
} else if (action === "resume") {
|
||||
patchIntentState(session.intent_id, "open");
|
||||
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 {
|
||||
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。
|
||||
patchIntentState(session.intent_id, "stopped");
|
||||
// 假删除:意图置 deleted、记录删除原因,保留节点与产出。
|
||||
patchIntentState(session.intent_id, "deleted");
|
||||
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
|
||||
setCancelReason("");
|
||||
}
|
||||
@@ -1139,17 +1156,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
for (const node of allIntents) {
|
||||
let title = `Intent ${node.id}`;
|
||||
let parsedPayload: unknown = node.payload;
|
||||
let deleted = false;
|
||||
let deleteReason = "";
|
||||
// 假删除:意图 state='deleted',删除原因在独立字段 delete_reason 上。
|
||||
const deleted = node.state === "deleted";
|
||||
const deleteReason = node.delete_reason ?? "";
|
||||
if (node.payload) {
|
||||
try {
|
||||
const p = JSON.parse(node.payload);
|
||||
parsedPayload = p;
|
||||
if (p?.summary) title = String(p.summary);
|
||||
if (p?.cancelled_by_user) {
|
||||
deleted = true;
|
||||
deleteReason = String(p.cancel_reason ?? "");
|
||||
}
|
||||
} catch {
|
||||
title = node.payload.trim() || title;
|
||||
}
|
||||
@@ -1288,16 +1302,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
}, [activeKey]);
|
||||
|
||||
const focusKey = approvalFocus.state?.source?.session;
|
||||
const loadFocusPage = React.useCallback((before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE), [taskId, focusKey]);
|
||||
const mergeFocusPage = React.useCallback((page: { items: Activity[]; hasMore: boolean }) => {
|
||||
if (!focusKey) return;
|
||||
patchStore(focusKey, (s) => {
|
||||
const items = mergeBySeq(page.items, s.items);
|
||||
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);
|
||||
const loadFocusPage = React.useCallback(
|
||||
(before: number) => api.activityHistory(taskId, focusKey ?? "main", before, PAGE),
|
||||
[taskId, focusKey],
|
||||
);
|
||||
const mergeFocusPage = React.useCallback(
|
||||
(page: { items: Activity[]; hasMore: boolean }) => {
|
||||
if (!focusKey) return;
|
||||
patchStore(focusKey, (s) => {
|
||||
const items = mergeBySeq(page.items, s.items);
|
||||
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(() => {
|
||||
if (approvalFocus.state) atBottomRef.current = false;
|
||||
}, [approvalFocus.state]);
|
||||
@@ -1828,10 +1853,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<ApprovalExecutionFocus focus={{ ...approvalFocus, close: () => {
|
||||
setActiveId(activeId);
|
||||
approvalFocus.close();
|
||||
} }} history={focusHistory} />
|
||||
<ApprovalExecutionFocus
|
||||
focus={{
|
||||
...approvalFocus,
|
||||
close: () => {
|
||||
setActiveId(activeId);
|
||||
approvalFocus.close();
|
||||
},
|
||||
}}
|
||||
history={focusHistory}
|
||||
/>
|
||||
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
|
||||
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
|
||||
out the width and defeat the truncation below — the transcript wraps to
|
||||
@@ -1863,7 +1894,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
) : 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">
|
||||
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
|
||||
@@ -1908,7 +1945,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
inputGroup
|
||||
rows={1}
|
||||
aria-label="给主 Agent 发消息"
|
||||
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"}
|
||||
placeholder={
|
||||
mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"
|
||||
}
|
||||
value={input}
|
||||
disabled={sending}
|
||||
onValueChange={setInput}
|
||||
@@ -2069,6 +2108,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
if (!open) {
|
||||
setCancelIntent(null);
|
||||
setCancelReason("");
|
||||
setDeleteMode("soft");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2076,33 +2116,68 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words whitespace-normal">
|
||||
删除会<strong>停止该意图</strong>
|
||||
(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞
|
||||
<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
{deleteMode === "hard" ? (
|
||||
<>
|
||||
<strong>真删除</strong>会物理移除该意图,以及<strong>仅由它支撑</strong>
|
||||
的下游节点(级联到叶子,避免留下孤立数据);共享节点、目标和任务根事实会保留。
|
||||
<strong>此操作不可恢复。</strong>规划者会收到删除通知并据此重新规划。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>假删除</strong>会把该意图置为「已删除」并记录删除原因,意图节点、执行记录、
|
||||
已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="grid gap-2 py-1">
|
||||
<label htmlFor="cancel-reason" className="text-sm font-medium">
|
||||
删除原因(必填)
|
||||
</label>
|
||||
<Textarea
|
||||
id="cancel-reason"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="grid gap-3 py-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteMode("soft")}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
||||
deleteMode === "soft" ? "border-primary bg-primary/5" : "hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>返回</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
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 />}
|
||||
确认删除
|
||||
{deleteMode === "hard" ? "彻底删除" : "确认删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
+10
-3
@@ -315,12 +315,19 @@ export const api = {
|
||||
deleteTaskArchive: (id: number) => del<TaskArchive>(`/task-archives/${id}`),
|
||||
deleteTaskArchives: (archiveIds: number[]) =>
|
||||
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<{
|
||||
id: number;
|
||||
state: "paused" | "open" | "stopped";
|
||||
state: "paused" | "open" | "deleted" | "";
|
||||
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) =>
|
||||
post<{
|
||||
id: number;
|
||||
|
||||
+24
-12
@@ -1452,20 +1452,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (b.action === "cancel") {
|
||||
const index = mockIntents.findIndex((item) => item.id === id);
|
||||
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 状态已变化");
|
||||
}
|
||||
mockIntents.splice(index, 1);
|
||||
return {
|
||||
id: Number(id.replace(/\D/g, "")) || 0,
|
||||
state: "cancelled",
|
||||
deleted: {
|
||||
intents: 1,
|
||||
facts: 1,
|
||||
findings: 1,
|
||||
activities: mockActivity.filter((item) => item.intent_id === id).length,
|
||||
},
|
||||
};
|
||||
const numId = Number(id.replace(/\D/g, "")) || 0;
|
||||
if (b.mode === "hard") {
|
||||
// 真删除:从列表移除,返回级联删除计数。
|
||||
mockIntents.splice(index, 1);
|
||||
return {
|
||||
id: numId,
|
||||
state: "",
|
||||
deleted: {
|
||||
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 result = controlMockIntent(id, action);
|
||||
|
||||
+9
-20
@@ -1,31 +1,18 @@
|
||||
// Centralised status → color/label semantics, reused across the whole app.
|
||||
// Spec §8.3: 意图 / 覆盖 / 任务 / 严重度 each have a consistent color set.
|
||||
|
||||
export type Tone =
|
||||
| "neutral"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "rose"
|
||||
| "violet"
|
||||
| "slate";
|
||||
export type Tone = "neutral" | "blue" | "green" | "amber" | "red" | "rose" | "violet" | "slate";
|
||||
|
||||
export const toneClasses: Record<Tone, string> = {
|
||||
neutral:
|
||||
"bg-muted text-muted-foreground border-transparent",
|
||||
neutral: "bg-muted text-muted-foreground border-transparent",
|
||||
blue: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
green:
|
||||
"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",
|
||||
green: "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",
|
||||
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
// rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。
|
||||
rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white",
|
||||
violet:
|
||||
"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",
|
||||
violet: "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",
|
||||
};
|
||||
|
||||
export const toneDot: Record<Tone, string> = {
|
||||
@@ -53,8 +40,10 @@ const intent: Record<string, StatusMeta> = {
|
||||
blocked: { label: "执行出错", tone: "red" },
|
||||
// exhausted = 达到步数/时间预算被中途掐断、只写回部分结果(非方向已探尽)。
|
||||
exhausted: { label: "预算耗尽", tone: "violet" },
|
||||
// stopped = 用户手动删除/停止了该 work(保留已产出的事实)。
|
||||
// stopped = 历史软删除状态(保留,历史数据)。
|
||||
stopped: { label: "已停止", tone: "slate" },
|
||||
// deleted = 用户假删除了该意图(保留节点与血缘,删除原因见 delete_reason 字段)。
|
||||
deleted: { label: "已删除", tone: "slate" },
|
||||
};
|
||||
|
||||
const task: Record<string, StatusMeta> = {
|
||||
|
||||
@@ -418,6 +418,7 @@ export interface TaskNode {
|
||||
ts: string;
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
delete_reason?: string; // 意图假删除(state='deleted')时的删除原因
|
||||
}
|
||||
|
||||
// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引),
|
||||
@@ -754,7 +755,7 @@ export interface LogLine {
|
||||
}
|
||||
|
||||
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).
|
||||
export interface DailyTokenBucket {
|
||||
|
||||
Reference in new issue
Block a user