feat(llm): 任务 LLM 配置链改为任何状态都可修改

任务结束(done/failed/timeout)后主 Agent 对话仍走任务配置链,链上模型出问题时
既不能改配置链、也就没法继续交互。去掉 HTTP 与 DB 事务两处终态拦截,前端弹窗
对终态任务同样开放编辑与保存(顺带修掉 queued 状态也不能编辑的口子)。

终态任务保存时不再重开额度阻塞意图:那些意图会从 blocked 挪到 open,既没有
worker 执行、也不再满足「重跑意图」的可重跑条件,反而变成死状态。终态任务想
继续跑仍走重跑意图/新增目标,由它们把任务重新 admit 回运行态。
This commit is contained in:
Autumn-27 committed 2026-08-31 09:26:33 -04:00
1 parent db4f97ee95
commit a2f7ac9747
6 files changed
+59 -31

No files matched your search

+4 -5
View File
@@ -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
}
+27 -3
View File
@@ -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)
}
}