fix(tasks): 任务列表改按 id 降序,消除同刻创建任务的抖动

原先按 created_at 降序排,但没有二级排序键。同一时刻创建的多个任务时间戳
相同,排序不稳定;叠加前端每 10 秒轮询、以及 Manager 用内存 map 存任务
(遍历顺序随机、每次 List 都要重排),这些任务就会在列表里频繁互换位置。

改用 id 降序:id 由 BIGSERIAL 单调递增分配,即插入顺序,唯一且稳定,最新
创建的在最前。db.ListTasks 的 ORDER BY 与 Manager.List 的内存排序同步改。

验证: 新增 TestListTasksOrderByIDDesc 连本地 PG 实跑通过;db/server 全量
无新增失败(仅剩既有的 TestCompanyScopeInvalid)。
This commit is contained in:
Autumn-27 committed 2026-08-24 11:08:10 -04:00
1 parent dfbbb33069
commit 4cf36c7896
9 files changed
+128 -3

No files matched your search

+10
View File
@@ -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
+3 -1
View File
@@ -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
}
+42
View File
@@ -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]})
}
}