fix: Worker 池评审修复(系统会话可见报错、暂停意图续跑保持绑定模型、加载失败不清空池、原子写入)

Code authored by Codex CLI gpt-6.1-sol (addresses Grok review #1).
This commit is contained in:
RuoJi6 committed 2026-10-24 22:55:34 +08:00
1 parent d1b2b5a996
commit 06801bc5aa
8 files changed
+270 -32

No files matched your search

+27
View File
@@ -3,6 +3,7 @@ package db
import (
"context"
"database/sql"
"sort"
)
// Settings is a tiny key-value store for global app config the UI toggles at
@@ -33,6 +34,32 @@ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key
return err
}
// SetSettingsAtomic upserts several settings in one transaction. Keys are
// sorted so concurrent callers acquire row locks in a deterministic order.
func (d *DB) SetSettingsAtomic(ctx context.Context, values map[string]string) error {
if len(values) == 0 {
return nil
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // commit makes this a no-op
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, err := tx.ExecContext(ctx, `
INSERT INTO settings(key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, values[key]); err != nil {
return err
}
}
return tx.Commit()
}
// InsertSettingIfAbsent 只在 key 尚未存在时写入,已存在则原样保留并返回 inserted=false。
// 给 auth.password_hash 这类"只允许首次设置"的键用:判定落在数据库的主键约束上,
// 调用方先 GetSetting 再写的那种检查就只是快速失败路径——读出错或并发撞车时,
+28
View File
@@ -0,0 +1,28 @@
package db
import (
"context"
"fmt"
"testing"
"time"
)
func TestSetSettingsAtomic(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
defer d.Close()
prefix := fmt.Sprintf("test.atomic_settings.%d", time.Now().UnixNano())
defer func() { _, _ = d.Exec(`DELETE FROM settings WHERE key LIKE $1`, prefix+".%") }()
values := map[string]string{prefix + ".worker_pools": "[]", prefix + ".workers": "3"}
if err := d.SetSettingsAtomic(context.Background(), values); err != nil {
t.Fatal(err)
}
for key, want := range values {
got, ok, err := d.GetSetting(key)
if err != nil || !ok || got != want {
t.Fatalf("key %q: value=%q ok=%v err=%v, want %q", key, got, ok, err, want)
}
}
}
+33 -6
View File
@@ -150,6 +150,7 @@ type Engine struct {
resolveAuthoritative bool
workerResolve func(t *Task, slot WorkerSlot) (*agent.Worker, error)
slotState sync.Map // task/slot -> slotAvailability
slotPlans sync.Map // task -> map[slot name]profile ID; immutable for a run
// readiness reports whether a global LLM provider is configured — the signal behind
// Ready()/the llm_configured indicator. Wired once at startup; nil → not ready.
readiness func() bool
@@ -292,6 +293,7 @@ func (e *Engine) StopTask(taskID string) {
e.stamped.Delete(taskID)
e.inflight.Delete(taskID)
e.coordStarted.Delete(taskID)
e.slotPlans.Delete(taskID)
e.slotState.Range(func(key, _ any) bool {
keyText, ok := key.(string)
if ok && strings.HasPrefix(keyText, taskID+"/") {
@@ -741,7 +743,7 @@ func (e *Engine) reportSlotAvailability(t *Task, slot WorkerSlot, err error) {
if err == nil {
if loaded && !state.available {
e.slotState.Store(key, slotAvailability{available: true, lastEmit: now})
e.emitActivity(t, db.Activity{Worker: slot.Name, Kind: "text", Summary: slot.Name + " 绑定的 LLM 配置已恢复,可以继续领取任务"})
e.emitActivity(t, db.Activity{Worker: "system", Kind: "text", Summary: fmt.Sprintf("%s 绑定的 LLM 配置 #%d 已恢复,可以继续领取任务", slot.Name, slot.ProfileID)})
}
return
}
@@ -756,7 +758,7 @@ func (e *Engine) reportSlotAvailability(t *Task, slot WorkerSlot, err error) {
e.slotState.Store(key, state)
}
summary := fmt.Sprintf("%s 绑定的 LLM 配置 #%d 不可用(已删除或配置无效),该会话暂停领取任务,不会回退到默认模型", slot.Name, slot.ProfileID)
e.emitActivity(t, db.Activity{Worker: slot.Name, Kind: "text", IsError: true, Summary: summary, Detail: err.Error()})
e.emitActivity(t, db.Activity{Worker: "system", Kind: "text", IsError: true, Summary: summary, Detail: err.Error()})
log.Printf("[worker %s] task %s pinned profile #%d unavailable: %v", slot.Name, t.ID, slot.ProfileID, err)
}
@@ -807,6 +809,11 @@ func (e *Engine) Run(ctx context.Context, t *Task) {
return
}
rt := e.registerTaskRoutines(ctx, t.ID, 1+len(slots))
plan := make(map[string]int64, len(slots))
for _, slot := range slots {
plan[slot.Name] = slot.ProfileID
}
e.slotPlans.Store(t.ID, plan)
e.deleteMu.RUnlock()
e.touch(t.ID)
runTaskRoutine(rt, func(loopCtx context.Context) { e.plannerLoop(loopCtx, t) })
@@ -1245,10 +1252,6 @@ func (e *Engine) runDetachedIntent(ctx context.Context, t *Task, intentID int64,
e.decInflight(t.ID)
}
}()
_, worker := e.snapshotFor(t)
if worker == nil {
return fmt.Errorf("worker 尚未就绪")
}
node, err := t.Store.GetNode(intentID)
if err != nil {
return err
@@ -1256,6 +1259,30 @@ func (e *Engine) runDetachedIntent(ctx context.Context, t *Task, intentID int64,
if node == nil || node.Kind != db.KindIntent {
return fmt.Errorf("intent not found")
}
owner := node.Owner
var worker *agent.Worker
if value, ok := e.slotPlans.Load(t.ID); ok {
if plan, ok := value.(map[string]int64); ok {
if profileID, pinned := plan[owner]; pinned && profileID > 0 {
if e.workerResolve == nil {
return fmt.Errorf("意图所属的 LLM 配置 #%d 不可用:pinned worker resolver unavailable", profileID)
}
worker, err = e.workerResolve(t, WorkerSlot{Name: owner, ProfileID: profileID})
if err != nil || worker == nil {
if err == nil {
err = errors.New("worker resolver returned no worker")
}
return fmt.Errorf("意图所属的 LLM 配置 #%d 不可用:%w", profileID, err)
}
}
}
}
if worker == nil {
_, worker = e.snapshotFor(t)
if worker == nil {
return fmt.Errorf("worker 尚未就绪")
}
}
changed, err := t.Store.CompareAndSetIntentState(intentID, "paused", "running")
if err != nil {
return err
+17 -10
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
@@ -444,9 +445,10 @@ func (m *Manager) WorkerPools() []WorkerPool {
return pools
}
// SetWorkerPools validates profile references at save time and synchronizes the
// legacy workers setting with the pool total. An empty slice restores legacy mode.
func (m *Manager) SetWorkerPools(pools []WorkerPool) error {
// SetWorkerPools validates profile references at save time and atomically
// synchronizes the legacy workers setting with the pool total. An empty slice
// restores legacy mode; an optional legacy count is used when clearing pools.
func (m *Manager) SetWorkerPools(pools []WorkerPool, legacyWorkers ...int) error {
if pools == nil {
pools = []WorkerPool{}
}
@@ -468,17 +470,22 @@ func (m *Manager) SetWorkerPools(pools []WorkerPool) error {
if err != nil {
return err
}
if err := m.pg.SetSetting(settingWorkerPools, string(b)); err != nil {
return err
}
workerCount := m.Workers()
if len(pools) > 0 {
total := 0
workerCount = 0
for _, pool := range pools {
total += pool.Workers
workerCount += pool.Workers
}
return m.pg.SetSetting(settingWorkers, strconv.Itoa(total))
} else if len(legacyWorkers) > 0 {
if legacyWorkers[0] <= 0 {
return fmt.Errorf("workers 必须 >0")
}
workerCount = legacyWorkers[0]
}
return nil
return m.pg.SetSettingsAtomic(context.Background(), map[string]string{
settingWorkerPools: string(b),
settingWorkers: strconv.Itoa(workerCount),
})
}
// SetWorkers persists the concurrent work-agent count. Values <=0 are rejected.
+5 -7
View File
@@ -3593,16 +3593,14 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "workers 必须 >0")
return
}
if err := s.m.SetWorkerPools(*req.WorkerPools); err != nil {
legacyWorkers := []int(nil)
if req.Workers != nil {
legacyWorkers = []int{*req.Workers}
}
if err := s.m.SetWorkerPools(*req.WorkerPools, legacyWorkers...); err != nil {
writeErr(w, 400, err.Error())
return
}
if len(*req.WorkerPools) == 0 && req.Workers != nil {
if err := s.m.SetWorkers(*req.Workers); err != nil {
writeErr(w, 400, err.Error())
return
}
}
} else if req.Workers != nil {
// An omitted worker_pools field leaves the saved mode unchanged. If a
// non-empty pool configuration is already saved, its total remains the
+131 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
)
func TestExpandWorkerSlotsLegacy(t *testing.T) {
@@ -210,6 +211,51 @@ func TestPinnedRuntimeMissingProfileDoesNotFallback(t *testing.T) {
}
}
func TestPinnedRuntimeQuotaDoesNotAdvanceTaskFailover(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer m.Close()
task, err := m.CreateTask("pinned quota", "test", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer m.DeleteTask(task.ID, DeleteTaskOptions{})
profileID, err := m.pg.SaveProfile(&db.LLMProfile{Name: "pinned-quota", Format: "openai", Model: "pinned", APIKey: "key"})
if err != nil {
t.Fatal(err)
}
defer m.pg.DeleteProfile(profileID)
if _, err := m.ReplaceTaskLLMProfiles(task.ID, []int64{profileID}, profileID); err != nil {
t.Fatal(err)
}
before, err := m.pg.GetTask(taskIDInt(t, task.ID))
if err != nil || before == nil || before.ActiveLLMProfileID == nil {
t.Fatalf("load task failover state: task=%+v err=%v", before, err)
}
provider := &scriptedLLMProvider{err: errors.New("insufficient_quota")}
s := &Server{m: m, provByProfile: map[int64]*provEntry{profileID: {prov: provider, cfg: agent.Config{}}}}
id := profileID
runtime := &taskLLMRuntime{s: s, taskID: task.ID, agentKey: "worker", pinProfileID: &id}
if _, _, _, err := runtime.Complete(context.Background(), llm.CompletionRequest{}); err == nil {
t.Fatal("pinned quota error unexpectedly succeeded")
}
after, err := m.pg.GetTask(taskIDInt(t, task.ID))
if err != nil || after == nil || after.LLMChainRevision != before.LLMChainRevision || after.ActiveLLMProfileID == nil || *after.ActiveLLMProfileID != *before.ActiveLLMProfileID {
t.Fatalf("pinned quota changed task failover state: before=%+v after=%+v err=%v", before, after, err)
}
}
func taskIDInt(t *testing.T, id string) int64 {
t.Helper()
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
t.Fatal(err)
}
return n
}
func TestPinnedWorkerLoopDoesNotConsultTaskRouterAndReportsFailureOnce(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
@@ -253,7 +299,7 @@ func TestPinnedWorkerLoopDoesNotConsultTaskRouterAndReportsFailureOnce(t *testin
}
var failures int
for _, activity := range activities {
if activity.Worker == "work#4" && activity.IsError {
if activity.Worker == "system" && activity.IsError {
failures++
}
}
@@ -261,3 +307,87 @@ func TestPinnedWorkerLoopDoesNotConsultTaskRouterAndReportsFailureOnce(t *testin
t.Fatalf("pinned failure activities=%d, want one throttled entry: %+v", failures, activities)
}
}
func TestPinnedWorkerAvailabilityRecoveryUsesSystemSession(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer m.Close()
task, err := m.CreateTask("pinned availability", "test", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer m.DeleteTask(task.ID, DeleteTaskOptions{})
e := NewEngine(m)
slot := WorkerSlot{Name: "work#4", ProfileID: 12}
e.reportSlotAvailability(task, slot, errors.New("profile unavailable"))
e.reportSlotAvailability(task, slot, nil)
activities, _, err := task.Store.ActivityList(nil, 0, 20)
if err != nil {
t.Fatal(err)
}
var failure, recovery int
for _, activity := range activities {
if activity.Worker != "system" {
continue
}
if activity.IsError && strings.Contains(activity.Summary, "work#4") && strings.Contains(activity.Summary, "#12") {
failure++
}
if !activity.IsError && strings.Contains(activity.Summary, "work#4") && strings.Contains(activity.Summary, "#12") {
recovery++
}
}
if failure != 1 || recovery != 1 {
t.Fatalf("system availability activities failure=%d recovery=%d: %+v", failure, recovery, activities)
}
}
func TestDetachedIntentPinnedOwnerUsesPinnedResolver(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer m.Close()
task, err := m.CreateTask("detached pinned owner", "test", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer m.DeleteTask(task.ID, DeleteTaskOptions{})
intentID, err := task.Store.AddIntent(map[string]any{"text": "continue"}, 0, nil, "test")
if err != nil {
t.Fatal(err)
}
if ok, err := task.Store.ClaimIntent(intentID, "work#4"); err != nil || !ok {
t.Fatalf("claim intent: ok=%v err=%v", ok, err)
}
if changed, err := task.Store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
t.Fatalf("pause intent: changed=%v err=%v", changed, err)
}
e := NewEngine(m)
e.slotPlans.Store(task.ID, map[string]int64{"work#4": 12})
var pinnedCalls, snapshotCalls atomic.Int32
e.SetWorkerResolver(func(_ *Task, slot WorkerSlot) (*agent.Worker, error) {
pinnedCalls.Add(1)
if slot.Name != "work#4" || slot.ProfileID != 12 {
t.Fatalf("unexpected pinned slot: %+v", slot)
}
return nil, errors.New("profile unavailable")
})
e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) {
snapshotCalls.Add(1)
return nil, nil
})
err = e.runDetachedIntent(context.Background(), task, intentID, "request", "human", "agent")
if err == nil || !strings.Contains(err.Error(), "LLM 配置 #12 不可用") {
t.Fatalf("runDetachedIntent err=%v", err)
}
if pinnedCalls.Load() != 1 || snapshotCalls.Load() != 0 {
t.Fatalf("pinned calls=%d snapshot calls=%d", pinnedCalls.Load(), snapshotCalls.Load())
}
node, err := task.Store.GetNode(intentID)
if err != nil || node == nil || node.State != "paused" {
t.Fatalf("intent changed after unavailable resolver: node=%+v err=%v", node, err)
}
}
+28 -6
View File
@@ -40,6 +40,7 @@ export default function SystemSettingsPage() {
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [settingsLoaded, setSettingsLoaded] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [savingKey, setSavingKey] = React.useState(false);
const [pyInterp, setPyInterp] = React.useState("");
@@ -69,13 +70,17 @@ export default function SystemSettingsPage() {
setGlobalProxyInput(s.global_proxy ?? "");
setPyInterp(s.python_interpreter ?? "");
setWorkers(String(s.workers ?? 3));
setWorkerPools(s.worker_pools);
setWorkerPools(s.worker_pools ?? []);
setInjectPlanner(s.constraints_inject_planner !== false);
setInjectWorker(s.constraints_inject_worker !== false);
setNoaCompaction(!!s.noa_compaction);
}, []);
const saveWorkers = () => {
if (!settingsLoaded) {
toast.error("设置加载失败,请刷新后再保存 Worker 配置");
return;
}
const n = Number(workers);
if (workerPools.length > 0) {
const total = workerPools.reduce((sum, pool) => sum + Number(pool.workers), 0);
@@ -150,8 +155,14 @@ export default function SystemSettingsPage() {
React.useEffect(() => {
api
.settings()
.then(apply)
.catch(() => undefined)
.then((s) => {
apply(s);
setSettingsLoaded(true);
})
.catch(() => {
setSettingsLoaded(false);
toast.error("设置加载失败,Worker 配置已锁定,避免覆盖服务器上的模型池");
})
.finally(() => setLoaded(true));
}, [apply]);
@@ -804,7 +815,12 @@ export default function SystemSettingsPage() {
<div className="flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<Label className="text-sm">多模型 Worker 池</Label>
<Button variant="outline" size="sm" onClick={addWorkerPool} disabled={!loaded || savingWorkers}>
<Button
variant="outline"
size="sm"
onClick={addWorkerPool}
disabled={!loaded || !settingsLoaded || savingWorkers}
>
添加模型池
</Button>
</div>
@@ -817,6 +833,7 @@ export default function SystemSettingsPage() {
<div className="flex items-center gap-2" key={`${pool.profile_id}-${index}`}>
<Select
value={pool.profile_id || undefined}
disabled={!settingsLoaded || savingWorkers}
onValueChange={(value) =>
setWorkerPools((current) =>
current.map((item, itemIndex) =>
@@ -844,6 +861,7 @@ export default function SystemSettingsPage() {
min={1}
className="w-24 font-mono text-sm"
value={pool.workers}
disabled={!settingsLoaded || savingWorkers}
onChange={(event) =>
setWorkerPools((current) =>
current.map((item, itemIndex) =>
@@ -855,6 +873,7 @@ export default function SystemSettingsPage() {
<Button
variant="ghost"
size="sm"
disabled={!settingsLoaded || savingWorkers}
onClick={() => setWorkerPools((current) => current.filter((_, itemIndex) => itemIndex !== index))}
>
删除
@@ -875,16 +894,19 @@ export default function SystemSettingsPage() {
className="w-32 font-mono text-sm"
placeholder="3"
value={workers}
disabled={!loaded || savingWorkers || workerPools.length > 0}
disabled={!loaded || !settingsLoaded || savingWorkers || workerPools.length > 0}
onChange={(e) => setWorkers(e.target.value)}
/>
{workerPools.length > 0 ? (
<span className="text-muted-foreground text-xs">已配置模型池时由各池数量决定并发。</span>
) : null}
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
<Button onClick={saveWorkers} disabled={!loaded || !settingsLoaded || savingWorkers}>
保存
</Button>
</div>
{!settingsLoaded && loaded ? (
<p className="text-destructive text-xs">设置加载失败,Worker 配置已锁定;刷新成功后才能编辑或保存。</p>
) : null}
</CardContent>
</Card>
+1 -2
View File
@@ -2475,8 +2475,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const from = q.get("from") || "";
const to = q.get("to") || "";
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
if (decisionSource && !["model", "rule", "manual", "unknown"].includes(decisionSource))
throw new Error("无效判定来源");
if (decisionSource && !["model", "rule", "manual", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
if ([...search].length > 200) throw new Error("搜索关键词不能超过 200 个字符");
if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数");
for (const value of [from, to]) {