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
+543 -188

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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 != "" {
+5
View File
@@ -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"
+154 -110
View File
@@ -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,61 +438,121 @@ 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 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": id,
"cancelled_intent_id": iid,
"token_day": bucket.Day.Format(time.DateOnly),
"token_rollup": true,
})
@@ -520,34 +560,38 @@ func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) {
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,
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)
// 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
}
}
// 删节点(边随 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 removed, _ := res.RowsAffected(); removed != 1+out.Facts+out.Findings {
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)
+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,
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
+2
View File
@@ -177,6 +177,7 @@ 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 {
@@ -184,6 +185,7 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
ID: i64s(n.ID),
Type: n.Kind,
Payload: rawString(n.Payload),
DeleteReason: n.DeleteReason,
Priority: n.Priority,
State: n.State,
Origin: n.Origin,
+5 -3
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 {
+38 -8
View File
@@ -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 {
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, reason)
out.State = "stopped"
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
}
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 }) => {
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);
},
[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: () => {
<ApprovalExecutionFocus
focus={{
...approvalFocus,
close: () => {
setActiveId(activeId);
approvalFocus.close();
} }} history={focusHistory} />
},
}}
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,12 +2116,46 @@ 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">
<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>
@@ -2094,15 +2168,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
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
View File
@@ -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;
+15 -3
View File
@@ -1452,13 +1452,20 @@ 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 状态已变化");
}
const numId = Number(id.replace(/\D/g, "")) || 0;
if (b.mode === "hard") {
// 真删除:从列表移除,返回级联删除计数。
mockIntents.splice(index, 1);
return {
id: Number(id.replace(/\D/g, "")) || 0,
state: "cancelled",
id: numId,
state: "",
deleted: {
intents: 1,
facts: 1,
@@ -1467,6 +1474,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
},
};
}
// 假删除(默认):置 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);
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
+9 -20
View File
@@ -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> = {
+2 -1
View File
@@ -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 {