diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e5616..1ec30dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### LLM + +#### 修改的功能 + +- 任务 LLM 配置链改为任何状态都可修改,不再限制「运行中 / 暂停 / 配置链耗尽」:任务结束(done / failed / timeout)后主 Agent 对话仍走这条链,链上模型出问题时原先既不能改也就没法继续交互。后端 HTTP 与 DB 事务两处终态拦截一并去掉,前端弹窗对终态任务也开放编辑与保存。终态任务保存时不再重开额度阻塞意图(那些意图会被挪到 open,既没有 worker 执行、也不再满足「重跑意图」的条件),想继续跑仍走重跑意图 / 新增目标,由它们把任务重新拉回运行态。 + ## [0.3.6] - 2026-08-27 ### LLM diff --git a/db/task_context.go b/db/task_context.go index cf23c7c..c1d08d3 100644 --- a/db/task_context.go +++ b/db/task_context.go @@ -402,6 +402,8 @@ FROM task_llm_profiles WHERE task_id=$1 ORDER BY position`, taskID) // ReplaceTaskLLMProfiles atomically replaces and resets the explicit task chain. // activeProfileID=0 selects the first entry. An empty list restores the existing // agent-binding/global fallback behavior. +// 终态(done/failed/timeout)任务同样允许改链:任务结束后主 Agent 对话仍会走这条链, +// 链上模型不可用时必须能换,否则已完成任务就再也没法交互了。 func (d *DB) ReplaceTaskLLMProfiles(taskID int64, profileIDs []int64, activeProfileID int64) error { tx, err := d.Begin() if err != nil { @@ -411,16 +413,13 @@ func (d *DB) ReplaceTaskLLMProfiles(taskID int64, profileIDs []int64, activeProf // Keep the task -> profile lock order shared by all task LLM mutations and // DeleteProfile. Inserts below may take KEY SHARE locks on llm_profiles for // their foreign keys, so the task row must be locked before any of them. - var status string - if err := tx.QueryRow(`SELECT status FROM tasks WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&status); err != nil { + var lockedID int64 + if err := tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&lockedID); err != nil { if err == sql.ErrNoRows { return fmt.Errorf("task %d not found", taskID) } return err } - if IsTerminal(status) { - return fmt.Errorf("terminal task cannot change LLM profiles") - } if _, err := tx.Exec(`DELETE FROM task_llm_profiles WHERE task_id=$1`, taskID); err != nil { return err } diff --git a/db/tasks_test.go b/db/tasks_test.go index ae63e01..3008273 100644 --- a/db/tasks_test.go +++ b/db/tasks_test.go @@ -464,7 +464,7 @@ func TestTaskRelationsAndLLMFailoverChain(t *testing.T) { } } -func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) { +func TestTaskContextRejectsDuplicatesAndAllowsTerminalLLMEdits(t *testing.T) { d, err := Open(testDSN(t)) if err != nil { t.Skipf("postgres unavailable (%v) - skipping", err) @@ -480,6 +480,17 @@ func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) { t.Fatal("duplicate source task ids should be rejected") } + // 终态任务仍然可以改 LLM 配置链:任务结束后主 Agent 对话继续走这条链, + // 链上模型不可用时必须还能换。 + profileID, err := d.SaveProfile(&LLMProfile{ + Name: fmt.Sprintf("terminal-chain-%d", time.Now().UnixNano()), Format: "openai", + Model: "terminal-model", APIKey: "test-key", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteProfile(profileID) }) + task, err := d.CreateTask("terminal", "goal", nil, 0, 0) if err != nil { t.Fatal(err) @@ -488,8 +499,21 @@ func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) { if err := d.SetStatus(task.ID, "done"); err != nil { t.Fatal(err) } - if err := d.ReplaceTaskLLMProfiles(task.ID, nil, 0); err == nil { - t.Fatal("terminal task LLM edit should be rejected") + if err := d.ReplaceTaskLLMProfiles(task.ID, []int64{profileID}, profileID); err != nil { + t.Fatalf("terminal task LLM edit should be allowed: %v", err) + } + got, err := d.GetTask(task.ID) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(got.LLMProfileIDs) != fmt.Sprint([]int64{profileID}) { + t.Fatalf("terminal task chain not persisted: %v", got.LLMProfileIDs) + } + if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileID { + t.Fatalf("terminal task active profile not persisted: %v", got.ActiveLLMProfileID) + } + if err := d.ReplaceTaskLLMProfiles(task.ID, nil, 0); err != nil { + t.Fatalf("clearing a terminal task's chain should be allowed: %v", err) } } diff --git a/server/manager.go b/server/manager.go index cb198c2..7001c97 100644 --- a/server/manager.go +++ b/server/manager.go @@ -935,8 +935,9 @@ func (m *Manager) DeleteCompanyWithAssets(id int64, deleteAssets bool) (int64, e return assetsDeleted, nil } -// ReplaceTaskLLMProfiles resets a non-terminal task's ordered provider chain and -// mirrors the committed state onto the live task handle. +// ReplaceTaskLLMProfiles resets a task's ordered provider chain and mirrors the +// committed state onto the live task handle. Terminal tasks are editable too — +// their 主 Agent 对话 keeps running on the chain after the task finishes. func (m *Manager) ReplaceTaskLLMProfiles(id string, profileIDs []int64, activeProfileID int64) (int64, error) { n, err := strconv.ParseInt(id, 10, 64) if err != nil { @@ -954,6 +955,13 @@ func (m *Manager) ReplaceTaskLLMProfiles(id string, profileIDs []int64, activePr task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMChainRevision, pt.LLMFailoverState, pt.LLMFailoverReason) } m.mu.Unlock() + // 终态任务不重开额度阻塞意图:任务已经没有 worker 在跑,重开只会把它们从 + // blocked 挪到 open——那里既没人执行,也不再满足「重跑意图」的可重跑条件, + // 反而变成死状态。终态任务想接着跑,走重跑意图/新增目标,那条路会把任务重新 + // admit 回运行态。 + if pgdb.IsTerminal(pt.Status) { + return 0, nil + } if task, ok := m.Task(id); ok { reopened, reopenErr := task.Store.ReopenIntentsByBlockedReason(pgdb.IntentBlockedLLMQuota) if reopenErr != nil { diff --git a/server/server.go b/server/server.go index f820b1e..7f0689a 100644 --- a/server/server.go +++ b/server/server.go @@ -1511,10 +1511,8 @@ func (s *Server) updateTaskLLMProfiles(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "task not found") return } - if isTerminalStatus(t.lifecycleSnapshot().Status) { - writeErr(w, 409, "已结束的任务不能修改 LLM 配置") - return - } + // 任何生命周期状态(含终态)都可以改链:任务结束后主 Agent 对话仍走这条链, + // 模型不可用时不换链就等于把已完成任务的交互一起锁死。 before := t.llmStateSnapshot() var req struct { LLMProfileIDs []int64 `json:"llm_profile_ids"` @@ -1534,11 +1532,7 @@ func (s *Server) updateTaskLLMProfiles(w http.ResponseWriter, r *http.Request) { } reopened, err := s.m.ReplaceTaskLLMProfiles(t.ID, req.LLMProfileIDs, active) if err != nil { - if strings.Contains(err.Error(), "terminal") { - writeErr(w, 409, err.Error()) - } else { - writeErr(w, 400, err.Error()) - } + writeErr(w, 400, err.Error()) return } // Profile edits affect only subsequent LLM calls. Existing in-flight calls diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 7c74b45..5e71f0e 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -77,8 +77,9 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L const chain = taskProfileIDs(task); const exhausted = task.llm_failover_state === "chain_exhausted"; + // 任何状态都可以改链,终态也不例外:任务结束后主 Agent 对话仍走这条链, + // 链上模型出问题时必须能换掉,否则已完成任务就没法继续交互。 const terminal = ["done", "failed", "timeout"].includes(task.status); - const editable = !terminal && (task.status === "running" || task.status === "paused" || exhausted); // A null active profile on an exhausted, non-empty chain is a persisted end // cursor. Keep the status display honest; choosing the first profile is only // the editor's reset draft and does not mean it is currently active. @@ -94,9 +95,8 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""] .filter(Boolean) .join(" · "); - let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。"; - if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。"; - if (terminal) editorDescription = "已结束的任务仅支持查看配置。"; + let editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。"; + if (terminal) editorDescription = "任务已结束,改动只影响后续的主 Agent 对话。"; let saveLabel = "保存"; if (exhausted) saveLabel = "保存并重置"; if (saving) saveLabel = "保存中"; @@ -118,7 +118,6 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L }; const save = async () => { - if (!editable) return; setSaving(true); try { const result = await api.updateTaskLLMProfiles( @@ -181,7 +180,7 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L activeProfileId={activeProfileID} onActiveProfileChange={setActiveProfileID} inputId="task-llm-profiles" - disabled={!editable || saving} + disabled={saving} portalContainer={popoverContentRef} /> @@ -189,12 +188,10 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L - {editable && ( - - )} +