Files
ARTEX-chixuweihu/server/task_control.go
T
Autumn-27 4756bb1c45 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 状态(含旧库迁移)。
- 前端: 删除对话框加模式切换与动态说明,状态徽章/图标新增「已删除」,
  播报板展开展示删除原因。
2026-09-17 12:52:57 -04:00

289 lines
8.5 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
const maxBatchControlIDs = 100
type taskControlResult struct {
ID string `json:"id"`
Paused bool `json:"paused"`
Queued bool `json:"queued"`
Status string `json:"status"`
}
type intentControlResult struct {
ID int64 `json:"id"`
State string `json:"state"`
Deleted *db.IntentCleanup `json:"deleted,omitempty"`
}
// parsedTaskID carries one requested batch id together with whether it parsed.
// Invalid ids are kept rather than dropped so the response can name them.
type parsedTaskID struct {
id string
valid bool
}
// normalizeBatchTaskIDs trims, canonicalizes and de-duplicates the ids of one
// batch request while preserving the caller's order. Shared by every batch
// endpoint so they agree on what counts as a duplicate.
func normalizeBatchTaskIDs(raw []string) []parsedTaskID {
seen := map[string]bool{}
seenInvalid := map[string]bool{}
taskIDs := make([]parsedTaskID, 0, len(raw))
for _, item := range raw {
trimmed := strings.TrimSpace(item)
id, valid := canonicalTaskID(trimmed)
if !valid {
if seenInvalid[trimmed] {
continue
}
seenInvalid[trimmed] = true
taskIDs = append(taskIDs, parsedTaskID{id: trimmed})
continue
}
if seen[id] {
continue
}
seen[id] = true
taskIDs = append(taskIDs, parsedTaskID{id: id, valid: true})
}
return taskIDs
}
type batchControlItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Status string `json:"status,omitempty"`
Queued bool `json:"queued,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) resumeAdmissionMode(t *Task) string {
if t == nil {
return "resume"
}
lifecycle := t.lifecycleSnapshot()
if lifecycle.QueueMode == "bootstrap" {
return "bootstrap"
}
if lifecycle.FirstRunAt == 0 {
goals, err := t.Store.ListByKind(db.KindGoal, 1)
if err == nil && len(goals) == 0 {
return "bootstrap"
}
}
return "resume"
}
// applyTaskControl is shared by the single and batch endpoints. Task resume is
// deliberately limited to paused tasks; reruns and finding follow-ups use
// admitTask directly when they need to revive a terminal task.
func (s *Server) applyTaskControl(t *Task, action string) (taskControlResult, error) {
return s.applyTaskControlWithCause(t, action, agent.AbortPausedByUser)
}
func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause error) (taskControlResult, error) {
if t == nil {
return taskControlResult{}, fmt.Errorf("task not found")
}
out := taskControlResult{ID: t.ID}
switch action {
case "pause":
s.concMu.Lock()
defer s.concMu.Unlock()
current, exists := s.m.Task(t.ID)
if !exists || current != t || s.engine.IsDeleting(t.ID) {
return out, fmt.Errorf("任务正在删除,无法控制")
}
if !s.engine.beginTaskOperation(t.ID) {
return out, fmt.Errorf("任务正在删除,无法控制")
}
defer s.engine.decInflight(t.ID)
lifecycle := t.lifecycleSnapshot()
if isTerminalStatus(lifecycle.Status) {
return out, fmt.Errorf("终态任务不能执行暂停")
}
if lifecycle.Paused {
return out, fmt.Errorf("任务已经暂停")
}
wasQueued := lifecycle.Queued
wasEnginePaused := s.engine.IsPaused(t.ID)
if pauseCause == nil {
pauseCause = agent.AbortPausedByUser
}
s.engine.Pause(t.ID, pauseCause)
if err := s.m.ApplyTaskPause(t.ID); err != nil {
if !wasEnginePaused && !wasQueued {
s.engine.Resume(t)
}
return out, err
}
// Main Agent is independently cancellable. Only cancel its current turn
// after the persistent pause commits, so a failed control request is fully
// compensated and does not lose an otherwise valid conversation turn.
s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
out.Paused, out.Status = true, "paused"
go s.reconcileConcurrency()
case "resume":
queued, err := s.admitPausedTask(t)
if err != nil {
return out, err
}
out.Queued = queued
out.Status = map[bool]string{true: "queued", false: "running"}[queued]
default:
return out, fmt.Errorf("action must be pause|resume")
}
log.Printf("[task] #%s %s", t.ID, map[string]string{"pause": "已暂停", "resume": "已继续"}[action])
return out, nil
}
// 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 {
return out, err
}
if node == nil {
if inherited, sourceErr := t.Store.GetNodeWithSources(iid); sourceErr == nil && inherited != nil && inherited.Inherited {
return out, fmt.Errorf("继承意图为只读,不能控制")
}
return out, fmt.Errorf("intent not found")
}
if node.Kind != db.KindIntent {
return out, fmt.Errorf("node is not an intent")
}
switch action {
case "pause":
if node.State != "running" {
return out, fmt.Errorf("仅运行中的意图可以暂停")
}
if err := s.engine.ControlWork(ctx, iid, "pause"); err != nil {
return out, err
}
out.State = "paused"
case "resume":
if node.State != "paused" {
return out, fmt.Errorf("仅已暂停的意图可以恢复")
}
changed, err := t.Store.CompareAndSetIntentState(iid, "paused", "open")
if err != nil {
return out, err
}
if !changed {
return out, fmt.Errorf("%w: 意图不再是 paused 状态", db.ErrIntentStateConflict)
}
t.Notify()
out.State = "open"
case "cancel":
// 删除支持两种模式:
// 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 == "" {
return out, fmt.Errorf("请填写删除原因")
}
if node.State == "running" {
if err := s.engine.ControlWork(ctx, iid, "cancel"); 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
}
default:
return out, fmt.Errorf("action must be pause|resume|cancel")
}
return out, nil
}
func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 256<<10)
var req struct {
TaskIDs []string `json:"task_ids"`
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if req.Action != "pause" && req.Action != "resume" {
writeErr(w, 400, "action must be pause|resume")
return
}
taskIDs := normalizeBatchTaskIDs(req.TaskIDs)
if len(taskIDs) == 0 || len(taskIDs) > maxBatchControlIDs {
writeErr(w, 400, fmt.Sprintf("task_ids 数量必须为 1-%d", maxBatchControlIDs))
return
}
items := make([]batchControlItem, 0, len(taskIDs))
for _, parsed := range taskIDs {
item := batchControlItem{ID: parsed.id}
if !parsed.valid {
item.Error = "bad task id"
items = append(items, item)
continue
}
t, ok := s.m.Task(parsed.id)
if !ok {
item.Error = "task not found"
items = append(items, item)
continue
}
result, err := s.applyTaskControl(t, req.Action)
if err != nil {
item.Error = err.Error()
} else {
item.OK, item.Status, item.Queued = true, result.Status, result.Queued
}
items = append(items, item)
}
writeJSON(w, 200, map[string]any{"items": items})
}