Files
ARTEX/db/tasks.go
T
2026-08-24 14:14:15 +08:00

351 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"encoding/json"
"time"
)
// Task is a row in the task registry (1:1 with an exploration).
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"` // 可选任务名称;空=未命名
Description string `json:"description"`
Goal string `json:"goal"`
ExplorationID int64 `json:"exploration_id"`
Status string `json:"status"`
Paused bool `json:"paused"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录;空=顶层)
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil
// 任务级超时(见 docs/任务级超时与收尾设计.md)。
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"`
// CoverageEnabled 是「资产覆盖度功能」总开关(默认 true)。false 时:不计算/不展示测试
// 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
CoverageEnabled bool `json:"coverage_enabled"`
}
// IsTerminal reports whether a task status is a terminal (finished) state.
// 单一真源,替换散落各处的 done/failed 硬编码判定。
func IsTerminal(status string) bool {
return status == "done" || status == "failed" || status == "timeout"
}
// 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.
// MinPlanHeartbeatSeconds 是 planner 心跳间隔的下限 = 默认 = 10min。
// 低于它(含缺省 0 / 负值 / 误配的小值)一律抬到 10min,防止把 planner 打爆。
const MinPlanHeartbeatSeconds = 600
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) {
<<<<<<< Updated upstream
=======
var ids []int64
if llmProfileID != nil {
ids = []int64{*llmProfileID}
}
return d.CreateTaskWithOptions(description, goal, TaskCreateOptions{
LLMProfileIDs: ids, TimeoutSeconds: timeoutSeconds, PlanHeartbeatSeconds: planHeartbeatSeconds,
})
}
// TaskCreateOptions contains the task data that must be committed atomically
// with the task/exploration row.
type TaskCreateOptions struct {
Name string // 可选任务名称;空=未命名
SourceTaskIDs []int64
CompanyIDs []int64
LLMProfileIDs []int64
TimeoutSeconds int
PlanHeartbeatSeconds int
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
CoverageEnabled *bool
}
// CreateTaskWithOptions creates an exploration, task, direct source relations,
// and the ordered task LLM chain in one transaction.
func (d *DB) CreateTaskWithOptions(description, goal string, opts TaskCreateOptions) (*Task, error) {
if len(opts.SourceTaskIDs) > MaxTaskSourceCount {
return nil, fmt.Errorf("too many source tasks: got %d, maximum is %d", len(opts.SourceTaskIDs), MaxTaskSourceCount)
}
companyIDs, err := NormalizeTaskCompanyIDs(opts.CompanyIDs)
if err != nil {
return nil, err
}
opts.CompanyIDs = companyIDs
>>>>>>> Stashed changes
tx, err := d.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var expID int64
if err := tx.QueryRow(`INSERT INTO explorations(description, goal) VALUES ($1,$2) RETURNING id`, description, goal).Scan(&expID); err != nil {
return nil, err
}
// origin fact: the exploration graph's root, a KindFact node (state='origin')
// holding the original task description. Goals/intents/findings descend from
// it, and the seeded target asset is anchored to it as lineage (the asset graph
// is global and shared, not isolated per task). Being a fact (not a special 'begin' kind) lets every
// intent uniformly connect to a fact node, including the first ones.
originPayload, _ := json.Marshal(map[string]any{
"summary": "任务起点:" + description + ";目标:" + goal,
"description": description,
"goal": goal,
})
if _, err := tx.Exec(`
INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin)
VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); err != nil {
return nil, err
}
<<<<<<< Updated upstream
if timeoutSeconds < 0 {
timeoutSeconds = 0
=======
if opts.TimeoutSeconds < 0 {
opts.TimeoutSeconds = 0
}
opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds)
coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled
var active *int64
if len(opts.LLMProfileIDs) > 0 {
id := opts.LLMProfileIDs[0]
active = &id
}
t := &Task{
Name: opts.Name,
Description: description, Goal: goal, ExplorationID: expID,
LLMProfileID: active, ActiveLLMProfileID: active,
LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...),
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
CoverageEnabled: coverageEnabled,
>>>>>>> Stashed changes
}
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(`
<<<<<<< Updated upstream
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 {
=======
INSERT INTO tasks(name, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8)
RETURNING id, status, paused, created_at`, opts.Name, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
>>>>>>> Stashed changes
return nil, err
}
return t, tx.Commit()
}
<<<<<<< Updated upstream
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.PlanHeartbeatSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil {
=======
func insertTaskCompanies(tx *sql.Tx, taskID int64, companyIDs []int64) error {
if len(companyIDs) == 0 {
return nil
}
var inserted int
err := tx.QueryRow(`
WITH requested(company_id, position) AS (
SELECT company_id, position
FROM unnest($2::bigint[]) WITH ORDINALITY AS requested(company_id, position)
), inserted AS (
INSERT INTO task_scope(task_id, kind, company_id, source, reason)
SELECT $1, 'company', companies.id, 'manual', '任务创建时关联企业'
FROM requested
JOIN companies ON companies.id=requested.company_id
ORDER BY requested.position
RETURNING company_id
)
SELECT count(*) FROM inserted`, taskID, companyIDs).Scan(&inserted)
if err != nil {
return err
}
if inserted != len(companyIDs) {
return fmt.Errorf("%w: one or more companies do not exist", ErrTaskCompanyNotFound)
}
return nil
}
func insertTaskRelations(tx *sql.Tx, taskID int64, sourceIDs []int64) error {
seen := map[int64]bool{}
for _, sourceID := range sourceIDs {
if sourceID <= 0 || sourceID == taskID || seen[sourceID] {
return fmt.Errorf("invalid or duplicate source task id %d", sourceID)
}
seen[sourceID] = true
res, err := tx.Exec(`INSERT INTO task_relations(task_id, source_task_id)
SELECT $1, id FROM tasks WHERE id=$2 AND deleted_at IS NULL`, taskID, sourceID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return fmt.Errorf("source task %d not found", sourceID)
}
}
return nil
}
func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
seen := map[int64]bool{}
for position, profileID := range profileIDs {
if profileID <= 0 || seen[profileID] {
return fmt.Errorf("invalid or duplicate LLM profile id %d", profileID)
}
seen[profileID] = true
if _, err := tx.Exec(`INSERT INTO task_llm_profiles(task_id, profile_id, position) VALUES ($1,$2,$3)`, taskID, profileID, position); err != nil {
return err
}
}
return nil
}
const taskCols = `id, COALESCE(name,''), description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task
if err := sc.Scan(&t.ID, &t.Name, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
>>>>>>> Stashed changes
return nil, err
}
return &t, nil
}
// SetParentRef records a task's parent task id (编排 agent spawn_task 关联).
func (d *DB) SetParentRef(id int64, parentRef string) error {
_, err := d.Exec(`UPDATE tasks SET parent_ref=NULLIF($2,'') WHERE id=$1`, id, parentRef)
return err
}
// 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`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Task
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// GetTask returns one alive task (nil if not found/deleted).
func (d *DB) GetTask(id int64) (*Task, error) {
t, err := scanTask(d.QueryRow(`SELECT `+taskCols+` FROM tasks WHERE id=$1 AND deleted_at IS NULL`, id))
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nil
}
return nil, err
}
return t, nil
}
// SetPaused persists a task's paused flag.
func (d *DB) SetPaused(id int64, paused bool) error {
_, err := d.Exec(`UPDATE tasks SET paused=$1 WHERE id=$2`, paused, id)
return err
}
// SetStatus updates a task's lifecycle status. Entering a terminal state
// (done/failed/timeout) stamps completed_at once (COALESCE keeps the first stamp
// stable); moving back to a non-terminal state clears it, so a re-run has no stale
// finish time.
func (d *DB) SetStatus(id int64, status string) error {
_, err := d.Exec(`
UPDATE tasks
SET status = $1,
completed_at = CASE WHEN $1 IN ('done','failed','timeout') THEN COALESCE(completed_at, now()) ELSE NULL END
WHERE id = $2`, status, id)
return err
}
// SetTerminalStatusGuarded sets a terminal status only when the task is NOT already
// terminal, so a completed↔timeout race resolves to the first writer (won=true).
// Returns won=false (no error) when another terminal status already stuck — the
// caller then leaves it alone. Non-terminal transitions / re-run still use SetStatus.
func (d *DB) SetTerminalStatusGuarded(id int64, status string) (won bool, err error) {
res, err := d.Exec(`
UPDATE tasks
SET status = $1,
completed_at = COALESCE(completed_at, now())
WHERE id = $2 AND status NOT IN ('done','failed','timeout')`, status, id)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// StampFirstRun records a task's first-real-run moment and computes its absolute
// deadline (= now + timeoutSeconds). Idempotent: only stamps when first_run_at is
// still NULL, so restarts / re-entries keep the original clock. timeoutSeconds<=0
// leaves deadline_at NULL (不限时). Returns the resulting deadline (nil = 不限/未变).
func (d *DB) StampFirstRun(id int64, timeoutSeconds int) (*time.Time, error) {
var deadline *time.Time
err := d.QueryRow(`
UPDATE tasks
SET first_run_at = COALESCE(first_run_at, now()),
deadline_at = CASE
WHEN first_run_at IS NOT NULL THEN deadline_at -- 已盖过章:不动
WHEN $2 > 0 THEN now() + make_interval(secs => $2)
ELSE NULL END
WHERE id = $1
RETURNING deadline_at`, id, timeoutSeconds).Scan(&deadline)
return deadline, err
}
// DeleteTask hard-deletes a task and its exploration subgraph (nodes/edges/
// activity via ON DELETE CASCADE). The task row is removed first so the
// ON DELETE RESTRICT on tasks.exploration_id does not block the exploration delete.
// Global assets are NOT touched.
func (d *DB) DeleteTask(id int64) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var expID int64
if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1`, id).Scan(&expID); err != nil {
if err.Error() == "sql: no rows in result set" {
return nil // already gone
}
return err
}
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1`, id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM explorations WHERE id=$1`, expID); err != nil {
return err
}
return tx.Commit()
}