diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe4eb1..711f6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - 任务列表勾选多个任务后可批量修改分类,目标分类支持「未分类」以将任务移出当前分类;整批写入在一个事务内完成,失败项只会是勾选后已被删除的任务。 +#### 修复的问题 + +- 任务列表改为按任务 id 降序(最新创建在最前),替换原先的按创建时间排序。同一时刻创建的多个任务时间戳相同、排序不稳定,配合每 10 秒轮询和内存 map 的随机遍历,会导致它们在列表里频繁互换位置。 + ### 资产 #### 修复的问题 diff --git a/agent/mainagent.go b/agent/mainagent.go index be9e573..99531df 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -62,7 +62,8 @@ const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent" 1. 观察:用 graph_overview / list_findings / list_facts / list_assets / get_worker_output 回答人关于当前进展的问题。 2. 操舵(把人的意图落到系统): - 人想"改方向/强调某类漏洞/重点某区域" → 用 add_hint 写提示(规划者下次会读到)。 - - 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。 + - 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。系统会自动把已完成的任务拉回运行态、让 worker 领这条意图执行,跑完即回到已完成状态。 + **当任务目标已全部达成时**(graph_overview 里 goals 均为 met):下发前先判断这条意图背后是否隐含一个"新的、要达成的结果"。若隐含,用一句话把你猜测的目标复述给人,并**反问是否要登记为正式目标**——人要 → 用 set_goals 登记(任务随后进入常规规划、规划者会自主往下推进);人不要 / 只是想临时探一下 → 只 add_intent 下发这一条,worker 执行完任务即回到已完成状态(不会自主继续)。若这条意图明显只是一次性查证、不隐含新目标,直接 add_intent 即可,不必每次都问。 - 人想"对某条正在运行的意图(work)实时纠偏(别再走 X、聚焦 Y)" → 用 steer_work(不打断、不丢已有进展,worker 下一步动作前生效);先用 get_worker_output 看它在干嘛。方向整个错了则改用 add_intent 另下新意图。 - 人想"新增一个要达成的最终目标" → 用 set_goals 增补目标。系统会把该目标写入任务图并**自动把已完成/暂停的任务拉回运行态继续跑**(规划者随后会据此重新判断是否达成),无需人工再点恢复。 - 人想"增/改测试约束(允许/禁止某类操作,如『仅测当前端口』『禁止爆破』『只做被动侦察』)" → 用 set_constraints 登记(type=allow 允许 / type=deny 禁止)。约束会在下一轮规划时注入 planner/worker 的提示词以框定探索边界;也可在总览「约束管理」里增删改。 diff --git a/agent/tools.go b/agent/tools.go index a1830f2..3d5ad2e 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -1016,6 +1016,7 @@ func (t *ToolSet) addIntent() actool.CoreTool { ids := make([]int64, len(items)) errs := map[string]string{} + createdAny := false for i, it := range items { id, err := t.addOneIntent(it) if err != nil { @@ -1023,6 +1024,15 @@ func (t *ToolSet) addIntent() actool.CoreTool { continue } ids[i] = id + createdAny = true + } + + // 人经主 agent 直投意图 → 若任务已 done(无 open 目标的 goalless 分支),把它 + // 拉回 running,worker 才能领这条意图执行。resumeTask 仅由主 agent 的 Chat 接入 + // (SetResumeTask);planner 的 ToolSet 为 nil,故 planner 自己调 add_intent 时此段 + // no-op,不影响其正常产意图。意图节点已在上面建好(open),复活时不会被误判抽干。 + if createdAny && t.resumeTask != nil { + t.resumeTask() } if !batch { // 单条:保持原返回 diff --git a/db/exploration.go b/db/exploration.go index 8747fa6..3a8c563 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -857,6 +857,16 @@ WHERE exploration_id=$1 AND kind='intent' AND state IN ('open','running'))`, s.e return exists, err } +// HasOpenGoal reports whether this exploration still has any goal in state 'open'. +// false ⇒ 所有目标已 met/abandoned(或本任务无目标)⇒ 进入 goalless(人工直投)分支: +// planner 停跑,任务是否结束改由 frontier 是否抽干决定。met 与 abandoned 都算"已了结"。 +func (s *ExplorationStore) HasOpenGoal() (bool, error) { + var exists bool + err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM exploration_nodes +WHERE exploration_id=$1 AND kind='goal' AND state='open')`, s.expID).Scan(&exists) + return exists, err +} + // ClaimIntent atomically moves an open intent to running. Returns true if claimed. func (s *ExplorationStore) ClaimIntent(id int64, owner string) (bool, error) { res, err := s.db.Exec(`UPDATE exploration_nodes SET state='running', owner=$1 diff --git a/db/tasks.go b/db/tasks.go index bc0a57b..2e0d1a4 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -322,7 +322,9 @@ func (d *DB) SetParentRef(id int64, parentRef string) error { // ListTasks returns alive tasks, newest first. func (d *DB) ListTasks() ([]*Task, error) { - rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC`) + // 按 id 降序:id 是 BIGSERIAL,单调递增即插入顺序,最新的在最前。不用 created_at + // 是因为同一时刻创建的多个任务时间戳相同,排序不稳定,列表会频繁换位(见 List)。 + rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL ORDER BY id DESC`) if err != nil { return nil, err } diff --git a/db/tasks_test.go b/db/tasks_test.go index 0f622e3..ef5dc26 100644 --- a/db/tasks_test.go +++ b/db/tasks_test.go @@ -563,3 +563,45 @@ func TestCreateTaskWithCompanyScopes(t *testing.T) { t.Fatalf("failed company association leaked %d exploration rows", leaked) } } + +// TestListTasksOrderByIDDesc pins list ordering to id-descending. created_at is +// deliberately not the sort key: tasks created in the same instant share a +// timestamp and would reorder between polls; id is unique and monotonic. +func TestListTasksOrderByIDDesc(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + stamp := time.Now().UnixNano() + var ids []int64 + for i := range 3 { + tk, err := d.CreateTask(fmt.Sprintf("order-%d-%d", stamp, i), "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(tk.ID) }) + ids = append(ids, tk.ID) + } + + list, err := d.ListTasks() + if err != nil { + t.Fatal(err) + } + // Reduce to just the ids created here; other rows may exist in the shared DB. + mine := map[int64]bool{ids[0]: true, ids[1]: true, ids[2]: true} + var seen []int64 + for _, task := range list { + if mine[task.ID] { + seen = append(seen, task.ID) + } + } + if len(seen) != 3 { + t.Fatalf("found %d of the created tasks in the list, want 3", len(seen)) + } + // Newest (largest id) first. + if seen[0] != ids[2] || seen[1] != ids[1] || seen[2] != ids[0] { + t.Fatalf("order=%v, want descending %v", seen, []int64{ids[2], ids[1], ids[0]}) + } +} diff --git a/server/engine.go b/server/engine.go index 551aa0a..f3c7074 100644 --- a/server/engine.go +++ b/server/engine.go @@ -792,6 +792,24 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) { if e.isSettling(t.ID) { return } + // goalless(人工直投)分支:任务已无 open 目标时 planner 不跑——跑了会重判 + // met→cancelExec 杀掉用户经主 agent 直投的意图。是否结束改由 frontier 决定: + // 还有 open/running 意图 → 保持 running、静默等待;意图已全部跑干 → 落 done。 + // 整段纯 Go、不触发任何 LLM 调用,也不打规划轮 marker。 + if open, err := t.Store.HasOpenGoal(); err == nil && !open { + t.drainTriggers() // 丢弃累积的 done/finding 触发,避免 goalless 长会话里无界增长 + if active, err := t.Store.HasActiveIntent(); err == nil && !active { + // frontier 抽干且无在跑意图 → 收尾。用 Guarded 版做 CAS,避免踩到并发的 + // pause/delete/超时收尾的状态转换。 + if won, err := e.m.SetTaskStatusGuarded(t.ID, "done"); err != nil { + log.Printf("[goalless] task %s 收尾落 done 失败: %v", t.ID, err) + } else if won { + e.emitActivity(t, db.Activity{Worker: "system", Kind: "text", + Summary: "目标已全部达成,直投意图已执行完毕,任务结束"}) + } + } + return // goalless 分支永不进入 planner.Plan + } if !e.beginTaskOperation(t.ID) { return } diff --git a/server/manager.go b/server/manager.go index 41a6bae..b6ba14a 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1558,7 +1558,14 @@ func (m *Manager) List() []*Task { for _, t := range m.tasks { out = append(out, t) } - sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + // 按 id 降序(最新插入在最前)。m.tasks 是 map,遍历顺序随机,每次轮询都要重排; + // 若按 CreatedAt 排,同一秒创建的任务时间戳相同,排序不稳定,会在列表里频繁换 + // 位置。id 由 BIGSERIAL 单调递增分配,唯一且稳定,顺序恒定。 + sort.Slice(out, func(i, j int) bool { + ai, _ := strconv.ParseInt(out[i].ID, 10, 64) + aj, _ := strconv.ParseInt(out[j].ID, 10, 64) + return ai > aj + }) return out } diff --git a/server/orchestration.go b/server/orchestration.go index 3d4b104..c282921 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -451,6 +451,7 @@ func (s *Server) seedOrchestrationTools() { s.seedAutoReportFindingBinding() s.unbindGoalMetDefault() s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性) + s.reseedMainAgentPrompt() // mainagent 提示词加入「目标达成后 add_intent 反问是否建目标」(一次性) s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性) // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 // list_assets/insert_assets/report_finding/list_findings/list_companies 连同 @@ -540,6 +541,36 @@ func (s *Server) reseedGoalsPrompt() { log.Printf("[prompts] goals 提示词已追加新默认版本(加入抽操作约束步,一次性)") } +// reseedMainAgentPrompt 把 mainagent 提示词刷成【当前代码默认】——默认正文新增了「目标全部 +// 达成后 add_intent 直投意图时,反问人是否登记为正式目标」这段引导,而 SeedPromptIfEmpty 首插入 +// only,旧库已有版本收不到。用版本管理【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍 +// 保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次。全新库无需处理 +// (SeedPromptIfEmpty 已 seed 最新默认)。与 reseedGoalsPrompt 完全同构。 +func (s *Server) reseedMainAgentPrompt() { + const flag = "mainagent_prompt_goalless_intent_v1" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次 + a, err := s.m.pg.GetAgentByKey("mainagent") + if err != nil || a == nil { + return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移 + } + tmpl := agent.BuiltinPromptSeeds()["mainagent"] + if tmpl == "" { + return + } + // 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。 + if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl { + return + } + if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil { + log.Printf("[prompts] mainagent 提示词重刷为新默认失败: %v", err) + return + } + log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)") +} + // seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除): // 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的 // 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。