feat(planner): finding 当场唤醒 + planner N 秒心跳触发(任务参数,默认5min)

触发机制补两条(见 docs/planner-trigger-impl-plan.md):

1) finding 当场唤醒:worker 飞行中 report_finding 落库即唤醒本任务 planner,
   不再等 worker 整趟收工才反应。
   - worker.Execute 加 notify 入参并 SetNotify(补上 worker toolset 之前缺失的接线)
   - addFinding 写 KindFinding 成功后调 notify(只对 finding,不对 fact)
   - engine 两处 worker.Execute 调用传 t.Notify

2) planner N 秒心跳:距上轮规划结束/任务开始满 N 秒且期间无触发 → 触发一轮,
   兜底卡死 + 唤醒去监督飞行中的 worker(steer/kill) + 周期复查。
   - plannerLoop 重构为 runRound 闭包 + 三臂 select(ctx/notify/heartbeat)
   - 心跳在 loop 入口臂 = 从任务 start 计时(覆盖 seed 跳过首轮),每轮结束重臂
   - 新增任务参数 plan_heartbeat_seconds,下限=默认=300(5min),低于自动抬到300
   - DB 迁移:schema.sql 加列 + ALTER ADD COLUMN IF NOT EXISTS 补旧库
   - 全链路接线:db.CreateTask/manager/server HTTP/orchestration spawn_task
   - 前端:api.createTask 传参 + 建任务表单新增"planner 心跳(分钟)"字段
This commit is contained in:
Autumn-27 committed 2026-08-12 11:18:07 -04:00
1 parent 06c1ac748f
commit 47ba58bda6
11 files changed
+168 -82

No files matched your search

+3
View File
@@ -238,6 +238,7 @@ CREATE TABLE IF NOT EXISTS tasks (
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
parent_ref TEXT,
timeout_seconds INTEGER NOT NULL DEFAULT 0,
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
first_run_at TIMESTAMPTZ,
deadline_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
@@ -250,6 +251,8 @@ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status) WHERE dele
DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks;
CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- planner 心跳触发间隔(秒);补旧库。默认 300s(5min)。见 docs/planner-trigger-impl-plan.md
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
-- 任务测试范围(资产覆盖度的分母 + 授权边界)。
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
+21 -6
View File
@@ -21,6 +21,9 @@ type Task struct {
TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时
FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行
DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds;nil=不限或未运行
// planner 心跳触发间隔(秒):距上轮 plan 结束/任务开始满该值且期间无触发 → 触发一轮。
// 下限=默认=300(5min),低于一律抬到 300(在 CreateTask 归一)。见 docs/planner-trigger-impl-plan.md
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
}
// IsTerminal reports whether a task status is a terminal (finished) state.
@@ -32,7 +35,18 @@ func IsTerminal(status string) bool {
// CreateTask creates an exploration + task in one transaction and returns the task.
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时); deadline_at is
// stamped later at first real run (see engine), not here.
func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds int) (*Task, error) {
// MinPlanHeartbeatSeconds 是 planner 心跳间隔的下限 = 默认 = 5min。
// 低于它(含缺省 0 / 负值 / 误配的小值)一律抬到 5min,防止把 planner 打爆。
const MinPlanHeartbeatSeconds = 300
func normalizeHeartbeat(sec int) int {
if sec < MinPlanHeartbeatSeconds {
return MinPlanHeartbeatSeconds
}
return sec
}
func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
tx, err := d.Begin()
if err != nil {
return nil, err
@@ -60,20 +74,21 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
t := &Task{Description: description, Goal: goal, ExplorationID: expID, LLMProfileID: llmProfileID, TimeoutSeconds: timeoutSeconds}
planHeartbeatSeconds = normalizeHeartbeat(planHeartbeatSeconds) // <=0→300,<30→30
t := &Task{Description: description, Goal: goal, ExplorationID: expID, LLMProfileID: llmProfileID, TimeoutSeconds: timeoutSeconds, PlanHeartbeatSeconds: planHeartbeatSeconds}
if err := tx.QueryRow(`
INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, timeout_seconds) VALUES ($1,$2,$3,$4,$5)
RETURNING id, status, paused, created_at`, description, goal, expID, llmProfileID, timeoutSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, timeout_seconds, plan_heartbeat_seconds) VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, status, paused, created_at`, description, goal, expID, llmProfileID, timeoutSeconds, planHeartbeatSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
return nil, err
}
return t, tx.Commit()
}
const taskCols = `id, description, goal, exploration_id, status, paused, llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), first_run_at, deadline_at`
const taskCols = `id, description, goal, exploration_id, status, paused, llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), first_run_at, deadline_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task
if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.LLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil {
if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.LLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
return &t, nil
+1 -1
View File
@@ -9,7 +9,7 @@ func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
}
defer d.Close()
tk, err := d.CreateTask("迁移测试", "目标X", nil, 0)
tk, err := d.CreateTask("迁移测试", "目标X", nil, 0, 0)
if err != nil {
t.Fatal(err)
}