feat(llm): 任务 LLM 配置链改为任何状态都可修改
任务结束(done/failed/timeout)后主 Agent 对话仍走任务配置链,链上模型出问题时 既不能改配置链、也就没法继续交互。去掉 HTTP 与 DB 事务两处终态拦截,前端弹窗 对终态任务同样开放编辑与保存(顺带修掉 queued 状态也不能编辑的口子)。 终态任务保存时不再重开额度阻塞意图:那些意图会从 blocked 挪到 open,既没有 worker 执行、也不再满足「重跑意图」的可重跑条件,反而变成死状态。终态任务想 继续跑仍走重跑意图/新增目标,由它们把任务重新 admit 回运行态。
This commit is contained in:
1 parent
db4f97ee95
commit
a2f7ac9747
6 files changed
+59
-31
No files matched your search
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### LLM
|
||||||
|
|
||||||
|
#### 修改的功能
|
||||||
|
|
||||||
|
- 任务 LLM 配置链改为任何状态都可修改,不再限制「运行中 / 暂停 / 配置链耗尽」:任务结束(done / failed / timeout)后主 Agent 对话仍走这条链,链上模型出问题时原先既不能改也就没法继续交互。后端 HTTP 与 DB 事务两处终态拦截一并去掉,前端弹窗对终态任务也开放编辑与保存。终态任务保存时不再重开额度阻塞意图(那些意图会被挪到 open,既没有 worker 执行、也不再满足「重跑意图」的条件),想继续跑仍走重跑意图 / 新增目标,由它们把任务重新拉回运行态。
|
||||||
|
|
||||||
## [0.3.6] - 2026-08-27
|
## [0.3.6] - 2026-08-27
|
||||||
|
|
||||||
### LLM
|
### LLM
|
||||||
|
|||||||
+4
-5
@@ -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.
|
// ReplaceTaskLLMProfiles atomically replaces and resets the explicit task chain.
|
||||||
// activeProfileID=0 selects the first entry. An empty list restores the existing
|
// activeProfileID=0 selects the first entry. An empty list restores the existing
|
||||||
// agent-binding/global fallback behavior.
|
// agent-binding/global fallback behavior.
|
||||||
|
// 终态(done/failed/timeout)任务同样允许改链:任务结束后主 Agent 对话仍会走这条链,
|
||||||
|
// 链上模型不可用时必须能换,否则已完成任务就再也没法交互了。
|
||||||
func (d *DB) ReplaceTaskLLMProfiles(taskID int64, profileIDs []int64, activeProfileID int64) error {
|
func (d *DB) ReplaceTaskLLMProfiles(taskID int64, profileIDs []int64, activeProfileID int64) error {
|
||||||
tx, err := d.Begin()
|
tx, err := d.Begin()
|
||||||
if err != nil {
|
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
|
// 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
|
// 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.
|
// their foreign keys, so the task row must be locked before any of them.
|
||||||
var status string
|
var lockedID int64
|
||||||
if err := tx.QueryRow(`SELECT status FROM tasks WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&status); err != nil {
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return fmt.Errorf("task %d not found", taskID)
|
return fmt.Errorf("task %d not found", taskID)
|
||||||
}
|
}
|
||||||
return err
|
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 {
|
if _, err := tx.Exec(`DELETE FROM task_llm_profiles WHERE task_id=$1`, taskID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -464,7 +464,7 @@ func TestTaskRelationsAndLLMFailoverChain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) {
|
func TestTaskContextRejectsDuplicatesAndAllowsTerminalLLMEdits(t *testing.T) {
|
||||||
d, err := Open(testDSN(t))
|
d, err := Open(testDSN(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
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")
|
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)
|
task, err := d.CreateTask("terminal", "goal", nil, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -488,8 +499,21 @@ func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) {
|
|||||||
if err := d.SetStatus(task.ID, "done"); err != nil {
|
if err := d.SetStatus(task.ID, "done"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := d.ReplaceTaskLLMProfiles(task.ID, nil, 0); err == nil {
|
if err := d.ReplaceTaskLLMProfiles(task.ID, []int64{profileID}, profileID); err != nil {
|
||||||
t.Fatal("terminal task LLM edit should be rejected")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -935,8 +935,9 @@ func (m *Manager) DeleteCompanyWithAssets(id int64, deleteAssets bool) (int64, e
|
|||||||
return assetsDeleted, nil
|
return assetsDeleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceTaskLLMProfiles resets a non-terminal task's ordered provider chain and
|
// ReplaceTaskLLMProfiles resets a task's ordered provider chain and mirrors the
|
||||||
// mirrors the committed state onto the live task handle.
|
// 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) {
|
func (m *Manager) ReplaceTaskLLMProfiles(id string, profileIDs []int64, activeProfileID int64) (int64, error) {
|
||||||
n, err := strconv.ParseInt(id, 10, 64)
|
n, err := strconv.ParseInt(id, 10, 64)
|
||||||
if err != nil {
|
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)
|
task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMChainRevision, pt.LLMFailoverState, pt.LLMFailoverReason)
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
// 终态任务不重开额度阻塞意图:任务已经没有 worker 在跑,重开只会把它们从
|
||||||
|
// blocked 挪到 open——那里既没人执行,也不再满足「重跑意图」的可重跑条件,
|
||||||
|
// 反而变成死状态。终态任务想接着跑,走重跑意图/新增目标,那条路会把任务重新
|
||||||
|
// admit 回运行态。
|
||||||
|
if pgdb.IsTerminal(pt.Status) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
if task, ok := m.Task(id); ok {
|
if task, ok := m.Task(id); ok {
|
||||||
reopened, reopenErr := task.Store.ReopenIntentsByBlockedReason(pgdb.IntentBlockedLLMQuota)
|
reopened, reopenErr := task.Store.ReopenIntentsByBlockedReason(pgdb.IntentBlockedLLMQuota)
|
||||||
if reopenErr != nil {
|
if reopenErr != nil {
|
||||||
|
|||||||
+3
-9
@@ -1511,10 +1511,8 @@ func (s *Server) updateTaskLLMProfiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 404, "task not found")
|
writeErr(w, 404, "task not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isTerminalStatus(t.lifecycleSnapshot().Status) {
|
// 任何生命周期状态(含终态)都可以改链:任务结束后主 Agent 对话仍走这条链,
|
||||||
writeErr(w, 409, "已结束的任务不能修改 LLM 配置")
|
// 模型不可用时不换链就等于把已完成任务的交互一起锁死。
|
||||||
return
|
|
||||||
}
|
|
||||||
before := t.llmStateSnapshot()
|
before := t.llmStateSnapshot()
|
||||||
var req struct {
|
var req struct {
|
||||||
LLMProfileIDs []int64 `json:"llm_profile_ids"`
|
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)
|
reopened, err := s.m.ReplaceTaskLLMProfiles(t.ID, req.LLMProfileIDs, active)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "terminal") {
|
writeErr(w, 400, err.Error())
|
||||||
writeErr(w, 409, err.Error())
|
|
||||||
} else {
|
|
||||||
writeErr(w, 400, err.Error())
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Profile edits affect only subsequent LLM calls. Existing in-flight calls
|
// Profile edits affect only subsequent LLM calls. Existing in-flight calls
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
|
|||||||
|
|
||||||
const chain = taskProfileIDs(task);
|
const chain = taskProfileIDs(task);
|
||||||
const exhausted = task.llm_failover_state === "chain_exhausted";
|
const exhausted = task.llm_failover_state === "chain_exhausted";
|
||||||
|
// 任何状态都可以改链,终态也不例外:任务结束后主 Agent 对话仍走这条链,
|
||||||
|
// 链上模型出问题时必须能换掉,否则已完成任务就没法继续交互。
|
||||||
const terminal = ["done", "failed", "timeout"].includes(task.status);
|
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
|
// 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
|
// 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.
|
// 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} 个备用` : ""]
|
const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。";
|
let editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。";
|
||||||
if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。";
|
if (terminal) editorDescription = "任务已结束,改动只影响后续的主 Agent 对话。";
|
||||||
if (terminal) editorDescription = "已结束的任务仅支持查看配置。";
|
|
||||||
let saveLabel = "保存";
|
let saveLabel = "保存";
|
||||||
if (exhausted) saveLabel = "保存并重置";
|
if (exhausted) saveLabel = "保存并重置";
|
||||||
if (saving) saveLabel = "保存中";
|
if (saving) saveLabel = "保存中";
|
||||||
@@ -118,7 +118,6 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
|
|||||||
};
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!editable) return;
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.updateTaskLLMProfiles(
|
const result = await api.updateTaskLLMProfiles(
|
||||||
@@ -181,7 +180,7 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
|
|||||||
activeProfileId={activeProfileID}
|
activeProfileId={activeProfileID}
|
||||||
onActiveProfileChange={setActiveProfileID}
|
onActiveProfileChange={setActiveProfileID}
|
||||||
inputId="task-llm-profiles"
|
inputId="task-llm-profiles"
|
||||||
disabled={!editable || saving}
|
disabled={saving}
|
||||||
portalContainer={popoverContentRef}
|
portalContainer={popoverContentRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -189,12 +188,10 @@ function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: L
|
|||||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
{editable && (
|
<Button type="button" size="sm" onClick={save} disabled={saving}>
|
||||||
<Button type="button" size="sm" onClick={save} disabled={saving}>
|
{saving && <Spinner data-icon="inline-start" />}
|
||||||
{saving && <Spinner data-icon="inline-start" />}
|
{saveLabel}
|
||||||
{saveLabel}
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
Reference in new issue
Block a user