feat(tasks): 任务并发上限(可选,超限排队自动补位)

任务页新建任务左侧加「并发设置」齿轮:开关(默认关) + 并发数量(开启默认5)。
开启后新建任务若「运行中」数已达上限,进入「排队中」不启动;有空位时后台
每 5s 自动把最早排队的任务补位启动。

- db: tasks 加持久化 queued 列(schema + ALTER 补旧库);Task.Queued/SetQueued。
- manager: ConcurrencyLimit/SetConcurrency(settings KV);SetTaskQueued(落库+同步内存)。
- 准入与促进器(goals.go):launchTask 统一准入,满则挂起排队;抽出 startTaskEngine
  复用;occupiesSlot/runningTaskCount/queueIfAtCapacity;reconcileConcurrency 每
  scheduler tick 补位(关闭则放行全部排队),concMu 串行化防超发;启动重载/setActive/
  reviveTask 三处'排队则不启动'门。
- settings: /api/settings 增 task_concurrency_enabled/limit;PUT 后立即协调一次。
- web: 齿轮 Dialog(Switch+数量);新增 queued/「排队中」状态(类型/状态色/筛选)。
- 超限只排队新任务,不打断在跑的;排队任务不消耗 LLM(目标拆解推迟到出队)。

go build/vet、tsc 通过;DB queued 往返测试通过。
This commit is contained in:
Autumn-27 committed 2026-08-19 10:05:01 -04:00
1 parent a6bd18050c
commit 8047bb7aca
10 files changed
+360 -30

No files matched your search

+3
View File
@@ -234,6 +234,7 @@ CREATE TABLE IF NOT EXISTS tasks (
status TEXT NOT NULL DEFAULT 'created'
CHECK (status IN ('created','running','paused','done','failed','timeout')),
paused BOOLEAN NOT NULL DEFAULT false,
queued BOOLEAN NOT NULL DEFAULT false,
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
parent_ref TEXT,
@@ -253,6 +254,8 @@ 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;
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
-- 任务测试范围(资产覆盖度的分母 + 授权边界)。
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
+10 -2
View File
@@ -13,6 +13,7 @@ type Task struct {
ExplorationID int64 `json:"exploration_id"`
Status string `json:"status"`
Paused bool `json:"paused"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录;空=顶层)
CreatedAt time.Time `json:"created_at"`
@@ -84,11 +85,11 @@ RETURNING id, status, paused, created_at`, description, goal, expID, llmProfileI
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), COALESCE(plan_heartbeat_seconds,300), first_run_at, deadline_at`
const taskCols = `id, description, goal, exploration_id, status, paused, queued, 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 {
if err := sc.Scan(&t.ID, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.LLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
return &t, nil
@@ -136,6 +137,13 @@ func (d *DB) SetPaused(id int64, paused bool) error {
return err
}
// SetQueued persists a task's queued flag (并发上限挂起态). A queued task is held
// (engine not started) until the concurrency reconciler promotes it into a free slot.
func (d *DB) SetQueued(id int64, queued bool) error {
_, err := d.Exec(`UPDATE tasks SET queued=$1 WHERE id=$2`, queued, 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
+17
View File
@@ -27,6 +27,23 @@ func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
if err != nil || got == nil || !got.Paused {
t.Fatalf("paused not persisted: %+v err=%v", got, err)
}
if got.Queued {
t.Fatalf("new task should not be queued: %+v", got)
}
// queued (concurrency-hold) flag round-trips independently of paused
if err := d.SetQueued(tk.ID, true); err != nil {
t.Fatal(err)
}
if g, _ := d.GetTask(tk.ID); g == nil || !g.Queued {
t.Fatalf("queued not persisted: %+v", g)
}
if err := d.SetQueued(tk.ID, false); err != nil {
t.Fatal(err)
}
if g, _ := d.GetTask(tk.ID); g == nil || g.Queued {
t.Fatalf("queued not cleared: %+v", g)
}
// list contains it
list, _ := d.ListTasks()
+114 -14
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
@@ -29,21 +30,116 @@ func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) {
if seedFirstIntent {
s.seedFirstIntent(t)
}
go func() {
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: "第 0 轮目标拆解"})
goals := s.createGoals(s.ctx, t, func(r db.Activity) {
s.engine.emitActivity(t, r)
})
for _, g := range goals {
summary := g.Text
if g.VulnClass != "" {
summary = fmt.Sprintf("[%s] %s", g.VulnClass, g.Text)
}
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text", Summary: summary})
// 并发上限:满了就挂起排队(不做目标拆解、不启动引擎),由 reconcileConcurrency 补位启动。
if s.queueIfAtCapacity(t) {
return
}
go s.startTaskEngine(t)
}
// startTaskEngine does the actual running work: 第0轮目标拆解 + 启动引擎循环。
// Shared by fresh launches and by the concurrency reconciler promoting a queued task.
func (s *Server) startTaskEngine(t *Task) {
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: "第 0 轮目标拆解"})
goals := s.createGoals(s.ctx, t, func(r db.Activity) {
s.engine.emitActivity(t, r)
})
for _, g := range goals {
summary := g.Text
if g.VulnClass != "" {
summary = fmt.Sprintf("[%s] %s", g.VulnClass, g.Text)
}
s.engine.Run(s.ctx, t)
}()
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text", Summary: summary})
}
s.engine.Run(s.ctx, t)
}
// occupiesSlot reports whether a task counts against the concurrency cap: it is
// live (not terminal), not queued, and not paused. A just-created / decomposing
// task counts too (it will run momentarily).
func (s *Server) occupiesSlot(t *Task) bool {
if t == nil || t.Queued || isTerminalStatus(t.Status) {
return false
}
return !t.Paused && !s.engine.IsPaused(t.ID)
}
// runningTaskCount counts tasks occupying a concurrency slot, excluding excludeID
// (pass the candidate's id when deciding admission so it doesn't count itself).
func (s *Server) runningTaskCount(excludeID string) int {
n := 0
for _, t := range s.m.List() {
if t.ID == excludeID {
continue
}
if s.occupiesSlot(t) {
n++
}
}
return n
}
// queueIfAtCapacity holds a task in the queued state when the concurrency cap is
// enabled and already full. Returns true if the task was queued (caller must not
// start it). No-op (false) when the feature is off or a slot is free.
func (s *Server) queueIfAtCapacity(t *Task) bool {
s.concMu.Lock()
defer s.concMu.Unlock()
enabled, limit := s.m.ConcurrencyLimit()
if !enabled || s.runningTaskCount(t.ID) < limit {
return false
}
if err := s.m.SetTaskQueued(t.ID, true); err != nil {
log.Printf("[concurrency] task %s 置排队失败: %v", t.ID, err)
return false // 落库失败宁可放它跑,也别静默丢任务
}
t.Queued = true
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text",
Summary: fmt.Sprintf("已排队:达到并发上限 %d,等待空位后自动开始", limit)})
log.Printf("[concurrency] task %s 排队(并发上限 %d)", t.ID, limit)
return true
}
// reconcileConcurrency promotes queued tasks into free slots. Runs on every
// scheduler tick. When the feature is disabled it releases ALL queued tasks;
// when enabled it starts the oldest-queued tasks up to (limit - running).
func (s *Server) reconcileConcurrency() {
s.concMu.Lock()
defer s.concMu.Unlock()
var queued []*Task
for _, t := range s.m.List() {
if t.Queued && !isTerminalStatus(t.Status) {
queued = append(queued, t)
}
}
if len(queued) == 0 {
return
}
// 最早排队的先启动。
sort.Slice(queued, func(i, j int) bool { return queued[i].CreatedAt < queued[j].CreatedAt })
enabled, limit := s.m.ConcurrencyLimit()
slots := len(queued) // feature off → 全部放行
if enabled {
if !s.engine.Ready() {
return // 引擎未就绪(无 LLM):继续挂起,别空跑
}
slots = limit - s.runningTaskCount("")
}
for _, t := range queued {
if slots <= 0 {
break
}
if err := s.m.SetTaskQueued(t.ID, false); err != nil {
log.Printf("[concurrency] task %s 解除排队失败: %v", t.ID, err)
continue
}
t.Queued = false
log.Printf("[concurrency] task %s 出队启动", t.ID)
go s.startTaskEngine(t)
slots--
}
}
// reviveTask 让一个已停下的任务重新跑起来:把终态(done/failed/timeout)拉回 running、
@@ -56,6 +152,10 @@ func (s *Server) reviveTask(t *Task) {
if t == nil {
return
}
// 排队中的任务不复活启动——它本就等待并发空位,交给 reconcileConcurrency。
if t.Queued {
return
}
// 终态 → 拉回 running(SetTaskStatus 会清 completed_at 并同步内存 handle 的 Status,
// 使 planner/worker 循环的终态门放行)。
if isTerminalStatus(t.Status) {
+53 -1
View File
@@ -32,6 +32,7 @@ type Task struct {
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
Paused bool `json:"paused"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录)
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务 planner/worker 的 LLM 配置;nil=用全局激活配置
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout 为终态;空/其它则由运行态推导)
@@ -85,12 +86,45 @@ const (
settingWebSearchProxy = "web_search_proxy"
settingWorkers = "workers"
settingLLMRecord = "llm_record"
// 任务并发上限:开关 + 上限数。默认关闭;开启后默认上限 5(见 defaultConcurrencyLimit)。
settingConcurrencyOn = "task_concurrency_enabled"
settingConcurrencyLimit = "task_concurrency_limit"
// defaultWebSearchBackend is used when web search is on but no backend was picked.
defaultWebSearchBackend = "ddgs"
// defaultWorkers is the concurrent work-agent count when the setting is unset.
defaultWorkers = 3
// defaultConcurrencyLimit is the simultaneous-running-task cap when the feature
// is enabled but no explicit limit was saved.
defaultConcurrencyLimit = 5
)
// ConcurrencyLimit returns whether the simultaneous-running-task cap is enabled and
// its limit (default 5 when enabled but unset). limit is always >=1 when enabled.
func (m *Manager) ConcurrencyLimit() (enabled bool, limit int) {
on, _, _ := m.pg.GetSetting(settingConcurrencyOn)
if strings.TrimSpace(on) != "true" {
return false, 0
}
limit = defaultConcurrencyLimit
if v, ok, _ := m.pg.GetSetting(settingConcurrencyLimit); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 {
limit = n
}
}
return true, limit
}
// SetConcurrency persists the running-task concurrency cap. limit<1 is clamped to 1.
func (m *Manager) SetConcurrency(enabled bool, limit int) error {
if limit < 1 {
limit = defaultConcurrencyLimit
}
if err := m.pg.SetSetting(settingConcurrencyLimit, strconv.Itoa(limit)); err != nil {
return err
}
return m.pg.SetSetting(settingConcurrencyOn, strconv.FormatBool(enabled))
}
// Workers returns the configured concurrent work-agent count (default 3). Read
// per-task at engine.Run, so a change applies to tasks started afterwards.
func (m *Manager) Workers() int {
@@ -457,7 +491,7 @@ func unixOrZero(t *time.Time) int64 {
func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Interceptor) *Task {
return &Task{
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused,
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
LLMProfileID: pt.LLMProfileID,
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
@@ -526,6 +560,24 @@ func (m *Manager) SetTaskPaused(id string, paused bool) error {
return m.pg.SetPaused(n, paused)
}
// SetTaskQueued persists a task's queued (concurrency-hold) state and syncs the
// in-memory handle so listTasks/占位统计 see it immediately.
func (m *Manager) SetTaskQueued(id string, queued bool) error {
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return err
}
if err := m.pg.SetQueued(n, queued); err != nil {
return err
}
m.mu.Lock()
if t := m.tasks[id]; t != nil {
t.Queued = queued
}
m.mu.Unlock()
return nil
}
// TaskStatus returns a task's current in-memory status (empty if unknown).
func (m *Manager) TaskStatus(id string) string {
m.mu.RLock()
+1
View File
@@ -50,6 +50,7 @@ func (sc *Scheduler) Run(ctx context.Context) {
case <-ctx.Done():
return
case <-t.C:
sc.s.reconcileConcurrency() // 并发上限:有空位就把排队任务补位启动
sc.step()
}
}
+49 -11
View File
@@ -42,6 +42,11 @@ type Server struct {
skillDir string // root directory for skill subdirectories
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
// scheduler tick and an HTTP settings change / task creation can't both count
// free slots off the same snapshot and over-promote past the limit.
concMu sync.Mutex
cfgMu sync.Mutex
mainAgent *agent.MainAgent // nil when no LLM provider is configured
chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM
@@ -227,7 +232,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
}
// resume the active task's engine; other tasks resume when opened (setActive).
// (a restored-paused task's loops still start but idle until resumed.)
if t := m.ActiveTask(); t != nil {
// 排队中的任务不在此启动——留给 reconcileConcurrency 按空位补位。
if t := m.ActiveTask(); t != nil && !t.Queued {
s.engine.Run(ctx, t)
}
return s
@@ -846,6 +852,8 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
switch {
case isTerminalStatus(t.Status): // 持久化终态优先(done/failed/timeout)
status = t.Status
case t.Queued: // 因并发上限挂起、等待空位(尚未启动)
status = "queued"
case t.Paused || s.engine.IsPaused(t.ID):
status = "paused"
case s.engine.Ready() && s.engine.Started(t.ID):
@@ -880,7 +888,8 @@ func (s *Server) setActive(w http.ResponseWriter, r *http.Request) {
return
}
// resume the engine for the opened task (idempotent — no-op if already running).
if t, ok := s.m.Task(req.ID); ok {
// 排队中的任务:仅设为活跃可查看,不启动引擎(维持并发上限,由 reconcile 补位)。
if t, ok := s.m.Task(req.ID); ok && !t.Queued {
s.engine.Run(s.ctx, t)
}
writeJSON(w, 200, map[string]any{"active": req.ID})
@@ -2145,16 +2154,22 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) settingsPayload() map[string]any {
on, backend, braveKey, tavilyKey, proxy := s.m.WebSearch()
pyStored, _, _ := s.m.pg.GetSetting(settingPythonInterp)
concOn, concLimit := s.m.ConcurrencyLimit()
if concLimit == 0 {
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
}
return map[string]any{
"traffic_capture": s.m.TrafficEnabled(),
"llm_record": s.m.LLMRecordEnabled(),
"web_search_enabled": on,
"web_search_backend": backend,
"brave_key_set": strings.TrimSpace(braveKey) != "",
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"traffic_capture": s.m.TrafficEnabled(),
"llm_record": s.m.LLMRecordEnabled(),
"web_search_enabled": on,
"web_search_backend": backend,
"brave_key_set": strings.TrimSpace(braveKey) != "",
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
}
}
@@ -2188,6 +2203,9 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
ConcurrencyEnabled *bool `json:"task_concurrency_enabled"`
ConcurrencyLimit *int `json:"task_concurrency_limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -2199,6 +2217,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.ConcurrencyEnabled != nil || req.ConcurrencyLimit != nil {
// 部分 PUT:未给的字段用当前值兜底,避免只改一个把另一个重置。
curOn, curLimit := s.m.ConcurrencyLimit()
if curLimit == 0 {
curLimit = defaultConcurrencyLimit
}
on, limit := curOn, curLimit
if req.ConcurrencyEnabled != nil {
on = *req.ConcurrencyEnabled
}
if req.ConcurrencyLimit != nil {
limit = *req.ConcurrencyLimit
}
if err := s.m.SetConcurrency(on, limit); err != nil {
writeErr(w, 400, err.Error())
return
}
// 立即协调一次:关闭时放行全部排队,调高上限时补位启动,不必等下一个 tick。
go s.reconcileConcurrency()
}
if req.PythonInterp != nil {
if err := s.m.pg.SetSetting(settingPythonInterp, strings.TrimSpace(*req.PythonInterp)); err != nil {
writeErr(w, 500, err.Error())
+108 -1
View File
@@ -13,6 +13,7 @@ import {
ChevronRightIcon,
PaperclipIcon,
Loader2Icon,
SlidersHorizontalIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
@@ -30,8 +31,19 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
@@ -143,6 +155,7 @@ function fmtDateTime(unix?: number): string {
const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ value: "created", label: "已创建" },
{ value: "queued", label: "排队中" },
{ value: "running", label: "运行中" },
{ value: "paused", label: "已暂停" },
{ value: "done", label: "已完成" },
@@ -263,6 +276,7 @@ export default function TasksPage() {
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<ConcurrencySettingsDialog />
<CreateTaskSheet onCreated={load} />
</div>
@@ -423,6 +437,99 @@ const TaskRow = React.memo(function TaskRow({
);
});
// ConcurrencySettingsDialog is the 齿轮 button left of 新建任务: toggle the
// simultaneous-running-task cap and pick the limit. Default off; default limit 5.
// Persisted globally via /api/settings — applies to tasks created afterwards.
function ConcurrencySettingsDialog() {
const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false);
const [limit, setLimit] = React.useState("5");
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
// 每次打开都拉取当前值,避免显示陈旧状态。
React.useEffect(() => {
if (!open) return;
setLoading(true);
api
.settings()
.then((s) => {
setEnabled(!!s.task_concurrency_enabled);
setLimit(String(s.task_concurrency_limit ?? 5));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [open]);
async function save() {
const n = Math.max(1, Math.floor(Number(limit) || 5));
setSaving(true);
try {
await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: n });
toast.success(enabled ? `已开启并发限制:最多同时运行 ${n} 个任务` : "已关闭任务并发限制");
setOpen(false);
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置">
<SlidersHorizontalIcon /> 并发设置
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>任务并发限制</DialogTitle>
<DialogDescription>
限制同时「运行中」的任务数量。关闭时不限制;开启后新建任务若已达上限,会进入「排队中」,有空位时自动开始。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-1">
<Label htmlFor="conc-enabled">开启任务并发限制</Label>
<span className="text-muted-foreground text-xs">默认关闭(不限制并发)</span>
</div>
<Switch id="conc-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} />
</div>
{enabled && (
<div className="grid gap-2">
<Label htmlFor="conc-limit">并发数量(同时运行的任务上限)</Label>
<Input
id="conc-limit"
type="number"
min={1}
className="w-32"
value={limit}
onChange={(e) => setLimit(e.target.value)}
disabled={loading}
/>
<span className="text-muted-foreground text-xs">开启后默认 5,最小 1。</span>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={save} disabled={loading || saving}>
{saving ? <Loader2Icon className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
@@ -499,7 +606,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<Button size="sm">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
+1
View File
@@ -54,6 +54,7 @@ const intent: Record<string, StatusMeta> = {
const task: Record<string, StatusMeta> = {
created: { label: "已创建", tone: "slate" },
queued: { label: "排队中", tone: "amber" },
running: { label: "运行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
+4 -1
View File
@@ -1,7 +1,7 @@
// ARTEX domain model — types used across the UI.
// Derived from the functional spec (section 7: 关键数据形状).
export type TaskStatus = "created" | "running" | "paused" | "done" | "failed" | "timeout";
export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout";
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
@@ -498,6 +498,9 @@ export interface Settings {
web_search_proxy?: string;
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
task_concurrency_enabled?: boolean; // 默认 false
task_concurrency_limit?: number; // 开启后默认 5
}
// ---- LLM config ----