diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..4c53e7e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,37 @@
+# 更新日志
+
+本项目的重要变更记录在此文件中,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
+
+## [Unreleased] - 2026-08-20
+
+### Added
+
+- 新建任务支持关联多个已有任务,实时、只读继承直接来源任务的事实、漏洞、已完成意图、资产范围和黑板上下文;新任务仍使用独立的执行队列、工作目录和会话历史。
+- 任务支持有序 LLM 配置链。明确识别到供应商额度不足时自动切换到下一配置,并持久化当前配置、耗尽状态和结构化审计活动。
+- 运行中和暂停任务支持编辑 LLM 配置链、调整顺序及手动切换当前配置;自动切换、手动切换和全链耗尽会在任务详情页提示。
+- 运行中的 Worker 支持暂停、恢复和取消。暂停保留黑板数据,取消会在 Worker 停止写入后事务性清理该意图及其直接产出的事实、漏洞和执行记录。
+- 删除任务可选择同时清理关联资产、流量、漏洞和任务落地文件,并增加删除屏障、并发保护和可审计的删除统计。
+- 增加可选的任务并发上限;达到上限的新任务按 FIFO 排队,并在运行槽位释放后自动启动。
+- 增加任务资产分页、公司资产分页、任务分类统计及分页意图 Mock 契约。
+- 增加 `build.sh` 单二进制构建脚本,支持静态前端导出、资源嵌入、跨平台目标和构建版本注入。
+
+### Changed
+
+- 任务级显式 LLM 配置链与现有全局 LLM Pool 并存;显式链继续采用严格的额度故障转移语义,无显式链时沿用 Agent 绑定和全局配置规则。
+- 主 Agent 输入框支持自动增长的多行输入,`Enter` 发送、`Shift+Enter` 换行,并避免中文输入法组合态误发送。
+- Planner、Worker 和主 Agent 共享任务级 LLM runtime;任务链使用候选模型中最小上下文窗口作为安全压缩阈值。
+- 任务详情页按真实 LLM 调用状态展示运行、空闲和暂停状态;来源任务的事实、漏洞、意图、资产引用和图谱节点统一标记来源并保持只读。
+- Agent 预览、任务报告和相关详情视图统一使用共享 Markdown 渲染组件。
+- LLM 模型配置采用卡片和抽屉交互,模型列表在抽屉内支持独立滚动。
+
+### Fixed
+
+- 修复暂停任务后主 Agent 会话仍继续运行,以及编排 Agent 暂停任务时未同步停止主 Agent 的问题。
+- 修复任务完成、Planner、Worker 或主 Agent 实际运行时,任务状态徽标与操作按钮状态不同步的问题。
+- 修复 Worker 取消、任务删除和并发写入之间可能产生迟到黑板写入或残留文件的问题。
+- 修复 Markdown 预览失效、删除确认长文本溢出、移动端宽度和部分任务详情按钮未对齐的问题。
+- 为所有 Agent 取消路径增加具名终止原因,活动详情可显示取消方、终态、运行轮数、耗时、Token 使用量和未返回的工具调用。
+- 修复后端关闭时父 context 可能抢先覆盖具名 `shutdown` 原因的竞态,以及中文活动摘要按字节截断导致乱码的问题。
+- 修复带部分流式输出的取消事件丢失真实终止原因的问题,同时保留取消前已生成的内容。
+
+[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...HEAD
diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go
new file mode 100644
index 0000000..978c8bf
--- /dev/null
+++ b/agent/blackboard_inheritance_test.go
@@ -0,0 +1,198 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/Autumn-27/artex/db"
+ actool "github.com/Autumn-27/norma/tool"
+)
+
+func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
+ t.Helper()
+ result, err := tool.Call(context.Background(), json.RawMessage(input), nil)
+ if err != nil {
+ t.Fatalf("tool call: %v", err)
+ }
+ var out any
+ if err := json.Unmarshal([]byte(result.Flatten()), &out); err != nil {
+ t.Fatalf("decode tool result: %v; raw=%s", err, result.Flatten())
+ }
+ return out
+}
+
+func TestBlackboardToolsReadDirectSources(t *testing.T) {
+ d := testDB(t)
+ defer d.Close()
+
+ grand, err := d.CreateTask("grand", "grand goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source, err := d.CreateTaskWithOptions("source", "source goal", db.TaskCreateOptions{SourceTaskIDs: []int64{grand.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ current, err := d.CreateTaskWithOptions("current", "current goal", db.TaskCreateOptions{SourceTaskIDs: []int64{source.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(current.ID)
+ _ = d.DeleteTask(source.ID)
+ _ = d.DeleteTask(grand.ID)
+ })
+
+ grandStore := d.Exploration(grand.ExplorationID)
+ sourceStore := d.Exploration(source.ExplorationID)
+ currentStore := d.Exploration(current.ExplorationID)
+ grandFact, err := grandStore.AddNode(db.KindFact, map[string]any{"summary": "indirect-only"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceFact, err := sourceStore.AddNode(db.KindFact, map[string]any{"summary": "shared fact", "confidence": "verified"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceIntent, err := sourceStore.AddIntent(map[string]any{"summary": "shared work"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sourceStore.SetIntentState(sourceIntent, "done"); err != nil {
+ t.Fatal(err)
+ }
+ sourceFinding, err := sourceStore.AddNode(db.KindFinding, map[string]any{
+ "summary": "shared finding", "vulnclass": "idor", "severity": "high",
+ }, 9, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sourceStore.Link(sourceIntent, db.RelYields, sourceFinding); err != nil {
+ t.Fatal(err)
+ }
+ stepID, err := sourceStore.AppendActivity(db.Activity{
+ NodeID: &sourceIntent, Worker: "source-worker", Kind: "result", Tool: "HTTP",
+ Summary: "shared trace marker", Detail: "shared trace full detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 301; i++ {
+ if _, err := sourceStore.AddIntent(map[string]any{"summary": fmt.Sprintf("newer live source work %d", i)}, 1, nil, "planner"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ assets := d.Assets()
+ host := fmt.Sprintf("overview-host-%d.invalid", source.ID)
+ assetID, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: source.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = assets.DeleteByIDs([]int64{assetID}) })
+ if _, err := sourceStore.AddNode(db.KindFact, map[string]any{"summary": "source host anchor"}, 0, "confirmed", "worker", []int64{assetID}); err != nil {
+ t.Fatal(err)
+ }
+
+ tools := NewToolSet(currentStore, "worker")
+ tools.SetTaskID(current.ID)
+ tools.SetAssetStore(assets, assets.Companies())
+ parentID, _ := json.Marshal(sourceFact)
+ derivedID, err := tools.addOneIntent(intentItem{
+ Summary: "derive locally from shared fact", ParentIDs: []json.RawMessage{parentID},
+ })
+ if err != nil {
+ t.Fatalf("derive from inherited fact: %v", err)
+ }
+ if derived, err := currentStore.GetNode(derivedID); err != nil || derived == nil {
+ t.Fatalf("derived intent must be local: node=%+v err=%v", derived, err)
+ }
+ beforeFacts, _ := currentStore.ListByKind(db.KindFact, 100)
+ sourceIntentID, _ := json.Marshal(sourceIntent)
+ if _, err := tools.recordOneFact(factItem{Summary: "must not attach to inherited intent", IntentID: json.RawMessage(sourceIntentID)}, sourceIntent); err == nil {
+ t.Fatal("record_fact must reject inherited intent")
+ }
+ afterFacts, _ := currentStore.ListByKind(db.KindFact, 100)
+ if len(afterFacts) != len(beforeFacts) {
+ t.Fatalf("rejected inherited write persisted a fact: before=%d after=%d", len(beforeFacts), len(afterFacts))
+ }
+ overview := tools.graphOverviewData()
+ related, ok := overview["related_tasks"].([]map[string]any)
+ if !ok || len(related) != 1 {
+ t.Fatalf("related_tasks: %#v", overview["related_tasks"])
+ }
+ if related[0]["source_task_id"] != source.ID || related[0]["inherited"] != true {
+ t.Fatalf("related source provenance: %#v", related[0])
+ }
+ if facts, ok := related[0]["recent_facts"].([]map[string]any); !ok || len(facts) == 0 || facts[0]["id"] != sourceFact {
+ t.Fatalf("related fact summary: %#v", related[0]["recent_facts"])
+ }
+ if findings, ok := related[0]["recent_findings"].([]map[string]any); !ok || len(findings) == 0 || findings[0]["id"] != sourceFinding {
+ t.Fatalf("related finding summary: %#v", related[0]["recent_findings"])
+ }
+ results, ok := related[0]["recent_intent_results"].([]map[string]any)
+ if !ok || len(results) == 0 || results[0]["id"] != sourceIntent {
+ t.Fatalf("terminal source intent was starved by newer live work: %#v", related[0]["recent_intent_results"])
+ }
+ if results[0]["result_summary"] != "shared trace marker" {
+ t.Fatalf("terminal source intent result was not distilled: %#v", results[0])
+ }
+ coverage, ok := overview["coverage"].(map[string]any)
+ if !ok {
+ t.Fatalf("coverage missing from overview: %#v", overview["coverage"])
+ }
+ hosts, ok := coverage["hosts"].([]string)
+ if !ok || len(hosts) != 1 || hosts[0] != host || coverage["host_count"] != 1 {
+ t.Fatalf("inherited host context missing: %#v", coverage)
+ }
+
+ facts := callReadJSON(t, tools.listFacts(), `{}`).([]any)
+ seenSource, seenGrand := false, false
+ for _, raw := range facts {
+ item := raw.(map[string]any)
+ id := int64(item["id"].(float64))
+ if id == sourceFact {
+ seenSource = item["inherited"] == true && int64(item["source_task_id"].(float64)) == source.ID
+ }
+ seenGrand = seenGrand || id == grandFact
+ }
+ if !seenSource || seenGrand {
+ t.Fatalf("list_facts direct-only: source=%v grand=%v payload=%#v", seenSource, seenGrand, facts)
+ }
+
+ findings := callReadJSON(t, tools.listFindings(), `{}`).([]any)
+ if len(findings) != 1 {
+ t.Fatalf("list_findings: %#v", findings)
+ }
+ finding := findings[0].(map[string]any)
+ if finding["inherited"] != true || int64(finding["task_id"].(float64)) != source.ID || int64(finding["intent_id"].(float64)) != sourceIntent {
+ t.Fatalf("inherited finding provenance: %#v", finding)
+ }
+
+ detailInput, _ := json.Marshal(map[string]any{"id": sourceFact})
+ detail := callReadJSON(t, tools.nodeDetail(), string(detailInput)).(map[string]any)
+ if detail["inherited"] != true || int64(detail["source_task_id"].(float64)) != source.ID {
+ t.Fatalf("inherited node detail: %#v", detail)
+ }
+
+ traceInput, _ := json.Marshal(map[string]any{"intent_id": sourceIntent})
+ trace := callReadJSON(t, tools.getWorkerTrace(), string(traceInput)).(map[string]any)
+ if trace["inherited"] != true || int64(trace["source_task_id"].(float64)) != source.ID {
+ t.Fatalf("inherited trace: %#v", trace)
+ }
+ steps := trace["steps"].([]any)
+ if len(steps) != 1 || int64(steps[0].(map[string]any)["step_id"].(float64)) != stepID {
+ t.Fatalf("inherited trace steps: %#v", steps)
+ }
+ output := callReadJSON(t, tools.getWorkerOutput(), string(traceInput)).(map[string]any)
+ if output["inherited"] != true || output["final_text"] != "shared trace full detail" {
+ t.Fatalf("inherited worker output: %#v", output)
+ }
+
+ search := callReadJSON(t, tools.searchAllWorkerTraces(), `{"q":"shared trace marker"}`).(map[string]any)
+ hits := search["hits"].([]any)
+ if len(hits) != 1 || hits[0].(map[string]any)["inherited"] != true {
+ t.Fatalf("inherited trace search: %#v", hits)
+ }
+}
diff --git a/agent/cancelcause.go b/agent/cancelcause.go
index cfda073..f4705bc 100644
--- a/agent/cancelcause.go
+++ b/agent/cancelcause.go
@@ -6,25 +6,12 @@ import (
"fmt"
)
-// AbortCause names WHY a planner/worker run's context was cancelled.
-//
-// Background: when a run's ctx is cancelled mid-flight the harness finishes with
-// ReasonAbortedStreaming / ReasonAbortedTools and Terminal.Err = context.Canceled.
-// ARTEX cancels that ctx from a dozen different places (pause, delete, kill_work,
-// goal-met, settle drain backstop, chat stop, process shutdown, hard run timeout,
-// …) but by the time it reaches captureRun they all collapse into one bare
-// context.Canceled — so the trace could only print a guess-list, not a diagnosis.
-//
-// Fix: every cancel site uses context.WithCancelCause / WithTimeoutCause and
-// attaches one of the causes below; captureRun recovers it via context.Cause and
-// prints exactly who cancelled the run, why, and what happens to the intent.
-//
-// When you add a new cancel site, add its cause HERE — a site that cancels
-// without one shows up in the trace as "canceled_no_cause", by design.
+// AbortCause names why an agent run's context was cancelled. Every cancellation
+// site should attach one so the activity trace can report the real initiator.
type AbortCause struct {
- Code string // stable machine-readable id (logs / future filtering)
- Short string // 一句话,进折叠摘要行
- Text string // 完整说明:谁取消的、为什么、这条意图接下来会怎样
+ Code string
+ Short string
+ Text string
}
func (c *AbortCause) Error() string { return c.Text }
@@ -33,84 +20,71 @@ func cause(code, short, text string) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: text}
}
-// Causef builds an ad-hoc cause with runtime detail (e.g. which intent, which
-// deadline). Use the predeclared vars below when there is nothing dynamic to add.
+// Causef builds a cause that includes runtime-specific detail.
func Causef(code, short, format string, args ...any) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)}
}
-// The full enumeration of cancel sites, grouped by which context gets cancelled.
var (
- // ---- 任务级 exec ctx(Engine.cancelExec:取消该任务在跑的 planner + 全部 worker)----
-
+ // Task-level execution context.
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
- "用户暂停了任务(前端「暂停」按钮 / POST /api/tasks/{id}/state action=pause)。本次运行被主动取消,该意图已退回 frontier(open),恢复任务后会被重新领取、从头重跑")
-
- AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 agent 调用 pause_task 暂停了任务",
- "编排 agent 调用了 pause_task 工具暂停本任务。本次运行被主动取消,该意图已退回 frontier(open),恢复后重跑")
-
+ "用户通过任务控制接口(POST /api/tasks/{id}/control,action=pause)暂停了任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复任务后重新领取并从头执行")
+ AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 Agent 暂停了任务",
+ "编排 Agent 调用了 pause_task 工具暂停本任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复后重新执行")
AbortTaskDeleted = cause("task_deleted", "任务被删除",
- "任务被删除(DELETE /api/tasks/{id})——删除前先取消在跑的 planner/worker。本次运行的结果不会再被使用")
+ "任务正在删除(DELETE /api/tasks/{id}),删除屏障已取消该任务正在运行的 Planner、Worker 和主 Agent;本次运行结果不会再被使用")
+ AbortPausedOnReload = cause("paused_on_reload", "后端恢复了任务的暂停状态",
+ "后端启动时根据数据库中持久化的状态恢复了任务暂停。本次运行被取消;正常情况下恢复阶段没有正在运行的 Agent")
+ AbortGoalMet = cause("goal_met", "规划者判定任务目标已达成",
+ "规划者判定任务目标已达成并将任务置为 done,随后取消仍在运行的 Worker;这些意图会标记为 stopped,而不是失败")
+ AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务超时收尾的等待时间已用尽",
+ "任务到达 timeout 后等待正在运行的 Worker 优雅收尾,但 90 秒 drain 宽限仍不足,因此执行硬取消;意图会标记为 exhausted,收尾阶段已经写入的事实和资产会保留")
- AbortPausedOnReload = cause("paused_on_reload", "后端启动时恢复了「已暂停」状态",
- "后端启动时按库里持久化的「已暂停」状态恢复暂停。本次运行被取消(正常情况下此时并没有在跑的 run)")
+ // Per-work context.
+ AbortKilledByPlanner = cause("killed_by_planner", "规划者终止了这条意图",
+ "规划者调用 kill_work 主动终止了这条意图,通常表示方向跑偏或已无继续价值;意图会标记为 stopped,不会自动重新领取")
+ AbortWorkPausedByUser = cause("work_paused_by_user", "用户暂停了这条 Worker 意图",
+ "用户暂停了正在运行的 Worker。本次调用被取消,意图转为 paused;已经登记的意图、事实、漏洞和活动记录全部保留,恢复后从头重新执行")
+ AbortWorkCancelledByUser = cause("work_cancelled_by_user", "用户取消了这条 Worker 意图",
+ "用户取消了正在运行的 Worker。本次调用被取消;Worker 退出写入区后,服务端会事务性删除该意图及其直接产生的事实、漏洞和执行记录")
+ AbortWorkFinished = cause("work_finished", "Worker 已正常结束并释放 context",
+ "Worker 已正常结束,引擎在 detachWork 中释放其 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
+ AbortPausedRaceGuard = cause("paused_race_guard", "任务暂停期间拒绝启动新运行",
+ "任务处于暂停状态时,引擎拒绝发出新的执行 context,用于防止 claim 与暂停之间的竞态导致 Worker 继续启动;已领取的意图会退回 frontier")
- AbortGoalMet = cause("goal_met", "规划者判定目标已达成,任务落终态 done",
- "规划者判定任务目标已达成、任务落终态 done,随即取消所有在跑的 worker——它们手头的意图跑完也没意义了。该意图标记为 stopped,不是失败")
+ // Main Agent and standalone conversation contexts.
+ AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户停止了本轮对话",
+ "用户点击了停止,主动中止本轮主 Agent 或会话 Agent 运行。已经产生的活动记录会保留,可以继续发送下一条消息")
+ AbortChatPausedWithTask = cause("chat_paused_with_task", "任务暂停并中止了主 Agent 对话",
+ "用户暂停任务时,正在运行的主 Agent 对话也被同步取消。已经产生的活动记录会保留;恢复任务后不会自动重放本轮消息")
+ AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束并释放 context",
+ "本轮对话已正常结束,服务端正在释放该轮 context 资源。这不是运行中断;若它出现在中断消息中,说明取消与收场事件发生了竞态")
- AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务级超时收尾:drain 宽限用尽后强制取消",
- "任务级超时收尾的硬兜底:到达任务 timeout 后等在跑 worker 优雅收尾,超过 90s drain 宽限仍未结束 → 强制取消。该意图标记为 exhausted,收尾阶段通常已把事实/资产写回")
-
- // ---- 单个 work 的 ctx(Engine.workCancel[intentID])----
-
- AbortKilledByPlanner = cause("killed_by_planner", "规划者用 kill_work 终止了这条意图",
- "规划者调用 kill_work 主动终止了这条意图(判定方向跑偏/无意义,止损)。该意图标记为 stopped,不会被自动重领")
-
- AbortWorkFinished = cause("work_finished", "work 已正常结束,引擎释放其 context",
- "work 已正常结束,引擎在 unregisterWork 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
-
- AbortPausedRaceGuard = cause("paused_race_guard", "任务处于暂停态,引擎拒绝启动新 run",
- "任务处于暂停态时请求执行上下文,引擎直接返回一个已取消的 context(守卫 claim→Execute 的竞态,避免暂停后仍启动新 run)。该意图会退回 frontier")
-
- // ---- 对话 / 主 Agent 的单轮 ctx(Server.chatCancel[busyKey])----
-
- AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户点了「停止」中止本轮对话",
- "用户点了「停止」,主动中止本轮对话运行(POST .../chat/stop 或 .../conversations/{id}/stop)。已产生的步骤都保留,可以直接发下一条消息")
-
- AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束,服务端释放其 context",
- "本轮对话已正常结束,服务端在 defer 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
-
- // ---- 进程级 ----
-
- AbortShutdown = cause("shutdown", "后端进程正在关停(重启 / 更新 / Ctrl-C)",
- "后端进程收到 SIGINT/SIGTERM 正在关停(重启 / 更新 / 手动 Ctrl-C)。所有在跑的 planner/worker 一并取消;重启后残留的 running 意图会被重置为 open 重跑")
-
- // ---- 单次 run 级(worker.Execute / planner.Plan 的硬兜底)----
-
- AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底触发:某一步卡死了",
- "单次运行的硬超时兜底被触发:正常情况下软墙钟预算(MaxDuration)会在回合边界优雅收尾,只有当某一步(模型请求或某个工具)自身卡死、久久不返回、导致回合边界检查根本跑不到时,才会走到「软预算 + 90s 宽限」的硬 cancel。请重点看中断前最后一个 tool_use——多半是它挂住了")
+ // Process-level and per-run hard backstop.
+ AbortShutdown = cause("shutdown", "后端进程正在关闭",
+ "后端进程收到 SIGINT 或 SIGTERM,正在重启、更新或关闭。所有运行中的 Agent 会被取消;重启后残留的 running 意图会重置为 open 并重新执行")
+ AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底已触发",
+ "单次运行超过软墙钟预算及额外宽限,说明模型请求或某个工具长时间没有返回,导致正常的回合边界收尾无法执行。请重点检查中断前最后一个未返回的工具调用")
)
-// AbortReason resolves the cause attached to a cancelled run context. ok=false
-// means the ctx carries no cause at all (nothing was cancelled).
+// AbortReason resolves the named cause attached to a cancelled run context.
func AbortReason(ctx context.Context) (code, short, text string, ok bool) {
c := context.Cause(ctx)
if c == nil {
return "", "", "", false
}
- if ac, ok := errors.AsType[*AbortCause](c); ok {
+ var ac *AbortCause
+ if errors.As(c, &ac) {
return ac.Code, ac.Short, ac.Text, true
}
switch {
case errors.Is(c, context.DeadlineExceeded):
- return "deadline_exceeded", "上游 context 到达 deadline(未挂具名原因)",
- "上游 context 到达 deadline,但设置方没有用 WithTimeoutCause 挂具名原因: " + c.Error(), true
+ return "deadline_exceeded", "上游 context 到达 deadline",
+ "上游 context 到达 deadline,但设置方没有通过 WithTimeoutCause 附加具名原因: " + c.Error(), true
case errors.Is(c, context.Canceled):
- // a plain Canceled means some cancel site still lacks a cause — say so
- // plainly instead of inventing one.
- return "canceled_no_cause", "取消方未挂具名原因(漏网的取消点)",
- "上游 context 被取消,但取消方没有用 context.WithCancelCause 挂上具名原因。" +
- "这属于漏网的取消点——请在 agent/cancelcause.go 补一条枚举并接到该取消处", true
+ return "canceled_no_cause", "取消方未附加具名原因",
+ "上游 context 被取消,但取消方没有通过 context.WithCancelCause 附加具名原因;请在 agent/cancelcause.go 登记原因并接入该取消点", true
+ default:
+ return "other", firstLine(c.Error(), 80), c.Error(), true
}
- return "other", firstLine(c.Error(), 80), c.Error(), true
}
diff --git a/agent/capture.go b/agent/capture.go
index bbede4f..ca36acc 100644
--- a/agent/capture.go
+++ b/agent/capture.go
@@ -6,10 +6,10 @@ import (
"time"
"unicode/utf8"
+ "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
- "github.com/Autumn-27/artex/db"
)
// captureRun drives one agent turn-to-completion over Session.Prompt and emits a
@@ -65,9 +65,6 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = kind
tbuf.WriteString(text)
}
-
- // breadcrumbs for the terminal message: when a run is aborted or times out,
- // "which tool was still running, for how long" is the whole diagnosis.
lastTool := &runTrace{startedAt: time.Now()}
var finalText string
@@ -75,7 +72,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
for ev, err := range s.Prompt(ctx, input) {
if err != nil {
flush()
- if ctx.Err() != nil { // ctx cancelled = stopped by the engine, not a failure
+ if ctx.Err() != nil { // engine/user cancellation, not a provider failure
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
return finalText, reason, ctx.Err()
@@ -130,7 +127,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
}
flush() // flush any trailing thinking / non-final text
sum, detail := ev.Terminal.Text, ev.Terminal.Text
- if sum == "" {
+ if sum == "" || ev.Terminal.Reason == harness.ReasonAbortedTools || ev.Terminal.Reason == harness.ReasonAbortedStreaming {
sum, detail = terminalText(ctx, ev.Terminal, lastTool)
}
u := ev.Terminal.Usage // cumulative token usage for this session
@@ -162,9 +159,7 @@ func blocksText(blocks []llm.ContentBlock) string {
return b.String()
}
-// firstLine returns a single-line, length-capped preview for the summary column.
-// max counts RUNES, not bytes: these previews are mostly 中文, and byte-slicing
-// used to cut a multi-byte rune in half and render as "恢�…".
+// firstLine returns a single-line, rune-capped preview for the summary column.
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if before, _, found := strings.Cut(s, "\n"); found {
diff --git a/agent/goals.go b/agent/goals.go
index dbbae9f..3501157 100644
--- a/agent/goals.go
+++ b/agent/goals.go
@@ -84,6 +84,16 @@ type GoalSpec struct {
// runtime). The returned specs are read back from the store so callers can emit
// per-goal activity and detect the "LLM produced nothing" case for their fallback.
func DecomposeGoals(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
+ if prov == nil {
+ return nil
+ }
+ return DecomposeGoalsWithProvider(ctx, prov, dataDir, goalText, desc, as, ts, taskID, emit)
+}
+
+// DecomposeGoalsWithProvider is the task-runtime variant used when a task has an
+// ordered provider chain. It preserves the same tools and write behavior while
+// letting the caller own provider selection/failover.
+func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
if prov == nil {
return nil
}
diff --git a/agent/mainagent.go b/agent/mainagent.go
index 6736ded..86b198b 100644
--- a/agent/mainagent.go
+++ b/agent/mainagent.go
@@ -21,6 +21,7 @@ type MainAgent struct {
model string
tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction)
+ windowFn func() int // optional dynamic task-chain minimum
maxTurns int // max agent turns per run (0 = unlimited)
proxyAddr string // recording proxy for WebFetch (empty = direct)
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
@@ -32,6 +33,15 @@ func NewMainAgent(prov llm.Provider, model, workDir string, tx *transcript.Store
return &MainAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns}
}
+func (m *MainAgent) SetCompactionWindowResolver(fn func() int) { m.windowFn = fn }
+
+func (m *MainAgent) compactionWindow() int {
+ if m.windowFn != nil {
+ return m.windowFn()
+ }
+ return m.window
+}
+
// SetProxy points the main agent's WebFetch at the recording proxy plus the CA
// cert it trusts to verify HTTPS through it (empty addr = direct).
func (m *MainAgent) SetProxy(addr, caCert string) { m.proxyAddr, m.proxyCACert = addr, caCert }
@@ -70,9 +80,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
tsx.SetAssetStore(as, as.Companies())
}
tsx.SetTaskID(taskID)
- tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
- tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
- tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
+ tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
+ tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
+ tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
@@ -101,9 +111,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA
WorkingDir: mainDir, // 本任务工作目录 /tasks/
ToolOutputDir: cmdOutDir(mainDir),
- MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
- Compaction: compactionConfig(m.window), // long chats stay within the window
- Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
+ MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
+ Compaction: compactionConfig(m.compactionWindow()), // long chats stay within the window
+ Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
Settlement: wrapupSettlement("mainagent", nil),
}
diff --git a/agent/planner.go b/agent/planner.go
index b567d30..b2757fe 100644
--- a/agent/planner.go
+++ b/agent/planner.go
@@ -24,6 +24,7 @@ type Planner struct {
model string
tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction)
+ windowFn func() int // optional dynamic task-chain minimum
maxTurns int // max agent turns per run (0 = unlimited)
killWork func(intentID int64) error // engine callback to terminate a running work (nil = off)
steerWork func(intentID int64, msg string) error // engine callback to steer a running work mid-run (nil = off)
@@ -44,6 +45,15 @@ func NewPlanner(prov llm.Provider, model, workDir string, tx *transcript.Store,
return &Planner{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, todos: map[int64]*actool.TodoStore{}}
}
+func (p *Planner) SetCompactionWindowResolver(fn func() int) { p.windowFn = fn }
+
+func (p *Planner) compactionWindow() int {
+ if p.windowFn != nil {
+ return p.windowFn()
+ }
+ return p.window
+}
+
// SetProxy points the planner's WebFetch at the recording proxy plus the CA cert
// it trusts to verify HTTPS through it (empty addr = direct).
func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = addr, caCert }
@@ -94,10 +104,11 @@ func renderPlannerTodos(items []actool.Todo) string {
// TriggerEvent describes what concretely caused this planning round to fire, so
// the planner looks first at the actual change instead of re-scanning the whole
// overview. Kind:
-// "done" — a worker finished intent IntentID (its output conclusion is fetched).
-// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
-// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
-// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
+//
+// "done" — a worker finished intent IntentID (its output conclusion is fetched).
+// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
+// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
+// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
type TriggerEvent struct {
Kind string
IntentID int64
@@ -308,7 +319,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
ToolOutputDir: cmdOutDir(taskDir),
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
- Compaction: compactionConfig(p.window),
+ Compaction: compactionConfig(p.compactionWindow()),
// 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。
Todos: p.todoFor(ts.ID()),
// 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、
diff --git a/agent/provider.go b/agent/provider.go
index 7a0a78e..465812b 100644
--- a/agent/provider.go
+++ b/agent/provider.go
@@ -8,8 +8,14 @@
package agent
import (
+ "bytes"
"context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
"os"
+ "regexp"
"strings"
"time"
@@ -167,12 +173,16 @@ func (c Config) Provider() string {
// limiter lives on the single provider instance — so planner + all workers +
// main agent (which share this provider) are bounded by one shared rate limit.
func (c Config) NewProvider() (llm.Provider, error) {
+ client, err := quotaAwareHTTPClient(c.Proxy)
+ if err != nil {
+ return nil, err
+ }
lc := llm.Config{
- Format: c.Format,
- BaseURL: c.BaseURL,
- APIKey: c.APIKey,
- Model: c.Model,
- Proxy: c.Proxy,
+ Format: c.Format,
+ BaseURL: c.BaseURL,
+ APIKey: c.APIKey,
+ Model: c.Model,
+ HTTPClient: client,
}
// Derive the provider thinking params from the single UI selector:
// "" → omit both (provider default); "off" → disable; else → enable + effort.
@@ -191,6 +201,93 @@ func (c Config) NewProvider() (llm.Provider, error) {
return llm.NewProvider(lc)
}
+// IsQuotaExhaustedMessage deliberately recognizes only explicit balance,
+// billing, credit, or quota-exhaustion signals. Generic 429/rate-limit text,
+// authentication failures, network errors, and server failures are excluded.
+var nonFailoverHTTPStatus = regexp.MustCompile(`(?:status(?:\s+code)?|http(?:\s+status)?)\s*[=:]?\s*(?:401|403|5\d\d)\b`)
+var transientQuotaLimit = regexp.MustCompile(`(?i)(?:\b(?:rpm|tpm|rpd|qps)\b|quota[_\s-]*metric|rate[_\s-]*limit|too many requests|(?:requests?|tokens?)\s+(?:per|/)\s*(?:second|minute)|(?:per|/)\s*(?:second|minute)\s+(?:requests?|tokens?)|generate[_\s-]*requests[_\s-]*per[_\s-]*(?:minute|second)|tokens?[_\s-]*per[_\s-]*(?:minute|second))`)
+
+func IsQuotaExhaustedMessage(message string) bool {
+ message = strings.ToLower(message)
+ // Authentication/authorization and provider-side 5xx failures never rotate,
+ // even when a gateway happens to echo a quota-looking phrase in the body.
+ if nonFailoverHTTPStatus.MatchString(message) {
+ return false
+ }
+ // Provider APIs frequently describe an ordinary rate limit as "quota
+ // exceeded", especially Google-style responses containing a quota metric.
+ // These limits recover with time and must stay on the current provider.
+ if transientQuotaLimit.MatchString(message) {
+ return false
+ }
+ markers := []string{
+ "insufficient_quota", "quota_exceeded", "quota exceeded", "quota exhausted",
+ "exceeded your current quota", "billing_hard_limit_reached",
+ "billing hard limit", "billing_not_active", "credit balance", "insufficient credit",
+ "insufficient balance", "balance is too low", "payment required", "status 402",
+ "余额不足", "额度不足", "额度已用尽", "欠费",
+ }
+ for _, marker := range markers {
+ if strings.Contains(message, marker) {
+ return true
+ }
+ }
+ // gRPC RESOURCE_EXHAUSTED is overloaded for both account quota and ordinary
+ // request-rate limiting. Preserve it as an explicit exhaustion signal only
+ // when the same error does not identify a transient rate limit.
+ return strings.Contains(message, "resource_exhausted") &&
+ !strings.Contains(message, "rate limit") &&
+ !strings.Contains(message, "too many requests")
+}
+
+// quotaAwareTransport preserves Norma's normal retry behavior except for a 429
+// whose body explicitly says the account quota/balance is exhausted. Norma's
+// retry loop treats every 429 as transient; normalizing only that response to
+// 402 lets a task router fail over immediately while retaining the original
+// response body for provider-specific classification and audit logs.
+type quotaAwareTransport struct{ base http.RoundTripper }
+
+func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ resp, err := t.base.RoundTrip(req)
+ if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests {
+ return resp, err
+ }
+ body, readErr := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ resp.Body = io.NopCloser(bytes.NewReader(body))
+ resp.ContentLength = int64(len(body))
+ if readErr != nil {
+ return resp, nil
+ }
+ if IsQuotaExhaustedMessage(string(body)) {
+ resp.StatusCode = http.StatusPaymentRequired
+ resp.Status = "402 Payment Required"
+ }
+ return resp, nil
+}
+
+func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ proxy = strings.TrimSpace(proxy)
+ if proxy == "" {
+ transport.Proxy = http.ProxyFromEnvironment
+ } else {
+ proxyURL, err := url.Parse(proxy)
+ if err != nil {
+ return nil, fmt.Errorf("llm: invalid proxy %q: %w", proxy, err)
+ }
+ switch proxyURL.Scheme {
+ case "http", "https", "socks5":
+ case "":
+ return nil, fmt.Errorf("llm: proxy %q missing scheme (use http://, https:// or socks5://)", proxy)
+ default:
+ return nil, fmt.Errorf("llm: unsupported proxy scheme %q (use http, https or socks5)", proxyURL.Scheme)
+ }
+ transport.Proxy = http.ProxyURL(proxyURL)
+ }
+ return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil
+}
+
// TestConnection makes a minimal real completion to verify the provider/model/
// endpoint/key actually work. Returns the round-trip latency.
func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
diff --git a/agent/provider_quota_test.go b/agent/provider_quota_test.go
new file mode 100644
index 0000000..d905ad3
--- /dev/null
+++ b/agent/provider_quota_test.go
@@ -0,0 +1,131 @@
+package agent
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Autumn-27/norma/llm"
+)
+
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
+
+func TestIsQuotaExhaustedMessage(t *testing.T) {
+ t.Parallel()
+ positive := []string{
+ `{"error":{"code":"insufficient_quota"}}`,
+ `RESOURCE_EXHAUSTED`,
+ `You exceeded your current quota, please check your plan and billing details.`,
+ `billing_not_active`,
+ `credit balance is too low`,
+ `账户余额不足,请充值`,
+ }
+ for _, message := range positive {
+ if !IsQuotaExhaustedMessage(message) {
+ t.Errorf("expected quota classification for %q", message)
+ }
+ }
+
+ negative := []string{
+ `status 429: rate limit exceeded`,
+ `RESOURCE_EXHAUSTED: rate limit exceeded`,
+ `too many requests per minute`,
+ `quota exceeded for quota metric GenerateRequestsPerMinutePerProjectPerBaseModel`,
+ `RESOURCE_EXHAUSTED: TPM quota exceeded`,
+ `tokens per minute quota exceeded`,
+ `rate_limit_exceeded: requests per second`,
+ `status 401: invalid api key`,
+ `status 401: insufficient_quota`,
+ `HTTP 403: billing_hard_limit_reached`,
+ `status 500: internal server error`,
+ `status=503: insufficient_quota`,
+ `context length exceeded`,
+ }
+ for _, message := range negative {
+ if IsQuotaExhaustedMessage(message) {
+ t.Errorf("unexpected quota classification for %q", message)
+ }
+ }
+}
+
+func TestQuotaAwareTransportOnlyNormalizesExplicitQuota429(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ status int
+ body string
+ wantStatus int
+ }{
+ {name: "quota", status: http.StatusTooManyRequests, body: `{"code":"insufficient_quota"}`, wantStatus: http.StatusPaymentRequired},
+ {name: "ordinary rate limit", status: http.StatusTooManyRequests, body: `{"message":"rate limit exceeded"}`, wantStatus: http.StatusTooManyRequests},
+ {name: "server error", status: http.StatusInternalServerError, body: `insufficient_quota`, wantStatus: http.StatusInternalServerError},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ transport := quotaAwareTransport{base: roundTripperFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: tt.status,
+ Status: http.StatusText(tt.status),
+ Body: io.NopCloser(strings.NewReader(tt.body)),
+ Header: make(http.Header),
+ }, nil
+ })}
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.invalid", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := transport.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != tt.wantStatus {
+ t.Fatalf("status=%d, want %d", resp.StatusCode, tt.wantStatus)
+ }
+ gotBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(gotBody) != tt.body {
+ t.Fatalf("body=%q, want %q", gotBody, tt.body)
+ }
+ })
+ }
+}
+
+func TestProviderDoesNotRetryExplicitQuota429(t *testing.T) {
+ t.Parallel()
+ var requests atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ requests.Add(1)
+ w.Header().Set("content-type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = io.WriteString(w, `{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}`)
+ }))
+ defer upstream.Close()
+
+ provider, err := ConfigFrom("openai", "test-model", upstream.URL, "test-key", "").NewProvider()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var streamErr error
+ for _, err := range provider.Stream(context.Background(), llm.CompletionRequest{
+ Messages: []llm.Message{llm.UserText("ping")},
+ }) {
+ if err != nil {
+ streamErr = err
+ }
+ }
+ if streamErr == nil || !IsQuotaExhaustedMessage(streamErr.Error()) {
+ t.Fatalf("expected explicit quota error, got %v", streamErr)
+ }
+ if got := requests.Load(); got != 1 {
+ t.Fatalf("explicit quota request retried %d times, want exactly one request", got)
+ }
+}
diff --git a/agent/terminalreason.go b/agent/terminalreason.go
index 90228b2..838cbd1 100644
--- a/agent/terminalreason.go
+++ b/agent/terminalreason.go
@@ -9,26 +9,15 @@ import (
"github.com/Autumn-27/norma/harness"
)
-// This file turns a run's terminal state into something a human can act on.
-//
-// Before: a cancelled run produced one fixed sentence — "(运行被中断:任务暂停 /
-// 终止 / 重启所致;未完成)" — a guess-list, not a diagnosis. The information to do
-// better was already there (the cancel cause, the terminal reason, the turn
-// count, the tool that never returned); it was just being thrown away.
-//
-// terminalText produces a self-contained ONE-LINE summary (the transcript's
-// collapsed row, cut at the first newline by firstLine) plus a multi-line detail
-// block rendered in full when the row is expanded.
-
-// runTrace records which tool call is in flight, so an aborted / timed-out run can
-// name the step it died on — the single most useful fact when a run hangs.
+// runTrace retains the latest tool call so an interrupted run can identify the
+// operation that was still in flight.
type runTrace struct {
startedAt time.Time
- id string // tool_use id of the most recent call
- name string // its tool name
- input string // its raw input JSON
+ id string
+ name string
+ input string
at time.Time
- pending bool // true = no tool_result arrived for it yet
+ pending bool
}
func (t *runTrace) start(id, name, input string) {
@@ -41,73 +30,73 @@ func (t *runTrace) done(id string) {
}
}
-// reasonHint explains each harness terminal reason in plain 中文: what it means,
-// whether results were still written back, and what the engine does next. Covers
-// every TerminalReason the harness can emit.
var reasonHint = map[harness.TerminalReason]string{
- harness.ReasonCompleted: "模型主动结束了本轮(正常收场),但没有留下文字总结。事实/资产以工具调用为准,看上面的 tool_use 记录",
- harness.ReasonMaxTurns: "撞到步数上限(MaxTurns):SDK 已自动跑了一轮收尾(settlement)把事实/资产写回,只是缺一段文字总结。" +
- "意图标记为 exhausted——表示「这个方向试过但没做完」,规划者会据此换角度,不是失败",
- harness.ReasonTimeout: "撞到单次运行的软墙钟预算(MaxDuration):SDK 在回合边界优雅收尾并写回事实/资产,只是缺文字总结。" +
- "意图标记为 exhausted,不是失败",
- harness.ReasonModelError: "模型/API 调用失败(网络、鉴权、限流、供应商 5xx 等)。引擎会退避后自动重跑几次;重试用尽仍失败则意图标记为 blocked",
- harness.ReasonBlockingLimit: "上下文长度撞硬上限,请求在发出前就被拦下。通常是单次运行塞进了过多工具输出——考虑收窄意图粒度或压缩工具返回",
- harness.ReasonPromptTooLong: "提示词过长且压缩(compaction)重试已用尽,无法继续。同样指向单次运行累积的上下文过大",
- harness.ReasonImageError: "多模态内容不被当前模型支持(截图/图片块)。换支持视觉的模型,或让工具别回图",
- harness.ReasonStopHookPrevented: "Stop 钩子阻止了本轮结束(守卫判定还不能收场),且随后没能继续下去。" +
- "检查任务的 Guard 规则是否过严",
- harness.ReasonHookStopped: "某个工具/钩子主动叫停了继续执行(守卫拦截,如越界目标、禁用命令)。看上面最后一条 tool_result 里的拦截说明",
- harness.ReasonAbortedStreaming: "运行在【模型输出流式生成阶段】被取消",
- harness.ReasonAbortedTools: "运行在【工具执行阶段】被取消",
+ harness.ReasonCompleted: "模型正常结束了本轮,但没有留下文字总结;事实和资产以本轮工具调用记录为准",
+ harness.ReasonMaxTurns: "达到步数上限(MaxTurns):SDK 已执行收尾并写回事实和资产,意图会标记为 exhausted,供规划者换方向继续,而不是作为失败处理",
+ harness.ReasonTimeout: "达到单次运行的软墙钟预算(MaxDuration):SDK 已在回合边界收尾并写回事实和资产,意图会标记为 exhausted",
+ harness.ReasonModelError: "模型或 API 调用失败(网络、鉴权、限流、供应商 5xx 等);引擎会按策略退避重试,重试用尽后意图标记为 blocked",
+ harness.ReasonBlockingLimit: "上下文长度达到硬上限,请求在发出前被拦截;应收窄意图粒度或压缩工具返回",
+ harness.ReasonPromptTooLong: "提示词过长且上下文压缩重试已经用尽,无法继续执行",
+ harness.ReasonImageError: "当前模型不支持本轮多模态内容;请切换支持视觉的模型或避免工具返回图片",
+ harness.ReasonStopHookPrevented: "Stop 钩子阻止本轮结束,随后未能继续;请检查任务 Guard 规则是否过严",
+ harness.ReasonHookStopped: "工具或钩子主动停止继续执行,例如越界目标或禁用命令;请检查最后一条 tool_result 的拦截说明",
+ harness.ReasonAbortedStreaming: "运行在模型输出流式生成阶段被取消",
+ harness.ReasonAbortedTools: "运行在工具执行阶段被取消",
}
-// terminalText renders a terminal event with no final text into (summary, detail).
-// summary is one self-contained line; detail is the expandable breakdown.
+// terminalText renders a terminal event with no final text into a compact summary
+// and a Markdown detail block.
func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) {
reason := term.Reason
aborted := reason == harness.ReasonAbortedStreaming || reason == harness.ReasonAbortedTools
+ // Prompt may return ctx.Err directly without a terminal event. Preserve the
+ // cancellation cause instead of falling back to an empty/unknown terminal reason.
+ if reason == "" && ctx.Err() != nil {
+ aborted = true
+ }
- // ---- one-line summary: cause first (that's what the reader is after), then
- // just enough run shape to judge it without expanding. ----
var sum string
- switch {
- case aborted:
+ if aborted {
_, short, _, ok := AbortReason(ctx)
if !ok {
- short = "取不到取消原因(run 的 context 上没有挂 cause)"
+ short = "未能取得取消原因"
}
- stage := "工具执行阶段"
- if reason == harness.ReasonAbortedStreaming {
+ stage := "执行过程中"
+ switch reason {
+ case harness.ReasonAbortedStreaming:
stage = "模型输出阶段"
+ case harness.ReasonAbortedTools:
+ stage = "工具执行阶段"
}
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
- case reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout:
- sum = "(达运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
- default:
- hint := reasonHint[reason]
- if hint == "" {
- hint = "未知终态(harness 新增了终态但这里还没登记,请补 reasonHint)"
- }
- sum = "(无文字总结,终态 " + string(reason) + ":" + firstLine(hint, 80) + ")"
+ } else if reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout {
+ sum = "(达到运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
+ } else {
+ hint := terminalReasonHint(reason)
+ sum = "(无文字总结,终态 " + terminalReasonLabel(reason) + ":" + firstLine(hint, 80) + ")"
}
- // ---- expandable detail ----
var b strings.Builder
b.WriteString(sum)
b.WriteString("\n\n")
- fmt.Fprintf(&b, "- **终态**: `%s` — %s\n", reason, reasonHint[reason])
+ displayReason := terminalReasonLabel(reason)
+ fmt.Fprintf(&b, "- **终态**: `%s` - %s\n", displayReason, terminalReasonHint(reason))
if aborted {
code, _, why, ok := AbortReason(ctx)
if ok {
fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why)
} else {
- b.WriteString("- **中断原因**: 取不到——取消方没有用 context.WithCancelCause 挂上具名原因。" +
- "这属于漏网的取消点,请补 agent/cancelcause.go 里的枚举\n")
+ b.WriteString("- **中断原因**: 无法取得;取消方可能没有通过 context.WithCancelCause 附加具名原因\n")
}
}
if term.Err != nil {
fmt.Fprintf(&b, "- **底层错误**: `%v`\n", term.Err)
}
+ if aborted && strings.TrimSpace(term.Text) != "" {
+ b.WriteString("- **取消前已生成的部分输出**:\n\n")
+ b.WriteString(term.Text)
+ b.WriteString("\n\n")
+ }
if term.Turns > 0 {
fmt.Fprintf(&b, "- **已执行**: %d 轮模型回合\n", term.Turns)
}
@@ -118,21 +107,34 @@ func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (st
fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
}
- if tr.name != "" {
- if tr.pending {
- fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已跑 %s,**未返回结果**) — 若是硬超时/长时间无进展,问题多半就在这一步\n ```\n %s\n ```\n",
- tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
- } else {
- fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
- }
+ if tr.name == "" {
+ b.WriteString("- **工具调用**: 本次运行还没有发出工具调用就结束了\n")
+ } else if tr.pending {
+ fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已运行 %s,**未返回结果**)\n\n ```json\n %s\n ```\n",
+ tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
} else {
- b.WriteString("- **工具调用**: 本次运行还没发出任何工具调用就结束了\n")
+ fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
}
return sum, b.String()
}
-// progressSuffix adds "已跑 N 轮 / 12m30s" to a summary line — how far the run got
-// before it died, which decides whether an abort cost anything worth re-running.
+func terminalReasonLabel(reason harness.TerminalReason) string {
+ if reason == "" {
+ return "context_canceled"
+ }
+ return string(reason)
+}
+
+func terminalReasonHint(reason harness.TerminalReason) string {
+ if hint := reasonHint[reason]; hint != "" {
+ return hint
+ }
+ if reason == "" {
+ return "运行的 context 已取消,但底层没有产生 Terminal 事件"
+ }
+ return "未知终态;harness 可能新增了 TerminalReason,请补充 reasonHint"
+}
+
func progressSuffix(term *harness.Terminal, tr *runTrace) string {
var parts []string
if term.Turns > 0 {
@@ -144,10 +146,9 @@ func progressSuffix(term *harness.Terminal, tr *runTrace) string {
if len(parts) == 0 {
return ""
}
- return ",已跑 " + strings.Join(parts, " / ")
+ return ",已运行 " + strings.Join(parts, " / ")
}
-// roundDur formats a duration at a readable granularity (no 3.400213s noise).
func roundDur(d time.Duration) string {
switch {
case d < time.Minute:
diff --git a/agent/terminalreason_test.go b/agent/terminalreason_test.go
index a3ecd20..ff53fe4 100644
--- a/agent/terminalreason_test.go
+++ b/agent/terminalreason_test.go
@@ -12,8 +12,6 @@ import (
"github.com/Autumn-27/norma/llm"
)
-// every TerminalReason the harness can emit must have an explanation — otherwise
-// a run silently falls back to "未知终态", which is the bug this file exists to fix.
func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
all := []harness.TerminalReason{
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
@@ -21,152 +19,151 @@ func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
harness.ReasonMaxTurns, harness.ReasonTimeout,
}
- for _, r := range all {
- if strings.TrimSpace(reasonHint[r]) == "" {
- t.Errorf("terminal reason %q 没有中文说明", r)
+ for _, reason := range all {
+ if strings.TrimSpace(reasonHint[reason]) == "" {
+ t.Errorf("terminal reason %q has no explanation", reason)
}
}
}
-// the cause attached at cancel time must survive the ctx chain the engine builds:
-// task exec ctx -> per-work child -> value ctx -> the run's hard-timeout backstop.
func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
type key struct{}
- exec, cancelExec := context.WithCancelCause(context.Background())
- work, cancelWork := context.WithCancelCause(exec)
+ execCtx, cancelExec := context.WithCancelCause(context.Background())
+ workCtx, cancelWork := context.WithCancelCause(execCtx)
defer cancelWork(nil)
- valued := context.WithValue(work, key{}, "task-1")
- run, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
+ valued := context.WithValue(workCtx, key{}, "task-1")
+ runCtx, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
defer cancelRun()
cancelExec(AbortPausedByUser)
-
- code, _, text, ok := AbortReason(run)
+ code, _, text, ok := AbortReason(runCtx)
if !ok || code != "paused_by_user" {
- t.Fatalf("code=%q ok=%v,期望 paused_by_user", code, ok)
+ t.Fatalf("code=%q ok=%v, want paused_by_user", code, ok)
}
- if !strings.Contains(text, "退回 frontier") {
- t.Errorf("原因文案没传下来: %q", text)
+ if !strings.Contains(text, "frontier") {
+ t.Fatalf("cause detail did not propagate: %q", text)
}
}
func TestAbortReasonFallbacks(t *testing.T) {
if _, _, _, ok := AbortReason(context.Background()); ok {
- t.Error("未取消的 ctx 不应报出中断原因")
+ t.Fatal("live context must not report an abort reason")
}
- // a cancel site that forgot to attach a cause must be called out, not guessed at
plain, cancel := context.WithCancel(context.Background())
cancel()
- code, _, _, ok := AbortReason(plain)
- if !ok || code != "canceled_no_cause" {
- t.Errorf("code=%q ok=%v,期望 canceled_no_cause", code, ok)
+ if code, _, _, ok := AbortReason(plain); !ok || code != "canceled_no_cause" {
+ t.Fatalf("code=%q ok=%v, want canceled_no_cause", code, ok)
}
-
- timed, cancelT := context.WithTimeout(context.Background(), time.Nanosecond)
- defer cancelT()
+ timed, cancelTimed := context.WithTimeout(context.Background(), time.Nanosecond)
+ defer cancelTimed()
<-timed.Done()
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" {
- t.Errorf("code=%q,期望 deadline_exceeded", code)
+ t.Fatalf("code=%q, want deadline_exceeded", code)
}
}
-// an aborted run's one-line summary must name the actual cause, and the detail
-// must pin down the tool that never came back.
func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortKilledByPlanner)
-
- tr := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
- tr.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
-
+ trace := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
+ trace.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
term := &harness.Terminal{
Reason: harness.ReasonAbortedTools,
Err: context.Canceled,
Turns: 7,
Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340},
}
- sum, detail := terminalText(ctx, term, tr)
-
- if !strings.Contains(sum, "kill_work") {
- t.Errorf("摘要没点出取消方: %q", sum)
- }
- if strings.Contains(sum, "\n") {
- t.Errorf("摘要必须是单行(前端按首行截断): %q", sum)
+ summary, detail := terminalText(ctx, term, trace)
+ if !strings.Contains(summary, "规划者") || strings.Contains(summary, "\n") {
+ t.Fatalf("unexpected summary: %q", summary)
}
for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} {
if !strings.Contains(detail, want) {
- t.Errorf("详情缺少 %q:\n%s", want, detail)
+ t.Errorf("detail missing %q:\n%s", want, detail)
}
}
}
-// a tool that DID return must not be reported as the hang site.
+func TestTerminalTextDirectContextCancellationKeepsCause(t *testing.T) {
+ ctx, cancel := context.WithCancelCause(context.Background())
+ cancel(AbortChatStoppedByUser)
+ summary, detail := terminalText(ctx, &harness.Terminal{Err: context.Canceled}, &runTrace{startedAt: time.Now()})
+ if !strings.Contains(summary, "用户停止") || !strings.Contains(detail, "chat_stopped_by_user") {
+ t.Fatalf("direct context cancellation lost cause: %q\n%s", summary, detail)
+ }
+ if !strings.Contains(detail, "context_canceled") {
+ t.Fatalf("missing synthesized terminal label: %s", detail)
+ }
+}
+
+func TestTerminalTextAbortedPreservesPartialOutput(t *testing.T) {
+ ctx, cancel := context.WithCancelCause(context.Background())
+ cancel(AbortPausedByUser)
+ summary, detail := terminalText(ctx, &harness.Terminal{
+ Reason: harness.ReasonAbortedStreaming,
+ Text: "已经生成的半段回答",
+ }, &runTrace{startedAt: time.Now()})
+ if !strings.Contains(summary, "用户暂停") {
+ t.Fatalf("abort summary lost cause: %q", summary)
+ }
+ if !strings.Contains(detail, "已经生成的半段回答") {
+ t.Fatalf("abort detail lost partial output: %s", detail)
+ }
+}
+
func TestTerminalTextCompletedToolNotBlamed(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortShutdown)
- tr := &runTrace{startedAt: time.Now()}
- tr.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
- tr.done("tu_1")
-
- _, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, tr)
- if strings.Contains(detail, "未返回结果") {
- t.Errorf("已返回的工具被误报为卡住:\n%s", detail)
- }
- if !strings.Contains(detail, "已正常返回") {
- t.Errorf("详情没记录最后一个工具:\n%s", detail)
+ trace := &runTrace{startedAt: time.Now()}
+ trace.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
+ trace.done("tu_1")
+ _, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, trace)
+ if strings.Contains(detail, "未返回结果") || !strings.Contains(detail, "已正常返回") {
+ t.Fatalf("completed tool was blamed:\n%s", detail)
}
}
-// non-abort terminals keep their own explanation instead of the interruption text.
func TestTerminalTextNonAbortReasons(t *testing.T) {
- ctx := context.Background()
- tr := &runTrace{startedAt: time.Now()}
-
- sum, _ := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonMaxTurns}, tr)
- if !strings.Contains(sum, "达运行预算上限") || strings.Contains(sum, "中断") {
- t.Errorf("max_turns 摘要不对: %q", sum)
+ trace := &runTrace{startedAt: time.Now()}
+ summary, _ := terminalText(context.Background(), &harness.Terminal{Reason: harness.ReasonMaxTurns}, trace)
+ if !strings.Contains(summary, "运行预算上限") || strings.Contains(summary, "中断") {
+ t.Fatalf("unexpected max_turns summary: %q", summary)
}
-
- sum, detail := terminalText(ctx, &harness.Terminal{
- Reason: harness.ReasonModelError, Err: errors.New("429 rate limited")}, tr)
- if !strings.Contains(sum, "model_error") {
- t.Errorf("model_error 摘要不对: %q", sum)
- }
- if !strings.Contains(detail, "429 rate limited") {
- t.Errorf("详情没带上底层错误:\n%s", detail)
+ summary, detail := terminalText(context.Background(), &harness.Terminal{
+ Reason: harness.ReasonModelError, Err: errors.New("429 rate limited"),
+ }, trace)
+ if !strings.Contains(summary, "model_error") || !strings.Contains(detail, "429 rate limited") {
+ t.Fatalf("model_error detail incomplete: %q\n%s", summary, detail)
}
}
-// every enumerated cause must carry both a compact headline (for the collapsed
-// row) and a full explanation (for the expanded detail).
func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
- AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkFinished,
- AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatTurnFinished,
+ AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser,
+ AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard,
+ AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished,
AbortShutdown, AbortRunHardTimeout,
}
seen := map[string]bool{}
- for _, c := range all {
+ for _, abort := range all {
switch {
- case c.Code == "" || seen[c.Code]:
- t.Errorf("code 缺失或重复: %q", c.Code)
- case c.Short == "" || utf8.RuneCountInString(c.Short) > 40:
- t.Errorf("%s: Short 必须存在且 ≤40 字(进折叠摘要行): %q", c.Code, c.Short)
- case utf8.RuneCountInString(c.Text) <= utf8.RuneCountInString(c.Short):
- t.Errorf("%s: Text 应比 Short 更完整", c.Code)
+ case abort.Code == "" || seen[abort.Code]:
+ t.Errorf("missing or duplicate code: %q", abort.Code)
+ case abort.Short == "" || utf8.RuneCountInString(abort.Short) > 40:
+ t.Errorf("%s has invalid short text: %q", abort.Code, abort.Short)
+ case utf8.RuneCountInString(abort.Text) <= utf8.RuneCountInString(abort.Short):
+ t.Errorf("%s detail must be longer than short text", abort.Code)
}
- seen[c.Code] = true
+ seen[abort.Code] = true
}
}
-// summaries are mostly 中文; capping by bytes used to slice a rune in half.
func TestFirstLineCapsByRuneNotByte(t *testing.T) {
- got := firstLine(strings.Repeat("恢复", 10), 5)
- if got != "恢复恢复恢…" {
- t.Errorf("firstLine 按字节截断了: %q", got)
+ if got := firstLine(strings.Repeat("恢复", 10), 5); got != "恢复恢复恢…" {
+ t.Fatalf("firstLine split a Unicode character: %q", got)
}
if got := firstLine("头\n尾", 100); got != "头" {
- t.Errorf("多行没截到首行: %q", got)
+ t.Fatalf("firstLine did not stop at newline: %q", got)
}
}
diff --git a/agent/tools.go b/agent/tools.go
index 93b71b7..d0958a2 100644
--- a/agent/tools.go
+++ b/agent/tools.go
@@ -23,6 +23,10 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "summary": p["summary"], "state": n.State}
+ if n.Inherited {
+ m["source_task_id"] = n.SourceTaskID
+ m["inherited"] = true
+ }
// asset_ids is the structured "which assets this direction covers" signal for
// dedup; fall back to legacy payload keys (target_ids plural, then target_id
// single) so intents stored before the rename still surface their anchors.
@@ -265,132 +269,438 @@ func (t *ToolSet) graphOverview() actool.CoreTool {
// the model needn't spend a turn calling the tool — every plan round starts with
// an empty context and always needs this first).
func (t *ToolSet) graphOverviewData() map[string]any {
- {
- out := map[string]any{}
- // goals summary folded in so the planner needn't call list_goals each round.
- goals, _ := t.ts.ListByKind(db.KindGoal, 100)
- gsum := make([]map[string]any, 0, len(goals))
- for _, g := range goals {
- var p map[string]any
- _ = json.Unmarshal(g.Payload, &p)
- gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
+ out := map[string]any{}
+ // goals summary folded in so the planner needn't call list_goals each round.
+ goals, _ := t.ts.ListByKind(db.KindGoal, 100)
+ gsum := make([]map[string]any, 0, len(goals))
+ for _, g := range goals {
+ var p map[string]any
+ _ = json.Unmarshal(g.Payload, &p)
+ gsum = append(gsum, map[string]any{"id": g.ID, "state": g.State, "text": p["text"]})
+ }
+ out["goals"] = gsum
+ // hints: 人类/主 agent 通过 add_hint 挂上图的战略提示;folded in so the
+ // planner reads them every round when generating intents (否则只写不读).
+ hints, _ := t.ts.ListByKind(db.KindHint, 50)
+ hsum := make([]map[string]any, 0, len(hints))
+ for _, h := range hints {
+ var p map[string]any
+ _ = json.Unmarshal(h.Payload, &p)
+ hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
+ }
+ out["hints"] = hsum
+ // lineage from the exploration edges: an intent's parents (what it
+ // derived_from — possibly several facts combined) and its yields (the
+ // facts/findings it produced). factFrom maps a fact → the intent that
+ // produced it. This is the relationship layer the flat lists lacked.
+ edges, _ := t.ts.Edges(5000)
+ parentsOf := map[int64][]int64{}
+ yieldsOf := map[int64][]int64{}
+ factFrom := map[int64]int64{}
+ for _, e := range edges {
+ switch e.Rel {
+ case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent)
+ parentsOf[e.To] = append(parentsOf[e.To], e.From)
+ case db.RelYields: // intent --yields--> fact/finding
+ yieldsOf[e.From] = append(yieldsOf[e.From], e.To)
+ factFrom[e.To] = e.From
}
- out["goals"] = gsum
- // hints: 人类/主 agent 通过 add_hint 挂上图的战略提示;folded in so the
- // planner reads them every round when generating intents (否则只写不读).
- hints, _ := t.ts.ListByKind(db.KindHint, 50)
- hsum := make([]map[string]any, 0, len(hints))
- for _, h := range hints {
- var p map[string]any
- _ = json.Unmarshal(h.Payload, &p)
- hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
+ }
+ fr, _ := t.ts.Frontier(100)
+ out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
+ all, _ := t.ts.ListByKind(db.KindIntent, 300)
+ var running, recentDone []*db.Node
+ for _, n := range all {
+ switch n.State {
+ case "running":
+ running = append(running, n)
+ case "done", "blocked", "exhausted":
+ if len(recentDone) < 15 {
+ recentDone = append(recentDone, n)
+ }
}
- out["hints"] = hsum
- // lineage from the exploration edges: an intent's parents (what it
- // derived_from — possibly several facts combined) and its yields (the
- // facts/findings it produced). factFrom maps a fact → the intent that
- // produced it. This is the relationship layer the flat lists lacked.
- edges, _ := t.ts.Edges(5000)
+ }
+ out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
+ out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
+ out["frontier_open"] = len(fr)
+ // findings (confirmed vulns) and facts (worker exploration results) are
+ // now distinct node kinds. recent_facts surfaces fact summaries (esp.
+ // negative results) so the planner sees them in one call; full content
+ // via node_detail(id).
+ vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
+ factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
+ out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它)
+ out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
+ recentFacts := make([]map[string]any, 0, 20)
+ for _, n := range factNodes {
+ if len(recentFacts) >= 20 {
+ break
+ }
+ m := compactNode(n)
+ if from := factFrom[n.ID]; from > 0 {
+ m["from_intent"] = from // 本事实由哪个意图产生
+ }
+ // confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
+ // 别当铁案);evidence 较长,留给 node_detail(id)。
+ var fp map[string]any
+ if json.Unmarshal(n.Payload, &fp) == nil {
+ if c, ok := fp["confidence"].(string); ok && c != "" {
+ m["confidence"] = c
+ }
+ }
+ recentFacts = append(recentFacts, m)
+ }
+ out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
+ // the original task (root) so the planner always has it, not just the
+ // decomposed goals.
+ if description, goal, err := t.ts.Root(); err == nil {
+ out["task"] = map[string]any{"description": description, "goal": goal}
+ }
+ // Direct source tasks are a live, read-only blackboard view. Keep their
+ // summaries in a separate field so their intents never enter this task's
+ // frontier or get mistaken for locally claimable work.
+ out["related_tasks"] = t.relatedTaskOverviews()
+ // coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
+ // 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
+ if t.as != nil && t.ts != nil && t.taskID > 0 {
+ if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil {
+ m := map[string]any{
+ "denominator": cov.Denominator,
+ "tested": cov.Tested,
+ "by_type": cov.ByType,
+ "note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;",
+ }
+ if cov.Denominator == 0 {
+ m["pct"] = nil
+ m["status"] = "范围未锚定"
+ } else {
+ m["pct"] = cov.Pct
+ }
+ // scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底
+ // 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。
+ if rows, err := t.as.ListTaskScopeWithSources(t.taskID); err == nil && len(rows) > 0 {
+ scope := make([]map[string]any, 0, len(rows))
+ for _, r := range rows {
+ e := map[string]any{"kind": r.Kind, "source": r.Source, "task_id": r.TaskID}
+ if r.TaskID != t.taskID {
+ inheritedMap(e, r.TaskID)
+ }
+ switch {
+ case r.Domain != "":
+ e["value"] = r.Domain
+ case r.Net != "":
+ e["value"] = r.Net
+ case r.CompanyID != nil:
+ e["company_id"] = *r.CompanyID
+ }
+ scope = append(scope, e)
+ }
+ m["scope"] = scope
+ }
+ if hosts, err := t.as.HostsByTaskWithSources(t.taskID); err == nil {
+ const hostContextLimit = 500
+ visible := hosts
+ if len(visible) > hostContextLimit {
+ visible = visible[:hostContextLimit]
+ }
+ m["hosts"] = visible
+ m["host_count"] = len(hosts)
+ if len(visible) < len(hosts) {
+ m["hosts_truncated"] = true
+ }
+ }
+ out["coverage"] = m
+ }
+ }
+ return out
+}
+
+func inheritedMap(m map[string]any, sourceTaskID int64) map[string]any {
+ m["source_task_id"] = sourceTaskID
+ m["inherited"] = true
+ return m
+}
+
+const (
+ relatedOverviewTotalTextRunes = 48_000
+ relatedOverviewMaxTextPerSource = 8_000
+ relatedOverviewMaxGoalsPerSource = 8
+ relatedOverviewMaxHintsPerSource = 6
+ relatedOverviewMaxFactsPerSource = 12
+ relatedOverviewMaxFindingsPerTask = 6
+ relatedOverviewMaxIntentsPerTask = 8
+ relatedOverviewMaxScopePerSource = 12
+)
+
+// overviewTextBudget bounds inherited prompt text while preserving a fair slice
+// for every direct source. Full evidence remains available through the on-demand
+// read tools, so truncation here does not discard persisted blackboard data.
+type overviewTextBudget struct {
+ remaining int
+ truncated bool
+}
+
+func relatedOverviewBudgetForSources(sourceCount int) int {
+ if sourceCount <= 0 {
+ return 0
+ }
+ if sourceCount > db.MaxTaskSourceCount {
+ sourceCount = db.MaxTaskSourceCount
+ }
+ perSource := relatedOverviewTotalTextRunes / sourceCount
+ if perSource > relatedOverviewMaxTextPerSource {
+ perSource = relatedOverviewMaxTextPerSource
+ }
+ return perSource
+}
+
+func (b *overviewTextBudget) take(value any, fieldLimit int) string {
+ var text string
+ switch value := value.(type) {
+ case string:
+ text = strings.TrimSpace(value)
+ case nil:
+ return ""
+ default:
+ text = strings.TrimSpace(fmt.Sprint(value))
+ }
+ if text == "" {
+ return ""
+ }
+ if b.remaining <= 0 || fieldLimit <= 0 {
+ b.truncated = true
+ return ""
+ }
+ runes := []rune(text)
+ limit := fieldLimit
+ if limit > b.remaining {
+ limit = b.remaining
+ }
+ if len(runes) > limit {
+ b.truncated = true
+ if limit == 1 {
+ text = "…"
+ } else {
+ text = string(runes[:limit-1]) + "…"
+ }
+ runes = []rune(text)
+ }
+ b.remaining -= len(runes)
+ return text
+}
+
+func recentTerminalIntents(store *db.ExplorationStore, limit int) []*db.Node {
+ if limit <= 0 {
+ return []*db.Node{}
+ }
+ const batch = 300
+ cursor := int64(0)
+ out := make([]*db.Node, 0, limit)
+ for len(out) < limit {
+ page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch)
+ if err != nil || len(page) == 0 {
+ break
+ }
+ for _, intent := range page {
+ switch intent.State {
+ case "done", "blocked", "exhausted", "stopped":
+ out = append(out, intent)
+ }
+ if len(out) >= limit {
+ break
+ }
+ }
+ if !more {
+ break
+ }
+ cursor = page[len(page)-1].ID
+ }
+ return out
+}
+
+// relatedTaskOverviews distills persistent blackboard state from direct source
+// tasks. It intentionally reads each source's local store methods, never its own
+// related sources, so inheritance is one level only.
+func (t *ToolSet) relatedTaskOverviews() []map[string]any {
+ sources, err := t.ts.DirectSourceStores()
+ if err != nil {
+ return []map[string]any{}
+ }
+ if len(sources) > db.MaxTaskSourceCount {
+ sources = sources[:db.MaxTaskSourceCount]
+ }
+ perSourceTextBudget := relatedOverviewBudgetForSources(len(sources))
+ out := make([]map[string]any, 0, len(sources))
+ for _, source := range sources {
+ ts := source.Store
+ budget := overviewTextBudget{remaining: perSourceTextBudget}
+ item := map[string]any{
+ "source_task_id": source.Task.TaskID,
+ "inherited": true,
+ "task": map[string]any{
+ "description": budget.take(source.Task.Description, 800),
+ "goal": budget.take(source.Task.Goal, 800),
+ "status": source.Task.Status,
+ },
+ }
+ stats, statsErr := ts.Stats()
+
+ edges, _ := ts.Edges(5000)
parentsOf := map[int64][]int64{}
yieldsOf := map[int64][]int64{}
factFrom := map[int64]int64{}
- for _, e := range edges {
- switch e.Rel {
- case db.RelDerivedFrom, db.RelSpawns: // upstream: derived_from (fact/finding/intent→intent) or spawns (origin fact→goal, legacy begin→intent)
- parentsOf[e.To] = append(parentsOf[e.To], e.From)
- case db.RelYields: // intent --yields--> fact/finding
- yieldsOf[e.From] = append(yieldsOf[e.From], e.To)
- factFrom[e.To] = e.From
+ for _, edge := range edges {
+ switch edge.Rel {
+ case db.RelDerivedFrom, db.RelSpawns:
+ parentsOf[edge.To] = append(parentsOf[edge.To], edge.From)
+ case db.RelYields:
+ yieldsOf[edge.From] = append(yieldsOf[edge.From], edge.To)
+ factFrom[edge.To] = edge.From
}
}
- fr, _ := t.ts.Frontier(100)
- out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
- all, _ := t.ts.ListByKind(db.KindIntent, 300)
- var running, recentDone []*db.Node
- for _, n := range all {
- switch n.State {
- case "running":
- running = append(running, n)
- case "done", "blocked", "exhausted":
- if len(recentDone) < 15 {
- recentDone = append(recentDone, n)
- }
+
+ goals, _ := ts.ListByKind(db.KindGoal, relatedOverviewMaxGoalsPerSource)
+ goalSummary := make([]map[string]any, 0, len(goals))
+ for _, goal := range goals {
+ var payload map[string]any
+ _ = json.Unmarshal(goal.Payload, &payload)
+ goalSummary = append(goalSummary, inheritedMap(map[string]any{
+ "id": goal.ID, "state": goal.State, "text": budget.take(payload["text"], 400),
+ }, source.Task.TaskID))
+ }
+ item["goals"] = goalSummary
+
+ hints, _ := ts.ListByKind(db.KindHint, relatedOverviewMaxHintsPerSource)
+ hintSummary := make([]map[string]any, 0, len(hints))
+ for _, hint := range hints {
+ var payload map[string]any
+ _ = json.Unmarshal(hint.Payload, &payload)
+ hintSummary = append(hintSummary, inheritedMap(map[string]any{
+ "id": hint.ID, "state": hint.State, "text": budget.take(payload["text"], 400),
+ }, source.Task.TaskID))
+ }
+ item["hints"] = hintSummary
+
+ facts, _ := ts.ListByKind(db.KindFact, relatedOverviewMaxFactsPerSource)
+ findings, _ := ts.ListByKind(db.KindFinding, relatedOverviewMaxFindingsPerTask)
+ intentNodes, _ := ts.ListByKind(db.KindIntent, 300)
+ terminalIntent := make(map[int64]bool, len(intentNodes))
+ for _, intent := range intentNodes {
+ terminalIntent[intent.ID] = inheritedIntentSummaryState(intent.State)
+ }
+ item["facts"] = len(facts)
+ item["findings"] = len(findings)
+ if statsErr == nil {
+ item["facts"] = stats[db.KindFact]
+ item["findings"] = stats[db.KindFinding]
+ if stats[db.KindGoal] > len(goals) || stats[db.KindHint] > len(hints) ||
+ stats[db.KindFact] > len(facts) || stats[db.KindFinding] > len(findings) {
+ budget.truncated = true
}
}
- out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
- out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
- out["frontier_open"] = len(fr)
- // findings (confirmed vulns) and facts (worker exploration results) are
- // now distinct node kinds. recent_facts surfaces fact summaries (esp.
- // negative results) so the planner sees them in one call; full content
- // via node_detail(id).
- vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
- factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
- out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它)
- out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
- recentFacts := make([]map[string]any, 0, 20)
- for _, n := range factNodes {
- if len(recentFacts) >= 20 {
- break
+ recentFindings := make([]map[string]any, 0, len(findings))
+ for _, finding := range findings {
+ entry := inheritedMap(compactFinding(finding), source.Task.TaskID)
+ entry["summary"] = budget.take(entry["summary"], 400)
+ recentFindings = append(recentFindings, entry)
+ }
+ item["recent_findings"] = recentFindings
+ recentFacts := make([]map[string]any, 0, len(facts))
+ for _, fact := range facts {
+ m := inheritedMap(compactNode(fact), source.Task.TaskID)
+ m["summary"] = budget.take(m["summary"], 400)
+ if from := factFrom[fact.ID]; from > 0 && terminalIntent[from] {
+ m["from_intent"] = from
}
- m := compactNode(n)
- if from := factFrom[n.ID]; from > 0 {
- m["from_intent"] = from // 本事实由哪个意图产生
- }
- // confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
- // 别当铁案);evidence 较长,留给 node_detail(id)。
- var fp map[string]any
- if json.Unmarshal(n.Payload, &fp) == nil {
- if c, ok := fp["confidence"].(string); ok && c != "" {
- m["confidence"] = c
+ var payload map[string]any
+ if json.Unmarshal(fact.Payload, &payload) == nil {
+ if confidence, ok := payload["confidence"].(string); ok && confidence != "" {
+ m["confidence"] = confidence
}
}
recentFacts = append(recentFacts, m)
}
- out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
- // the original task (root) so the planner always has it, not just the
- // decomposed goals.
- if description, goal, err := t.ts.Root(); err == nil {
- out["task"] = map[string]any{"description": description, "goal": goal}
+ item["recent_facts"] = recentFacts
+
+ recentDone := recentTerminalIntents(ts, relatedOverviewMaxIntentsPerTask)
+ for _, intent := range recentDone {
+ intent.Inherited = true
+ intent.SourceTaskID = source.Task.TaskID
}
- // coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
- // 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
- if t.as != nil && t.ts != nil && t.taskID > 0 {
- if cov, err := t.as.TaskCoverage(t.taskID, t.ts.ID()); err == nil {
- m := map[string]any{
- "denominator": cov.Denominator,
- "tested": cov.Tested,
- "by_type": cov.ByType,
- "note": "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:容器型资产/大量枚举会让它偏低,勿据此认为已测完;scope 是当前任务测试范围的根资产(root_domain/subdomain/ip/cidr/company 原始行),即覆盖度分母的边界、本任务授权/圈定的目标本身;可用 add_task_scope 增补范围、list_untested_assets 看未测的具体资产【通常不调用list_untested_assets,按照任务推进即可】;",
+ intentResults := compactIntents(recentDone, parentsOf, yieldsOf)
+ for i, intent := range recentDone {
+ intentResults[i]["summary"] = budget.take(intentResults[i]["summary"], 400)
+ acts, _, err := ts.ActivityPageForTerminalIntent(intent.ID, 0, 20)
+ if err != nil {
+ continue
+ }
+ var resultSummary, textFallback string
+ for _, activity := range acts {
+ switch activity.Kind {
+ case "result":
+ resultSummary = activity.Summary
+ case "text":
+ textFallback = activity.Summary
}
- if cov.ScopeRows == 0 {
- m["pct"] = nil
- m["status"] = "范围未锚定"
- } else {
- m["pct"] = cov.Pct
- }
- // scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底
- // 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。
- if rows, err := t.as.ListTaskScope(t.taskID); err == nil && len(rows) > 0 {
- scope := make([]map[string]any, 0, len(rows))
- for _, r := range rows {
- e := map[string]any{"kind": r.Kind, "source": r.Source}
- switch {
- case r.Domain != "":
- e["value"] = r.Domain
- case r.Net != "":
- e["value"] = r.Net
- case r.CompanyID != nil:
- e["company_id"] = *r.CompanyID
- }
- scope = append(scope, e)
- }
- m["scope"] = scope
- }
- out["coverage"] = m
+ }
+ if resultSummary == "" {
+ resultSummary = textFallback
+ }
+ if resultSummary != "" {
+ intentResults[i]["result_summary"] = budget.take(resultSummary, 800)
}
}
- return out
+ item["recent_intent_results"] = intentResults
+ if statsErr == nil {
+ item["node_stats"] = stats
+ }
+
+ if t.as != nil {
+ if scopeRows, err := t.as.ListTaskScope(source.Task.TaskID); err == nil && len(scopeRows) > 0 {
+ scopeCount := len(scopeRows)
+ if len(scopeRows) > relatedOverviewMaxScopePerSource {
+ scopeRows = scopeRows[:relatedOverviewMaxScopePerSource]
+ budget.truncated = true
+ }
+ scope := make([]map[string]any, 0, len(scopeRows))
+ for _, row := range scopeRows {
+ entry := map[string]any{"kind": row.Kind, "source": budget.take(row.Source, 300)}
+ switch {
+ case row.Domain != "":
+ entry["value"] = budget.take(row.Domain, 400)
+ case row.Net != "":
+ entry["value"] = budget.take(row.Net, 400)
+ case row.CompanyID != nil:
+ entry["company_id"] = *row.CompanyID
+ }
+ scope = append(scope, entry)
+ }
+ item["asset_scope"] = scope
+ item["asset_scope_count"] = scopeCount
+ }
+ if coverage, err := t.as.TaskCoverage(source.Task.TaskID, source.Task.ExplorationID); err == nil {
+ item["asset_coverage"] = map[string]any{
+ "denominator": coverage.Denominator,
+ "tested": coverage.Tested,
+ "pct": coverage.Pct,
+ "by_type": coverage.ByType,
+ }
+ }
+ }
+ if budget.truncated {
+ item["summary_truncated"] = true
+ }
+ out = append(out, item)
+ }
+ return out
+}
+
+func inheritedIntentSummaryState(state string) bool {
+ switch state {
+ case "done", "blocked", "exhausted", "stopped":
+ return true
+ default:
+ return false
}
}
@@ -399,7 +709,11 @@ func (t *ToolSet) graphOverviewData() map[string]any {
func compactNode(n *db.Node) map[string]any {
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
- return map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
+ m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
+ if n.Inherited {
+ inheritedMap(m, n.SourceTaskID)
+ }
+ return m
}
// compactFinding is compactNode plus the vuln-specific vulnclass/severity.
@@ -407,6 +721,9 @@ func compactFinding(n *db.Node) map[string]any {
var p map[string]any
_ = json.Unmarshal(n.Payload, &p)
m := map[string]any{"id": n.ID, "state": n.State, "summary": p["summary"]}
+ if n.Inherited {
+ inheritedMap(m, n.SourceTaskID)
+ }
if vc, ok := p["vulnclass"]; ok && vc != nil && vc != "" {
m["vulnclass"] = vc
}
@@ -417,16 +734,23 @@ func compactFinding(n *db.Node) map[string]any {
}
func (t *ToolSet) listFindings() actool.CoreTool {
- return readTool("list_findings", "列【确认漏洞】(紧凑:id+task_id+intent_id+vulnclass+severity+摘要+状态)。task_id=该漏洞所属任务, intent_id=产生它的意图。这里只有漏洞,不含普通探索事实(那是 list_facts)。详情用 node_detail(id)。",
+ return readTool("list_findings", "列本任务及直接关联任务的【确认漏洞】(紧凑:id+task_id+intent_id+vulnclass+severity+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。这里只含漏洞;普通探索事实用 list_facts,详情用 node_detail(id)。",
obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
- f, _ := t.ts.ListByKind(db.KindFinding, 500)
- intentOf, _ := t.ts.FindingIntents() // finding id -> 产生它的 intent id
- taskID := t.ts.ID() // exploration id = 任务 id(本 store 内所有 finding 同属)
+ f, _ := t.ts.ListByKindWithSources(db.KindFinding, 500)
+ intentOf, _ := t.ts.FindingIntentsWithSources() // finding id -> 产生它的 intent id
+ taskID := t.taskID
+ if taskID <= 0 {
+ taskID, _ = t.ts.TaskID()
+ }
out := make([]map[string]any, 0, len(f))
for _, n := range f {
m := compactFinding(n)
- m["task_id"] = taskID
+ if n.Inherited {
+ m["task_id"] = n.SourceTaskID
+ } else {
+ m["task_id"] = taskID
+ }
if iid, ok := intentOf[n.ID]; ok {
m["intent_id"] = iid
}
@@ -437,10 +761,10 @@ func (t *ToolSet) listFindings() actool.CoreTool {
}
func (t *ToolSet) listFacts() actool.CoreTool {
- return readTool("list_facts", "列【探索事实/结论】(紧凑:id+摘要+状态),即 worker 按意图探出的结论(含'端口关闭/不可注入'等否定结论)。详情用 node_detail(id)。漏洞看 list_findings。",
+ return readTool("list_facts", "列本任务及直接关联任务的【探索事实/结论】(紧凑:id+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。详情用 node_detail(id),漏洞看 list_findings。",
obj(map[string]any{}),
func(context.Context, json.RawMessage) (actool.Result, error) {
- f, _ := t.ts.ListByKind(db.KindFact, 500)
+ f, _ := t.ts.ListByKindWithSources(db.KindFact, 500)
out := make([]map[string]any, 0, len(f))
for _, n := range f {
out = append(out, compactNode(n))
@@ -450,7 +774,7 @@ func (t *ToolSet) listFacts() actool.CoreTool {
}
func (t *ToolSet) nodeDetail() actool.CoreTool {
- return readTool("node_detail", "按 id 取某个【探索图节点】的完整内容(事实/发现/意图/目标:摘要 + 详情/证据/PoC)。仅限探索图节点 id(来自 list_facts / list_findings / graph_overview 的 recent_facts 返回的 id)。查【资产】请用 list_assets / asset_neighbors——资产 id 不是探索节点 id,不要传进来。",
+ return readTool("node_detail", "按 id 取本任务或直接关联任务的【探索图节点】完整内容。继承节点带 source_task_id/inherited=true 且只读。仅限 list_facts/list_findings/graph_overview 返回的探索节点 id;资产请用 list_assets/asset_neighbors。",
obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
@@ -461,7 +785,7 @@ func (t *ToolSet) nodeDetail() actool.CoreTool {
if id <= 0 {
return actool.Errorf("id 必填"), nil
}
- n, err := t.ts.GetNode(id)
+ n, err := t.ts.GetNodeWithSources(id)
if err != nil {
return actool.Errorf(err.Error()), nil
}
@@ -493,9 +817,9 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
// 先校验锚点(建节点前,避免坏锚点留下孤儿意图)。
parents := pidList(it.ParentIDs)
for _, pidv := range parents {
- n, err := t.ts.GetNode(pidv)
+ n, err := t.ts.GetNodeWithSources(pidv)
if err != nil || n == nil {
- return 0, fmt.Errorf("parent_id %d 不存在:parent_ids 必须是已存在的【事实(fact)/发现(finding)】节点 id;顶层全新方向请留空 parent_ids", pidv)
+ return 0, fmt.Errorf("parent_id %d 不存在于本任务或直接关联任务:parent_ids 必须是已存在的【事实(fact)/发现(finding)】节点 id;顶层全新方向请留空 parent_ids", pidv)
}
if n.Kind != db.KindFact && n.Kind != db.KindFinding {
return 0, fmt.Errorf("parent_id %d 是 %q 节点,不能作为意图锚点:意图只能锚在已确认的【事实(fact)/发现(finding)】上,不能挂在意图/目标/提示上;顶层全新方向请留空 parent_ids", pidv, n.Kind)
@@ -604,6 +928,14 @@ func (t *ToolSet) proveGoal() actool.CoreTool {
if goal == 0 || ev == 0 {
return actool.Errorf("goal_id 和 evidence_id 必填"), nil
}
+ goalNode, err := t.ts.GetNode(goal)
+ if err != nil || goalNode == nil || goalNode.Kind != db.KindGoal {
+ return actool.Errorf("goal_id 必须是本任务的目标节点(关联任务目标只读)"), nil
+ }
+ evidenceNode, err := t.ts.GetNodeWithSources(ev)
+ if err != nil || evidenceNode == nil || (evidenceNode.Kind != db.KindFact && evidenceNode.Kind != db.KindFinding) {
+ return actool.Errorf("evidence_id 必须是本任务或直接关联任务的事实/漏洞节点"), nil
+ }
_ = t.ts.Link(ev, db.RelProves, goal)
_ = t.ts.SetNodeState(goal, "met")
// 每标记一个目标 met,就检查本任务是否【所有目标】都已 met;若是,自动判定
@@ -668,12 +1000,19 @@ func (t *ToolSet) addFinding() actool.CoreTool {
}
var id int64
if t.ts != nil {
+ intent := pid(a.IntentID)
+ if intent > 0 {
+ node, err := t.ts.GetNode(intent)
+ if err != nil || node == nil || node.Kind != db.KindIntent {
+ return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
+ }
+ }
var err error
id, err = t.ts.AddNode(db.KindFinding, payload, 9, "confirmed", t.worker, anchors)
if err != nil {
return actool.Errorf(err.Error()), nil
}
- if intent := pid(a.IntentID); intent > 0 {
+ if intent > 0 {
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding
}
_, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Name, a.Severity, a.Summary, a.Evidence, t.worker, anchors)
@@ -728,15 +1067,21 @@ func (t *ToolSet) recordOneFact(it factItem, defaultIntent int64) (int64, error)
if c := strings.TrimSpace(it.Confidence); c != "" {
payload["confidence"] = c
}
+ intent := pid(it.IntentID)
+ if intent <= 0 {
+ intent = defaultIntent
+ }
+ if intent > 0 {
+ node, err := t.ts.GetNode(intent)
+ if err != nil || node == nil || node.Kind != db.KindIntent {
+ return 0, fmt.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)")
+ }
+ }
// a fact is its OWN node kind (distinct from a vuln finding).
id, err := t.ts.AddNode(db.KindFact, payload, 5, "confirmed", t.worker, pidList(it.AssetIDs))
if err != nil {
return 0, err
}
- intent := pid(it.IntentID)
- if intent <= 0 {
- intent = defaultIntent
- }
if intent > 0 {
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> fact
}
@@ -984,6 +1329,10 @@ func (t *ToolSet) killWorkTool() actool.CoreTool {
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
+ node, err := t.ts.GetNode(id)
+ if err != nil || node == nil || node.Kind != db.KindIntent {
+ return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
+ }
if err := t.killWork(id); err != nil {
return actool.Errorf(err.Error()), nil
}
@@ -1014,6 +1363,10 @@ func (t *ToolSet) steerWorkTool() actool.CoreTool {
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
+ node, err := t.ts.GetNode(id)
+ if err != nil || node == nil || node.Kind != db.KindIntent {
+ return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
+ }
if strings.TrimSpace(a.Message) == "" {
return actool.Errorf("message 必填"), nil
}
@@ -1026,7 +1379,7 @@ func (t *ToolSet) steerWorkTool() actool.CoreTool {
// getWorkerOutput returns a work's final (or截至中止时的) conclusion text by intent id.
func (t *ToolSet) getWorkerOutput() actool.CoreTool {
- return readTool("get_worker_output", "取某条意图(work)的最终输出结论。正常结束返回其总结;被终止(stopped)/异常的 work 返回其截至中止时的最后输出(terminated=true)。",
+ return readTool("get_worker_output", "取本任务或直接关联任务某条意图(work)的最终输出结论。关联任务结果带 source_task_id/inherited=true 且只读。正常结束返回其总结;被终止(stopped)/异常的 work 返回其截至中止时的最后输出(terminated=true)。",
obj(map[string]any{"intent_id": idp("意图 id(= work 句柄)")}, "intent_id"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
@@ -1037,7 +1390,14 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
- acts, _, err := t.ts.ActivityList(&id, 0, 1000)
+ intentNode, err := t.ts.GetNodeWithSources(id)
+ if err != nil {
+ return actool.Errorf(err.Error()), nil
+ }
+ if intentNode == nil || intentNode.Kind != db.KindIntent {
+ return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
+ }
+ acts, _, err := t.ts.ActivityListWithSources(id, 0, 1000)
if err != nil {
return actool.Errorf(err.Error()), nil
}
@@ -1056,16 +1416,25 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
pick, terminated = fallback, true
}
if pick == nil {
+ if intentNode.Inherited {
+ return jsonResult(inheritedMap(map[string]any{
+ "intent_id": id, "final_text": "(该 work 尚无任何输出)", "terminated": true,
+ }, intentNode.SourceTaskID))
+ }
return actool.Text("(该 work 尚无任何输出)"), nil
}
- detail, _ := t.ts.ActivityDetail(pick.ID)
+ detail, _ := t.ts.ActivityDetailWithSources(pick.ID)
if detail == "" {
detail = pick.Summary
}
- return jsonResult(map[string]any{
+ result := map[string]any{
"intent_id": id, "worker_name": pick.Worker, "final_text": detail,
"summary": pick.Summary, "is_error": pick.IsError, "terminated": terminated,
- })
+ }
+ if intentNode.Inherited {
+ inheritedMap(result, intentNode.SourceTaskID)
+ }
+ return jsonResult(result)
})
}
@@ -1075,10 +1444,14 @@ func (t *ToolSet) getWorkerOutput() actool.CoreTool {
func traceSteps(acts []db.Activity) []map[string]any {
steps := make([]map[string]any, 0, len(acts))
for i := range acts {
- steps = append(steps, map[string]any{
+ step := map[string]any{
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "summary": firstLine(acts[i].Summary, 100),
- })
+ }
+ if acts[i].Inherited {
+ inheritedMap(step, acts[i].SourceTaskID)
+ }
+ steps = append(steps, step)
}
return steps
}
@@ -1092,7 +1465,7 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
"① 只传 intent_id → 返回该 work 每一步的摘要流(summary≤100字,含 step_id;只是动作轮廓,不含完整输出);\n"+
"② intent_id + q → 只返回命中关键字的步骤摘要(在摘要和完整输出里都搜;仍只给 summary,要看内容用③);\n"+
"③ intent_id + step_ids(最多5个)→ 返回这些步骤的完整内容(detail)。\n"+
- "典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。",
+ "典型流程:先①/②定位可疑步骤的 step_id,再用③取其完整输出。不含思考(thinking)步骤。支持直接关联任务的历史 trace;其结果带 source_task_id/inherited=true 且只读。",
obj(map[string]any{
"intent_id": idp("意图 id(= work 句柄)"),
"q": str("关键字:只返回摘要/完整输出命中它的步骤(可选;与 step_ids 互斥)"),
@@ -1111,6 +1484,13 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
if id <= 0 {
return actool.Errorf("intent_id 必填"), nil
}
+ intentNode, nodeErr := t.ts.GetNodeWithSources(id)
+ if nodeErr != nil {
+ return actool.Errorf(nodeErr.Error()), nil
+ }
+ if intentNode == nil || intentNode.Kind != db.KindIntent {
+ return actool.Errorf("intent_id 不属于本任务或其直接关联任务"), nil
+ }
// ③ detail drill-down by step ids (max 5), thinking excluded by the store.
if len(a.StepIDs) > 0 {
var ids []int64
@@ -1122,31 +1502,47 @@ func (t *ToolSet) getWorkerTrace() actool.CoreTool {
if len(ids) > 5 {
return actool.Errorf("step_ids 一次最多 5 个(先用不带 step_ids 的调用定位,再分批取)"), nil
}
- acts, err := t.ts.ActivityByIDs(ids)
+ acts, err := t.ts.ActivityByIDsWithSources(ids)
if err != nil {
return actool.Errorf(err.Error()), nil
}
steps := make([]map[string]any, 0, len(acts))
for i := range acts {
- steps = append(steps, map[string]any{
+ if acts[i].NodeID == nil || *acts[i].NodeID != id || acts[i].Inherited != intentNode.Inherited ||
+ (acts[i].Inherited && acts[i].SourceTaskID != intentNode.SourceTaskID) {
+ continue
+ }
+ step := map[string]any{
"step_id": acts[i].ID, "kind": acts[i].Kind, "tool": acts[i].Tool,
"is_error": acts[i].IsError, "detail": acts[i].Detail,
- })
+ }
+ if acts[i].Inherited {
+ inheritedMap(step, acts[i].SourceTaskID)
+ }
+ steps = append(steps, step)
}
- return jsonResult(map[string]any{"intent_id": id, "steps": steps})
+ result := map[string]any{"intent_id": id, "steps": steps}
+ if intentNode.Inherited {
+ inheritedMap(result, intentNode.SourceTaskID)
+ }
+ return jsonResult(result)
}
// ①/② summary stream, optionally keyword-filtered; 100-char summaries.
var acts []db.Activity
var err error
if strings.TrimSpace(a.Q) != "" {
- acts, err = t.ts.ActivityTraceSearch(&id, a.Q, a.Limit)
+ acts, err = t.ts.ActivityTraceSearchWithSources(id, a.Q, a.Limit)
} else {
- acts, err = t.ts.ActivityTrace(id, a.Limit)
+ acts, err = t.ts.ActivityTraceWithSources(id, a.Limit)
}
if err != nil {
return actool.Errorf(err.Error()), nil
}
- return jsonResult(map[string]any{"intent_id": id, "steps": traceSteps(acts)})
+ result := map[string]any{"intent_id": id, "steps": traceSteps(acts)}
+ if intentNode.Inherited {
+ inheritedMap(result, intentNode.SourceTaskID)
+ }
+ return jsonResult(result)
})
}
@@ -1172,7 +1568,7 @@ func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
return actool.Errorf("q 必填"), nil
}
// 排除调用者自身这条意图的步骤(worker 的自有 trace 已在其上下文里)。
- acts, err := t.ts.ActivityTraceSearchExcluding(t.ownerNode, a.Q, a.Limit)
+ acts, err := t.ts.ActivityTraceSearchAllWithSources(t.ownerNode, a.Q, a.Limit)
if err != nil {
return actool.Errorf(err.Error()), nil
}
@@ -1182,11 +1578,15 @@ func (t *ToolSet) searchAllWorkerTraces() actool.CoreTool {
if acts[i].NodeID != nil {
intent = *acts[i].NodeID
}
- hits = append(hits, map[string]any{
+ hit := map[string]any{
"intent_id": intent, "step_id": acts[i].ID, "worker": acts[i].Worker,
"kind": acts[i].Kind, "tool": acts[i].Tool, "is_error": acts[i].IsError,
"summary": firstLine(acts[i].Summary, 100),
- })
+ }
+ if acts[i].Inherited {
+ inheritedMap(hit, acts[i].SourceTaskID)
+ }
+ hits = append(hits, hit)
}
return jsonResult(map[string]any{"query": a.Q, "hits": hits})
})
@@ -1217,13 +1617,16 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
if limit <= 0 {
limit = 50
}
- all, err := t.ts.ListByKind(db.KindIntent, 500)
+ all, err := t.ts.ListByKindWithSources(db.KindIntent, 500)
if err != nil {
return actool.Errorf(err.Error()), nil
}
q := strings.ToLower(strings.TrimSpace(a.Q))
out := make([]map[string]any, 0, limit)
for _, n := range all {
+ if n.Inherited && n.State == "running" {
+ continue
+ }
switch n.State {
case "running", "done", "exhausted", "blocked", "stopped": // has run → has a process
default:
@@ -1235,7 +1638,11 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
if q != "" && !strings.Contains(strings.ToLower(summary), q) {
continue
}
- out = append(out, map[string]any{"intent_id": n.ID, "summary": summary, "state": n.State})
+ item := map[string]any{"intent_id": n.ID, "summary": summary, "state": n.State}
+ if n.Inherited {
+ inheritedMap(item, n.SourceTaskID)
+ }
+ out = append(out, item)
if len(out) >= limit {
break
}
diff --git a/agent/tools_insert.go b/agent/tools_insert.go
index c808988..65f7cf2 100644
--- a/agent/tools_insert.go
+++ b/agent/tools_insert.go
@@ -398,13 +398,12 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
)
}
-// listUntestedAssets lets the plan agent pull the CURRENT TASK's in-scope, not-yet-
-// tested assets on demand (filter by type, paginated) — so it can decide what to
-// test next itself, instead of coverage pushing a backlog list into every prompt.
+// listUntestedAssets lets the plan agent pull the current + directly inherited
+// scope's not-yet-tested assets on demand (filter by type, paginated).
func (t *ToolSet) listUntestedAssets() actool.CoreTool {
return readTool(
"list_untested_assets",
- "查询【本任务】范围内、还没被测过的资产(供你自己判断要不要补测,不代替你决策)。\n"+
+ "查询【本任务及直接关联任务】范围内、还没被事实锚点覆盖的资产(关联范围只读,供你自己判断要不要补测,不代替你决策)。\n"+
"可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+
"分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。",
obj(map[string]any{
@@ -432,7 +431,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool {
a.PageSize = 10
}
offset := (a.Page - 1) * a.PageSize
- assets, total, err := t.as.ListUntestedAssets(t.taskID, t.ts.ID(), strings.TrimSpace(a.Type), a.PageSize, offset)
+ assets, total, err := t.as.ListUntestedAssetsWithSources(t.taskID, strings.TrimSpace(a.Type), a.PageSize, offset)
if err != nil {
return actool.Errorf(err.Error()), nil
}
diff --git a/agent/tools_overview_test.go b/agent/tools_overview_test.go
new file mode 100644
index 0000000..8fa6a80
--- /dev/null
+++ b/agent/tools_overview_test.go
@@ -0,0 +1,31 @@
+package agent
+
+import (
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+func TestOverviewTextBudgetIsFairAndUTF8Safe(t *testing.T) {
+ if got := relatedOverviewBudgetForSources(1); got != relatedOverviewMaxTextPerSource {
+ t.Fatalf("single source budget=%d want=%d", got, relatedOverviewMaxTextPerSource)
+ }
+ perSource := relatedOverviewBudgetForSources(db.MaxTaskSourceCount)
+ if perSource*db.MaxTaskSourceCount > relatedOverviewTotalTextRunes {
+ t.Fatalf("aggregate budget exceeded: per_source=%d", perSource)
+ }
+
+ budget := overviewTextBudget{remaining: 5}
+ got := budget.take(strings.Repeat("中", 10), 20)
+ if !utf8.ValidString(got) {
+ t.Fatalf("budget truncation produced invalid UTF-8: %q", got)
+ }
+ if utf8.RuneCountInString(got) != 5 || !budget.truncated || budget.remaining != 0 {
+ t.Fatalf("unexpected truncation: got=%q budget=%+v", got, budget)
+ }
+ if tail := budget.take("more", 20); tail != "" {
+ t.Fatalf("exhausted budget returned more text: %q", tail)
+ }
+}
diff --git a/agent/worker.go b/agent/worker.go
index 71e1eb9..47e25e9 100644
--- a/agent/worker.go
+++ b/agent/worker.go
@@ -49,6 +49,7 @@ type Worker struct {
mem *memory.Store // cross-engagement tradecraft memory (G4)
tx *transcript.Store // raw LLM conversation persistence (nil = off)
window int // context window in tokens (for compaction)
+ windowFn func() int // optional dynamic task-chain minimum
maxTurns int // max agent turns per run (0 = unlimited)
// runTimeout is the wall-clock budget for the main exploration of one intent
// (0 = unlimited). When it fires, the run is cut and a settlement round is
@@ -84,6 +85,15 @@ func NewWorker(prov llm.Provider, model, workDir string, tx *transcript.Store, w
return &Worker{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, extraTools: extra}
}
+func (w *Worker) SetCompactionWindowResolver(fn func() int) { w.windowFn = fn }
+
+func (w *Worker) compactionWindow() int {
+ if w.windowFn != nil {
+ return w.windowFn()
+ }
+ return w.window
+}
+
// SetProxy configures the recording proxy address that workers route target
// traffic through, plus the CA cert path WebFetch trusts to verify HTTPS through
// that MITM proxy. Empty addr disables the hint.
@@ -306,8 +316,8 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
// large tool output spills to cmd-output/ with a head + pointer (SDK tool.Capture);
// full output preserved on disk. 截断上限用 SDK 默认(30000 字符)。
ToolOutputDir: cmdOutDir(runDir),
- Compaction: compactionConfig(w.window), // long tool-heavy runs stay within the window
- Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
+ Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
+ Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
}
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
opts.Hooks = hooks
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..7d046cc
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# ARTEX 单二进制构建脚本:静态导出前端并嵌入 Go 二进制。
+#
+# 可选环境变量:
+# ARTEX_TARGET_OS=linux 目标系统,默认当前系统
+# ARTEX_TARGET_ARCH=amd64 目标架构,默认当前架构
+# ARTEX_BUILD_VERSION=v0.3.0 构建版本,默认取 git describe
+# ARTEX_OUTPUT=/path/to/artex 输出路径
+# ARTEX_SKIP_NPM_CI=1 跳过 npm ci,复用现有依赖
+# ARTEX_GOSUMDB=sum.golang.org Go checksum database
+set -euo pipefail
+
+cd "$(cd "$(dirname "$0")" && pwd)"
+
+info() { printf '\033[36m[*]\033[0m %s\n' "$*"; }
+ok() { printf '\033[32m[+]\033[0m %s\n' "$*"; }
+die() { printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
+
+command -v go >/dev/null 2>&1 || die "未检测到 Go(项目需要 Go 1.26 或更高版本)"
+command -v npm >/dev/null 2>&1 || die "未检测到 npm(前端静态构建需要 Node.js/npm)"
+command -v rsync >/dev/null 2>&1 || die "未检测到 rsync"
+
+ARTEX_GOSUMDB="${ARTEX_GOSUMDB:-sum.golang.org}"
+ARTEX_TARGET_OS="${ARTEX_TARGET_OS:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOOS)}"
+ARTEX_TARGET_ARCH="${ARTEX_TARGET_ARCH:-$(GOSUMDB="$ARTEX_GOSUMDB" go env GOARCH)}"
+
+if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then
+ if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ ARTEX_BUILD_VERSION="$(git describe --tags --always --dirty)"
+ else
+ ARTEX_BUILD_VERSION="dev"
+ fi
+fi
+
+ARTEX_BINARY_NAME="artex"
+[ "$ARTEX_TARGET_OS" = "windows" ] && ARTEX_BINARY_NAME="artex.exe"
+ARTEX_OUTPUT="${ARTEX_OUTPUT:-dist/artex-${ARTEX_TARGET_OS}-${ARTEX_TARGET_ARCH}/${ARTEX_BINARY_NAME}}"
+
+info "构建前端静态资源"
+if [ "${ARTEX_SKIP_NPM_CI:-0}" != "1" ]; then
+ (cd web && npm ci)
+fi
+(cd web && npm run build:static)
+
+info "同步前端资源到 server/webui/dist"
+mkdir -p server/webui/dist
+rsync -a --delete web/out/ server/webui/dist/
+
+info "编译 ${ARTEX_TARGET_OS}/${ARTEX_TARGET_ARCH},版本 ${ARTEX_BUILD_VERSION}"
+mkdir -p "$(dirname "$ARTEX_OUTPUT")"
+GOSUMDB="$ARTEX_GOSUMDB" \
+CGO_ENABLED=0 \
+GOOS="$ARTEX_TARGET_OS" \
+GOARCH="$ARTEX_TARGET_ARCH" \
+go build \
+ -tags embedui \
+ -trimpath \
+ -ldflags "-s -w -X main.version=${ARTEX_BUILD_VERSION}" \
+ -o "$ARTEX_OUTPUT" \
+ ./cmd/artex
+
+ok "编译完成:$ARTEX_OUTPUT"
+if command -v file >/dev/null 2>&1; then
+ file "$ARTEX_OUTPUT"
+fi
diff --git a/cmd/artex/main.go b/cmd/artex/main.go
index b5579dc..cbc5b42 100644
--- a/cmd/artex/main.go
+++ b/cmd/artex/main.go
@@ -73,14 +73,8 @@ func main() {
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
- // re-wrap with a named cause so a run cancelled by shutdown says "后端进程正在
- // 关停" in its trace instead of a bare context.Canceled (see agent/cancelcause.go).
- ctx, shutdown := context.WithCancelCause(sigCtx)
+ ctx, shutdown := shutdownContext(sigCtx)
defer shutdown(agent.AbortShutdown)
- go func() {
- <-sigCtx.Done()
- shutdown(agent.AbortShutdown)
- }()
mgr, err := server.NewManager(*dataDir, *proxy)
if err != nil {
@@ -113,3 +107,18 @@ func main() {
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}
+
+// shutdownContext deliberately does not derive from signalCtx. If it did, the
+// parent's plain context.Canceled could win the race before AbortShutdown was
+// attached to the child, losing the diagnostic cause in every running Agent.
+func shutdownContext(signalCtx context.Context) (context.Context, context.CancelCauseFunc) {
+ ctx, shutdown := context.WithCancelCause(context.Background())
+ go func() {
+ select {
+ case <-signalCtx.Done():
+ shutdown(agent.AbortShutdown)
+ case <-ctx.Done():
+ }
+ }()
+ return ctx, shutdown
+}
diff --git a/cmd/artex/main_test.go b/cmd/artex/main_test.go
new file mode 100644
index 0000000..149c7c6
--- /dev/null
+++ b/cmd/artex/main_test.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Autumn-27/artex/agent"
+)
+
+func TestShutdownContextPreservesNamedCause(t *testing.T) {
+ signalCtx, signalCancel := context.WithCancel(context.Background())
+ ctx, shutdown := shutdownContext(signalCtx)
+ defer shutdown(nil)
+ signalCancel()
+ <-ctx.Done()
+ if code, _, _, ok := agent.AbortReason(ctx); !ok || code != "shutdown" {
+ t.Fatalf("code=%q ok=%v, want shutdown", code, ok)
+ }
+}
diff --git a/db/assets.go b/db/assets.go
index 583cc87..c1bcb47 100644
--- a/db/assets.go
+++ b/db/assets.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/url"
+ "sort"
"strings"
)
@@ -913,7 +914,7 @@ func (s *AssetStore) CountByType(typ string) (int, error) {
}
// QueryByCompany returns assets for a company, optionally filtered by type.
-// limit <= 0 means no limit — pair it with CountByCompany for server-side paging.
+// limit <= 0 means no limit.
func (s *AssetStore) QueryByCompany(companyID int64, typ string, limit, offset int) ([]*Asset, error) {
q := `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
@@ -940,8 +941,6 @@ FROM assets WHERE company_id = $1`
return scanAssets(rows)
}
-// CountByCompany returns the full number of assets attributed to a company
-// (optionally filtered by type), ignoring limit/offset.
func (s *AssetStore) CountByCompany(companyID int64, typ string) (int, error) {
q := `SELECT count(*) FROM assets WHERE company_id = $1`
args := []any{companyID}
@@ -954,8 +953,6 @@ func (s *AssetStore) CountByCompany(companyID int64, typ string) (int, error) {
return n, err
}
-// pageClause appends limit/offset placeholders to args and returns the trailing
-// ORDER BY … LIMIT … OFFSET fragment. limit <= 0 means no limit.
func pageClause(args *[]any, limit, offset int) string {
q := ` ORDER BY last_seen DESC`
if limit > 0 {
@@ -970,7 +967,6 @@ func pageClause(args *[]any, limit, offset int) string {
}
// QueryByTask returns assets rows that have a given task_id in task_ids.
-// limit <= 0 means no limit — pair it with CountByTask for server-side paging.
func (s *AssetStore) QueryByTask(taskID int64, typ string, limit, offset int) ([]*Asset, error) {
q := `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
@@ -997,8 +993,6 @@ FROM assets WHERE $1 = ANY(task_ids)`
return scanAssets(rows)
}
-// CountByTask returns the full number of assets attached to a task (optionally
-// filtered by type), ignoring limit/offset — lets the UI paginate on the server.
func (s *AssetStore) CountByTask(taskID int64, typ string) (int, error) {
q := `SELECT count(*) FROM assets WHERE $1 = ANY(task_ids)`
args := []any{taskID}
@@ -1011,7 +1005,6 @@ func (s *AssetStore) CountByTask(taskID int64, typ string) (int, error) {
return n, err
}
-// CountsByTypeForTask returns asset counts per type for a single task.
func (s *AssetStore) CountsByTypeForTask(taskID int64) (map[string]int, error) {
rows, err := s.db.Query(`SELECT type, COUNT(*) FROM assets WHERE $1 = ANY(task_ids) GROUP BY type`, taskID)
if err != nil {
@@ -1021,6 +1014,151 @@ func (s *AssetStore) CountsByTypeForTask(taskID int64) (map[string]int, error) {
return scanTypeCounts(rows)
}
+// DeleteByTaskID removes assets owned only by taskID and detaches taskID from
+// assets shared with other tasks. Full task deletion uses the coordinated
+// transaction in DeleteTaskCascadePrepared; this method remains for callers
+// that explicitly manage only asset associations.
+func (s *AssetStore) DeleteByTaskID(taskID int64) (int64, error) {
+ res, err := s.db.Exec(`DELETE FROM assets WHERE task_ids = ARRAY[$1]::bigint[]`, taskID)
+ if err != nil {
+ return 0, err
+ }
+ deleted, _ := res.RowsAffected()
+ if _, err := s.db.Exec(`UPDATE assets SET task_ids = array_remove(task_ids, $1) WHERE $1 = ANY(task_ids)`, taskID); err != nil {
+ return deleted, err
+ }
+ return deleted, nil
+}
+
+// HostsByTask returns the exact HTTP host candidates attached to a task's
+// assets. Domain/IP columns cover root domains, subdomains and non-HTTP
+// services; URL covers HTTP services and endpoints.
+func (s *AssetStore) HostsByTask(taskID int64) ([]string, error) {
+ rows, err := s.db.Query(`
+SELECT COALESCE(domain,''), COALESCE(ip,''), COALESCE(url,'')
+FROM assets WHERE $1 = ANY(task_ids)`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ hosts := make(map[string]struct{})
+ add := func(host string) {
+ host = strings.TrimSpace(strings.ToLower(host))
+ if host != "" {
+ hosts[host] = struct{}{}
+ }
+ }
+ for rows.Next() {
+ var domain, ip, rawURL string
+ if err := rows.Scan(&domain, &ip, &rawURL); err != nil {
+ return nil, err
+ }
+ add(domain)
+ add(ip)
+ if u, err := url.Parse(rawURL); err == nil {
+ add(u.Hostname())
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(hosts))
+ for host := range hosts {
+ out = append(out, host)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// HostsForTaskDeletion returns hosts that belong to the task being deleted and
+// are not referenced by any other live task. Current-task candidates include
+// both task_ids ownership and exploration anchors so legacy seeded assets (which
+// were anchor-only) are covered. Protection is host-wide: if another live task
+// references any asset for a candidate host, that host's global traffic remains.
+func (s *AssetStore) HostsForTaskDeletion(taskID, explorationID int64) ([]string, error) {
+ return hostsForTaskDeletion(s.db, taskID, explorationID)
+}
+
+type rowsQuerier interface {
+ Query(query string, args ...any) (*sql.Rows, error)
+}
+
+// hostsForTaskDeletion is shared by the read-only AssetStore API and the task
+// deletion transaction. Coordinated traffic deletion must call it through the
+// transaction path so asset/anchor writes stay locked until the task delete is
+// committed.
+func hostsForTaskDeletion(q rowsQuerier, taskID, explorationID int64) ([]string, error) {
+ rows, err := q.Query(`
+WITH current_assets AS (
+ SELECT id FROM assets WHERE $1 = ANY(task_ids)
+ UNION
+ SELECT ea.asset_id
+ FROM exploration_anchors ea
+ JOIN exploration_nodes n ON n.id=ea.node_id
+ WHERE n.exploration_id=$2
+),
+other_assets AS (
+ SELECT DISTINCT a.id
+ FROM assets a
+ WHERE EXISTS (
+ SELECT 1 FROM tasks t
+ WHERE t.id<>$1 AND t.deleted_at IS NULL AND t.id=ANY(a.task_ids)
+ ) OR EXISTS (
+ SELECT 1
+ FROM exploration_anchors ea
+ JOIN exploration_nodes n ON n.id=ea.node_id
+ JOIN tasks t ON t.exploration_id=n.exploration_id
+ WHERE ea.asset_id=a.id AND t.id<>$1 AND t.deleted_at IS NULL
+ )
+)
+SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,''), true
+FROM assets a JOIN current_assets c ON c.id=a.id
+UNION ALL
+SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,''), false
+FROM assets a JOIN other_assets o ON o.id=a.id`, taskID, explorationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ candidates := make(map[string]struct{})
+ protected := make(map[string]struct{})
+ add := func(dst map[string]struct{}, raw string) {
+ raw = strings.TrimSpace(strings.ToLower(raw))
+ if raw != "" {
+ dst[raw] = struct{}{}
+ }
+ }
+ for rows.Next() {
+ var domain, ip, rawURL string
+ var candidate bool
+ if err := rows.Scan(&domain, &ip, &rawURL, &candidate); err != nil {
+ return nil, err
+ }
+ dst := protected
+ if candidate {
+ dst = candidates
+ }
+ add(dst, domain)
+ add(dst, ip)
+ if u, err := url.Parse(rawURL); err == nil {
+ add(dst, u.Hostname())
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(candidates))
+ for host := range candidates {
+ if _, shared := protected[host]; !shared {
+ out = append(out, host)
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
const assetSelectCols = `SELECT id, type, company_id, array_to_json(task_ids)::text,
COALESCE(domain,''), COALESCE(root_domain,''), COALESCE(ip,''),
COALESCE(c_segment::text,''), port,
@@ -1053,45 +1191,6 @@ func (s *AssetStore) GetByIDs(ids []int64) ([]*Asset, error) {
return scanAssets(rows)
}
-// DeleteByTaskID removes a task's assets: hard-deletes assets owned solely by this
-// task (task_ids = {taskID}) and de-associates the rest (drops this task from their
-// task_ids, keeping assets shared with other tasks intact). Returns rows deleted.
-func (s *AssetStore) DeleteByTaskID(taskID int64) (int64, error) {
- res, err := s.db.Exec(`DELETE FROM assets WHERE task_ids = ARRAY[$1]::bigint[]`, taskID)
- if err != nil {
- return 0, err
- }
- n, _ := res.RowsAffected()
- if _, err := s.db.Exec(`UPDATE assets SET task_ids = array_remove(task_ids, $1) WHERE $1 = ANY(task_ids)`, taskID); err != nil {
- return n, err
- }
- return n, nil
-}
-
-// HostsByTask returns the distinct hostnames/IPs the task's assets carry (domain,
-// root_domain, ip) — used to purge host-keyed traffic recorded for that task.
-func (s *AssetStore) HostsByTask(taskID int64) ([]string, error) {
- rows, err := s.db.Query(`
-SELECT DISTINCT h FROM (
- SELECT NULLIF(domain,'') AS h FROM assets WHERE $1 = ANY(task_ids)
- UNION SELECT NULLIF(root_domain,'') FROM assets WHERE $1 = ANY(task_ids)
- UNION SELECT NULLIF(ip,'') FROM assets WHERE $1 = ANY(task_ids)
-) q WHERE h IS NOT NULL AND h <> ''`, taskID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var out []string
- for rows.Next() {
- var h string
- if err := rows.Scan(&h); err != nil {
- return nil, err
- }
- out = append(out, h)
- }
- return out, rows.Err()
-}
-
// DeleteByCompanyID hard-deletes all assets belonging to a company. Returns rows deleted.
func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) {
res, err := s.db.Exec(`DELETE FROM assets WHERE company_id = $1`, companyID)
@@ -1161,7 +1260,6 @@ func (s *AssetStore) CountsByType() (map[string]int, error) {
return scanTypeCounts(rows)
}
-// scanTypeCounts scans a (type, count) result set into a map.
func scanTypeCounts(rows *sql.Rows) (map[string]int, error) {
out := map[string]int{}
for rows.Next() {
diff --git a/db/assets_test.go b/db/assets_test.go
index 367f7db..6e92030 100644
--- a/db/assets_test.go
+++ b/db/assets_test.go
@@ -290,12 +290,12 @@ func TestUpsertHTTPService(t *testing.T) {
sc := 200
cl := int64(1024)
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{
- URL: "https://www.httptest.example.com/",
- Technologies: []string{"nginx", "vue"},
- StatusCode: &sc,
+ URL: "https://www.httptest.example.com/",
+ Technologies: []string{"nginx", "vue"},
+ StatusCode: &sc,
ContentLength: &cl,
- PageTitle: "Test Site",
- TaskID: 1,
+ PageTitle: "Test Site",
+ TaskID: 1,
})
if err != nil {
t.Fatal(err)
diff --git a/db/config.go b/db/config.go
index 67d9edc..8d91487 100644
--- a/db/config.go
+++ b/db/config.go
@@ -1,8 +1,11 @@
package db
import (
+ "context"
"database/sql"
"encoding/json"
+ "errors"
+ "fmt"
"time"
)
@@ -133,9 +136,236 @@ VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$
return p.ID, err
}
+var (
+ ErrActiveLLMProfileDelete = errors.New("cannot delete the active LLM profile; activate another profile first")
+ ErrLLMProfileReferencesChanged = errors.New("LLM profile references changed while deleting; retry the request")
+ ErrLLMProfileNotFound = errors.New("LLM profile not found")
+)
+
func (d *DB) DeleteProfile(id int64) error {
- _, err := d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id)
- return err
+ return d.DeleteProfileContext(context.Background(), id)
+}
+
+// DeleteProfileContext removes a non-default profile while preserving the task
+// failover cursor. Reference changes are retried because a task, agent, or
+// conversation may start pointing at the profile between the initial scan and
+// the profile row lock. A bound prevents a continuously changing workload from
+// keeping an HTTP request alive forever.
+func (d *DB) DeleteProfileContext(ctx context.Context, id int64) error {
+ const (
+ maxAttempts = 8
+ maxDuration = 15 * time.Second
+ )
+ ctx, cancel := context.WithTimeout(ctx, maxDuration)
+ defer cancel()
+ for attempt := 0; attempt < maxAttempts; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ retry, err := d.deleteProfile(ctx, id)
+ if err != nil || !retry {
+ return err
+ }
+ }
+ return fmt.Errorf("%w: profile %d", ErrLLMProfileReferencesChanged, id)
+}
+
+func (d *DB) deleteProfile(ctx context.Context, id int64) (bool, error) {
+ tx, err := d.BeginTx(ctx, nil)
+ if err != nil {
+ return false, err
+ }
+ defer tx.Rollback()
+
+ // Reference setters lock their task/agent/conversation row before an
+ // llm_profiles row can be locked by a foreign-key check. Keep deletion in the
+ // same order: the old profile -> child order deadlocked with a setter's child
+ // -> profile order. Sorting also gives concurrent profile deletions a stable
+ // order when their chains overlap multiple tasks.
+ lockedTasks, err := lockProfileReferenceRows(ctx, tx, `SELECT t.id
+FROM tasks t
+WHERE t.llm_profile_id=$1
+ OR t.active_llm_profile_id=$1
+ OR EXISTS (
+ SELECT 1 FROM task_llm_profiles x
+ WHERE x.task_id=t.id AND x.profile_id=$1
+ )
+ORDER BY t.id
+FOR UPDATE OF t`, id)
+ if err != nil {
+ return false, err
+ }
+ lockedAgents, err := lockProfileReferenceRows(ctx, tx, `SELECT id FROM agents
+WHERE llm_profile_id=$1
+ORDER BY id
+FOR UPDATE`, id)
+ if err != nil {
+ return false, err
+ }
+ lockedConversations, err := lockProfileReferenceRows(ctx, tx, `SELECT id FROM conversations
+WHERE llm_profile_id=$1
+ORDER BY id
+FOR UPDATE`, id)
+ if err != nil {
+ return false, err
+ }
+
+ var isDefault bool
+ if err := tx.QueryRowContext(ctx, `SELECT is_default FROM llm_profiles WHERE id=$1 FOR UPDATE`, id).Scan(&isDefault); err != nil {
+ if err == sql.ErrNoRows {
+ return false, ErrLLMProfileNotFound
+ }
+ return false, err
+ }
+ if isDefault {
+ return false, ErrActiveLLMProfileDelete
+ }
+
+ type affectedTask struct {
+ id int64
+ position int
+ wasActive bool
+ }
+ // A task may have committed a new reference after the first statement took
+ // its snapshot but before this transaction acquired the profile lock. The
+ // profile lock now prevents further references; retry if that committed task
+ // was not part of the task-first lock set. Never acquire a new task lock while
+ // holding the profile lock, because that would recreate the inversion.
+ rows, err := tx.QueryContext(ctx, `SELECT ref_kind, ref_id FROM (
+ SELECT 'task'::text AS ref_kind, t.id AS ref_id
+FROM tasks t
+WHERE t.llm_profile_id=$1
+ OR t.active_llm_profile_id=$1
+ OR EXISTS (
+ SELECT 1 FROM task_llm_profiles x
+ WHERE x.task_id=t.id AND x.profile_id=$1
+ )
+ UNION ALL
+ SELECT 'agent', a.id FROM agents a WHERE a.llm_profile_id=$1
+ UNION ALL
+ SELECT 'conversation', c.id FROM conversations c WHERE c.llm_profile_id=$1
+) refs
+ORDER BY ref_kind, ref_id`, id)
+ if err != nil {
+ return false, err
+ }
+ for rows.Next() {
+ var (
+ kind string
+ rowID int64
+ )
+ if err := rows.Scan(&kind, &rowID); err != nil {
+ rows.Close()
+ return false, err
+ }
+ locked := false
+ switch kind {
+ case "task":
+ _, locked = lockedTasks[rowID]
+ case "agent":
+ _, locked = lockedAgents[rowID]
+ case "conversation":
+ _, locked = lockedConversations[rowID]
+ }
+ if !locked {
+ rows.Close()
+ return true, nil
+ }
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return false, err
+ }
+ if err := rows.Close(); err != nil {
+ return false, err
+ }
+
+ rows, err = tx.QueryContext(ctx, `SELECT x.task_id, x.position, COALESCE(t.active_llm_profile_id=$1, false)
+FROM task_llm_profiles x
+JOIN tasks t ON t.id=x.task_id
+WHERE x.profile_id=$1
+ORDER BY x.task_id`, id)
+ if err != nil {
+ return false, err
+ }
+ var affected []affectedTask
+ for rows.Next() {
+ var task affectedTask
+ if err := rows.Scan(&task.id, &task.position, &task.wasActive); err != nil {
+ rows.Close()
+ return false, err
+ }
+ affected = append(affected, task)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return false, err
+ }
+ if err := rows.Close(); err != nil {
+ return false, err
+ }
+ if _, err := tx.ExecContext(ctx, `DELETE FROM llm_profiles WHERE id=$1`, id); err != nil {
+ return false, err
+ }
+ // Only a successor after the deleted cursor is eligible. If there is none,
+ // clear the explicit chain so the task falls back to its Agent/global provider;
+ // profiles before a manually selected cursor must never be revived.
+ for _, task := range affected {
+ if !task.wasActive {
+ if _, err := tx.ExecContext(ctx, `UPDATE tasks SET llm_chain_revision=llm_chain_revision+1 WHERE id=$1`, task.id); err != nil {
+ return false, err
+ }
+ continue
+ }
+ var next int64
+ err := tx.QueryRowContext(ctx, `SELECT profile_id FROM task_llm_profiles
+WHERE task_id=$1 AND position>$2 AND status='ready'
+ORDER BY position
+LIMIT 1`, task.id, task.position).Scan(&next)
+ if err != nil && err != sql.ErrNoRows {
+ return false, err
+ }
+ if err == sql.ErrNoRows {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM task_llm_profiles WHERE task_id=$1`, task.id); err != nil {
+ return false, err
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE tasks
+SET active_llm_profile_id=NULL, llm_profile_id=NULL, llm_chain_revision=llm_chain_revision+1
+WHERE id=$1`, task.id); err != nil {
+ return false, err
+ }
+ continue
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE tasks
+SET active_llm_profile_id=$2, llm_profile_id=$2, llm_chain_revision=llm_chain_revision+1
+WHERE id=$1`, task.id, next); err != nil {
+ return false, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func lockProfileReferenceRows(ctx context.Context, tx *sql.Tx, query string, profileID int64) (map[int64]struct{}, error) {
+ rows, err := tx.QueryContext(ctx, query, profileID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ locked := make(map[int64]struct{})
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ locked[id] = struct{}{}
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return locked, nil
}
// SetActiveProfile makes one profile the global default (single-default invariant).
@@ -148,9 +378,13 @@ func (d *DB) SetActiveProfile(id int64) error {
if _, err := tx.Exec(`UPDATE llm_profiles SET is_default=false WHERE is_default`); err != nil {
return err
}
- if _, err := tx.Exec(`UPDATE llm_profiles SET is_default=true WHERE id=$1`, id); err != nil {
+ res, err := tx.Exec(`UPDATE llm_profiles SET is_default=true WHERE id=$1`, id)
+ if err != nil {
return err
}
+ if n, _ := res.RowsAffected(); n != 1 {
+ return ErrLLMProfileNotFound
+ }
return tx.Commit()
}
@@ -313,8 +547,45 @@ func (d *DB) SetAgentMaxTurns(key string, maxTurns int) error {
// the binding (id == nil) so the agent follows the task/conversation pin, else the
// global active profile. Precedence at runtime: agent binding → task/conv pin → active.
func (d *DB) SetAgentLLMProfile(key string, id *int64) error {
- _, err := d.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE key=$2`, id, key)
- return err
+ tx, err := d.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // DeleteProfile locks reference rows before the profile row. Keep the same
+ // order here so a concurrent rebind cannot form a child/profile deadlock.
+ var agentID int64
+ if err := tx.QueryRow(`SELECT id FROM agents WHERE key=$1 FOR UPDATE`, key).Scan(&agentID); err != nil {
+ if err == sql.ErrNoRows {
+ // Preserve the previous UPDATE semantics: an unknown key is a no-op.
+ return tx.Commit()
+ }
+ return err
+ }
+ if err := lockLLMProfileForReference(tx, id); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE id=$2`, id, agentID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// lockLLMProfileForReference makes the profile side of the shared lock-order
+// protocol explicit. Callers must already own the referencing child row.
+func lockLLMProfileForReference(tx *sql.Tx, profileID *int64) error {
+ if profileID == nil {
+ return nil
+ }
+ var lockedID int64
+ if err := tx.QueryRow(`SELECT id FROM llm_profiles WHERE id=$1 FOR KEY SHARE`, *profileID).Scan(&lockedID); err != nil {
+ if err == sql.ErrNoRows {
+ return ErrLLMProfileNotFound
+ }
+ return err
+ }
+ return nil
}
// SetAgentWebSearch toggles whether an agent uses network search (still gated by
diff --git a/db/config_test.go b/db/config_test.go
index 6f1743e..16f5d8c 100644
--- a/db/config_test.go
+++ b/db/config_test.go
@@ -1,7 +1,9 @@
package db
import (
+ "context"
"encoding/json"
+ "errors"
"testing"
)
@@ -53,6 +55,15 @@ func TestPoolProfilesOrder(t *testing.T) {
}
}
+func TestDeleteProfileContextHonorsCancellation(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ var d DB
+ if err := d.DeleteProfileContext(ctx, 1); !errors.Is(err, context.Canceled) {
+ t.Fatalf("DeleteProfileContext error=%v, want context cancellation", err)
+ }
+}
+
func TestConfigStores(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
@@ -69,6 +80,9 @@ func TestConfigStores(t *testing.T) {
if err := d.SetActiveProfile(pid); err != nil {
t.Fatal(err)
}
+ if err := d.DeleteProfile(pid); !errors.Is(err, ErrActiveLLMProfileDelete) {
+ t.Fatalf("deleting active profile error=%v, want %v", err, ErrActiveLLMProfileDelete)
+ }
act, err := d.ActiveProfile()
if err != nil || act == nil || act.APIKey != "secret123" {
t.Fatalf("active profile/key: %+v err=%v", act, err)
diff --git a/db/conversation.go b/db/conversation.go
index 9e7e97a..2b2ad41 100644
--- a/db/conversation.go
+++ b/db/conversation.go
@@ -69,19 +69,60 @@ func scanConv(row interface{ Scan(...any) error }) (Conversation, error) {
// CreateConversation opens a new chat thread for agentKey with an initial title.
// llmProfileID may be nil to use the globally active profile.
func (d *DB) CreateConversation(agentKey, title string, llmProfileID *int64) (*Conversation, error) {
- c, err := scanConv(d.QueryRow(`
-INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, $3)
-RETURNING `+convCols, agentKey, title, llmProfileID))
+ tx, err := d.Begin()
if err != nil {
return nil, err
}
+ defer tx.Rollback()
+
+ // Insert the child without a profile first. The new row is exclusively owned
+ // by this transaction before it takes a profile lock, matching DeleteProfile's
+ // child-row -> profile-row protocol.
+ c, err := scanConv(tx.QueryRow(`
+INSERT INTO conversations(agent_key, title, llm_profile_id) VALUES ($1, $2, NULL)
+RETURNING `+convCols, agentKey, title))
+ if err != nil {
+ return nil, err
+ }
+ if err := lockLLMProfileForReference(tx, llmProfileID); err != nil {
+ return nil, err
+ }
+ if llmProfileID != nil {
+ c, err = scanConv(tx.QueryRow(`UPDATE conversations SET llm_profile_id=$2
+WHERE id=$1 RETURNING `+convCols, c.ID, llmProfileID))
+ if err != nil {
+ return nil, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
return &c, nil
}
// UpdateConversationProfile sets (or clears) the LLM profile override for a conversation.
func (d *DB) UpdateConversationProfile(id int64, llmProfileID *int64) error {
- _, err := d.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID)
- return err
+ tx, err := d.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var lockedID int64
+ if err := tx.QueryRow(`SELECT id FROM conversations WHERE id=$1 FOR UPDATE`, id).Scan(&lockedID); err != nil {
+ if err == sql.ErrNoRows {
+ // Preserve the previous UPDATE semantics: an unknown id is a no-op.
+ return tx.Commit()
+ }
+ return err
+ }
+ if err := lockLLMProfileForReference(tx, llmProfileID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE conversations SET llm_profile_id=$2 WHERE id=$1`, id, llmProfileID); err != nil {
+ return err
+ }
+ return tx.Commit()
}
// ListConversations returns all threads, most-recently-updated first.
diff --git a/db/exploration.go b/db/exploration.go
index 94f6086..be01753 100644
--- a/db/exploration.go
+++ b/db/exploration.go
@@ -25,34 +25,40 @@ func utf8Clean(s string) string {
// Node is a typed reasoning node (= old task_nodes). kind ∈ goal|intent|finding|hint.
type Node struct {
- ID int64 `json:"id"`
- Kind string `json:"kind"`
- Payload json.RawMessage `json:"payload"`
- Priority int `json:"priority"`
- State string `json:"state"`
- Origin string `json:"origin,omitempty"`
- Owner string `json:"owner,omitempty"`
- Anchors []int64 `json:"anchors,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ Payload json.RawMessage `json:"payload"`
+ Priority int `json:"priority"`
+ State string `json:"state"`
+ Origin string `json:"origin,omitempty"`
+ Owner string `json:"owner,omitempty"`
+ BlockedReason string `json:"blocked_reason,omitempty"`
+ Anchors []int64 `json:"anchors,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ SourceTaskID int64 `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
}
// Activity is one worker execution step (= old task_activity).
type Activity struct {
- ID int64 `json:"id"`
- NodeID *int64 `json:"node_id,omitempty"`
- Worker string `json:"worker,omitempty"`
- Kind string `json:"kind,omitempty"`
- Tool string `json:"tool,omitempty"`
- ToolUseID string `json:"tool_use_id,omitempty"`
- IsError bool `json:"is_error"`
- Summary string `json:"summary,omitempty"`
- Detail string `json:"-"` // full blob; lazy-loaded, omitted from list payloads
- CreatedAt time.Time `json:"created_at"`
+ ID int64 `json:"id"`
+ NodeID *int64 `json:"node_id,omitempty"`
+ Worker string `json:"worker,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ Tool string `json:"tool,omitempty"`
+ ToolUseID string `json:"tool_use_id,omitempty"`
+ IsError bool `json:"is_error"`
+ Summary string `json:"summary,omitempty"`
+ Detail string `json:"-"` // full blob; lazy-loaded, omitted from list payloads
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
// Token usage — set only on the terminal kind='result' record; nil elsewhere.
- InputTokens *int `json:"input_tokens,omitempty"`
- OutputTokens *int `json:"output_tokens,omitempty"`
- CacheReadTokens *int `json:"cache_read_tokens,omitempty"`
- CacheWriteTokens *int `json:"cache_write_tokens,omitempty"`
+ InputTokens *int `json:"input_tokens,omitempty"`
+ OutputTokens *int `json:"output_tokens,omitempty"`
+ CacheReadTokens *int `json:"cache_read_tokens,omitempty"`
+ CacheWriteTokens *int `json:"cache_write_tokens,omitempty"`
+ SourceTaskID int64 `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
}
// TokenUsage is a per-worker token aggregate (TokenStatsByWorker).
@@ -201,17 +207,17 @@ ON CONFLICT (exploration_id, src_id, rel, dst_id) DO NOTHING`, s.expID, from, re
// SetNodeState updates any node's state (never deletes).
func (s *ExplorationStore) SetNodeState(id int64, state string) error {
- _, err := s.db.Exec(`UPDATE exploration_nodes SET state=$1 WHERE id=$2 AND exploration_id=$3`, state, id, s.expID)
+ _, err := s.db.Exec(`UPDATE exploration_nodes SET state=$1, blocked_reason=NULL WHERE id=$2 AND exploration_id=$3`, state, id, s.expID)
return err
}
-// SetIntentState updates an intent node's state (done/blocked/exhausted/open).
+// SetIntentState updates an intent node's state.
// ResetRunningIntents returns any intent left in 'running' back to 'open' so it
// is re-claimed. Called on startup: a 'running' intent with no live worker (a
// backend restart or crashed worker goroutine left it stuck) would otherwise spin
// forever in the UI. Workers re-run an intent from scratch, so reopening is safe.
func (s *ExplorationStore) ResetRunningIntents() (int64, error) {
- res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL
+ res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL
WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID)
if err != nil {
return 0, err
@@ -226,7 +232,7 @@ WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID)
// changed (false = intent absent or not in a rerunnable state). Used by the "重跑" button.
func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) {
res, err := s.db.Exec(`UPDATE exploration_nodes
-SET state='open', completed_at=NULL
+SET state='open', completed_at=NULL, blocked_reason=NULL
WHERE id=$1 AND exploration_id=$2 AND kind='intent'
AND state IN ('blocked','exhausted','stopped')`, id, s.expID)
if err != nil {
@@ -240,7 +246,7 @@ WHERE id=$1 AND exploration_id=$2 AND kind='intent'
// (batch rerun after e.g. an LLM/network outage that blocked many at once). Returns the
// number reopened. Workers re-run each from scratch; kept graph writes remain.
func (s *ExplorationStore) ReopenBlockedIntents() (int64, error) {
- res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL
+ res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL
WHERE exploration_id=$1 AND kind='intent' AND state='blocked'`, s.expID)
if err != nil {
return 0, err
@@ -253,17 +259,92 @@ func (s *ExplorationStore) SetIntentState(id int64, state string) error {
// terminal states stamp completed_at; reopening (back to open/running) clears it.
terminal := state == "done" || state == "blocked" || state == "exhausted" || state == "stopped"
_, err := s.db.Exec(`UPDATE exploration_nodes
-SET state=$1, completed_at = CASE WHEN $4 THEN now() ELSE NULL END
+SET state=$1, blocked_reason=NULL, completed_at = CASE WHEN $4 THEN now() ELSE NULL END
WHERE id=$2 AND exploration_id=$3 AND kind='intent'`, state, id, s.expID, terminal)
return err
}
-const nodeCols = `id, kind, payload, priority, state, COALESCE(origin,''), COALESCE(owner,''), created_at`
+// IntentCleanup summarizes the blackboard records removed when a user cancels
+// one worker intent. Global assets and traffic are deliberately not part of this
+// operation; exploration anchors disappear only because their owning nodes do.
+type IntentCleanup struct {
+ Intents int64 `json:"intents"`
+ Facts int64 `json:"facts"`
+ Findings int64 `json:"findings"`
+ Activities int64 `json:"activities"`
+}
+
+// CancelIntent removes one local intent and the fact/finding nodes it directly
+// yielded. The standalone finding row must be deleted before its node (whose FK
+// otherwise uses ON DELETE SET NULL), so the entire cleanup is kept in one DB
+// transaction. The caller must first stop a running worker to prevent late writes.
+func (s *ExplorationStore) CancelIntent(id int64) (IntentCleanup, error) {
+ var out IntentCleanup
+ tx, err := s.db.Begin()
+ if err != nil {
+ return out, err
+ }
+ defer tx.Rollback()
+
+ var state string
+ if err := tx.QueryRow(`SELECT state FROM exploration_nodes
+ WHERE id=$1 AND exploration_id=$2 AND kind='intent' FOR UPDATE`, id, s.expID).Scan(&state); err != nil {
+ if err == sql.ErrNoRows {
+ return out, fmt.Errorf("intent not found")
+ }
+ return out, err
+ }
+ if state != "running" && state != "paused" {
+ return out, fmt.Errorf("intent state %s cannot be cancelled", state)
+ }
+
+ if err := tx.QueryRow(`SELECT
+ COUNT(*) FILTER (WHERE n.kind='fact'),
+ COUNT(*) FILTER (WHERE n.kind='finding')
+ FROM exploration_edges e
+ JOIN exploration_nodes n ON n.id=e.dst_id AND n.exploration_id=e.exploration_id
+ WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3
+ AND n.kind IN ('fact','finding')`, s.expID, id, RelYields).Scan(&out.Facts, &out.Findings); err != nil {
+ return out, err
+ }
+
+ if _, err := tx.Exec(`DELETE FROM findings f USING exploration_edges e, exploration_nodes n
+ WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3
+ AND n.id=e.dst_id AND n.exploration_id=e.exploration_id
+ AND n.kind='finding' AND f.node_id=n.id`, s.expID, id, RelYields); err != nil {
+ return out, err
+ }
+ res, err := tx.Exec(`DELETE FROM activity WHERE exploration_id=$1 AND node_id=$2`, s.expID, id)
+ if err != nil {
+ return out, err
+ }
+ out.Activities, _ = res.RowsAffected()
+
+ res, err = tx.Exec(`DELETE FROM exploration_nodes
+ WHERE exploration_id=$1 AND (
+ id=$2 OR id IN (
+ SELECT e.dst_id FROM exploration_edges e
+ JOIN exploration_nodes n ON n.id=e.dst_id AND n.exploration_id=e.exploration_id
+ WHERE e.exploration_id=$1 AND e.src_id=$2 AND e.rel=$3
+ AND n.kind IN ('fact','finding')
+ )
+ )`, s.expID, id, RelYields)
+ if err != nil {
+ return out, err
+ }
+ if removed, _ := res.RowsAffected(); removed != 1+out.Facts+out.Findings {
+ return out, fmt.Errorf("intent cleanup changed concurrently")
+ }
+ out.Intents = 1
+ return out, tx.Commit()
+}
+
+const nodeCols = `id, kind, payload, priority, state, COALESCE(origin,''), COALESCE(owner,''), COALESCE(blocked_reason,''), created_at`
func scanNode(sc interface{ Scan(...any) error }) (*Node, error) {
var n Node
var payload []byte
- if err := sc.Scan(&n.ID, &n.Kind, &payload, &n.Priority, &n.State, &n.Origin, &n.Owner, &n.CreatedAt); err != nil {
+ if err := sc.Scan(&n.ID, &n.Kind, &payload, &n.Priority, &n.State, &n.Origin, &n.Owner, &n.BlockedReason, &n.CreatedAt); err != nil {
return nil, err
}
n.Payload = json.RawMessage(payload)
@@ -455,6 +536,31 @@ WHERE e.exploration_id=$1 AND e.rel=$2 AND n.kind='finding'`, s.expID, RelYields
return out, rows.Err()
}
+// FindingIntentsTerminal is the inherited-history variant: it returns finding
+// lineage only when the producing intent has reached an immutable terminal
+// state. This keeps a source task's live worker topology private.
+func (s *ExplorationStore) FindingIntentsTerminal() (map[int64]int64, error) {
+ rows, err := s.db.Query(`SELECT e.dst_id, e.src_id
+ FROM exploration_edges e
+ JOIN exploration_nodes finding ON finding.id=e.dst_id AND finding.exploration_id=e.exploration_id
+ JOIN exploration_nodes intent ON intent.id=e.src_id AND intent.exploration_id=e.exploration_id
+ WHERE e.exploration_id=$1 AND e.rel=$2 AND finding.kind='finding'
+ AND intent.kind='intent' AND intent.state IN ('done','blocked','exhausted','stopped')`, s.expID, RelYields)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[int64]int64{}
+ for rows.Next() {
+ var findingID, intentID int64
+ if err := rows.Scan(&findingID, &intentID); err != nil {
+ return nil, err
+ }
+ out[findingID] = intentID
+ }
+ return out, rows.Err()
+}
+
// Frontier returns open intents ordered by priority desc then id (FIFO tiebreak).
func (s *ExplorationStore) Frontier(limit int) ([]*Node, error) {
if limit <= 0 {
@@ -516,12 +622,16 @@ func (s *ExplorationStore) Stats() (map[string]int, error) {
// AppendActivity records one worker step and returns its id.
func (s *ExplorationStore) AppendActivity(a Activity) (int64, error) {
+ metadata := a.Metadata
+ if len(metadata) == 0 {
+ metadata = json.RawMessage(`{}`)
+ }
var id int64
err := s.db.QueryRow(`
-INSERT INTO activity(exploration_id, node_id, worker, kind, tool, tool_use_id, is_error, summary, detail, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
-VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),$7,NULLIF($8,''),NULLIF($9,''),$10,$11,$12,$13)
+INSERT INTO activity(exploration_id, node_id, worker, kind, tool, tool_use_id, is_error, summary, detail, metadata, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
+VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),$7,NULLIF($8,''),NULLIF($9,''),$10,$11,$12,$13,$14)
RETURNING id`, s.expID, a.NodeID, utf8Clean(a.Worker), utf8Clean(a.Kind), utf8Clean(a.Tool), utf8Clean(a.ToolUseID), a.IsError, utf8Clean(a.Summary), utf8Clean(a.Detail),
- a.InputTokens, a.OutputTokens, a.CacheReadTokens, a.CacheWriteTokens).Scan(&id)
+ metadata, a.InputTokens, a.OutputTokens, a.CacheReadTokens, a.CacheWriteTokens).Scan(&id)
return id, err
}
@@ -659,7 +769,7 @@ func (s *ExplorationStore) ActivityList(nodeID *int64, sinceID int64, limit int)
}
var rows *sql.Rows
var err error
- const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
+ const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), metadata, created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
if nodeID != nil {
rows, err = s.db.Query(`SELECT `+cols+`
FROM activity WHERE exploration_id=$1 AND node_id=$2 AND id>$3 ORDER BY id LIMIT $4`, s.expID, *nodeID, sinceID, limit)
@@ -673,6 +783,43 @@ FROM activity WHERE exploration_id=$1 AND id>$2 ORDER BY id LIMIT $3`, s.expID,
defer rows.Close()
out := []Activity{}
cursor := sinceID
+ for rows.Next() {
+ var a Activity
+ if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.Metadata, &a.CreatedAt,
+ &a.InputTokens, &a.OutputTokens, &a.CacheReadTokens, &a.CacheWriteTokens); err != nil {
+ return nil, sinceID, err
+ }
+ if a.ID > cursor {
+ cursor = a.ID
+ }
+ out = append(out, a)
+ }
+ return out, cursor, rows.Err()
+}
+
+// ActivityListForTerminalIntent is the inherited, incremental-read boundary.
+// The intent state check and activity read happen in one statement so a source
+// intent reopened between API-level checks cannot expose its new in-flight trace.
+// Model reasoning and accounting rows are never inherited.
+func (s *ExplorationStore) ActivityListForTerminalIntent(nodeID, sinceID int64, limit int) ([]Activity, int64, error) {
+ if limit <= 0 {
+ limit = 300
+ }
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.worker,''), COALESCE(a.kind,''),
+ COALESCE(a.tool,''), COALESCE(a.tool_use_id,''), a.is_error, COALESCE(a.summary,''),
+ a.created_at, a.input_tokens, a.output_tokens, a.cache_read_tokens, a.cache_write_tokens
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND a.node_id=$2 AND a.id>$3
+ AND n.kind='intent' AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.kind NOT IN ('thinking','usage')
+ ORDER BY a.id LIMIT $4`, s.expID, nodeID, sinceID, limit)
+ if err != nil {
+ return nil, sinceID, err
+ }
+ defer rows.Close()
+ out := []Activity{}
+ cursor := sinceID
for rows.Next() {
var a Activity
if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt,
@@ -719,7 +866,7 @@ func (s *ExplorationStore) ActivityPage(f ActivitySessionFilter, before int64, l
if limit <= 0 {
limit = 200
}
- const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
+ const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), metadata, created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
args := []any{s.expID}
cond, cargs := f.cond(len(args) + 1)
args = append(args, cargs...)
@@ -736,6 +883,51 @@ ORDER BY id DESC LIMIT $%d`, cond, beforeArg, beforeArg, limitArg)
}
defer rows.Close()
desc := []Activity{}
+ for rows.Next() {
+ var a Activity
+ if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.Metadata, &a.CreatedAt,
+ &a.InputTokens, &a.OutputTokens, &a.CacheReadTokens, &a.CacheWriteTokens); err != nil {
+ return nil, false, err
+ }
+ desc = append(desc, a)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, false, err
+ }
+ hasMore := len(desc) > limit
+ if hasMore {
+ desc = desc[:limit]
+ }
+ // reverse the newest-first window into ascending id order for display.
+ out := make([]Activity, len(desc))
+ for i, a := range desc {
+ out[len(desc)-1-i] = a
+ }
+ return out, hasMore, nil
+}
+
+// ActivityPageForTerminalIntent is the inherited session-history boundary. It
+// combines ownership, terminal-state and row-kind checks in one query, closing
+// the race where a previously completed source intent is reopened before paging.
+func (s *ExplorationStore) ActivityPageForTerminalIntent(nodeID, before int64, limit int) ([]Activity, bool, error) {
+ if limit <= 0 {
+ limit = 200
+ }
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.worker,''), COALESCE(a.kind,''),
+ COALESCE(a.tool,''), COALESCE(a.tool_use_id,''), a.is_error, COALESCE(a.summary,''),
+ a.created_at, a.input_tokens, a.output_tokens, a.cache_read_tokens, a.cache_write_tokens
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND a.node_id=$2
+ AND n.kind='intent' AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.kind NOT IN ('thinking','usage')
+ AND ($3 <= 0 OR a.id < $3)
+ ORDER BY a.id DESC LIMIT $4`, s.expID, nodeID, before, limit+1)
+ if err != nil {
+ return nil, false, err
+ }
+ defer rows.Close()
+ desc := []Activity{}
for rows.Next() {
var a Activity
if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt,
@@ -751,7 +943,6 @@ ORDER BY id DESC LIMIT $%d`, cond, beforeArg, beforeArg, limitArg)
if hasMore {
desc = desc[:limit]
}
- // reverse the newest-first window into ascending id order for display.
out := make([]Activity, len(desc))
for i, a := range desc {
out[len(desc)-1-i] = a
@@ -817,6 +1008,26 @@ ORDER BY id LIMIT $3`, s.expID, nodeID, limit)
return scanTrace(rows)
}
+// ActivityTraceForTerminalIntent returns one inherited work trace only while its
+// source intent is still terminal at query time.
+func (s *ExplorationStore) ActivityTraceForTerminalIntent(nodeID int64, limit int) ([]Activity, error) {
+ if limit <= 0 {
+ limit = 500
+ }
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.worker,''), COALESCE(a.kind,''), COALESCE(a.tool,''), a.is_error, COALESCE(a.summary,'')
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND a.node_id=$2 AND n.kind='intent'
+ AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.kind NOT IN ('thinking','usage')
+ ORDER BY a.id LIMIT $3`, s.expID, nodeID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTrace(rows)
+}
+
// ActivityTraceSearch finds steps whose summary OR detail matches q
// (case-insensitive), returning summaries only. nodeID != nil scopes to one work;
// nil searches across ALL works (node_id IS NOT NULL — planner/main steps have no
@@ -844,13 +1055,59 @@ AND (summary ILIKE $2 OR detail ILIKE $2) ORDER BY id LIMIT $3`, s.expID, like,
return scanTrace(rows)
}
+// ActivityTraceSearchForTerminalIntent is the scoped inherited search variant;
+// terminal eligibility is evaluated by the same statement that reads the rows.
+func (s *ExplorationStore) ActivityTraceSearchForTerminalIntent(nodeID int64, q string, limit int) ([]Activity, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ like := "%" + q + "%"
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.worker,''), COALESCE(a.kind,''), COALESCE(a.tool,''), a.is_error, COALESCE(a.summary,'')
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND a.node_id=$2 AND n.kind='intent'
+ AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.kind NOT IN ('thinking','usage')
+ AND (a.summary ILIKE $3 OR a.detail ILIKE $3)
+ ORDER BY a.id LIMIT $4`, s.expID, nodeID, like, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTrace(rows)
+}
+
+// ActivityTraceSearchTerminalIntents searches inherited worker history without
+// exposing planner/main rows or work that is still open/running in the source.
+func (s *ExplorationStore) ActivityTraceSearchTerminalIntents(q string, limit int) ([]Activity, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ like := "%" + q + "%"
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.worker,''), COALESCE(a.kind,''), COALESCE(a.tool,''), a.is_error, COALESCE(a.summary,'')
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND n.kind='intent'
+ AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.kind NOT IN ('thinking','usage')
+ AND (a.summary ILIKE $2 OR a.detail ILIKE $2)
+ ORDER BY a.id LIMIT $3`, s.expID, like, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTrace(rows)
+}
+
// AssetRef is a compact exploration node (intent/fact/finding) that references an
// asset via an anchor — for the coverage graph's node drawer.
type AssetRef struct {
- ID int64 `json:"id"`
- Kind string `json:"kind"`
- State string `json:"state"`
- Summary string `json:"summary"`
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ State string `json:"state"`
+ Summary string `json:"summary"`
+ SourceTaskID int64 `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
}
// AssetRefs returns the intents / facts / findings in THIS exploration anchored to
@@ -943,6 +1200,42 @@ FROM activity WHERE exploration_id=$1 AND kind<>'thinking' AND id IN (`+strings.
return out, rows.Err()
}
+// ActivityByIDsForTerminalIntents is the inherited-detail read boundary. It
+// deliberately excludes node-less planner/main rows, non-intent activities, live
+// source work, and thinking. Local callers that need legacy behavior use
+// ActivityByIDs instead.
+func (s *ExplorationStore) ActivityByIDsForTerminalIntents(ids []int64) ([]Activity, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ ph := make([]string, len(ids))
+ args := make([]any, len(ids)+1)
+ args[0] = s.expID
+ for i, id := range ids {
+ ph[i] = fmt.Sprintf("$%d", i+2)
+ args[i+1] = id
+ }
+ rows, err := s.db.Query(`SELECT a.id, a.node_id, COALESCE(a.kind,''), COALESCE(a.tool,''), a.is_error, COALESCE(a.detail,'')
+ FROM activity a
+ JOIN exploration_nodes n ON n.id=a.node_id AND n.exploration_id=a.exploration_id
+ WHERE a.exploration_id=$1 AND a.kind NOT IN ('thinking','usage') AND n.kind='intent'
+ AND n.state IN ('done','blocked','exhausted','stopped')
+ AND a.id IN (`+strings.Join(ph, ",")+`) ORDER BY a.id`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Activity{}
+ for rows.Next() {
+ var a Activity
+ if err := rows.Scan(&a.ID, &a.NodeID, &a.Kind, &a.Tool, &a.IsError, &a.Detail); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
// AddStandaloneFinding writes a finding to the standalone findings table, which
// persists across task deletion (task_id / node_id become NULL when the task or
// exploration node is deleted). taskID and nodeID may be 0 (stored as NULL).
diff --git a/db/exploration_sources.go b/db/exploration_sources.go
new file mode 100644
index 0000000..3ccbb96
--- /dev/null
+++ b/db/exploration_sources.go
@@ -0,0 +1,350 @@
+package db
+
+import (
+ "database/sql"
+ "sort"
+)
+
+// DirectSourceStore binds one directly related task to its exploration store.
+// It is intentionally a read-side helper: callers keep using the receiver store
+// for every graph mutation, frontier lookup, and intent claim.
+type DirectSourceStore struct {
+ Task TaskSource
+ Store *ExplorationStore
+}
+
+// DirectSourceStores resolves the live, direct task relations for this
+// exploration. It deliberately queries on every call: relations disappear when
+// a source task is deleted, and inherited context must not retain stale rows.
+// Sources of a source are never expanded.
+func (s *ExplorationStore) DirectSourceStores() ([]DirectSourceStore, error) {
+ rows, err := s.db.Query(`
+SELECT source.id, source.exploration_id, source.description, source.goal, source.status
+FROM tasks owner
+JOIN task_relations relation ON relation.task_id=owner.id
+JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
+WHERE owner.exploration_id=$1 AND owner.deleted_at IS NULL
+ORDER BY relation.created_at, source.id`, s.expID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []DirectSourceStore{}
+ for rows.Next() {
+ var source TaskSource
+ if err := rows.Scan(&source.TaskID, &source.ExplorationID, &source.Description, &source.Goal, &source.Status); err != nil {
+ return nil, err
+ }
+ out = append(out, DirectSourceStore{Task: source, Store: s.db.Exploration(source.ExplorationID)})
+ }
+ return out, rows.Err()
+}
+
+// TaskID returns the live task bound to this exploration. Explorations created
+// directly in tests or maintenance code have no task and return zero.
+func (s *ExplorationStore) TaskID() (int64, error) {
+ var id int64
+ err := s.db.QueryRow(`SELECT id FROM tasks WHERE exploration_id=$1 AND deleted_at IS NULL`, s.expID).Scan(&id)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return 0, nil
+ }
+ return 0, err
+ }
+ return id, nil
+}
+
+func markInheritedNode(n *Node, taskID int64) *Node {
+ if n == nil {
+ return nil
+ }
+ n.SourceTaskID = taskID
+ n.Inherited = true
+ return n
+}
+
+func markInheritedActivities(in []Activity, taskID int64) []Activity {
+ for i := range in {
+ in[i].SourceTaskID = taskID
+ in[i].Inherited = true
+ }
+ return in
+}
+
+func inheritedIntentTerminal(state string) bool {
+ switch state {
+ case "done", "blocked", "exhausted", "stopped":
+ return true
+ default:
+ return false
+ }
+}
+
+// ListByKindWithSources returns local nodes followed by nodes from each direct
+// source in relation order. The limit remains per exploration, matching the
+// existing ListByKind contract while ensuring one large task cannot hide all
+// inherited context from another source.
+func (s *ExplorationStore) ListByKindWithSources(kind string, limit int) ([]*Node, error) {
+ out, err := s.ListByKind(kind, limit)
+ if err != nil {
+ return nil, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ nodes, err := source.Store.ListByKind(kind, limit)
+ if err != nil {
+ return nil, err
+ }
+ for _, node := range nodes {
+ if kind == KindIntent && !inheritedIntentTerminal(node.State) {
+ continue
+ }
+ out = append(out, markInheritedNode(node, source.Task.TaskID))
+ }
+ }
+ return out, nil
+}
+
+// GetNodeWithSources reads a node only when it belongs to this exploration or
+// one of its direct sources. Inherited nodes are tagged so tool callers can keep
+// them read-only and show their provenance.
+func (s *ExplorationStore) GetNodeWithSources(id int64) (*Node, error) {
+ node, err := s.GetNode(id)
+ if err != nil || node != nil {
+ return node, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ node, err = source.Store.GetNode(id)
+ if err != nil {
+ return nil, err
+ }
+ if node != nil {
+ if node.Kind == KindIntent && !inheritedIntentTerminal(node.State) {
+ continue
+ }
+ return markInheritedNode(node, source.Task.TaskID), nil
+ }
+ }
+ return nil, nil
+}
+
+// FindingIntentsWithSources combines finding lineage for the current
+// exploration and each direct source. Node ids are globally unique.
+func (s *ExplorationStore) FindingIntentsWithSources() (map[int64]int64, error) {
+ out, err := s.FindingIntents()
+ if err != nil {
+ return nil, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ items, err := source.Store.FindingIntentsTerminal()
+ if err != nil {
+ return nil, err
+ }
+ for findingID, intentID := range items {
+ out[findingID] = intentID
+ }
+ }
+ return out, nil
+}
+
+// ActivityTraceWithSources returns a work trace when its intent belongs to the
+// current exploration or a direct source. It never searches indirect sources.
+func (s *ExplorationStore) ActivityTraceWithSources(nodeID int64, limit int) ([]Activity, error) {
+ acts, err := s.ActivityTrace(nodeID, limit)
+ if err != nil || len(acts) > 0 {
+ return acts, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ node, nodeErr := source.Store.GetNode(nodeID)
+ if nodeErr != nil {
+ return nil, nodeErr
+ }
+ if node == nil || node.Kind != KindIntent {
+ continue
+ }
+ acts, err = source.Store.ActivityTraceForTerminalIntent(nodeID, limit)
+ if err != nil {
+ return nil, err
+ }
+ if len(acts) > 0 {
+ return markInheritedActivities(acts, source.Task.TaskID), nil
+ }
+ }
+ return []Activity{}, nil
+}
+
+// ActivityListWithSources is the source-aware equivalent used by
+// get_worker_output. Node ids are global, so the first owning exploration is
+// unambiguous even when the work has not emitted any activity yet.
+func (s *ExplorationStore) ActivityListWithSources(nodeID, sinceID int64, limit int) ([]Activity, int64, error) {
+ node, err := s.GetNode(nodeID)
+ if err != nil {
+ return nil, sinceID, err
+ }
+ if node != nil {
+ return s.ActivityList(&nodeID, sinceID, limit)
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, sinceID, err
+ }
+ for _, source := range sources {
+ node, err = source.Store.GetNode(nodeID)
+ if err != nil {
+ return nil, sinceID, err
+ }
+ if node == nil || node.Kind != KindIntent {
+ continue
+ }
+ acts, cursor, err := source.Store.ActivityListForTerminalIntent(nodeID, sinceID, limit)
+ if err != nil {
+ return nil, sinceID, err
+ }
+ return markInheritedActivities(acts, source.Task.TaskID), cursor, nil
+ }
+ return []Activity{}, sinceID, nil
+}
+
+// ActivityDetailWithSources keeps the legacy local-task lookup (including local
+// thinking rows), while inherited details are restricted to terminal worker
+// intents. Source planner/main rows have no node id and must never become part of
+// inherited context.
+func (s *ExplorationStore) ActivityDetailWithSources(id int64) (string, error) {
+ detail, err := s.ActivityDetail(id)
+ if err != nil || detail != "" {
+ return detail, err
+ }
+ acts, err := s.ActivityByIDsWithSources([]int64{id})
+ if err != nil || len(acts) == 0 {
+ return "", err
+ }
+ return acts[0].Detail, nil
+}
+
+// ActivityTraceSearchWithSources performs the scoped worker-trace search used
+// by get_worker_trace. A node id resolves to at most one exploration because
+// exploration node ids are global.
+func (s *ExplorationStore) ActivityTraceSearchWithSources(nodeID int64, q string, limit int) ([]Activity, error) {
+ acts, err := s.ActivityTraceSearch(&nodeID, q, limit)
+ if err != nil || len(acts) > 0 {
+ return acts, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ node, nodeErr := source.Store.GetNode(nodeID)
+ if nodeErr != nil {
+ return nil, nodeErr
+ }
+ if node == nil || node.Kind != KindIntent {
+ continue
+ }
+ acts, err = source.Store.ActivityTraceSearchForTerminalIntent(nodeID, q, limit)
+ if err != nil {
+ return nil, err
+ }
+ if len(acts) > 0 {
+ return markInheritedActivities(acts, source.Task.TaskID), nil
+ }
+ }
+ return []Activity{}, nil
+}
+
+// ActivityTraceSearchAllWithSources searches local worker traces plus every
+// direct source. The local owner is excluded only from the current exploration;
+// inherited traces are immutable historical context.
+func (s *ExplorationStore) ActivityTraceSearchAllWithSources(excludeNodeID int64, q string, limit int) ([]Activity, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ out, err := s.ActivityTraceSearchExcluding(excludeNodeID, q, limit)
+ if err != nil {
+ return nil, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ acts, err := source.Store.ActivityTraceSearchTerminalIntents(q, limit)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, markInheritedActivities(acts, source.Task.TaskID)...)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
+
+// ActivityByIDsWithSources loads local step details with the legacy behavior. For
+// direct sources it only returns rows attached to terminal intents, preventing an
+// arbitrary global activity id from exposing source planner/main transcripts.
+func (s *ExplorationStore) ActivityByIDsWithSources(ids []int64) ([]Activity, error) {
+ out, err := s.ActivityByIDs(ids)
+ if err != nil {
+ return nil, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ acts, err := source.Store.ActivityByIDsForTerminalIntents(ids)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, markInheritedActivities(acts, source.Task.TaskID)...)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}
+
+// AssetRefsWithSources returns anchored nodes from this exploration and each
+// direct source. Inherited entries retain their owning task id so API/UI callers
+// can present them as immutable context.
+func (s *ExplorationStore) AssetRefsWithSources(assetID int64) ([]AssetRef, error) {
+ out, err := s.AssetRefs(assetID)
+ if err != nil {
+ return nil, err
+ }
+ sources, err := s.DirectSourceStores()
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range sources {
+ refs, err := source.Store.AssetRefs(assetID)
+ if err != nil {
+ return nil, err
+ }
+ for i := range refs {
+ if refs[i].Kind == KindIntent && !inheritedIntentTerminal(refs[i].State) {
+ continue
+ }
+ refs[i].SourceTaskID = source.Task.TaskID
+ refs[i].Inherited = true
+ out = append(out, refs[i])
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
+ return out, nil
+}
diff --git a/db/exploration_sources_test.go b/db/exploration_sources_test.go
new file mode 100644
index 0000000..7c66fae
--- /dev/null
+++ b/db/exploration_sources_test.go
@@ -0,0 +1,431 @@
+package db
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestInheritedActivityReadsRequireTerminalIntent(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ expID, err := d.CreateExploration("source activity boundary", "source activity boundary")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) })
+ store := d.Exploration(expID)
+ intentID, err := store.AddIntent(map[string]any{"summary": "source work"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ appendStep := func(kind, summary string) int64 {
+ t.Helper()
+ id, appendErr := store.AppendActivity(Activity{
+ NodeID: &intentID, Worker: "worker", Kind: kind, Summary: summary, Detail: summary + " detail",
+ })
+ if appendErr != nil {
+ t.Fatal(appendErr)
+ }
+ return id
+ }
+ textID := appendStep("text", "shared text")
+ thinkingID := appendStep("thinking", "private reasoning")
+ usageID := appendStep("usage", "private accounting")
+ resultID := appendStep("result", "shared result")
+ if err := store.SetIntentState(intentID, "done"); err != nil {
+ t.Fatal(err)
+ }
+
+ page, more, err := store.ActivityPageForTerminalIntent(intentID, 0, 10)
+ if err != nil || more || len(page) != 2 || page[0].ID != textID || page[1].ID != resultID {
+ t.Fatalf("terminal page boundary: page=%+v more=%v err=%v", page, more, err)
+ }
+ list, cursor, err := store.ActivityListForTerminalIntent(intentID, 0, 10)
+ if err != nil || len(list) != 2 || cursor != resultID {
+ t.Fatalf("terminal list boundary: list=%+v cursor=%d err=%v", list, cursor, err)
+ }
+ trace, err := store.ActivityTraceForTerminalIntent(intentID, 10)
+ if err != nil || len(trace) != 2 {
+ t.Fatalf("terminal trace boundary: trace=%+v err=%v", trace, err)
+ }
+ hits, err := store.ActivityTraceSearchForTerminalIntent(intentID, "shared", 10)
+ if err != nil || len(hits) != 2 {
+ t.Fatalf("terminal search boundary: hits=%+v err=%v", hits, err)
+ }
+ details, err := store.ActivityByIDsForTerminalIntents([]int64{textID, thinkingID, usageID, resultID})
+ if err != nil || len(details) != 2 || details[0].ID != textID || details[1].ID != resultID {
+ t.Fatalf("terminal detail boundary: details=%+v err=%v", details, err)
+ }
+
+ // Reopening a source intent must close every inherited activity read even if
+ // callers still hold a stale terminal-state snapshot.
+ if err := store.SetIntentState(intentID, "running"); err != nil {
+ t.Fatal(err)
+ }
+ page, _, err = store.ActivityPageForTerminalIntent(intentID, 0, 10)
+ if err != nil || len(page) != 0 {
+ t.Fatalf("reopened intent leaked through page: page=%+v err=%v", page, err)
+ }
+ list, _, err = store.ActivityListForTerminalIntent(intentID, 0, 10)
+ if err != nil || len(list) != 0 {
+ t.Fatalf("reopened intent leaked through list: list=%+v err=%v", list, err)
+ }
+ trace, err = store.ActivityTraceForTerminalIntent(intentID, 10)
+ if err != nil || len(trace) != 0 {
+ t.Fatalf("reopened intent leaked through trace: trace=%+v err=%v", trace, err)
+ }
+ hits, err = store.ActivityTraceSearchForTerminalIntent(intentID, "shared", 10)
+ if err != nil || len(hits) != 0 {
+ t.Fatalf("reopened intent leaked through search: hits=%+v err=%v", hits, err)
+ }
+ details, err = store.ActivityByIDsForTerminalIntents([]int64{textID, resultID})
+ if err != nil || len(details) != 0 {
+ t.Fatalf("reopened intent leaked through detail: details=%+v err=%v", details, err)
+ }
+}
+
+func TestExplorationDirectSourceReadView(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ grand, err := d.CreateTask("grand source", "grand goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source, err := d.CreateTaskWithOptions("direct source", "source goal", TaskCreateOptions{
+ SourceTaskIDs: []int64{grand.ID},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ current, err := d.CreateTaskWithOptions("current", "current goal", TaskCreateOptions{
+ SourceTaskIDs: []int64{source.ID},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(current.ID)
+ _ = d.DeleteTask(source.ID)
+ _ = d.DeleteTask(grand.ID)
+ })
+
+ grandStore := d.Exploration(grand.ExplorationID)
+ sourceStore := d.Exploration(source.ExplorationID)
+ currentStore := d.Exploration(current.ExplorationID)
+
+ grandFact, err := grandStore.AddNode(KindFact, map[string]any{"summary": "grand-only fact"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceFact, err := sourceStore.AddNode(KindFact, map[string]any{"summary": "direct source fact"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceFinding, err := sourceStore.AddNode(KindFinding, map[string]any{"summary": "direct source finding"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceIntent, err := sourceStore.AddIntent(map[string]any{"summary": "source work"}, 9, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sourceStore.Link(sourceIntent, RelYields, sourceFinding); err != nil {
+ t.Fatal(err)
+ }
+ currentIntent, err := currentStore.AddIntent(map[string]any{"summary": "current work"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ currentFact, err := currentStore.AddNode(KindFact, map[string]any{"summary": "current fact"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ sources, err := currentStore.DirectSourceStores()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sources) != 1 || sources[0].Task.TaskID != source.ID {
+ t.Fatalf("want only direct source %d, got %+v", source.ID, sources)
+ }
+
+ frontier, err := currentStore.Frontier(20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(frontier) != 1 || frontier[0].ID != currentIntent {
+ t.Fatalf("source intent leaked into local frontier: %+v", frontier)
+ }
+ if claimed, err := currentStore.ClaimIntent(sourceIntent, "wrong-task-worker"); err != nil || claimed {
+ t.Fatalf("source intent must not be claimable: claimed=%v err=%v", claimed, err)
+ }
+ if got, err := currentStore.GetNodeWithSources(sourceIntent); err != nil || got != nil {
+ t.Fatalf("live source intent must not be readable as inherited history: node=%+v err=%v", got, err)
+ }
+ if got, err := currentStore.ListByKindWithSources(KindIntent, 100); err != nil || len(got) != 1 || got[0].ID != currentIntent {
+ t.Fatalf("live source intent leaked through inherited list: nodes=%+v err=%v", got, err)
+ }
+ if got, err := currentStore.FindingIntentsWithSources(); err != nil || got[sourceFinding] != 0 {
+ t.Fatalf("live source intent leaked through finding lineage: lineage=%+v err=%v", got, err)
+ }
+
+ facts, err := currentStore.ListByKindWithSources(KindFact, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seenCurrent, seenSource, seenGrand := false, false, false
+ for _, fact := range facts {
+ switch fact.ID {
+ case currentFact:
+ seenCurrent = !fact.Inherited && fact.SourceTaskID == 0
+ case sourceFact:
+ seenSource = fact.Inherited && fact.SourceTaskID == source.ID
+ case grandFact:
+ seenGrand = true
+ }
+ }
+ if !seenCurrent || !seenSource || seenGrand {
+ t.Fatalf("unexpected direct-source fact view: current=%v source=%v grand=%v", seenCurrent, seenSource, seenGrand)
+ }
+
+ gotSource, err := currentStore.GetNodeWithSources(sourceFact)
+ if err != nil || gotSource == nil || !gotSource.Inherited || gotSource.SourceTaskID != source.ID {
+ t.Fatalf("source node lookup: node=%+v err=%v", gotSource, err)
+ }
+ gotGrand, err := currentStore.GetNodeWithSources(grandFact)
+ if err != nil || gotGrand != nil {
+ t.Fatalf("indirect source must be invisible: node=%+v err=%v", gotGrand, err)
+ }
+
+ // Existing write methods remain bound to currentStore.expID. Passing an
+ // inherited id is a no-op and cannot mutate the source blackboard.
+ if err := currentStore.SetNodeState(sourceFact, "dismissed"); err != nil {
+ t.Fatal(err)
+ }
+ unchanged, err := sourceStore.GetNode(sourceFact)
+ if err != nil || unchanged == nil || unchanged.State != "confirmed" {
+ t.Fatalf("inherited fact was mutated: node=%+v err=%v", unchanged, err)
+ }
+
+ if err := sourceStore.SetIntentState(sourceIntent, "done"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := currentStore.GetNodeWithSources(sourceIntent); err != nil || got == nil || !got.Inherited {
+ t.Fatalf("terminal source intent should become readable history: node=%+v err=%v", got, err)
+ }
+ if got, err := currentStore.FindingIntentsWithSources(); err != nil || got[sourceFinding] != sourceIntent {
+ t.Fatalf("terminal source finding lineage missing: lineage=%+v err=%v", got, err)
+ }
+ sourcePlannerStep, err := sourceStore.AppendActivity(Activity{
+ Worker: "planner", Kind: "text", Summary: "private source planner", Detail: "private source planner transcript",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ openSourceIntent, err := sourceStore.AddIntent(map[string]any{"summary": "source work still running"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sourceStore.SetIntentState(openSourceIntent, "running"); err != nil {
+ t.Fatal(err)
+ }
+ openSourceStep, err := sourceStore.AppendActivity(Activity{
+ NodeID: &openSourceIntent, Worker: "source-live-worker", Kind: "text",
+ Summary: "private live source work", Detail: "private live source transcript",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceStep, err := sourceStore.AppendActivity(Activity{
+ NodeID: &sourceIntent, Worker: "source-worker", Kind: "tool_result", Tool: "HTTP",
+ Summary: "source-secret response", Detail: "source-secret full detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ currentPlannerStep, err := currentStore.AppendActivity(Activity{
+ Worker: "planner", Kind: "text", Summary: "current planner", Detail: "current planner detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ currentThinkingStep, err := currentStore.AppendActivity(Activity{
+ Worker: "planner", Kind: "thinking", Summary: "current thinking", Detail: "current thinking detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ grandStep, err := grandStore.AppendActivity(Activity{
+ NodeID: &grandFact, Worker: "grand-worker", Kind: "tool_result", Tool: "HTTP",
+ Summary: "grand-only response", Detail: "grand-only detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ trace, err := currentStore.ActivityTraceWithSources(sourceIntent, 20)
+ if err != nil || len(trace) != 1 || trace[0].ID != sourceStep || !trace[0].Inherited || trace[0].SourceTaskID != source.ID {
+ t.Fatalf("source trace lookup: trace=%+v err=%v", trace, err)
+ }
+ hits, err := currentStore.ActivityTraceSearchAllWithSources(0, "source-secret", 20)
+ if err != nil || len(hits) != 1 || hits[0].ID != sourceStep || !hits[0].Inherited {
+ t.Fatalf("source trace search: hits=%+v err=%v", hits, err)
+ }
+ grandHits, err := currentStore.ActivityTraceSearchAllWithSources(0, "grand-only", 20)
+ if err != nil || len(grandHits) != 0 {
+ t.Fatalf("indirect source trace leaked: hits=%+v err=%v", grandHits, err)
+ }
+ liveHits, err := currentStore.ActivityTraceSearchAllWithSources(0, "private live source", 20)
+ if err != nil || len(liveHits) != 0 {
+ t.Fatalf("live source trace leaked: hits=%+v err=%v", liveHits, err)
+ }
+ if liveTrace, err := currentStore.ActivityTraceWithSources(openSourceIntent, 20); err != nil || len(liveTrace) != 0 {
+ t.Fatalf("live inherited intent trace leaked: trace=%+v err=%v", liveTrace, err)
+ }
+ details, err := currentStore.ActivityByIDsWithSources([]int64{
+ sourcePlannerStep, openSourceStep, sourceStep, currentPlannerStep, grandStep,
+ })
+ if err != nil || len(details) != 2 || details[0].ID != sourceStep || !details[0].Inherited || details[1].ID != currentPlannerStep || details[1].Inherited {
+ t.Fatalf("source trace detail allow-list: details=%+v err=%v", details, err)
+ }
+ if detail, err := currentStore.ActivityDetailWithSources(sourcePlannerStep); err != nil || detail != "" {
+ t.Fatalf("source planner transcript leaked: detail=%q err=%v", detail, err)
+ }
+ if detail, err := currentStore.ActivityDetailWithSources(openSourceStep); err != nil || detail != "" {
+ t.Fatalf("live source intent transcript leaked: detail=%q err=%v", detail, err)
+ }
+ if detail, err := currentStore.ActivityDetailWithSources(currentPlannerStep); err != nil || detail != "current planner detail" {
+ t.Fatalf("local activity detail behavior changed: detail=%q err=%v", detail, err)
+ }
+ if detail, err := currentStore.ActivityDetailWithSources(currentThinkingStep); err != nil || detail != "current thinking detail" {
+ t.Fatalf("local thinking detail behavior changed: detail=%q err=%v", detail, err)
+ }
+}
+
+func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ grand, err := d.CreateTask("grand assets", "grand goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source, err := d.CreateTaskWithOptions("source assets", "source goal", TaskCreateOptions{SourceTaskIDs: []int64{grand.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ current, err := d.CreateTaskWithOptions("current assets", "current goal", TaskCreateOptions{SourceTaskIDs: []int64{source.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ assets := d.Assets()
+ sourceTestedDomain := fmt.Sprintf("context-tested-%d.invalid", source.ID)
+ sourceUntestedDomain := fmt.Sprintf("context-untested-%d.invalid", source.ID)
+ sourceAnchoredOnlyDomain := fmt.Sprintf("context-anchor-only-%d.invalid", source.ID)
+ grandDomain := fmt.Sprintf("context-grand-%d.invalid", grand.ID)
+ sourceTestedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sourceTestedDomain, TaskID: source.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceUntestedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sourceUntestedDomain, TaskID: source.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceAnchoredOnlyID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sourceAnchoredOnlyDomain, TaskID: source.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ grandID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: grandDomain, TaskID: grand.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(current.ID)
+ _ = d.DeleteTask(source.ID)
+ _ = d.DeleteTask(grand.ID)
+ _, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID})
+ })
+ if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "anchored-only source asset"}, 0, "confirmed", "worker", []int64{sourceAnchoredOnlyID}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exploration(grand.ExplorationID).AddNode(KindFact, map[string]any{"summary": "indirect tested asset"}, 0, "confirmed", "worker", []int64{grandID}); err != nil {
+ t.Fatal(err)
+ }
+
+ scopes, err := assets.ListTaskScopeWithSources(current.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(scopes) != 2 || scopes[0].TaskID != source.ID || scopes[1].TaskID != source.ID {
+ t.Fatalf("direct source scopes only: %+v", scopes)
+ }
+ cov, err := assets.TaskCoverageWithSources(current.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cov.Denominator != 3 || cov.Tested != 2 {
+ t.Fatalf("combined coverage: %+v", cov)
+ }
+ untested, total, err := assets.ListUntestedAssetsWithSources(current.ID, "", 10, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 1 || len(untested) != 1 || untested[0].ID != sourceUntestedID {
+ t.Fatalf("combined untested assets: total=%d assets=%+v", total, untested)
+ }
+ hosts, err := assets.HostsByTaskWithSources(current.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hostSet := map[string]bool{}
+ for _, host := range hosts {
+ hostSet[host] = true
+ }
+ if !hostSet[sourceTestedDomain] || !hostSet[sourceUntestedDomain] || !hostSet[sourceAnchoredOnlyDomain] || hostSet[grandDomain] {
+ t.Fatalf("direct source hosts only: %v", hosts)
+ }
+ refs, err := d.Exploration(current.ExplorationID).AssetRefsWithSources(sourceAnchoredOnlyID)
+ if err != nil || len(refs) != 1 || !refs[0].Inherited || refs[0].SourceTaskID != source.ID || refs[0].Kind != KindFact {
+ t.Fatalf("source asset refs provenance: refs=%+v err=%v", refs, err)
+ }
+ grandRefs, err := d.Exploration(current.ExplorationID).AssetRefsWithSources(grandID)
+ if err != nil || len(grandRefs) != 0 {
+ t.Fatalf("indirect source asset refs leaked: refs=%+v err=%v", grandRefs, err)
+ }
+ graph, err := assets.BuildCoverageGraph(current.ID, current.ExplorationID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seenAnchoredOnly := false
+ for _, node := range graph.Nodes {
+ if node.AssetID == sourceAnchoredOnlyID {
+ seenAnchoredOnly = node.Tested && node.InScope
+ }
+ }
+ if !seenAnchoredOnly {
+ t.Fatalf("anchored-only source asset missing from coverage graph: %+v", graph.Nodes)
+ }
+}
diff --git a/db/exploration_test.go b/db/exploration_test.go
index 1c31d9f..97d9f62 100644
--- a/db/exploration_test.go
+++ b/db/exploration_test.go
@@ -1,6 +1,9 @@
package db
-import "testing"
+import (
+ "fmt"
+ "testing"
+)
func TestExplorationFlow(t *testing.T) {
d, err := Open(testDSN(t))
@@ -112,3 +115,134 @@ func TestExplorationFlow(t *testing.T) {
t.Fatalf("stats: %+v", st)
}
}
+
+func TestIntentPauseResumeAndCancelCleanup(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) — skipping", err)
+ }
+ defer d.Close()
+
+ expID, err := d.CreateExploration("test", "worker control cleanup")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID)
+ es := d.Exploration(expID)
+
+ assetID, err := d.Assets().UpsertRootDomain(UpsertRootDomainReq{
+ Domain: fmt.Sprintf("cancel-intent-%d.invalid", expID),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer deleteAsset(d, assetID)
+
+ otherIntent, err := es.AddIntent(map[string]any{"summary": "keep intent"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ intentID, err := es.AddIntent(map[string]any{"summary": "cancel intent"}, 10, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claimed, err := es.ClaimIntent(intentID, "worker-control-test"); err != nil || !claimed {
+ t.Fatalf("initial claim: claimed=%v err=%v", claimed, err)
+ }
+ if err := es.SetIntentState(intentID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+
+ frontier, err := es.Frontier(10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, node := range frontier {
+ if node.ID == intentID {
+ t.Fatalf("paused intent %d must not enter frontier", intentID)
+ }
+ }
+ if claimed, err := es.ClaimIntent(intentID, "worker-while-paused"); err != nil || claimed {
+ t.Fatalf("paused intent claim: claimed=%v err=%v", claimed, err)
+ }
+ if err := es.SetIntentState(intentID, "open"); err != nil {
+ t.Fatal(err)
+ }
+ if claimed, err := es.ClaimIntent(intentID, "worker-after-resume"); err != nil || !claimed {
+ t.Fatalf("resumed intent claim: claimed=%v err=%v", claimed, err)
+ }
+ if err := es.SetIntentState(intentID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+
+ directFact, err := es.AddNode("fact", map[string]any{"summary": "remove fact"}, 0, "confirmed", "worker", []int64{assetID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ directFinding, err := es.AddNode("finding", map[string]any{"summary": "remove finding"}, 0, "confirmed", "worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keptFact, err := es.AddNode("fact", map[string]any{"summary": "keep fact"}, 0, "confirmed", "other-worker", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := es.Link(intentID, RelYields, directFact); err != nil {
+ t.Fatal(err)
+ }
+ if err := es.Link(intentID, RelYields, directFinding); err != nil {
+ t.Fatal(err)
+ }
+ findingRowID, err := es.AddStandaloneFinding(0, directFinding, "test", "cancelled finding", SeverityHigh, "summary", "evidence", "worker", []int64{assetID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ activityID, err := es.AppendActivity(Activity{NodeID: &intentID, Worker: "worker", Kind: "result", Summary: "remove activity"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ keptActivityID, err := es.AppendActivity(Activity{NodeID: &otherIntent, Worker: "other-worker", Kind: "result", Summary: "keep activity"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cleanup, err := es.CancelIntent(intentID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cleanup.Intents != 1 || cleanup.Facts != 1 || cleanup.Findings != 1 || cleanup.Activities != 1 {
+ t.Fatalf("unexpected cleanup counts: %+v", cleanup)
+ }
+ for _, nodeID := range []int64{intentID, directFact, directFinding} {
+ node, err := es.GetNode(nodeID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if node != nil {
+ t.Fatalf("node %d survived intent cancellation", nodeID)
+ }
+ }
+ for _, nodeID := range []int64{otherIntent, keptFact} {
+ node, err := es.GetNode(nodeID)
+ if err != nil || node == nil {
+ t.Fatalf("unrelated node %d removed: node=%v err=%v", nodeID, node, err)
+ }
+ }
+
+ assertCount := func(query string, want int, args ...any) {
+ t.Helper()
+ var got int
+ if err := d.QueryRow(query, args...).Scan(&got); err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Fatalf("query count=%d, want %d: %s", got, want, query)
+ }
+ }
+ assertCount(`SELECT COUNT(*) FROM findings WHERE id=$1`, 0, findingRowID)
+ assertCount(`SELECT COUNT(*) FROM activity WHERE id=$1`, 0, activityID)
+ assertCount(`SELECT COUNT(*) FROM activity WHERE id=$1`, 1, keptActivityID)
+ assertCount(`SELECT COUNT(*) FROM exploration_edges WHERE exploration_id=$1 AND (src_id=$2 OR dst_id=$2)`, 0, expID, intentID)
+ assertCount(`SELECT COUNT(*) FROM exploration_anchors WHERE node_id=$1`, 0, directFact)
+ assertCount(`SELECT COUNT(*) FROM assets WHERE id=$1`, 1, assetID)
+}
diff --git a/db/findings.go b/db/findings.go
index 6fc64db..9dfe6e2 100644
--- a/db/findings.go
+++ b/db/findings.go
@@ -9,7 +9,8 @@ import (
"time"
)
-// DBFinding is a row in the standalone findings table (persists across task deletion).
+// DBFinding is a row in the standalone findings table. It persists across task
+// deletion unless the caller explicitly requests related finding cleanup.
type DBFinding struct {
ID int64
TaskID *int64
diff --git a/db/schema.sql b/db/schema.sql
index 32ba6f9..90779f7 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -129,19 +129,40 @@ CREATE TABLE IF NOT EXISTS exploration_nodes (
state TEXT NOT NULL DEFAULT 'open',
origin TEXT,
owner TEXT,
+ blocked_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')),
CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR
- (kind='intent' AND state IN ('open','running','done','blocked','exhausted','stopped')) OR
+ (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR
(kind='goal' AND state IN ('open','met','abandoned')) OR
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
(kind='finding' AND state IN ('confirmed','dismissed')) OR
(kind='hint' AND state IN ('active','consumed'))
)
);
+ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT;
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid='exploration_nodes'::regclass
+ AND conname='ck_node_state'
+ AND pg_get_constraintdef(oid) NOT LIKE '%paused%'
+ ) THEN
+ ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state;
+ ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK (
+ (kind='begin' AND state IN ('open')) OR
+ (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR
+ (kind='goal' AND state IN ('open','met','abandoned')) OR
+ (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
+ (kind='finding' AND state IN ('confirmed','dismissed')) OR
+ (kind='hint' AND state IN ('active','consumed'))
+ );
+ END IF;
+END $$;
CREATE INDEX IF NOT EXISTS idx_expnodes_part ON exploration_nodes(exploration_id, kind);
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
WHERE kind='intent' AND state='open';
@@ -179,12 +200,14 @@ CREATE TABLE IF NOT EXISTS activity (
is_error BOOLEAN NOT NULL DEFAULT false,
summary TEXT,
detail TEXT,
+ metadata JSONB NOT NULL DEFAULT '{}',
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id);
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id);
-- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node
@@ -256,6 +279,8 @@ CREATE TABLE IF NOT EXISTS tasks (
paused BOOLEAN NOT NULL DEFAULT false,
queued BOOLEAN NOT NULL DEFAULT false,
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
+ active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
+ llm_chain_revision BIGINT NOT NULL DEFAULT 0,
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
parent_ref TEXT,
timeout_seconds INTEGER NOT NULL DEFAULT 0,
@@ -276,6 +301,57 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
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;
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
+
+-- Direct, read-only task context inheritance. Relations are intentionally not
+-- recursive: a task sees only the source tasks explicitly chosen at creation.
+CREATE TABLE IF NOT EXISTS task_relations (
+ task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ source_task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (task_id, source_task_id),
+ CONSTRAINT ck_task_relation_not_self CHECK (task_id <> source_task_id)
+);
+CREATE INDEX IF NOT EXISTS idx_task_relations_source ON task_relations(source_task_id);
+
+-- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped
+-- until the user saves/resets the chain, which clears all failure state.
+CREATE TABLE IF NOT EXISTS task_llm_profiles (
+ task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ profile_id BIGINT NOT NULL REFERENCES llm_profiles(id) ON DELETE CASCADE,
+ position INTEGER NOT NULL CHECK (position >= 0),
+ status TEXT NOT NULL DEFAULT 'ready'
+ CHECK (status IN ('ready','quota_exhausted')),
+ last_error TEXT,
+ exhausted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (task_id, profile_id),
+ UNIQUE (task_id, position)
+);
+CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_order ON task_llm_profiles(task_id, position);
+CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_profile ON task_llm_profiles(profile_id, task_id);
+CREATE INDEX IF NOT EXISTS idx_tasks_llm_profile ON tasks(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_tasks_active_llm_profile ON tasks(active_llm_profile_id) WHERE active_llm_profile_id IS NOT NULL;
+DROP TRIGGER IF EXISTS trg_task_llm_profiles_upd ON task_llm_profiles;
+CREATE TRIGGER trg_task_llm_profiles_upd BEFORE UPDATE ON task_llm_profiles
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- One-time-compatible backfill: old pinned tasks become one-entry chains. A user
+-- can still clear the chain later because the update path also clears the legacy
+-- llm_profile_id column, preventing this block from re-adding it on restart.
+INSERT INTO task_llm_profiles(task_id, profile_id, position)
+SELECT t.id, t.llm_profile_id, 0
+FROM tasks t
+WHERE t.llm_profile_id IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id)
+ON CONFLICT DO NOTHING;
+UPDATE tasks t
+SET active_llm_profile_id = t.llm_profile_id
+WHERE t.active_llm_profile_id IS NULL
+ AND t.llm_profile_id IS NOT NULL
+ AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id);
-- 任务测试范围(资产覆盖度的分母 + 授权边界)。
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
@@ -335,6 +411,7 @@ ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NUL
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。
ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_agents_llm_profile ON agents(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
@@ -454,6 +531,7 @@ CREATE TABLE IF NOT EXISTS conversations (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
diff --git a/db/task_assets_context.go b/db/task_assets_context.go
new file mode 100644
index 0000000..722cb36
--- /dev/null
+++ b/db/task_assets_context.go
@@ -0,0 +1,215 @@
+package db
+
+import (
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// directTaskContextCTE is the current task plus exactly its explicitly related
+// source tasks. It is deliberately non-recursive.
+const directTaskContextCTE = `
+context_tasks AS (
+ SELECT t.id AS task_id, t.exploration_id
+ FROM tasks t
+ WHERE t.id=$1 AND t.deleted_at IS NULL
+ UNION ALL
+ SELECT source.id, source.exploration_id
+ FROM task_relations relation
+ JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
+ WHERE relation.task_id=$1
+)`
+
+const contextCoverageCTE = directTaskContextCTE + `,
+target AS (
+ SELECT DISTINCT a.id, a.type,
+ COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
+ FROM assets a
+ JOIN task_scope ts ON (
+ (ts.kind='company' AND a.company_id = ts.company_id)
+ OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
+ OR (ts.kind='subdomain' AND a.domain = ts.domain)
+ OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
+ )
+ JOIN context_tasks ctx ON ctx.task_id=ts.task_id
+ UNION
+ SELECT a.id, a.type,
+ COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
+ FROM assets a
+ JOIN exploration_anchors ea ON ea.asset_id=a.id
+ JOIN exploration_nodes en ON en.id=ea.node_id
+ JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
+),
+tested AS (
+ SELECT DISTINCT ea.asset_id
+ FROM exploration_anchors ea
+ JOIN exploration_nodes en ON en.id=ea.node_id AND en.kind='fact'
+ JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
+)`
+
+// ListTaskScopeWithSources returns the current task's scope followed by the
+// scopes of its direct source tasks. TaskScope.TaskID preserves provenance.
+func (s *AssetStore) ListTaskScopeWithSources(taskID int64) ([]TaskScope, error) {
+ rows, err := s.db.Query(`WITH `+directTaskContextCTE+`
+SELECT ts.id, ts.task_id, ts.kind, COALESCE(ts.company_id,0), COALESCE(ts.domain,''),
+ COALESCE(ts.net::text,''), ts.source, COALESCE(ts.reason,'')
+FROM task_scope ts
+JOIN context_tasks ctx ON ctx.task_id=ts.task_id
+ORDER BY CASE WHEN ts.task_id=$1 THEN 0 ELSE 1 END, ts.id`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []TaskScope{}
+ for rows.Next() {
+ var scope TaskScope
+ var companyID int64
+ if err := rows.Scan(&scope.ID, &scope.TaskID, &scope.Kind, &companyID, &scope.Domain, &scope.Net, &scope.Source, &scope.Reason); err != nil {
+ return nil, err
+ }
+ if companyID > 0 {
+ scope.CompanyID = &companyID
+ }
+ out = append(out, scope)
+ }
+ return out, rows.Err()
+}
+
+// TaskCoverageWithSources computes one coverage view over the union of the
+// current task and its direct sources: source scopes and anchored assets extend
+// the denominator, while fact anchors count as tested. No row is copied.
+func (s *AssetStore) TaskCoverageWithSources(taskID int64) (*Coverage, error) {
+ cov := &Coverage{ByType: []CoverageByType{}}
+ _ = s.db.QueryRow(`WITH `+directTaskContextCTE+`
+SELECT count(*) FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id`, taskID).Scan(&cov.ScopeRows)
+ rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
+SELECT target.type, count(*) AS total,
+ count(*) FILTER (WHERE target.id IN (SELECT asset_id FROM tested)) AS tested
+FROM target GROUP BY target.type ORDER BY target.type`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var byType CoverageByType
+ if err := rows.Scan(&byType.Type, &byType.Total, &byType.Tested); err != nil {
+ return nil, err
+ }
+ cov.ByType = append(cov.ByType, byType)
+ cov.Denominator += byType.Total
+ cov.Tested += byType.Tested
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if cov.Denominator > 0 {
+ pct := float64(cov.Tested) / float64(cov.Denominator)
+ cov.Pct = &pct
+ }
+ return cov, nil
+}
+
+// ListUntestedAssetsWithSources is the direct-source-aware backlog query used
+// by inherited tasks. Source fact anchors remove assets from the backlog.
+func (s *AssetStore) ListUntestedAssetsWithSources(taskID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+ if offset < 0 {
+ offset = 0
+ }
+ typeFilter := ""
+ args := []any{taskID}
+ if typ != "" {
+ typeFilter = " AND target.type = $2"
+ args = append(args, typ)
+ }
+ var total int
+ if err := s.db.QueryRow(`WITH `+contextCoverageCTE+`
+SELECT count(*) FROM target WHERE target.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+ pageArgs := append(append([]any{}, args...), limit, offset)
+ limitPosition := strconv.Itoa(len(args) + 1)
+ offsetPosition := strconv.Itoa(len(args) + 2)
+ rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
+SELECT target.id, target.type, target.label FROM target
+WHERE target.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+`
+ORDER BY target.id LIMIT $`+limitPosition+` OFFSET $`+offsetPosition, pageArgs...)
+ if err != nil {
+ return nil, total, err
+ }
+ defer rows.Close()
+ out := []CoverageAsset{}
+ for rows.Next() {
+ var asset CoverageAsset
+ if err := rows.Scan(&asset.ID, &asset.Type, &asset.Label); err != nil {
+ return nil, total, err
+ }
+ out = append(out, asset)
+ }
+ return out, total, rows.Err()
+}
+
+// HostsByTaskWithSources resolves exact HTTP host candidates from assets that
+// are attached to, anchored by, or in scope for the current task or a direct
+// source. Traffic remains global and is not copied. This read helper must not be
+// used for destructive task cleanup; HostsByTask intentionally retains that
+// narrower, task-owned behavior.
+func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) {
+ rows, err := s.db.Query(`WITH `+directTaskContextCTE+`,
+context_assets AS (
+ SELECT DISTINCT a.id
+ FROM assets a
+ WHERE EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
+ UNION
+ SELECT ea.asset_id
+ FROM exploration_anchors ea
+ JOIN exploration_nodes en ON en.id=ea.node_id
+ JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
+ UNION
+ SELECT DISTINCT a.id
+ FROM assets a
+ JOIN task_scope ts ON (
+ (ts.kind='company' AND a.company_id=ts.company_id)
+ OR (ts.kind='root_domain' AND a.root_domain=ts.domain)
+ OR (ts.kind='subdomain' AND a.domain=ts.domain)
+ OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
+ )
+ JOIN context_tasks ctx ON ctx.task_id=ts.task_id
+)
+SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'')
+FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ hosts := map[string]struct{}{}
+ add := func(host string) {
+ host = strings.TrimSpace(strings.ToLower(host))
+ if host != "" {
+ hosts[host] = struct{}{}
+ }
+ }
+ for rows.Next() {
+ var domain, ip, rawURL string
+ if err := rows.Scan(&domain, &ip, &rawURL); err != nil {
+ return nil, err
+ }
+ add(domain)
+ add(ip)
+ if parsed, err := url.Parse(rawURL); err == nil {
+ add(parsed.Hostname())
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(hosts))
+ for host := range hosts {
+ out = append(out, host)
+ }
+ sort.Strings(out)
+ return out, nil
+}
diff --git a/db/task_context.go b/db/task_context.go
new file mode 100644
index 0000000..4927b02
--- /dev/null
+++ b/db/task_context.go
@@ -0,0 +1,388 @@
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const IntentBlockedLLMQuota = "llm_quota_exhausted"
+
+// TaskLLMProfile is one ordered entry in a task's explicit failover chain.
+type TaskLLMProfile struct {
+ ProfileID int64 `json:"profile_id"`
+ Position int `json:"position"`
+ Status string `json:"status"`
+ LastError string `json:"last_error,omitempty"`
+ ExhaustedAt *time.Time `json:"exhausted_at,omitempty"`
+}
+
+// TaskSource identifies one directly related task and its exploration.
+type TaskSource struct {
+ TaskID int64
+ ExplorationID int64
+ Description string
+ Goal string
+ Status string
+}
+
+// TaskLLMTransition reports the shared task-level result of marking one profile
+// quota-exhausted. NextProfileID is nil when the explicit chain is exhausted.
+type TaskLLMTransition struct {
+ PreviousProfileID int64
+ NextProfileID *int64
+ ChainExhausted bool
+ Advanced bool
+ // Stale means the chain was replaced after the failing call selected its
+ // provider. The caller may retry a pre-stream request against the new chain,
+ // but must not report or persist a transition for this result.
+ Stale bool
+}
+
+func (d *DB) hydrateTaskContext(t *Task) error {
+ if t == nil {
+ return nil
+ }
+ // A nil cursor on a non-empty chain is a persisted end-of-chain marker. Do not
+ // repair it from an earlier ready entry: the user may have manually selected a
+ // profile in the middle and legitimately exhausted every candidate after it.
+ legacyProfileID, activeProfileID, revision, chain, err := d.taskLLMContext(t.ID)
+ if err != nil {
+ return err
+ }
+ t.LLMProfileID = legacyProfileID
+ t.ActiveLLMProfileID = activeProfileID
+ t.LLMChainRevision = revision
+ sources, err := d.TaskSourceIDs(t.ID)
+ if err != nil {
+ return err
+ }
+ t.SourceTaskIDs = sources
+ t.LLMProfileIDs = make([]int64, 0, len(chain))
+ t.LLMFailoverState = "default"
+ var latest *TaskLLMProfile
+ activeReady := false
+ for i := range chain {
+ entry := chain[i]
+ t.LLMProfileIDs = append(t.LLMProfileIDs, entry.ProfileID)
+ if t.ActiveLLMProfileID != nil && entry.ProfileID == *t.ActiveLLMProfileID && entry.Status == "ready" {
+ activeReady = true
+ }
+ if entry.ExhaustedAt != nil && (latest == nil || entry.ExhaustedAt.After(*latest.ExhaustedAt)) {
+ copy := entry
+ latest = ©
+ }
+ }
+ if len(chain) > 0 {
+ if activeReady {
+ t.LLMFailoverState = "ready"
+ } else {
+ t.LLMFailoverState = "chain_exhausted"
+ }
+ }
+ if latest != nil {
+ t.LLMFailoverReason = latest.LastError
+ }
+ return nil
+}
+
+// taskLLMContext reads the compatibility profile, current cursor, and ordered
+// chain in one statement. Runtime chain edits commit atomically, and one SQL
+// statement gives hydration one matching snapshot instead of a transient mix of
+// an old task cursor and a newly replaced chain (or vice versa).
+func (d *DB) taskLLMContext(taskID int64) (*int64, *int64, int64, []TaskLLMProfile, error) {
+ rows, err := d.Query(`
+SELECT t.llm_profile_id, t.active_llm_profile_id, t.llm_chain_revision,
+ p.profile_id, p.position, p.status, COALESCE(p.last_error,''), p.exhausted_at
+FROM tasks t
+LEFT JOIN task_llm_profiles p ON p.task_id=t.id
+WHERE t.id=$1
+ORDER BY p.position NULLS LAST`, taskID)
+ if err != nil {
+ return nil, nil, 0, nil, err
+ }
+ defer rows.Close()
+ var (
+ legacyProfileID *int64
+ activeProfileID *int64
+ revision int64
+ chain []TaskLLMProfile
+ found bool
+ )
+ for rows.Next() {
+ var legacy, active, profileID, position sql.NullInt64
+ var rowRevision int64
+ var status, lastError sql.NullString
+ var exhaustedAt sql.NullTime
+ if err := rows.Scan(&legacy, &active, &rowRevision, &profileID, &position, &status, &lastError, &exhaustedAt); err != nil {
+ return nil, nil, 0, nil, err
+ }
+ if !found {
+ found = true
+ revision = rowRevision
+ if legacy.Valid {
+ id := legacy.Int64
+ legacyProfileID = &id
+ }
+ if active.Valid {
+ id := active.Int64
+ activeProfileID = &id
+ }
+ }
+ if !profileID.Valid {
+ continue
+ }
+ entry := TaskLLMProfile{
+ ProfileID: profileID.Int64,
+ Position: int(position.Int64),
+ Status: status.String,
+ LastError: lastError.String,
+ }
+ if exhaustedAt.Valid {
+ ts := exhaustedAt.Time
+ entry.ExhaustedAt = &ts
+ }
+ chain = append(chain, entry)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, nil, 0, nil, err
+ }
+ if !found {
+ return nil, nil, 0, nil, sql.ErrNoRows
+ }
+ return legacyProfileID, activeProfileID, revision, chain, nil
+}
+
+func (d *DB) TaskSourceIDs(taskID int64) ([]int64, error) {
+ rows, err := d.Query(`SELECT source_task_id FROM task_relations WHERE task_id=$1 ORDER BY created_at, source_task_id`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []int64
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
+func (d *DB) TaskSources(taskID int64) ([]TaskSource, error) {
+ rows, err := d.Query(`
+SELECT t.id, t.exploration_id, t.description, t.goal, t.status
+FROM task_relations r
+JOIN tasks t ON t.id=r.source_task_id AND t.deleted_at IS NULL
+WHERE r.task_id=$1
+ORDER BY r.created_at, r.source_task_id`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []TaskSource
+ for rows.Next() {
+ var source TaskSource
+ if err := rows.Scan(&source.TaskID, &source.ExplorationID, &source.Description, &source.Goal, &source.Status); err != nil {
+ return nil, err
+ }
+ out = append(out, source)
+ }
+ return out, rows.Err()
+}
+
+func (d *DB) TaskLLMProfiles(taskID int64) ([]TaskLLMProfile, error) {
+ rows, err := d.Query(`SELECT profile_id, position, status, COALESCE(last_error,''), exhausted_at
+FROM task_llm_profiles WHERE task_id=$1 ORDER BY position`, taskID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []TaskLLMProfile
+ for rows.Next() {
+ var entry TaskLLMProfile
+ if err := rows.Scan(&entry.ProfileID, &entry.Position, &entry.Status, &entry.LastError, &entry.ExhaustedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, entry)
+ }
+ return out, rows.Err()
+}
+
+// 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.
+func (d *DB) ReplaceTaskLLMProfiles(taskID int64, profileIDs []int64, activeProfileID int64) error {
+ tx, err := d.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ // 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 {
+ 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
+ }
+ if err := insertTaskLLMProfiles(tx, taskID, profileIDs); err != nil {
+ return err
+ }
+ if len(profileIDs) == 0 {
+ activeProfileID = 0
+ } else if activeProfileID == 0 {
+ activeProfileID = profileIDs[0]
+ }
+ if activeProfileID > 0 {
+ found := false
+ for _, id := range profileIDs {
+ found = found || id == activeProfileID
+ }
+ if !found {
+ return fmt.Errorf("active LLM profile %d is not in the task chain", activeProfileID)
+ }
+ }
+ var active any
+ if activeProfileID > 0 {
+ active = activeProfileID
+ }
+ if _, err := tx.Exec(`UPDATE tasks
+SET active_llm_profile_id=$2, llm_profile_id=$2, llm_chain_revision=llm_chain_revision+1
+WHERE id=$1`, taskID, active); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// MarkTaskLLMProfileQuotaExhausted advances the shared task cursor once. A late
+// in-flight error from an older profile records that entry as exhausted but does
+// not advance past the profile another call already selected.
+func (d *DB) MarkTaskLLMProfileQuotaExhausted(taskID, profileID int64, reason string) (TaskLLMTransition, error) {
+ return d.markTaskLLMProfileQuotaExhausted(taskID, profileID, 0, false, reason)
+}
+
+// MarkTaskLLMProfileQuotaExhaustedAtRevision applies a provider failure only
+// when it belongs to the chain snapshot used to start that call.
+func (d *DB) MarkTaskLLMProfileQuotaExhaustedAtRevision(taskID, profileID, revision int64, reason string) (TaskLLMTransition, error) {
+ return d.markTaskLLMProfileQuotaExhausted(taskID, profileID, revision, true, reason)
+}
+
+func (d *DB) markTaskLLMProfileQuotaExhausted(taskID, profileID, revision int64, checkRevision bool, reason string) (TaskLLMTransition, error) {
+ var out TaskLLMTransition
+ tx, err := d.Begin()
+ if err != nil {
+ return out, err
+ }
+ defer tx.Rollback()
+ var (
+ active sql.NullInt64
+ currentRevision int64
+ )
+ if err := tx.QueryRow(`SELECT active_llm_profile_id, llm_chain_revision FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&active, ¤tRevision); err != nil {
+ return out, err
+ }
+ out.PreviousProfileID = profileID
+ if checkRevision && revision != currentRevision {
+ out.Stale = true
+ return out, tx.Commit()
+ }
+ var (
+ position int
+ entryStatus string
+ )
+ if err := tx.QueryRow(`SELECT position, status FROM task_llm_profiles WHERE task_id=$1 AND profile_id=$2`, taskID, profileID).
+ Scan(&position, &entryStatus); err != nil {
+ if err == sql.ErrNoRows {
+ // The chain was edited while this request was in flight. Its provider
+ // error remains valid for the caller, but it must not mutate the new chain.
+ return out, tx.Commit()
+ }
+ return out, err
+ }
+ // Once the cursor reached the end, late failures must be idempotent. This also
+ // prevents an older in-flight request from reviving a ready entry before a
+ // manually selected starting position.
+ if !active.Valid {
+ out.ChainExhausted = true
+ return out, tx.Commit()
+ }
+ reason = truncateUTF8(strings.TrimSpace(reason), 1000)
+ if entryStatus != "quota_exhausted" {
+ if _, err := tx.Exec(`UPDATE task_llm_profiles
+SET status='quota_exhausted', last_error=$3, exhausted_at=now()
+WHERE task_id=$1 AND profile_id=$2`, taskID, profileID, reason); err != nil {
+ return out, err
+ }
+ }
+ if active.Valid && active.Int64 != profileID {
+ next := active.Int64
+ out.NextProfileID = &next
+ return out, tx.Commit()
+ }
+ var next int64
+ err = tx.QueryRow(`SELECT profile_id FROM task_llm_profiles
+WHERE task_id=$1 AND position>$2 AND status='ready'
+ORDER BY position LIMIT 1`, taskID, position).Scan(&next)
+ switch err {
+ case nil:
+ out.Advanced = true
+ out.NextProfileID = &next
+ if _, err := tx.Exec(`UPDATE tasks SET active_llm_profile_id=$2, llm_profile_id=$2 WHERE id=$1`, taskID, next); err != nil {
+ return out, err
+ }
+ case sql.ErrNoRows:
+ out.Advanced = true
+ out.ChainExhausted = true
+ if _, err := tx.Exec(`UPDATE tasks SET active_llm_profile_id=NULL, llm_profile_id=NULL WHERE id=$1`, taskID); err != nil {
+ return out, err
+ }
+ default:
+ return out, err
+ }
+ return out, tx.Commit()
+}
+
+func truncateUTF8(value string, maxBytes int) string {
+ value = strings.ToValidUTF8(value, "\uFFFD")
+ if maxBytes <= 0 {
+ return ""
+ }
+ if len(value) <= maxBytes {
+ return value
+ }
+ end := maxBytes
+ for end > 0 && !utf8.ValidString(value[:end]) {
+ end--
+ }
+ return value[:end]
+}
+
+func (s *ExplorationStore) SetIntentBlockedReason(id int64, reason string) error {
+ _, err := s.db.Exec(`UPDATE exploration_nodes
+SET state='blocked', blocked_reason=NULLIF($1,''), completed_at=now()
+WHERE id=$2 AND exploration_id=$3 AND kind='intent'`, reason, id, s.expID)
+ return err
+}
+
+func (s *ExplorationStore) ReopenIntentsByBlockedReason(reason string) (int64, error) {
+ res, err := s.db.Exec(`UPDATE exploration_nodes
+SET state='open', blocked_reason=NULL, completed_at=NULL
+WHERE exploration_id=$1 AND kind='intent' AND state='blocked' AND blocked_reason=$2`, s.expID, reason)
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
diff --git a/db/task_context_lock_test.go b/db/task_context_lock_test.go
new file mode 100644
index 0000000..06b455d
--- /dev/null
+++ b/db/task_context_lock_test.go
@@ -0,0 +1,527 @@
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestTaskLLMProfileMutationsLockTaskBeforeProfile(t *testing.T) {
+ dsn := testDSN(t)
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ first, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("lock-order-first-%d", suffix), Format: "openai", Model: "first", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("lock-order-second-%d", suffix), Format: "openai", Model: "second", APIKey: "test-key",
+ })
+ if err != nil {
+ _ = d.DeleteProfile(first)
+ t.Fatal(err)
+ }
+ task, err := d.CreateTaskWithOptions("LLM lock order", "verify concurrent mutation locks", TaskCreateOptions{
+ LLMProfileIDs: []int64{first, second},
+ })
+ if err != nil {
+ _ = d.DeleteProfile(first)
+ _ = d.DeleteProfile(second)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(task.ID)
+ _ = d.DeleteProfile(first)
+ _ = d.DeleteProfile(second)
+ })
+
+ replaceDB, replacePID := openSingleConnectionTestDB(t, dsn)
+ replaceBlocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer replaceBlocker.Rollback()
+ if _, err := replaceBlocker.Exec(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, task.ID); err != nil {
+ replaceBlocker.Rollback()
+ t.Fatal(err)
+ }
+ replaceDone := make(chan error, 1)
+ go func() {
+ replaceDone <- replaceDB.ReplaceTaskLLMProfiles(task.ID, []int64{second, first}, second)
+ }()
+ if err := waitForBackendBlock(d, replacePID, replaceDone); err != nil {
+ replaceBlocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, first, second)
+ if err := replaceBlocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(replaceDone); err != nil {
+ t.Fatalf("replace chain: %v", err)
+ }
+
+ deleteDB, deletePID := openSingleConnectionTestDB(t, dsn)
+ deleteBlocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer deleteBlocker.Rollback()
+ if _, err := deleteBlocker.Exec(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, task.ID); err != nil {
+ deleteBlocker.Rollback()
+ t.Fatal(err)
+ }
+ deleteDone := make(chan error, 1)
+ go func() {
+ deleteDone <- deleteDB.DeleteProfile(second)
+ }()
+ if err := waitForBackendBlock(d, deletePID, deleteDone); err != nil {
+ deleteBlocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, second)
+ if err := deleteBlocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(deleteDone); err != nil {
+ t.Fatalf("delete profile: %v", err)
+ }
+
+ got, err := d.GetTask(task.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != first {
+ t.Fatalf("deleting the active profile did not select its successor: %+v", got)
+ }
+}
+
+func TestDeleteProfileLocksNonTaskReferencesBeforeProfile(t *testing.T) {
+ dsn := testDSN(t)
+
+ t.Run("agent", func(t *testing.T) {
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("agent-lock-profile-%d", suffix), Format: "openai", Model: "agent-lock", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ agent, err := d.CreateAgent(fmt.Sprintf("lock_agent_%d", suffix), "lock agent", "")
+ if err != nil {
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ if err := d.SetAgentLLMProfile(agent.Key, &profileID); err != nil {
+ _ = d.DeleteAgent(agent.Key)
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteAgent(agent.Key)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ blocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Rollback()
+ if _, err := blocker.Exec(`SELECT id FROM agents WHERE id=$1 FOR UPDATE`, agent.ID); err != nil {
+ t.Fatal(err)
+ }
+ deleteDB, deletePID := openSingleConnectionTestDB(t, dsn)
+ deleteDone := make(chan error, 1)
+ go func() { deleteDone <- deleteDB.DeleteProfile(profileID) }()
+ if err := waitForBackendBlock(d, deletePID, deleteDone); err != nil {
+ blocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, profileID)
+ if err := blocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(deleteDone); err != nil {
+ t.Fatalf("delete agent profile: %v", err)
+ }
+ got, err := d.GetAgentByKey(agent.Key)
+ if err != nil || got == nil || got.LLMProfileID != nil {
+ t.Fatalf("agent binding was not cleared: agent=%+v err=%v", got, err)
+ }
+ })
+
+ t.Run("conversation", func(t *testing.T) {
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("conversation-lock-profile-%d", suffix), Format: "openai", Model: "conversation-lock", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ conversation, err := d.CreateConversation("planner", "lock conversation", &profileID)
+ if err != nil {
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteConversation(conversation.ID)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ blocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Rollback()
+ if _, err := blocker.Exec(`SELECT id FROM conversations WHERE id=$1 FOR UPDATE`, conversation.ID); err != nil {
+ t.Fatal(err)
+ }
+ deleteDB, deletePID := openSingleConnectionTestDB(t, dsn)
+ deleteDone := make(chan error, 1)
+ go func() { deleteDone <- deleteDB.DeleteProfile(profileID) }()
+ if err := waitForBackendBlock(d, deletePID, deleteDone); err != nil {
+ blocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, profileID)
+ if err := blocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(deleteDone); err != nil {
+ t.Fatalf("delete conversation profile: %v", err)
+ }
+ got, err := d.GetConversation(conversation.ID)
+ if err != nil || got == nil || got.LLMProfileID != nil {
+ t.Fatalf("conversation binding was not cleared: conversation=%+v err=%v", got, err)
+ }
+ })
+}
+
+func TestNonTaskProfileMutationsLockReferenceBeforeProfile(t *testing.T) {
+ dsn := testDSN(t)
+
+ t.Run("agent", func(t *testing.T) {
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("agent-write-lock-profile-%d", suffix), Format: "openai", Model: "agent-write-lock", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ agent, err := d.CreateAgent(fmt.Sprintf("write_lock_agent_%d", suffix), "write lock agent", "")
+ if err != nil {
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteAgent(agent.Key)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ blocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Rollback()
+ if _, err := blocker.Exec(`SELECT id FROM agents WHERE id=$1 FOR UPDATE`, agent.ID); err != nil {
+ t.Fatal(err)
+ }
+ mutationDB, mutationPID := openSingleConnectionTestDB(t, dsn)
+ mutationDone := make(chan error, 1)
+ go func() { mutationDone <- mutationDB.SetAgentLLMProfile(agent.Key, &profileID) }()
+ if err := waitForBackendBlock(d, mutationPID, mutationDone); err != nil {
+ blocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, profileID)
+ if err := blocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(mutationDone); err != nil {
+ t.Fatalf("bind agent profile: %v", err)
+ }
+ })
+
+ t.Run("conversation", func(t *testing.T) {
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("conversation-write-lock-profile-%d", suffix), Format: "openai", Model: "conversation-write-lock", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ conversation, err := d.CreateConversation("planner", "write lock conversation", nil)
+ if err != nil {
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteConversation(conversation.ID)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ blocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer blocker.Rollback()
+ if _, err := blocker.Exec(`SELECT id FROM conversations WHERE id=$1 FOR UPDATE`, conversation.ID); err != nil {
+ t.Fatal(err)
+ }
+ mutationDB, mutationPID := openSingleConnectionTestDB(t, dsn)
+ mutationDone := make(chan error, 1)
+ go func() { mutationDone <- mutationDB.UpdateConversationProfile(conversation.ID, &profileID) }()
+ if err := waitForBackendBlock(d, mutationPID, mutationDone); err != nil {
+ blocker.Rollback()
+ t.Fatal(err)
+ }
+ assertProfilesUnlocked(t, d, profileID)
+ if err := blocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(mutationDone); err != nil {
+ t.Fatalf("bind conversation profile: %v", err)
+ }
+ })
+}
+
+func TestCreateConversationAndDeleteProfileDoNotDeadlock(t *testing.T) {
+ dsn := testDSN(t)
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("conversation-create-race-profile-%d", suffix), Format: "openai", Model: "conversation-create-race", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ title := fmt.Sprintf("conversation create race %d", suffix)
+ t.Cleanup(func() {
+ _, _ = d.Exec(`DELETE FROM conversations WHERE title=$1`, title)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ profileBlocker, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer profileBlocker.Rollback()
+ if _, err := profileBlocker.Exec(`SELECT id FROM llm_profiles WHERE id=$1 FOR UPDATE`, profileID); err != nil {
+ t.Fatal(err)
+ }
+
+ createDB, createPID := openSingleConnectionTestDB(t, dsn)
+ type createResult struct {
+ conversation *Conversation
+ err error
+ }
+ createDone := make(chan createResult, 1)
+ createStatus := make(chan error, 1)
+ go func() {
+ conversation, err := createDB.CreateConversation("planner", title, &profileID)
+ createDone <- createResult{conversation: conversation, err: err}
+ createStatus <- err
+ }()
+ if err := waitForBackendBlock(d, createPID, createStatus); err != nil {
+ profileBlocker.Rollback()
+ t.Fatal(err)
+ }
+
+ deleteDB, deletePID := openSingleConnectionTestDB(t, dsn)
+ deleteDone := make(chan error, 1)
+ go func() { deleteDone <- deleteDB.DeleteProfile(profileID) }()
+ if err := waitForBackendBlock(d, deletePID, deleteDone); err != nil {
+ profileBlocker.Rollback()
+ t.Fatal(err)
+ }
+ if err := profileBlocker.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+
+ var created createResult
+ select {
+ case created = <-createDone:
+ case <-time.After(12 * time.Second):
+ t.Fatal("timed out waiting for conversation creation")
+ }
+ if err := waitForMutationResult(deleteDone); err != nil {
+ t.Fatalf("delete profile during conversation creation: %v", err)
+ }
+ var persisted int
+ if err := d.QueryRow(`SELECT count(*) FROM conversations WHERE title=$1`, title).Scan(&persisted); err != nil {
+ t.Fatal(err)
+ }
+ if created.err == nil {
+ if created.conversation == nil || persisted != 1 {
+ t.Fatalf("successful creation was not committed atomically: conversation=%+v count=%d", created.conversation, persisted)
+ }
+ } else if persisted != 0 {
+ t.Fatalf("failed creation left a partial conversation row: err=%v count=%d", created.err, persisted)
+ }
+}
+
+func TestDeleteProfileRetriesReferenceCommittedAfterInitialScan(t *testing.T) {
+ dsn := testDSN(t)
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("late-reference-profile-%d", suffix), Format: "openai", Model: "late-reference", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ task, err := d.CreateTask("late profile reference", "exercise delete retry", nil, 0, 0)
+ if err != nil {
+ _ = d.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(task.ID)
+ _ = d.DeleteProfile(profileID)
+ })
+
+ // Keep the new reference uncommitted while deletion takes its initial
+ // READ COMMITTED snapshot. The task is therefore absent from the first lock
+ // set, while its FK KEY SHARE lock makes deletion wait at the profile row.
+ referenceTx, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer referenceTx.Rollback()
+ if _, err := referenceTx.Exec(`UPDATE tasks
+SET llm_profile_id=$2, active_llm_profile_id=$2
+WHERE id=$1`, task.ID, profileID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := referenceTx.Exec(`INSERT INTO task_llm_profiles(task_id, profile_id, position)
+VALUES ($1,$2,0)`, task.ID, profileID); err != nil {
+ t.Fatal(err)
+ }
+
+ deleteDB, deletePID := openSingleConnectionTestDB(t, dsn)
+ deleteDone := make(chan error, 1)
+ go func() { deleteDone <- deleteDB.DeleteProfile(profileID) }()
+ if err := waitForBackendBlock(d, deletePID, deleteDone); err != nil {
+ referenceTx.Rollback()
+ t.Fatal(err)
+ }
+ if err := referenceTx.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForMutationResult(deleteDone); err != nil {
+ t.Fatalf("delete profile after late reference: %v", err)
+ }
+
+ got, err := d.GetTask(task.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID != nil || len(got.LLMProfileIDs) != 0 || got.LLMChainRevision != 1 {
+ t.Fatalf("late task reference was not handled by a locked retry: %+v", got)
+ }
+}
+
+func openSingleConnectionTestDB(t *testing.T, dsn string) (*DB, int) {
+ t.Helper()
+ sqlDB, err := sql.Open("pgx", dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sqlDB.SetMaxOpenConns(1)
+ sqlDB.SetMaxIdleConns(1)
+ t.Cleanup(func() { _ = sqlDB.Close() })
+ if _, err := sqlDB.Exec(`SET statement_timeout='10s'`); err != nil {
+ t.Fatal(err)
+ }
+ var pid int
+ if err := sqlDB.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
+ t.Fatal(err)
+ }
+ return &DB{sqlDB}, pid
+}
+
+func waitForBackendBlock(observer *DB, pid int, done <-chan error) error {
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ select {
+ case err := <-done:
+ return fmt.Errorf("mutation returned before reaching the expected reference-row lock: %v", err)
+ default:
+ }
+ var blockers int
+ if err := observer.QueryRow(`SELECT cardinality(pg_blocking_pids($1))`, pid).Scan(&blockers); err != nil {
+ return err
+ }
+ if blockers > 0 {
+ return nil
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return fmt.Errorf("backend %d did not block within 5s", pid)
+}
+
+func assertProfilesUnlocked(t *testing.T, d *DB, profileIDs ...int64) {
+ t.Helper()
+ probe, err := d.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer probe.Rollback()
+ for _, profileID := range profileIDs {
+ var lockedID int64
+ if err := probe.QueryRow(`SELECT id FROM llm_profiles WHERE id=$1 FOR UPDATE NOWAIT`, profileID).Scan(&lockedID); err != nil {
+ t.Fatalf("profile %d was locked before the task row: %v", profileID, err)
+ }
+ }
+}
+
+func waitForMutationResult(done <-chan error) error {
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(12 * time.Second):
+ return fmt.Errorf("timed out waiting for task LLM mutation")
+ }
+}
diff --git a/db/task_context_unit_test.go b/db/task_context_unit_test.go
new file mode 100644
index 0000000..519f304
--- /dev/null
+++ b/db/task_context_unit_test.go
@@ -0,0 +1,27 @@
+package db
+
+import (
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
+
+func TestTruncateUTF8PreservesValidEncoding(t *testing.T) {
+ t.Parallel()
+ input := strings.Repeat("额度不足", 400)
+ got := truncateUTF8(input, 1000)
+ if len(got) > 1000 {
+ t.Fatalf("truncated value has %d bytes, want at most 1000", len(got))
+ }
+ if !utf8.ValidString(got) {
+ t.Fatalf("truncated value is not valid UTF-8: %q", got[len(got)-8:])
+ }
+}
+
+func TestTruncateUTF8RepairsInvalidInput(t *testing.T) {
+ t.Parallel()
+ got := truncateUTF8("bad\xffvalue", 1000)
+ if !utf8.ValidString(got) {
+ t.Fatalf("repaired value is not valid UTF-8: %q", got)
+ }
+}
diff --git a/db/task_delete_concurrency_test.go b/db/task_delete_concurrency_test.go
new file mode 100644
index 0000000..0d65191
--- /dev/null
+++ b/db/task_delete_concurrency_test.go
@@ -0,0 +1,210 @@
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "testing"
+ "time"
+
+ _ "github.com/jackc/pgx/v5/stdlib"
+)
+
+func TestDeleteTaskTrafficHostsUseOneLockedTransaction(t *testing.T) {
+ dsn := testDSN(t)
+ d, err := Open(dsn)
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ t.Run("sharing committed before deletion lock is observed", func(t *testing.T) {
+ first, second, host, rootAssetID := createTaskDeleteRaceFixture(t, d)
+ serviceURL := "https://" + host + "/concurrent-owner"
+ t.Cleanup(func() {
+ _ = d.DeleteTask(first.ID)
+ _ = d.DeleteTask(second.ID)
+ _, _ = d.Exec(`DELETE FROM assets WHERE id=$1 OR url=$2`, rootAssetID, serviceURL)
+ })
+
+ writer, _ := openTaskDeleteTestDB(t, dsn)
+ writerTx, err := writer.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer writerTx.Rollback()
+ if _, err := writerTx.Exec(`
+INSERT INTO assets(type, url, service_type, domain, task_ids)
+VALUES ('service', $1, 'http', $2, ARRAY[$3]::bigint[])`, serviceURL, host, second.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ deleter, deleterPID := openTaskDeleteTestDB(t, dsn)
+ var preparedHosts []string
+ deleteDone := make(chan error, 1)
+ go func() {
+ _, deleteErr := deleter.DeleteTaskCascadePrepared(first.ID, true, false, false, func(p TaskDeletePreparation) error {
+ preparedHosts = append([]string(nil), p.TrafficHosts...)
+ return nil
+ })
+ deleteDone <- deleteErr
+ }()
+
+ if err := waitForTaskDeleteBlock(d, deleterPID, deleteDone); err != nil {
+ t.Fatal(err)
+ }
+ if err := writerTx.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForTaskDeleteResult(deleteDone); err != nil {
+ t.Fatal(err)
+ }
+ if containsDeleteHost(preparedHosts, host) {
+ t.Fatalf("newly shared host %q was selected for traffic deletion: %v", host, preparedHosts)
+ }
+ var remaining int
+ if err := d.QueryRow(`SELECT count(*) FROM assets WHERE url=$1 AND $2=ANY(task_ids)`, serviceURL, second.ID).Scan(&remaining); err != nil {
+ t.Fatal(err)
+ }
+ if remaining != 1 {
+ t.Fatalf("concurrent owner's asset was not preserved: count=%d", remaining)
+ }
+ })
+
+ t.Run("sharing cannot enter between host resolution and commit", func(t *testing.T) {
+ first, second, host, rootAssetID := createTaskDeleteRaceFixture(t, d)
+ serviceURL := "https://" + host + "/late-owner"
+ t.Cleanup(func() {
+ _ = d.DeleteTask(first.ID)
+ _ = d.DeleteTask(second.ID)
+ _, _ = d.Exec(`DELETE FROM assets WHERE id=$1 OR url=$2`, rootAssetID, serviceURL)
+ })
+
+ deleter, _ := openTaskDeleteTestDB(t, dsn)
+ prepared := make(chan TaskDeletePreparation, 1)
+ releasePrepare := make(chan struct{})
+ deleteDone := make(chan error, 1)
+ go func() {
+ _, deleteErr := deleter.DeleteTaskCascadePrepared(first.ID, true, false, false, func(p TaskDeletePreparation) error {
+ prepared <- p
+ <-releasePrepare
+ return nil
+ })
+ deleteDone <- deleteErr
+ }()
+
+ var plan TaskDeletePreparation
+ select {
+ case plan = <-prepared:
+ case err := <-deleteDone:
+ close(releasePrepare)
+ t.Fatalf("delete returned before preparation: %v", err)
+ case <-time.After(5 * time.Second):
+ close(releasePrepare)
+ t.Fatal("timed out waiting for task delete preparation")
+ }
+ if !containsDeleteHost(plan.TrafficHosts, host) {
+ close(releasePrepare)
+ t.Fatalf("exclusive host %q missing from preparation: %v", host, plan.TrafficHosts)
+ }
+
+ writer, writerPID := openTaskDeleteTestDB(t, dsn)
+ writerDone := make(chan error, 1)
+ go func() {
+ _, writeErr := writer.Exec(`
+INSERT INTO assets(type, url, service_type, domain, task_ids)
+VALUES ('service', $1, 'http', $2, ARRAY[$3]::bigint[])`, serviceURL, host, second.ID)
+ writerDone <- writeErr
+ }()
+ if err := waitForTaskDeleteBlock(d, writerPID, writerDone); err != nil {
+ close(releasePrepare)
+ t.Fatal(err)
+ }
+
+ close(releasePrepare)
+ if err := waitForTaskDeleteResult(deleteDone); err != nil {
+ t.Fatal(err)
+ }
+ if err := waitForTaskDeleteResult(writerDone); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
+
+func createTaskDeleteRaceFixture(t *testing.T, d *DB) (first, second *Task, host string, rootAssetID int64) {
+ t.Helper()
+ var err error
+ first, err = d.CreateTask("delete race owner", "delete safely", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err = d.CreateTask("delete race sharer", "preserve shared host", nil, 0, 0)
+ if err != nil {
+ _ = d.DeleteTask(first.ID)
+ t.Fatal(err)
+ }
+ host = fmt.Sprintf("task-delete-race-%d.example.test", first.ID)
+ rootAssetID, err = d.Assets().UpsertRootDomain(UpsertRootDomainReq{Domain: host, TaskID: first.ID})
+ if err != nil {
+ _ = d.DeleteTask(first.ID)
+ _ = d.DeleteTask(second.ID)
+ t.Fatal(err)
+ }
+ return first, second, host, rootAssetID
+}
+
+func openTaskDeleteTestDB(t *testing.T, dsn string) (*DB, int) {
+ t.Helper()
+ sqlDB, err := sql.Open("pgx", dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sqlDB.SetMaxOpenConns(1)
+ sqlDB.SetMaxIdleConns(1)
+ t.Cleanup(func() { _ = sqlDB.Close() })
+ if _, err := sqlDB.Exec(`SET statement_timeout='10s'`); err != nil {
+ t.Fatal(err)
+ }
+ var pid int
+ if err := sqlDB.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
+ t.Fatal(err)
+ }
+ return &DB{sqlDB}, pid
+}
+
+func waitForTaskDeleteBlock(observer *DB, pid int, done <-chan error) error {
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ select {
+ case err := <-done:
+ return fmt.Errorf("operation returned before reaching the task deletion lock: %v", err)
+ default:
+ }
+ var blockers int
+ if err := observer.QueryRow(`SELECT cardinality(pg_blocking_pids($1))`, pid).Scan(&blockers); err != nil {
+ return err
+ }
+ if blockers > 0 {
+ return nil
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return fmt.Errorf("backend %d did not block within 5s", pid)
+}
+
+func waitForTaskDeleteResult(done <-chan error) error {
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(12 * time.Second):
+ return fmt.Errorf("timed out waiting for concurrent task deletion operation")
+ }
+}
+
+func containsDeleteHost(hosts []string, want string) bool {
+ for _, host := range hosts {
+ if host == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/db/task_scope.go b/db/task_scope.go
index e3108eb..2c69dcd 100644
--- a/db/task_scope.go
+++ b/db/task_scope.go
@@ -347,23 +347,23 @@ func hostPortOf(n *CoverageGraphNode) (string, int) {
return host, port
}
-// BuildCoverageGraph assembles the full coverage graph for a task: every in-scope
-// asset (all types) plus the connector root domains / companies needed to link
-// them, with a tested flag on each in-scope asset. The frontend does the folding
-// and highlighting; this only returns nodes + derived containment edges.
-func (s *AssetStore) BuildCoverageGraph(taskID, expID int64) (*CoverageGraphData, error) {
+// BuildCoverageGraph assembles the full coverage graph for a task and its direct
+// read-only sources: every in-scope asset plus connector root domains/companies,
+// with current-or-source fact anchors reflected in Tested. The legacy expID
+// argument is retained for API compatibility; the task registry is authoritative.
+func (s *AssetStore) BuildCoverageGraph(taskID, _ int64) (*CoverageGraphData, error) {
g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}}
if taskID <= 0 {
return g, nil
}
- rows, err := s.db.Query(`WITH `+covTargetCTE+`
+ rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
SELECT a.id, a.type, COALESCE(a.company_id,0),
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0),
(a.id IN (SELECT asset_id FROM tested)) AS tested
FROM assets a JOIN target t ON t.id = a.id
-ORDER BY a.id`, taskID, expID)
+ORDER BY a.id`, taskID)
if err != nil {
return nil, err
}
diff --git a/db/task_source_limit_test.go b/db/task_source_limit_test.go
new file mode 100644
index 0000000..01596c8
--- /dev/null
+++ b/db/task_source_limit_test.go
@@ -0,0 +1,20 @@
+package db
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCreateTaskRejectsTooManySourcesBeforeOpeningTransaction(t *testing.T) {
+ sourceIDs := make([]int64, MaxTaskSourceCount+1)
+ for i := range sourceIDs {
+ sourceIDs[i] = int64(i + 1)
+ }
+
+ // No database handle is needed: validation must run before Begin so an
+ // oversized request cannot consume a connection or create partial rows.
+ _, err := (&DB{}).CreateTaskWithOptions("child", "goal", TaskCreateOptions{SourceTaskIDs: sourceIDs})
+ if err == nil || !strings.Contains(err.Error(), "too many source tasks") {
+ t.Fatalf("expected source-count validation error, got %v", err)
+ }
+}
diff --git a/db/tasks.go b/db/tasks.go
index 86dfac6..e5c4dc9 100644
--- a/db/tasks.go
+++ b/db/tasks.go
@@ -1,23 +1,34 @@
package db
import (
+ "database/sql"
"encoding/json"
+ "fmt"
+ "strconv"
"time"
)
// Task is a row in the task registry (1:1 with an exploration).
type Task struct {
- ID int64 `json:"id"`
- Description string `json:"description"`
- Goal string `json:"goal"`
- 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"`
- CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil
+ ID int64 `json:"id"`
+ Description string `json:"description"`
+ Goal string `json:"goal"`
+ ExplorationID int64 `json:"exploration_id"`
+ Status string `json:"status"`
+ Paused bool `json:"paused"`
+ Queued bool `json:"queued"`
+ LLMProfileID *int64 `json:"llm_profile_id,omitempty"`
+ // Task-level ordered LLM chain. LLMProfileID remains the compatibility alias
+ // for ActiveLLMProfileID while older API clients still send one profile id.
+ LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
+ ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
+ LLMChainRevision int64 `json:"-"`
+ LLMFailoverState string `json:"llm_failover_state,omitempty"`
+ LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
+ SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
+ ParentRef string `json:"parent_ref,omitempty"` // 父任务 id(编排 spawn 记录;空=顶层)
+ CreatedAt time.Time `json:"created_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil
// 任务级超时(见 docs/任务级超时与收尾设计.md)。
TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时
FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行
@@ -27,6 +38,23 @@ type Task struct {
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
}
+// TaskDeleteResult reports optional related-data cleanup performed in the same
+// transaction as the task/exploration delete.
+type TaskDeleteResult struct {
+ AssetsDeleted int64
+ AssetsDetached int64
+ FindingsDeleted int64
+ LLMRecordsDeleted int64
+}
+
+// TaskDeletePreparation is produced inside the PostgreSQL deletion transaction
+// after asset and anchor writers have been excluded. Prepare callbacks may use
+// TrafficHosts to stage an external traffic deletion before PostgreSQL commits.
+type TaskDeletePreparation struct {
+ ExplorationID int64
+ TrafficHosts []string
+}
+
// IsTerminal reports whether a task status is a terminal (finished) state.
// 单一真源,替换散落各处的 done/failed 硬编码判定。
func IsTerminal(status string) bool {
@@ -40,6 +68,12 @@ func IsTerminal(status string) bool {
// 低于它(含缺省 0 / 负值 / 误配的小值)一律抬到 10min,防止把 planner 打爆。
const MinPlanHeartbeatSeconds = 600
+// MaxTaskSourceCount bounds the amount of live inherited context one task can
+// pull into every planner/main-agent prompt. Inheritance is intentionally direct
+// only; keeping the fan-in bounded also prevents a single create request from
+// multiplying graph and asset-context queries without limit.
+const MaxTaskSourceCount = 8
+
func normalizeHeartbeat(sec int) int {
if sec < MinPlanHeartbeatSeconds {
return MinPlanHeartbeatSeconds
@@ -48,6 +82,30 @@ func normalizeHeartbeat(sec int) int {
}
func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
+ var ids []int64
+ if llmProfileID != nil {
+ ids = []int64{*llmProfileID}
+ }
+ return d.CreateTaskWithOptions(description, goal, TaskCreateOptions{
+ LLMProfileIDs: ids, TimeoutSeconds: timeoutSeconds, PlanHeartbeatSeconds: planHeartbeatSeconds,
+ })
+}
+
+// TaskCreateOptions contains the task data that must be committed atomically
+// with the task/exploration row.
+type TaskCreateOptions struct {
+ SourceTaskIDs []int64
+ LLMProfileIDs []int64
+ TimeoutSeconds int
+ PlanHeartbeatSeconds int
+}
+
+// CreateTaskWithOptions creates an exploration, task, direct source relations,
+// and the ordered task LLM chain in one transaction.
+func (d *DB) CreateTaskWithOptions(description, goal string, opts TaskCreateOptions) (*Task, error) {
+ if len(opts.SourceTaskIDs) > MaxTaskSourceCount {
+ return nil, fmt.Errorf("too many source tasks: got %d, maximum is %d", len(opts.SourceTaskIDs), MaxTaskSourceCount)
+ }
tx, err := d.Begin()
if err != nil {
return nil, err
@@ -72,24 +130,80 @@ INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, or
VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); err != nil {
return nil, err
}
- if timeoutSeconds < 0 {
- timeoutSeconds = 0
+ if opts.TimeoutSeconds < 0 {
+ opts.TimeoutSeconds = 0
+ }
+ opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds)
+ var active *int64
+ if len(opts.LLMProfileIDs) > 0 {
+ id := opts.LLMProfileIDs[0]
+ active = &id
+ }
+ t := &Task{
+ Description: description, Goal: goal, ExplorationID: expID,
+ LLMProfileID: active, ActiveLLMProfileID: active,
+ LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...),
+ SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
+ TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
}
- planHeartbeatSeconds = normalizeHeartbeat(planHeartbeatSeconds) // <=0→300,<30→30
- t := &Task{Description: description, Goal: goal, ExplorationID: expID, LLMProfileID: llmProfileID, TimeoutSeconds: timeoutSeconds, PlanHeartbeatSeconds: planHeartbeatSeconds}
if err := tx.QueryRow(`
-INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, timeout_seconds, plan_heartbeat_seconds) VALUES ($1,$2,$3,$4,$5,$6)
-RETURNING id, status, paused, created_at`, description, goal, expID, llmProfileID, timeoutSeconds, planHeartbeatSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
+INSERT INTO tasks(description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds)
+VALUES ($1,$2,$3,$4,$4,$5,$6)
+RETURNING id, status, paused, created_at`, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
return nil, err
}
+ if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
+ return nil, err
+ }
+ if err := insertTaskLLMProfiles(tx, t.ID, opts.LLMProfileIDs); err != nil {
+ return nil, err
+ }
+ if len(opts.LLMProfileIDs) == 0 {
+ t.LLMFailoverState = "default"
+ } else {
+ t.LLMFailoverState = "ready"
+ }
return t, tx.Commit()
}
-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 insertTaskRelations(tx *sql.Tx, taskID int64, sourceIDs []int64) error {
+ seen := map[int64]bool{}
+ for _, sourceID := range sourceIDs {
+ if sourceID <= 0 || sourceID == taskID || seen[sourceID] {
+ return fmt.Errorf("invalid or duplicate source task id %d", sourceID)
+ }
+ seen[sourceID] = true
+ res, err := tx.Exec(`INSERT INTO task_relations(task_id, source_task_id)
+SELECT $1, id FROM tasks WHERE id=$2 AND deleted_at IS NULL`, taskID, sourceID)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n != 1 {
+ return fmt.Errorf("source task %d not found", sourceID)
+ }
+ }
+ return nil
+}
+
+func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
+ seen := map[int64]bool{}
+ for position, profileID := range profileIDs {
+ if profileID <= 0 || seen[profileID] {
+ return fmt.Errorf("invalid or duplicate LLM profile id %d", profileID)
+ }
+ seen[profileID] = true
+ if _, err := tx.Exec(`INSERT INTO task_llm_profiles(task_id, profile_id, position) VALUES ($1,$2,$3)`, taskID, profileID, position); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+const taskCols = `id, description, goal, exploration_id, status, paused, queued, llm_profile_id, active_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.Queued, &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.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err
}
return &t, nil
@@ -116,7 +230,16 @@ func (d *DB) ListTasks() ([]*Task, error) {
}
out = append(out, t)
}
- return out, rows.Err()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ rows.Close()
+ for _, task := range out {
+ if err := d.hydrateTaskContext(task); err != nil {
+ return nil, err
+ }
+ }
+ return out, nil
}
// GetTask returns one alive task (nil if not found/deleted).
@@ -128,6 +251,9 @@ func (d *DB) GetTask(id int64) (*Task, error) {
}
return nil, err
}
+ if err := d.hydrateTaskContext(t); err != nil {
+ return nil, err
+ }
return t, nil
}
@@ -137,8 +263,6 @@ 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
@@ -192,28 +316,132 @@ UPDATE tasks
return deadline, err
}
-// DeleteTask hard-deletes a task and its exploration subgraph (nodes/edges/
-// activity via ON DELETE CASCADE). The task row is removed first so the
-// ON DELETE RESTRICT on tasks.exploration_id does not block the exploration delete.
-// Global assets are NOT touched.
+// DeleteTask preserves the historical behavior: remove the task and its
+// exploration subgraph while retaining global assets.
func (d *DB) DeleteTask(id int64) error {
+ _, err := d.DeleteTaskCascade(id, false, false)
+ return err
+}
+
+// DeleteTaskCascade hard-deletes a task and its exploration subgraph
+// (nodes/edges/activity via ON DELETE CASCADE). Optional standalone findings and
+// assets owned only by this task are deleted in the same transaction; shared
+// assets are retained and only have this task id detached. deleteLLMRecords is
+// optional to preserve callers of the original two-option API.
+func (d *DB) DeleteTaskCascade(id int64, deleteAssets, deleteFindings bool, deleteLLMRecords ...bool) (TaskDeleteResult, error) {
+ deleteRecords := len(deleteLLMRecords) > 0 && deleteLLMRecords[0]
+ return d.DeleteTaskCascadePrepared(id, deleteAssets, deleteFindings, deleteRecords, nil)
+}
+
+// DeleteTaskCascadePrepared coordinates reversible external deletion with the
+// PostgreSQL cascade. When prepare is non-nil, host ownership is resolved and
+// prepare is invoked inside this transaction while asset and anchor writers are
+// excluded. The locks remain held through commit, closing the window where a
+// host could become shared after its traffic had already been staged.
+//
+// prepare must only stage reversible work. Any returned error or later database
+// error rolls PostgreSQL back; the caller remains responsible for rolling back
+// external work that its callback staged successfully.
+func (d *DB) DeleteTaskCascadePrepared(
+ id int64,
+ deleteAssets, deleteFindings, deleteLLMRecords bool,
+ prepare func(TaskDeletePreparation) error,
+) (TaskDeleteResult, error) {
+ var result TaskDeleteResult
tx, err := d.Begin()
if err != nil {
- return err
+ return result, err
}
defer tx.Rollback()
var expID int64
- if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1`, id).Scan(&expID); err != nil {
- if err.Error() == "sql: no rows in result set" {
- return nil // already gone
+ if err := tx.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1 FOR UPDATE`, id).Scan(&expID); err != nil {
+ if err == sql.ErrNoRows {
+ return result, nil // already gone
}
- return err
+ return result, err
+ }
+
+ // SHARE ROW EXCLUSIVE conflicts with every INSERT/UPDATE/DELETE on these
+ // tables and with another coordinated deletion. Taking both in one fixed order
+ // prevents phantoms (a distinct asset row for the same host) as well as new
+ // ownership/anchor references until the deletion transaction commits.
+ if deleteAssets || prepare != nil {
+ if _, err := tx.Exec(`LOCK TABLE assets, exploration_anchors IN SHARE ROW EXCLUSIVE MODE`); err != nil {
+ return result, err
+ }
+ }
+ if prepare != nil {
+ hosts, err := hostsForTaskDeletion(tx, id, expID)
+ if err != nil {
+ return result, err
+ }
+ if err := prepare(TaskDeletePreparation{
+ ExplorationID: expID,
+ TrafficHosts: hosts,
+ }); err != nil {
+ return result, err
+ }
+ }
+ if deleteAssets {
+ // Ownership is the union of explicit task_ids and this exploration's anchors.
+ // An asset is deletable only when no other live task references it through
+ // either mechanism. This covers legacy anchor-only seeds without destroying
+ // evidence anchored by another task.
+ res, err := tx.Exec(`
+WITH candidate_assets AS (
+ SELECT id FROM assets WHERE $1 = ANY(task_ids)
+ UNION
+ SELECT ea.asset_id
+ FROM exploration_anchors ea
+ JOIN exploration_nodes n ON n.id=ea.node_id
+ WHERE n.exploration_id=$2
+),
+deletable AS (
+ SELECT a.id
+ FROM assets a
+ JOIN candidate_assets c ON c.id=a.id
+ WHERE NOT EXISTS (
+ SELECT 1 FROM tasks t
+ WHERE t.id<>$1 AND t.deleted_at IS NULL AND t.id=ANY(a.task_ids)
+ ) AND NOT EXISTS (
+ SELECT 1
+ FROM exploration_anchors ea
+ JOIN exploration_nodes n ON n.id=ea.node_id
+ JOIN tasks t ON t.exploration_id=n.exploration_id
+ WHERE ea.asset_id=a.id AND t.id<>$1 AND t.deleted_at IS NULL
+ )
+)
+DELETE FROM assets a USING deletable d WHERE a.id=d.id`, id, expID)
+ if err != nil {
+ return result, err
+ }
+ result.AssetsDeleted, _ = res.RowsAffected()
+
+ res, err = tx.Exec(`UPDATE assets SET task_ids = array_remove(task_ids, $1) WHERE $1 = ANY(task_ids)`, id)
+ if err != nil {
+ return result, err
+ }
+ result.AssetsDetached, _ = res.RowsAffected()
+ }
+ if deleteFindings {
+ res, err := tx.Exec(`DELETE FROM findings WHERE task_id = $1`, id)
+ if err != nil {
+ return result, err
+ }
+ result.FindingsDeleted, _ = res.RowsAffected()
+ }
+ if deleteLLMRecords {
+ res, err := tx.Exec(`DELETE FROM llm_records WHERE COALESCE(task_id,'')=$1`, strconv.FormatInt(id, 10))
+ if err != nil {
+ return result, err
+ }
+ result.LLMRecordsDeleted, _ = res.RowsAffected()
}
if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1`, id); err != nil {
- return err
+ return result, err
}
if _, err := tx.Exec(`DELETE FROM explorations WHERE id=$1`, expID); err != nil {
- return err
+ return result, err
}
- return tx.Commit()
+ return result, tx.Commit()
}
diff --git a/db/tasks_test.go b/db/tasks_test.go
index d407e03..655b06b 100644
--- a/db/tasks_test.go
+++ b/db/tasks_test.go
@@ -1,6 +1,11 @@
package db
-import "testing"
+import (
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+)
func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
d, err := Open(testDSN(t))
@@ -75,3 +80,406 @@ func TestTaskLifecycleAndDeleteCascade(t *testing.T) {
t.Fatalf("exploration should be deleted, got %d", exps)
}
}
+
+func TestTaskDeleteCascadeAssets(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) — skipping", err)
+ }
+ defer d.Close()
+
+ first, err := d.CreateTask("级联删除测试", "目标A", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := d.CreateTask("共享资产保留测试", "目标B", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assets := d.Assets()
+ exclusiveID, err := assets.UpsertRootDomain(UpsertRootDomainReq{
+ Domain: fmt.Sprintf("delete-%d.example.test", first.ID),
+ TaskID: first.ID,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sharedDomain := fmt.Sprintf("shared-%d.example.test", first.ID)
+ sharedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sharedDomain, TaskID: first.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: sharedDomain, TaskID: second.ID}); err != nil {
+ t.Fatal(err)
+ }
+ anchorOnlyDomain := fmt.Sprintf("anchor-only-%d.example.test", first.ID)
+ anchorOnlyID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: anchorOnlyDomain})
+ if err != nil {
+ t.Fatal(err)
+ }
+ firstOrigin, err := d.Exploration(first.ExplorationID).OriginFactID()
+ if err != nil || firstOrigin == 0 {
+ t.Fatalf("first origin: id=%d err=%v", firstOrigin, err)
+ }
+ if err := d.Exploration(first.ExplorationID).Anchor(firstOrigin, anchorOnlyID); err != nil {
+ t.Fatal(err)
+ }
+ protectedDomain := fmt.Sprintf("other-anchor-%d.example.test", first.ID)
+ protectedID, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: protectedDomain, TaskID: first.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondOrigin, err := d.Exploration(second.ExplorationID).OriginFactID()
+ if err != nil || secondOrigin == 0 {
+ t.Fatalf("second origin: id=%d err=%v", secondOrigin, err)
+ }
+ if err := d.Exploration(second.ExplorationID).Anchor(secondOrigin, protectedID); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(first.ID)
+ _ = d.DeleteTask(second.ID)
+ _, _ = assets.DeleteByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
+ })
+
+ hosts, err := assets.HostsByTask(first.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hostSet := make(map[string]bool, len(hosts))
+ for _, host := range hosts {
+ hostSet[host] = true
+ }
+ if !hostSet[fmt.Sprintf("delete-%d.example.test", first.ID)] || !hostSet[sharedDomain] {
+ t.Fatalf("task hosts missing cascade fixtures: %v", hosts)
+ }
+ deletableHosts, err := assets.HostsForTaskDeletion(first.ID, first.ExplorationID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ deletableSet := make(map[string]bool, len(deletableHosts))
+ for _, host := range deletableHosts {
+ deletableSet[host] = true
+ }
+ if !deletableSet[fmt.Sprintf("delete-%d.example.test", first.ID)] || !deletableSet[anchorOnlyDomain] {
+ t.Fatalf("exclusive or anchor-only cleanup host missing: %v", deletableHosts)
+ }
+ if deletableSet[sharedDomain] || deletableSet[protectedDomain] {
+ t.Fatalf("shared host was not protected from traffic cleanup: %v", deletableHosts)
+ }
+
+ findingID, err := d.AddFinding(first.ID, 0, "__task_delete_cascade__", "", SeverityHigh, "summary", "evidence", "tester", []int64{exclusiveID})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := d.DeleteTaskCascade(first.ID, true, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.AssetsDeleted < 2 || result.AssetsDetached < 2 {
+ t.Fatalf("unexpected asset cleanup result: %+v", result)
+ }
+ if result.FindingsDeleted != 1 {
+ t.Fatalf("unexpected finding cleanup result: %+v", result)
+ }
+ if finding, err := d.GetFinding(findingID); err != nil || finding != nil {
+ t.Fatalf("finding should be deleted, got finding=%+v err=%v", finding, err)
+ }
+ remaining, err := assets.GetByIDs([]int64{exclusiveID, sharedID, anchorOnlyID, protectedID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(remaining) != 2 {
+ t.Fatalf("only shared and other-anchored assets should remain, got %+v", remaining)
+ }
+ byID := make(map[int64]*Asset, len(remaining))
+ for _, asset := range remaining {
+ byID[asset.ID] = asset
+ }
+ if shared := byID[sharedID]; shared == nil || len(shared.TaskIDs) != 1 || shared.TaskIDs[0] != second.ID {
+ t.Fatalf("deleted task should be detached from shared asset: %+v", shared)
+ }
+ if protected := byID[protectedID]; protected == nil || len(protected.TaskIDs) != 0 {
+ t.Fatalf("other task's anchor should preserve the asset after detaching ownership: %+v", protected)
+ }
+}
+
+func TestTaskRelationsAndLLMFailoverChain(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ suffix := time.Now().UnixNano()
+ profileIDs := make([]int64, 0, 3)
+ for i := 0; i < 3; i++ {
+ id, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("task-chain-%d-%d", suffix, i), Format: "openai",
+ Model: fmt.Sprintf("model-%d", i), APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ profileIDs = append(profileIDs, id)
+ }
+
+ sourceA, err := d.CreateTask("source A", "goal A", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceB, err := d.CreateTask("source B", "goal B", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ child, err := d.CreateTaskWithOptions("child", "new goal", TaskCreateOptions{
+ SourceTaskIDs: []int64{sourceA.ID, sourceB.ID},
+ LLMProfileIDs: profileIDs,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = d.DeleteTask(child.ID)
+ _ = d.DeleteTask(sourceA.ID)
+ _ = d.DeleteTask(sourceB.ID)
+ for _, id := range profileIDs {
+ _ = d.DeleteProfile(id)
+ }
+ })
+
+ got, err := d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fmt.Sprint(got.SourceTaskIDs) != fmt.Sprint([]int64{sourceA.ID, sourceB.ID}) {
+ t.Fatalf("unexpected direct sources: %v", got.SourceTaskIDs)
+ }
+ if fmt.Sprint(got.LLMProfileIDs) != fmt.Sprint(profileIDs) || got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] {
+ t.Fatalf("unexpected initial chain: %+v", got)
+ }
+ initialRevision := got.LLMChainRevision
+ if err := d.ReplaceTaskLLMProfiles(child.ID, profileIDs, profileIDs[0]); err != nil {
+ t.Fatal(err)
+ }
+ staleRevision, err := d.MarkTaskLLMProfileQuotaExhaustedAtRevision(child.ID, profileIDs[0], initialRevision, "late quota from prior generation")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !staleRevision.Stale || staleRevision.Advanced || staleRevision.ChainExhausted {
+ t.Fatalf("old chain revision changed replacement chain: %+v", staleRevision)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.LLMChainRevision <= initialRevision || got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] {
+ t.Fatalf("replacement revision/cursor not preserved: %+v", got)
+ }
+
+ transition, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "insufficient_quota")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !transition.Advanced || transition.ChainExhausted || transition.NextProfileID == nil || *transition.NextProfileID != profileIDs[1] {
+ t.Fatalf("unexpected first transition: %+v", transition)
+ }
+ late, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "late duplicate")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if late.Advanced || late.NextProfileID == nil || *late.NextProfileID != profileIDs[1] {
+ t.Fatalf("late failure advanced past current profile: %+v", late)
+ }
+ if _, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[1], "quota_exceeded"); err != nil {
+ t.Fatal(err)
+ }
+ last, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "余额不足")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !last.Advanced || !last.ChainExhausted || last.NextProfileID != nil {
+ t.Fatalf("unexpected exhausted transition: %+v", last)
+ }
+ duplicateLast, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "late final duplicate")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if duplicateLast.Advanced || !duplicateLast.ChainExhausted || duplicateLast.NextProfileID != nil {
+ t.Fatalf("duplicate final failure must be idempotent: %+v", duplicateLast)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
+ t.Fatalf("chain exhaustion was not persisted: %+v", got)
+ }
+
+ // Two requests can observe the same last active profile. Only the transaction
+ // that clears the cursor may report an advance; the late one is idempotent.
+ if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2]}, profileIDs[2]); err != nil {
+ t.Fatal(err)
+ }
+ transitions := make(chan TaskLLMTransition, 2)
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ transition, markErr := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[2], "concurrent final quota")
+ if markErr != nil {
+ errs <- markErr
+ return
+ }
+ transitions <- transition
+ }()
+ }
+ wg.Wait()
+ close(errs)
+ close(transitions)
+ for markErr := range errs {
+ t.Fatal(markErr)
+ }
+ advanced := 0
+ for transition := range transitions {
+ if transition.Advanced {
+ advanced++
+ }
+ if !transition.ChainExhausted {
+ t.Fatalf("concurrent final transition must report exhausted chain: %+v", transition)
+ }
+ }
+ if advanced != 1 {
+ t.Fatalf("last profile advanced %d times, want exactly once", advanced)
+ }
+
+ // Starting manually from the middle consumes only candidates after that cursor.
+ // Earlier ready profiles must not be revived by hydration or unrelated deletes.
+ if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[0], profileIDs[1]}, profileIDs[0]); err != nil {
+ t.Fatal(err)
+ }
+ if step, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "middle quota"); err != nil || step.NextProfileID == nil || *step.NextProfileID != profileIDs[1] {
+ t.Fatalf("middle cursor did not advance to its successor: transition=%+v err=%v", step, err)
+ }
+ if end, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[1], "tail quota"); err != nil || !end.ChainExhausted {
+ t.Fatalf("tail did not exhaust manual chain: transition=%+v err=%v", end, err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
+ t.Fatalf("hydration revived a profile before the manual cursor: %+v", got)
+ }
+ unrelatedID, err := d.SaveProfile(&LLMProfile{
+ Name: fmt.Sprintf("task-chain-unrelated-%d", suffix), Format: "openai", Model: "other", APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := d.DeleteProfile(unrelatedID); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID != nil || got.LLMFailoverState != "chain_exhausted" {
+ t.Fatalf("deleting an unrelated profile revived an exhausted chain: %+v", got)
+ }
+
+ // A call that finishes after its profile was removed from the chain is stale:
+ // preserve the new cursor and surface the original provider error upstream.
+ if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[1]}, profileIDs[2]); err != nil {
+ t.Fatal(err)
+ }
+ stale, err := d.MarkTaskLLMProfileQuotaExhausted(child.ID, profileIDs[0], "obsolete in-flight quota")
+ if err != nil {
+ t.Fatalf("removed in-flight profile returned an internal error: %v", err)
+ }
+ if stale.Advanced || stale.ChainExhausted || stale.NextProfileID != nil {
+ t.Fatalf("removed in-flight profile changed the replacement chain: %+v", stale)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[2] {
+ t.Fatalf("stale failure changed active replacement profile: %+v", got)
+ }
+
+ if err := d.ReplaceTaskLLMProfiles(child.ID, []int64{profileIDs[2], profileIDs[0], profileIDs[1]}, profileIDs[0]); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[0] || got.LLMFailoverState != "ready" || got.LLMFailoverReason != "" {
+ t.Fatalf("chain reset did not clear failure state: %+v", got)
+ }
+
+ if err := d.DeleteProfile(profileIDs[0]); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID == nil || *got.ActiveLLMProfileID != profileIDs[1] {
+ t.Fatalf("deleting active profile did not select next ready entry: %+v", got)
+ }
+ if err := d.DeleteProfile(profileIDs[1]); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ActiveLLMProfileID != nil || len(got.LLMProfileIDs) != 0 || got.LLMFailoverState != "default" {
+ t.Fatalf("deleting active chain tail must fall back instead of wrapping: %+v", got)
+ }
+
+ if err := d.DeleteTask(sourceA.ID); err != nil {
+ t.Fatal(err)
+ }
+ got, err = d.GetTask(child.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fmt.Sprint(got.SourceTaskIDs) != fmt.Sprint([]int64{sourceB.ID}) {
+ t.Fatalf("source delete did not cascade only its relation: %v", got.SourceTaskIDs)
+ }
+}
+
+func TestTaskContextRejectsDuplicatesAndTerminalEdits(t *testing.T) {
+ d, err := Open(testDSN(t))
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer d.Close()
+
+ source, err := d.CreateTask("source", "goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.DeleteTask(source.ID) })
+ if _, err := d.CreateTaskWithOptions("bad", "goal", TaskCreateOptions{SourceTaskIDs: []int64{source.ID, source.ID}}); err == nil {
+ t.Fatal("duplicate source task ids should be rejected")
+ }
+
+ task, err := d.CreateTask("terminal", "goal", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
+ 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")
+ }
+}
diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go
index 37c256d..cf0be46 100644
--- a/llmrec/llmrec.go
+++ b/llmrec/llmrec.go
@@ -16,6 +16,27 @@ import (
"github.com/Autumn-27/artex/db"
)
+type taskIDContextKey struct{}
+
+// WithTaskID attaches the owning task registry id to an LLM call. Session ids
+// are based on exploration ids, which are not interchangeable with task ids.
+func WithTaskID(ctx context.Context, taskID string) context.Context {
+ taskID = strings.TrimSpace(taskID)
+ if taskID == "" {
+ return ctx
+ }
+ return context.WithValue(ctx, taskIDContextKey{}, taskID)
+}
+
+// TaskIDFrom returns the explicit task registry id attached by the task runtime.
+func TaskIDFrom(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+ taskID, _ := ctx.Value(taskIDContextKey{}).(string)
+ return strings.TrimSpace(taskID)
+}
+
// Recorder wraps an llm.Provider and records every completion call.
type Recorder struct {
inner llm.Provider
@@ -70,7 +91,13 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
}
start := time.Now()
session := transcript.SessionIDFrom(ctx)
- taskID, worker := parseSession(session)
+ parsedID, worker := parseSession(session)
+ taskID := TaskIDFrom(ctx)
+ if taskID == "" {
+ // Backward compatibility for non-task callers. For task calls, the task
+ // runtime always supplies the registry id explicitly.
+ taskID = parsedID
+ }
// Serialize the request for storage.
reqBody := serializeRequest(req)
@@ -120,7 +147,9 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
}
}
-// record persists one LLM call to PostgreSQL (async, best-effort).
+// record persists one LLM call to PostgreSQL before the provider stream returns.
+// Keeping the write inside the owning task operation means task deletion can
+// drain calls and then remove records without a late async insert recreating one.
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
latency := int(time.Since(start).Milliseconds())
status := "ok"
@@ -169,11 +198,9 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
ResponseBody: string(respBody),
}
- go func() {
- if err := r.pg.InsertLLMRecord(rec); err != nil {
- log.Printf("[llmrec] insert: %v", err)
- }
- }()
+ if err := r.pg.InsertLLMRecord(rec); err != nil {
+ log.Printf("[llmrec] insert: %v", err)
+ }
}
// serializeRequest builds a JSON representation of the completion request.
diff --git a/llmrec/llmrec_test.go b/llmrec/llmrec_test.go
new file mode 100644
index 0000000..e998684
--- /dev/null
+++ b/llmrec/llmrec_test.go
@@ -0,0 +1,29 @@
+package llmrec
+
+import (
+ "context"
+ "testing"
+)
+
+func TestTaskIDContextUsesExplicitRegistryID(t *testing.T) {
+ ctx := WithTaskID(context.Background(), " 42 ")
+ if got := TaskIDFrom(ctx); got != "42" {
+ t.Fatalf("TaskIDFrom()=%q want 42", got)
+ }
+ if got := TaskIDFrom(WithTaskID(ctx, " ")); got != "42" {
+ t.Fatalf("blank task id should preserve parent context, got %q", got)
+ }
+ if got := TaskIDFrom(nil); got != "" {
+ t.Fatalf("nil context returned %q", got)
+ }
+}
+
+func TestParseSessionFallbackIsExplorationScoped(t *testing.T) {
+ taskID, worker := parseSession("exp12-worker-i99")
+ if taskID != "12" || worker != "worker" {
+ t.Fatalf("parseSession()=(%q,%q)", taskID, worker)
+ }
+ if taskID, worker := parseSession("not-a-task"); taskID != "" || worker != "" {
+ t.Fatalf("unexpected non-session parse: (%q,%q)", taskID, worker)
+ }
+}
diff --git a/server/assets.go b/server/assets.go
index 62a794e..ac8dcba 100644
--- a/server/assets.go
+++ b/server/assets.go
@@ -217,7 +217,6 @@ func (s *Server) reattribute(w http.ResponseWriter, r *http.Request) {
// GET /api/assets
// =====================================================================
-// defaultAssetPageSize is the page size used when the caller omits ?limit.
const defaultAssetPageSize = 50
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
@@ -230,8 +229,6 @@ func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
typ := q.Get("type")
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
- // limit/offset are page controls, not a cap: `total` always reports the full
- // match count so callers can page through everything.
if limit <= 0 {
limit = defaultAssetPageSize
}
@@ -351,8 +348,8 @@ func (s *Server) insertAssets(w http.ResponseWriter, r *http.Request) {
Assets []struct {
Type string `json:"type"`
// root_domain / subdomain
- Domain string `json:"domain"`
- ICP string `json:"icp"`
+ Domain string `json:"domain"`
+ ICP string `json:"icp"`
RecordType string `json:"record_type"`
RecordValue []string `json:"record_value"`
// ip
diff --git a/server/chatupload.go b/server/chatupload.go
index 7ac55e1..82ae080 100644
--- a/server/chatupload.go
+++ b/server/chatupload.go
@@ -45,9 +45,11 @@ type chatAttachment struct {
// 文件先落这里,前端按返回的 abs 绝对路径写进任务描述)
func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
var sub string
+ taskScoped := false
switch r.URL.Query().Get("scope") {
case "task":
sub = "tasks"
+ taskScoped = true
case "session":
sub = "sessions"
case "staging":
@@ -61,6 +63,17 @@ func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "非法 id")
return
}
+ if taskScoped {
+ if s.m.ResolveTask(id) == nil {
+ writeErr(w, 404, "task not found")
+ return
+ }
+ if !s.engine.beginTaskOperation(id) {
+ writeErr(w, http.StatusConflict, "任务正在删除,无法上传附件")
+ return
+ }
+ defer s.engine.decInflight(id)
+ }
dir := filepath.Join(s.m.dir, sub, id, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
writeErr(w, 500, "建目录失败: "+err.Error())
diff --git a/server/core_test.go b/server/core_test.go
index 49e9417..56b8a18 100644
--- a/server/core_test.go
+++ b/server/core_test.go
@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strconv"
"testing"
"time"
@@ -92,15 +94,85 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
t.Fatalf("list tasks: %d", code)
}
- // delete → cascade removes the exploration subgraph
- code, _ = doRetry("DELETE", "/api/tasks/"+id, nil)
+ // The task detail header consumes the top-level engine mode while some clients
+ // read the active-task snapshot. Keep both representations in sync.
+ code, out = doRetry("GET", "/api/stats?task="+id, nil)
+ if code != 200 {
+ t.Fatalf("task stats: %d (%v)", code, out)
+ }
+ activeTask, ok := out["active_task"].(map[string]any)
+ if !ok || activeTask["engine_mode"] != out["engine_mode"] {
+ t.Fatalf("engine mode mismatch: top=%v active_task=%v", out["engine_mode"], activeTask)
+ }
+
+ // delete → cascade removes the exploration subgraph and selected related data
+ taskDir := filepath.Join(m.dir, "tasks", id)
+ if err := os.MkdirAll(taskDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(taskDir, "artifact.txt"), []byte("test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ transcriptBase := "exp" + strconv.FormatInt(expID, 10) + "-main"
+ transcriptPath := filepath.Join(m.dir, "transcripts", transcriptBase+".jsonl")
+ sidechainPath := filepath.Join(m.dir, "transcripts", transcriptBase)
+ if err := os.MkdirAll(filepath.Join(sidechainPath, "subagents"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(transcriptPath, []byte("{}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sidechainPath, "subagents", "child.jsonl"), []byte("{}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ nid, _ := strconv.ParseInt(id, 10, 64)
+ findingID, err := m.pg.AddFinding(nid, 0, "__core_task_delete__", "", db.SeverityHigh, "summary", "evidence", "tester", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := m.pg.EnsureLLMRecordsTable(); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.pg.InsertLLMRecord(&db.LLMRecord{TaskID: id, SessionID: "delete-test", Status: "ok", RequestBody: "secret"}); err != nil {
+ t.Fatal(err)
+ }
+ // A non-canonical path still resolves to the one canonical task key for the
+ // barrier, workspace, in-memory registry and DB delete.
+ code, out = doRetry("DELETE", "/api/tasks/000"+id, map[string]bool{
+ "delete_files": true,
+ "delete_findings": true,
+ "delete_llm_records": true,
+ })
if code != 200 {
t.Fatalf("delete task: %d", code)
}
- nid, _ := strconv.ParseInt(id, 10, 64)
+ if deleted, _ := out["files_deleted"].(bool); !deleted {
+ t.Fatalf("expected files_deleted response, got %v", out)
+ }
+ if deleted, _ := out["findings_deleted"].(float64); deleted != 1 {
+ t.Fatalf("expected one deleted finding, got %v", out)
+ }
+ if deleted, _ := out["llm_records_deleted"].(float64); deleted != 1 {
+ t.Fatalf("expected one deleted LLM record, got %v", out)
+ }
+ if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
+ t.Fatalf("task directory should be deleted, stat err=%v", err)
+ }
+ for _, path := range []string{transcriptPath, sidechainPath} {
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("task transcript should be deleted (%s), stat err=%v", path, err)
+ }
+ }
if got, _ := m.pg.GetTask(nid); got != nil {
t.Fatalf("task should be deleted from PG")
}
+ if finding, err := m.pg.GetFinding(findingID); err != nil || finding != nil {
+ t.Fatalf("finding should be deleted, got finding=%+v err=%v", finding, err)
+ }
+ var llmRecords int
+ if err := m.pg.QueryRow(`SELECT count(*) FROM llm_records WHERE task_id=$1`, id).Scan(&llmRecords); err != nil || llmRecords != 0 {
+ t.Fatalf("task LLM records should be deleted, count=%d err=%v", llmRecords, err)
+ }
var n int
m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n)
if n != 0 {
diff --git a/server/dto.go b/server/dto.go
index 1f7f0cd..9c320c0 100644
--- a/server/dto.go
+++ b/server/dto.go
@@ -30,21 +30,27 @@ func rawString(raw json.RawMessage) string {
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
type TaskDTO struct {
- ID string `json:"id"`
- ExplorationID int64 `json:"exploration_id"`
- Description string `json:"description"`
- Goal string `json:"goal"`
- Status string `json:"status"` // created | running | paused | done | failed
- CreatedAt string `json:"created_at"`
- CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc)
- CompletedAt string `json:"completed_at"` // RFC3339 finish time (done/failed); "" if unfinished
- CompletedUnix int64 `json:"completed_unix"` // completed_at as unix seconds (0 if unfinished)
- LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none)
- Paused bool `json:"paused"`
- Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption
- GoalsTotal int `json:"goals_total"`
- GoalsMet int `json:"goals_met"`
- LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default
+ ID string `json:"id"`
+ ExplorationID int64 `json:"exploration_id"`
+ Description string `json:"description"`
+ Goal string `json:"goal"`
+ Status string `json:"status"` // created | running | paused | done | failed
+ CreatedAt string `json:"created_at"`
+ CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc)
+ CompletedAt string `json:"completed_at"` // RFC3339 finish time (done/failed); "" if unfinished
+ CompletedUnix int64 `json:"completed_unix"` // completed_at as unix seconds (0 if unfinished)
+ LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none)
+ Paused bool `json:"paused"`
+ Queued bool `json:"queued"`
+ Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption
+ GoalsTotal int `json:"goals_total"`
+ GoalsMet int `json:"goals_met"`
+ LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default
+ LLMProfileIDs []int64 `json:"llm_profile_ids"`
+ ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
+ LLMFailoverState string `json:"llm_failover_state"`
+ LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
+ SourceTaskIDs []string `json:"source_task_ids"`
}
// TokenTotalDTO is a whole-task (all agents) token aggregate.
@@ -65,18 +71,30 @@ func tokenTotalDTO(u db.TokenUsage) TokenTotalDTO {
}
func taskDTO(t *Task, status string) TaskDTO {
+ llmState := t.llmStateSnapshot()
+ sourceIDs := make([]string, 0, len(t.SourceTaskIDs))
+ for _, id := range t.SourceTaskIDs {
+ sourceIDs = append(sourceIDs, i64s(id))
+ }
+ profileIDs := append(make([]int64, 0, len(llmState.ProfileIDs)), llmState.ProfileIDs...)
return TaskDTO{
- ID: t.ID,
- ExplorationID: t.ExpID,
- Description: t.Description,
- Goal: t.Goal,
- Status: status,
- CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
- CreatedUnix: t.CreatedAt,
- CompletedAt: completedRFC(t.CompletedAt),
- CompletedUnix: t.CompletedAt,
- Paused: t.Paused,
- LLMProfileID: t.LLMProfileID,
+ ID: t.ID,
+ ExplorationID: t.ExpID,
+ Description: t.Description,
+ Goal: t.Goal,
+ Status: status,
+ CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
+ CreatedUnix: t.CreatedAt,
+ CompletedAt: completedRFC(t.CompletedAt),
+ CompletedUnix: t.CompletedAt,
+ Paused: t.Paused,
+ Queued: t.Queued,
+ LLMProfileID: llmState.ProfileID,
+ LLMProfileIDs: profileIDs,
+ ActiveLLMProfileID: llmState.ActiveID,
+ LLMFailoverState: llmState.FailoverState,
+ LLMFailoverReason: llmState.FailoverReason,
+ SourceTaskIDs: sourceIDs,
}
}
@@ -120,17 +138,19 @@ func trafficDTOs(ex []traffic.ExchangeMeta) []TrafficExchangeDTO {
// ---- TaskNode (frontend "TaskNode") — frontier, intents, exploration graph nodes ----
type TaskNodeDTO struct {
- ID string `json:"id"`
- Type string `json:"type"` // db Node.Kind
- Payload string `json:"payload,omitempty"`
- Priority int `json:"priority"`
- State string `json:"state"`
- Origin string `json:"origin"`
- TS string `json:"ts"`
+ ID string `json:"id"`
+ Type string `json:"type"` // db Node.Kind
+ Payload string `json:"payload,omitempty"`
+ Priority int `json:"priority"`
+ State string `json:"state"`
+ Origin string `json:"origin"`
+ TS string `json:"ts"`
+ SourceTaskID string `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
}
func taskNodeDTO(n *db.Node) TaskNodeDTO {
- return TaskNodeDTO{
+ d := TaskNodeDTO{
ID: i64s(n.ID),
Type: n.Kind,
Payload: rawString(n.Payload),
@@ -139,6 +159,11 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
Origin: n.Origin,
TS: rfc3339(n.CreatedAt),
}
+ if n.SourceTaskID > 0 {
+ d.SourceTaskID = i64s(n.SourceTaskID)
+ }
+ d.Inherited = n.Inherited
+ return d
}
func taskNodeDTOs(in []*db.Node) []TaskNodeDTO {
@@ -169,6 +194,27 @@ func edgeDTOs(in []db.Edge) []EdgeDTO {
return out
}
+// ---- Coverage asset references ----
+
+type CoverageAssetRefDTO struct {
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ State string `json:"state"`
+ Summary string `json:"summary"`
+ SourceTaskID string `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
+}
+
+func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
+ out := CoverageAssetRefDTO{
+ ID: ref.ID, Kind: ref.Kind, State: ref.State, Summary: ref.Summary, Inherited: ref.Inherited,
+ }
+ if ref.SourceTaskID > 0 {
+ out.SourceTaskID = i64s(ref.SourceTaskID)
+ }
+ return out
+}
+
// ---- Finding (frontend "Finding") ----
type FindingDTO struct {
@@ -186,6 +232,8 @@ type FindingDTO struct {
ParamID string `json:"param_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskDescription string `json:"task_description,omitempty"`
+ SourceTaskID string `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
Assets []FindingAssetDTO `json:"assets,omitempty"`
TS string `json:"ts"`
}
@@ -241,7 +289,7 @@ type findingPayload struct {
func findingDTO(n *db.Node) FindingDTO {
var p findingPayload
_ = json.Unmarshal(n.Payload, &p)
- return FindingDTO{
+ d := FindingDTO{
ID: i64s(n.ID),
VulnClass: p.VulnClass,
Name: p.Name,
@@ -251,6 +299,11 @@ func findingDTO(n *db.Node) FindingDTO {
Evidence: rawString(p.Evidence),
TS: rfc3339(n.CreatedAt),
}
+ if n.SourceTaskID > 0 {
+ d.SourceTaskID = i64s(n.SourceTaskID)
+ }
+ d.Inherited = n.Inherited
+ return d
}
// findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with
@@ -260,11 +313,15 @@ func findingDTO(n *db.Node) FindingDTO {
// nodes with no row keep the 'pending' default and no finding_id (not editable).
// assets pre-resolves the anchored asset rows for label rendering.
func findingDTOsForTask(t *Task, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
+ return findingDTOsForOwner(t.ID, t.Description, in, meta, assets)
+}
+
+func findingDTOsForOwner(taskID, description string, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
out := make([]FindingDTO, 0, len(in))
for _, n := range in {
d := findingDTO(n)
- d.TaskID = t.ID
- d.TaskDescription = t.Description
+ d.TaskID = taskID
+ d.TaskDescription = description
if m, ok := meta[n.ID]; ok {
d.FindingID = i64s(m.ID)
d.Status = m.Status
@@ -320,16 +377,19 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
// ---- Activity (frontend "Activity") ----
type ActivityDTO struct {
- Seq int64 `json:"seq"` // db Activity.ID
- IntentID string `json:"intent_id,omitempty"` // db NodeID
- Worker string `json:"worker"`
- TS string `json:"ts"` // db CreatedAt
- Kind string `json:"kind"`
- Tool string `json:"tool,omitempty"`
- ToolUseID string `json:"tool_use_id,omitempty"`
- IsError bool `json:"is_error"`
- Summary string `json:"summary"`
- Detail string `json:"detail,omitempty"`
+ Seq int64 `json:"seq"` // db Activity.ID
+ IntentID string `json:"intent_id,omitempty"` // db NodeID
+ Worker string `json:"worker"`
+ TS string `json:"ts"` // db CreatedAt
+ Kind string `json:"kind"`
+ Tool string `json:"tool,omitempty"`
+ ToolUseID string `json:"tool_use_id,omitempty"`
+ IsError bool `json:"is_error"`
+ Summary string `json:"summary"`
+ Detail string `json:"detail,omitempty"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ SourceTaskID string `json:"source_task_id,omitempty"`
+ Inherited bool `json:"inherited,omitempty"`
// token usage (set only on kind='result'); used for per-session token totals.
InputTokens *int `json:"input_tokens,omitempty"`
OutputTokens *int `json:"output_tokens,omitempty"`
@@ -342,7 +402,7 @@ func activityDTO(a db.Activity) ActivityDTO {
if a.NodeID != nil {
intent = i64s(*a.NodeID)
}
- return ActivityDTO{
+ d := ActivityDTO{
Seq: a.ID,
IntentID: intent,
Worker: a.Worker,
@@ -353,11 +413,17 @@ func activityDTO(a db.Activity) ActivityDTO {
IsError: a.IsError,
Summary: a.Summary,
Detail: a.Detail, // list endpoint leaves this empty (lazy)
+ Metadata: a.Metadata,
InputTokens: a.InputTokens,
OutputTokens: a.OutputTokens,
CacheReadTokens: a.CacheReadTokens,
CacheWriteTokens: a.CacheWriteTokens,
}
+ if a.SourceTaskID > 0 {
+ d.SourceTaskID = i64s(a.SourceTaskID)
+ }
+ d.Inherited = a.Inherited
+ return d
}
func activityDTOs(in []db.Activity) []ActivityDTO {
diff --git a/server/engine.go b/server/engine.go
index 205f270..c52e76b 100644
--- a/server/engine.go
+++ b/server/engine.go
@@ -73,6 +73,13 @@ const (
modelErrorRetryBackoff = 3 * time.Second // 每次重试前的退避
)
+// retryableWorkerModelError excludes errors already handled by the task router.
+// In particular, a quota error after partial streaming advances the task cursor
+// for the next LLM call but must not replay this whole intent on the backup.
+func retryableWorkerModelError(reason harness.TerminalReason, err error) bool {
+ return reason == harness.ReasonModelError && !isTaskLLMRuntimeError(err)
+}
+
// Engine drives the event-driven exploration loop with real LLM agents
// (docs §4.3/§4.4): on asset/exploration-graph change (debounced) it wakes the
// planner, which reads the route, queries assets, judges goals and emits intents;
@@ -86,28 +93,43 @@ type Engine struct {
bc *Broadcaster // live activity pub/sub (SSE)
- mu sync.RWMutex
- planner *agent.Planner
- worker *agent.Worker
- started sync.Map // taskID -> bool, so Run is idempotent per task
- lastAct sync.Map // taskID -> int64 unix, last planner/worker activity (heartbeat)
- paused sync.Map // taskID -> bool, user-paused (planner + workers idle but loops alive)
- dropCnt sync.Map // taskID -> *int64, running count of dropped (unpersistable) activity records
+ mu sync.RWMutex
+ planner *agent.Planner
+ worker *agent.Worker
+ started sync.Map // taskID -> bool, so Run is idempotent per task
+ lastAct sync.Map // taskID -> int64 unix, last planner/worker activity (heartbeat)
+ llmCalls sync.Map // taskID -> *int64, actual planner/worker/main-agent LLM calls
+ paused sync.Map // taskID -> bool, user-paused (planner + workers idle but loops alive)
+ deleting sync.Map // taskID -> bool, delete barrier (no new task-owned writes)
+ // deleteWasPaused remembers whether BeginDelete temporarily paused an already
+ // running task. AbortDelete uses it to restore the exact pre-delete state.
+ deleteWasPaused sync.Map // taskID -> bool
+ dropCnt sync.Map // taskID -> *int64, running count of dropped (unpersistable) activity records
+
+ // deleteMu makes installing the delete barrier atomic with registering a new
+ // task operation. Once BeginDelete returns, every admitted writer is reflected
+ // in inflight and every later writer is rejected.
+ deleteMu sync.RWMutex
+
+ // Every long-lived task goroutine (planner, workers and deadline coordinator)
+ // runs under one task-scoped context. Successful deletion cancels that context,
+ // waits for all goroutines, then releases every task-level Engine reference.
+ runtimeMu sync.Mutex
+ runtimes map[string]*taskRuntime
// per-task execution context: each planner.Plan / worker.Execute runs under it,
// so pausing can CANCEL an in-flight run (not just skip the next one). Recreated
- // on resume since cancelling is one-shot.
- // CancelCauseFunc (not CancelFunc): every cancel site attaches a named
- // *agent.AbortCause so the run's trace can say WHICH of the ~10 cancel
- // paths fired instead of one catch-all "运行被中断" line.
+ // on resume since cancelling is one-shot. Every cancellation carries a named
+ // cause so the activity trace can identify the initiating control path.
execMu sync.Mutex
execCancel map[string]context.CancelCauseFunc
execCtx map[string]context.Context
- // per-work (per running intent) cancel, so the planner's kill_work can stop a
- // single worker without pausing the whole task. Keyed by globally-unique intent id.
- workMu sync.Mutex
- workCancel map[int64]context.CancelCauseFunc
+ // Per-work control lets the planner kill a worker and lets the UI pause/cancel
+ // one intent without pausing the whole task. The done channel closes only after
+ // runWorkerStep has stopped writing and committed its final state.
+ workMu sync.Mutex
+ work map[int64]*workExecution
// steerBox queues planner course-corrections for a running work (keyed by intent
// id). The worker's PreToolUse hook drains it before its next tool call and hands
@@ -124,9 +146,23 @@ type Engine struct {
inflight sync.Map // taskID -> *int64, 在跑的 planner.Plan + worker.Execute 计数(用于 drain)
coordStarted sync.Map // taskID -> bool, deadline 协调器是否已启动(Run/reload 去重)
- // resolve, if set, returns a task's dedicated planner/worker when it pins a
- // specific LLM profile (nil,nil = use the global active pair). Set once at startup.
- resolve func(t *Task) (*agent.Planner, *agent.Worker)
+ // resolve, if set, returns a task's dedicated planner/worker. In legacy mode
+ // nil,nil falls back to the global pair; an authoritative resolver uses nil,nil
+ // to mean this task is deliberately unavailable (for example an exhausted chain).
+ resolve func(t *Task) (*agent.Planner, *agent.Worker)
+ resolveAuthoritative bool
+}
+
+type taskRuntime struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+type workExecution struct {
+ cancel context.CancelCauseFunc
+ done chan struct{}
+ action string // user action: pause | cancel
}
// nextPlannerRound returns the next planner round number for a task (1-based).
@@ -139,17 +175,132 @@ func (e *Engine) nextPlannerRound(taskID string) int {
// Pause stops a task: marks it paused AND cancels any in-flight planner/worker run
// for it (a long worker.Execute would otherwise keep going until it finishes).
-// cause says WHY (user button / orchestrator tool / delete / reload) — it surfaces
-// verbatim in the cancelled run's trace, so pass the matching agent.Abort* value.
func (e *Engine) Pause(taskID string, cause error) {
e.paused.Store(taskID, true)
e.cancelExec(taskID, cause)
}
+// BeginDelete installs an execution barrier before task data/files are removed.
+// The temporary pause is not a user pause: AbortDelete restores the exact state
+// observed here when cleanup fails.
+func (e *Engine) BeginDelete(taskID string) bool {
+ e.deleteMu.Lock()
+ if _, loaded := e.deleting.LoadOrStore(taskID, true); loaded {
+ e.deleteMu.Unlock()
+ return false
+ }
+ wasPaused := e.IsPaused(taskID)
+ e.deleteWasPaused.Store(taskID, wasPaused)
+ e.paused.Store(taskID, true)
+ e.deleteMu.Unlock()
+ e.cancelExec(taskID, agent.AbortTaskDeleted)
+ return true
+}
+
+func (e *Engine) AbortDelete(taskID string) {
+ e.deleteMu.Lock()
+ if !e.IsDeleting(taskID) {
+ e.deleteMu.Unlock()
+ return
+ }
+ wasPaused := false
+ if v, ok := e.deleteWasPaused.Load(taskID); ok {
+ wasPaused, _ = v.(bool)
+ }
+ e.deleteWasPaused.Delete(taskID)
+ e.deleting.Delete(taskID)
+ if !wasPaused {
+ e.paused.Delete(taskID)
+ }
+ e.deleteMu.Unlock()
+ if !wasPaused && e.m != nil {
+ if t, ok := e.m.Task(taskID); ok {
+ t.Notify()
+ }
+ }
+}
+
+func (e *Engine) IsDeleting(taskID string) bool {
+ _, ok := e.deleting.Load(taskID)
+ return ok
+}
+
+// registerTaskRoutines reserves count goroutines in the task runtime. Callers
+// hold deleteMu for reading so StopTask cannot race WaitGroup.Add with Wait.
+func (e *Engine) registerTaskRoutines(parent context.Context, taskID string, count int) *taskRuntime {
+ e.runtimeMu.Lock()
+ defer e.runtimeMu.Unlock()
+ rt := e.runtimes[taskID]
+ if rt == nil {
+ ctx, cancel := context.WithCancel(parent)
+ rt = &taskRuntime{ctx: ctx, cancel: cancel}
+ e.runtimes[taskID] = rt
+ }
+ rt.wg.Add(count)
+ return rt
+}
+
+func runTaskRoutine(rt *taskRuntime, fn func(context.Context)) {
+ go func() {
+ defer rt.wg.Done()
+ fn(rt.ctx)
+ }()
+}
+
+// StopTask permanently stops every long-lived goroutine and removes all Engine
+// state for a successfully deleted task. The delete barrier remains installed
+// until cleanup finishes, so no new task operation can race the teardown.
+func (e *Engine) StopTask(taskID string) {
+ e.deleteMu.Lock()
+ e.deleting.Store(taskID, true)
+ e.deleteMu.Unlock()
+
+ e.cancelExec(taskID, agent.AbortTaskDeleted)
+ e.runtimeMu.Lock()
+ rt := e.runtimes[taskID]
+ if rt != nil {
+ rt.cancel()
+ }
+ e.runtimeMu.Unlock()
+ if rt != nil {
+ rt.wg.Wait()
+ }
+
+ e.execMu.Lock()
+ if cancel := e.execCancel[taskID]; cancel != nil {
+ cancel(agent.AbortTaskDeleted)
+ }
+ delete(e.execCancel, taskID)
+ delete(e.execCtx, taskID)
+ e.execMu.Unlock()
+
+ e.runtimeMu.Lock()
+ if e.runtimes[taskID] == rt {
+ delete(e.runtimes, taskID)
+ }
+ e.runtimeMu.Unlock()
+
+ e.started.Delete(taskID)
+ e.lastAct.Delete(taskID)
+ e.llmCalls.Delete(taskID)
+ e.paused.Delete(taskID)
+ e.dropCnt.Delete(taskID)
+ e.plannerRound.Delete(taskID)
+ e.settling.Delete(taskID)
+ e.deadline.Delete(taskID)
+ e.stamped.Delete(taskID)
+ e.inflight.Delete(taskID)
+ e.coordStarted.Delete(taskID)
+ e.deleteWasPaused.Delete(taskID)
+
+ e.deleteMu.Lock()
+ e.deleting.Delete(taskID)
+ e.deleteMu.Unlock()
+}
+
// cancelExec cancels a task's current per-task exec context (any in-flight
// planner.Plan / worker.Execute), if present. Shared by Pause and the settle
-// sequence's hard-drain backstop. cause is recovered by the aborted run via
-// context.Cause and printed in its result record.
+// sequence's hard-drain backstop.
func (e *Engine) cancelExec(taskID string, cause error) {
e.execMu.Lock()
if cancel := e.execCancel[taskID]; cancel != nil {
@@ -206,32 +357,94 @@ func (e *Engine) LastActivity(taskID string) int64 {
return 0
}
+// BeginLLMCall/EndLLMCall track actual provider calls separately from the
+// scheduler's task-operation counter. A task can have live loops while all of
+// them are waiting for a trigger; that state must remain idle in the UI.
+func (e *Engine) BeginLLMCall(taskID string) {
+ v, _ := e.llmCalls.LoadOrStore(taskID, new(int64))
+ atomic.AddInt64(v.(*int64), 1)
+}
+
+func (e *Engine) EndLLMCall(taskID string) {
+ if v, ok := e.llmCalls.Load(taskID); ok {
+ p := v.(*int64)
+ if atomic.AddInt64(p, -1) <= 0 {
+ atomic.StoreInt64(p, 0)
+ }
+ }
+}
+
+func (e *Engine) ActiveLLMCalls(taskID string) int64 {
+ if v, ok := e.llmCalls.Load(taskID); ok {
+ return atomic.LoadInt64(v.(*int64))
+ }
+ return 0
+}
+
func (e *Engine) touch(taskID string) { e.lastAct.Store(taskID, time.Now().Unix()) }
func NewEngine(m *Manager) *Engine {
return &Engine{m: m, debounce: 800 * time.Millisecond, bc: NewBroadcaster(),
execCancel: map[string]context.CancelCauseFunc{}, execCtx: map[string]context.Context{},
- workCancel: map[int64]context.CancelCauseFunc{}, steerBox: map[int64][]string{}}
+ work: map[int64]*workExecution{}, steerBox: map[int64][]string{},
+ runtimes: map[string]*taskRuntime{}}
}
// registerWork records the cancel for the work currently running intentID.
func (e *Engine) registerWork(intentID int64, cancel context.CancelCauseFunc) {
e.workMu.Lock()
- e.workCancel[intentID] = cancel
+ e.work[intentID] = &workExecution{cancel: cancel, done: make(chan struct{})}
e.workMu.Unlock()
}
-// unregisterWork removes and releases the work's cancel (called when Execute returns).
-func (e *Engine) unregisterWork(intentID int64) {
+// detachWork removes the live control handle once Execute has returned. complete
+// must be called after the final intent state write so a waiting cancel handler can
+// safely delete the worker's blackboard output without racing a late write.
+func (e *Engine) detachWork(intentID int64) (action string, complete func()) {
e.workMu.Lock()
- if c := e.workCancel[intentID]; c != nil {
- delete(e.workCancel, intentID)
- c(agent.AbortWorkFinished) // release context resources (no-op if already cancelled)
+ run := e.work[intentID]
+ if run != nil {
+ delete(e.work, intentID)
+ action = run.action
+ run.cancel(agent.AbortWorkFinished) // release resources (no-op if already cancelled)
}
e.workMu.Unlock()
e.steerMu.Lock()
delete(e.steerBox, intentID) // drop any undelivered steering for a finished work
e.steerMu.Unlock()
+ if run == nil {
+ return action, func() {}
+ }
+ return action, func() { close(run.done) }
+}
+
+// ControlWork requests a user-visible pause or cancellation and waits until the
+// worker has fully stopped writing. Cancellation cleanup is performed by the API
+// handler after this returns; pause state is committed by runWorkerStep itself.
+func (e *Engine) ControlWork(intentID int64, action string) error {
+ if action != "pause" && action != "cancel" {
+ return fmt.Errorf("unsupported work action %q", action)
+ }
+ e.workMu.Lock()
+ run := e.work[intentID]
+ if run == nil {
+ e.workMu.Unlock()
+ return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID)
+ }
+ if run.action != "" && run.action != action {
+ e.workMu.Unlock()
+ return fmt.Errorf("意图 %d 正在执行 %s 操作", intentID, run.action)
+ }
+ run.action = action
+ done := run.done
+ cause := error(agent.AbortWorkPausedByUser)
+ if action == "cancel" {
+ cause = agent.AbortWorkCancelledByUser
+ }
+ run.cancel(cause)
+ e.workMu.Unlock()
+ <-done
+ return nil
}
// SteerWork queues a mid-run course-correction for the work running intentID (the
@@ -242,7 +455,7 @@ func (e *Engine) SteerWork(intentID int64, msg string) error {
return fmt.Errorf("纠偏消息不能为空")
}
e.workMu.Lock()
- running := e.workCancel[intentID] != nil
+ running := e.work[intentID] != nil
e.workMu.Unlock()
if !running {
return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID)
@@ -307,12 +520,12 @@ func (h steerHooks) Stop(ctx context.Context, messages []llm.Message) (bool, []s
// The work's agent-core session honors ctx cancellation and aborts promptly.
func (e *Engine) KillWork(intentID int64) error {
e.workMu.Lock()
- c := e.workCancel[intentID]
+ run := e.work[intentID]
e.workMu.Unlock()
- if c == nil {
+ if run == nil {
return fmt.Errorf("意图 %d 当前没有运行中的 work(可能已结束或未被领取)", intentID)
}
- c(agent.AbortKilledByPlanner)
+ run.cancel(agent.AbortKilledByPlanner)
return nil
}
@@ -321,7 +534,7 @@ func (e *Engine) Broadcaster() *Broadcaster { return e.bc }
// emitActivity persists one captured step AND fans it out to live subscribers,
// from a single point so storage and the SSE stream never diverge.
-func (e *Engine) emitActivity(t *Task, r db.Activity) {
+func (e *Engine) emitActivity(t *Task, r db.Activity) db.Activity {
id, err := e.appendActivity(t, r)
if err != nil {
// NO LONGER SILENT: dropping a record breaks command↔result pairing in the
@@ -346,7 +559,7 @@ func (e *Engine) emitActivity(t *Task, r db.Activity) {
log.Printf("[activity] task %s 丢弃活动记录(该任务累计第 %d 条) worker=%s kind=%s tool=%s tuid=%s reason=%s summary=%q: %v%s",
t.ID, n, r.Worker, r.Kind, r.Tool, r.ToolUseID, dropReason(err), preview(r.Summary, 80), err, diag)
e.touch(t.ID)
- return
+ return r
}
r.ID = id
if r.CreatedAt.IsZero() {
@@ -354,6 +567,7 @@ func (e *Engine) emitActivity(t *Task, r db.Activity) {
}
e.bc.Publish(t.ID, r)
e.touch(t.ID)
+ return r
}
// appendActivity persists one activity row, retrying briefly on write failure.
@@ -396,6 +610,15 @@ func (e *Engine) snapshot() (*agent.Planner, *agent.Worker) {
// Called once at startup before any task loop runs, so no lock is needed on reads.
func (e *Engine) SetAgentResolver(fn func(t *Task) (*agent.Planner, *agent.Worker)) {
e.resolve = fn
+ e.resolveAuthoritative = false
+}
+
+// SetAuthoritativeAgentResolver installs a resolver whose nil result must not
+// fall through to the global provider. Task-level failover chains use this so a
+// fully exhausted chain cannot silently bypass its configured boundary.
+func (e *Engine) SetAuthoritativeAgentResolver(fn func(t *Task) (*agent.Planner, *agent.Worker)) {
+ e.resolve = fn
+ e.resolveAuthoritative = true
}
// snapshotFor returns the planner/worker a task should run on: the task's dedicated
@@ -403,7 +626,8 @@ func (e *Engine) SetAgentResolver(fn func(t *Task) (*agent.Planner, *agent.Worke
// pair. The resolver returning nil means "no override" (fall back to global).
func (e *Engine) snapshotFor(t *Task) (*agent.Planner, *agent.Worker) {
if e.resolve != nil {
- if p, w := e.resolve(t); p != nil && w != nil {
+ p, w := e.resolve(t)
+ if (p != nil && w != nil) || e.resolveAuthoritative {
return p, w
}
}
@@ -416,6 +640,14 @@ func (e *Engine) Ready() bool {
return p != nil && w != nil
}
+// ReadyFor reports whether a specific task can resolve a planner/worker pair.
+// An explicit task profile chain can be runnable even when no global default
+// provider is configured, so task status must not rely on Ready alone.
+func (e *Engine) ReadyFor(t *Task) bool {
+ p, w := e.snapshotFor(t)
+ return p != nil && w != nil
+}
+
// Mode reports "llm" when ready, else "idle".
func (e *Engine) Mode() string {
if e.Ready() {
@@ -428,15 +660,24 @@ func (e *Engine) Mode() string {
// but no-op until an LLM is configured (so a task created while idle picks up
// automatically once LLM is set from the UI).
func (e *Engine) Run(ctx context.Context, t *Task) {
+ workers := e.m.Workers()
+ e.deleteMu.RLock()
+ if e.IsDeleting(t.ID) {
+ e.deleteMu.RUnlock()
+ return
+ }
if _, loaded := e.started.LoadOrStore(t.ID, true); loaded {
+ e.deleteMu.RUnlock()
t.Notify() // already running — just nudge a planning round
return
}
+ rt := e.registerTaskRoutines(ctx, t.ID, 1+workers)
+ e.deleteMu.RUnlock()
e.touch(t.ID)
- go e.plannerLoop(ctx, t)
- workers := e.m.Workers()
+ runTaskRoutine(rt, func(loopCtx context.Context) { e.plannerLoop(loopCtx, t) })
for i := 0; i < workers; i++ {
- go e.workerLoop(ctx, t, fmt.Sprintf("work#%d", i+1))
+ name := fmt.Sprintf("work#%d", i+1)
+ runTaskRoutine(rt, func(loopCtx context.Context) { e.workerLoop(loopCtx, t, name) })
}
e.startDeadlineCoordinator(ctx, t) // 任务级超时定时器(仅 timeout>0;去重)
// 仅在「完全没有活动意图(open+running)」时才 kick 首轮规划。带种子意图的任务:种子已
@@ -497,6 +738,9 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
if e.IsPaused(t.ID) {
return // user-paused: don't plan
}
+ if e.IsDeleting(t.ID) {
+ return
+ }
// terminal task (goals all met → done, or failed): the run is over. A
// resume/nudge — e.g. auto-resume of the active task on restart — must NOT
// re-plan (it would burn an LLM round and re-confirm a settled result).
@@ -508,10 +752,17 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
if e.isSettling(t.ID) {
return
}
+ if !e.beginTaskOperation(t.ID) {
+ return
+ }
+ defer e.decInflight(t.ID)
e.stampFirstRun(t) // 首次真正规划 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
e.touch(t.ID)
emit := func(r db.Activity) { e.emitActivity(t, r) }
ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline
+ if ectx.Err() != nil || e.IsDeleting(t.ID) {
+ return
+ }
log.Printf("[planner] task %s 规划中…(%s 触发)", t.ID, src)
// round marker: each Plan() is one planner round; emit a boundary so the
// UI can separate rounds in the transcript (kind='round').
@@ -520,10 +771,10 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
// what fired this round (worker done / finding; may be several — debounce
// coalesces a burst; empty for time/heartbeat wakes).
triggers := t.drainTriggers()
- e.incInflight(t.ID)
taskIDInt, _ := strconv.ParseInt(t.ID, 10, 64)
+ e.BeginLLMCall(t.ID)
met, reason, err := planner.Plan(ectx, taskIDInt, e.m.assets, t.Store, t.Goal, triggers, emit)
- e.decInflight(t.ID)
+ e.EndLLMCall(t.ID)
switch {
case err != nil && ectx.Err() == nil:
log.Printf("[planner] task %s 规划出错: %v", t.ID, err)
@@ -578,6 +829,9 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
}
continue // user-paused: don't claim/execute intents
}
+ if e.IsDeleting(t.ID) {
+ return
+ }
if e.isSettling(t.ID) {
if sleepCtx(ctx, 1000*time.Millisecond) {
return
@@ -590,108 +844,144 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
}
continue // 任务已终态(done/failed/timeout):停止领取遗留意图,别在完成后空跑 frontier
}
- intent := e.claimNext(t, name)
- if intent == nil {
- if sleepCtx(ctx, 800*time.Millisecond) {
- return
- }
- continue
- }
- log.Printf("[worker %s] task %s 领取意图 #%d", name, t.ID, intent.ID)
- e.stampFirstRun(t) // 首次真正执行 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
- e.touch(t.ID)
- emit := func(r db.Activity) { e.emitActivity(t, r) }
- ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline
- // per-work child context so the planner's kill_work can stop just this work.
- workCtx, workCancel := context.WithCancelCause(ectx)
- e.registerWork(intent.ID, workCancel)
- // wrap the guard hooks so steer_work can inject a mid-run course-correction
- // for THIS intent (drained before the worker's next tool call).
- iid := intent.ID
- taskEmit := func(a db.Activity) {
- nid := iid
- a.NodeID, a.Worker = &nid, name
- emit(a)
- }
- workCtx = intercept.WithTaskContext(workCtx, t.ID, fmt.Sprintf("%s · #%d", name, iid), taskEmit)
- hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
- wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
- e.incInflight(t.ID) // 计入在跑,供收尾时序 drain 等待
- reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
- // model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
- // 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
- // 再重试,避免退避挤占其他 worker 的优雅收尾窗口)。
- for attempt := 1; attempt <= modelErrorRetries &&
- reason == harness.ReasonModelError &&
- workCtx.Err() == nil && ectx.Err() == nil && !e.IsPaused(t.ID) && !e.isSettling(t.ID); attempt++ {
- log.Printf("[worker %s] task %s 意图 #%d model_error 收场,%v 后重试 (%d/%d)",
- name, t.ID, intent.ID, modelErrorRetryBackoff, attempt, modelErrorRetries)
- if sleepCtx(workCtx, modelErrorRetryBackoff) {
- break // 退避期间被取消(终止/暂停)→ 交给下方分支处理
- }
- reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
+ if !e.beginTaskOperation(t.ID) {
+ return
}
+ claimed := e.runWorkerStep(ctx, t, name, worker)
e.decInflight(t.ID)
- // CAPTURE kill state BEFORE unregisterWork cancels workCtx. kill = this work's
- // ctx was cancelled (planner kill_work) while the TASK ctx kept running; a
- // pause cancels the task ctx (ectx) instead. Checking workCtx.Err() AFTER
- // unregister would always be true (unregister cancels it) → every completed
- // work would be wrongly marked stopped.
- killed := workCtx.Err() != nil && ectx.Err() == nil
- e.unregisterWork(intent.ID)
- // if a pause cancelled this run mid-flight, return the intent to the frontier
- // so it is re-claimed (and re-run from scratch) on resume — not marked done.
- if ectx.Err() != nil && e.IsPaused(t.ID) {
- _ = t.Store.SetIntentState(intent.ID, "open")
- continue
+ if !claimed && sleepCtx(ctx, 800*time.Millisecond) {
+ return
}
- // 任务超时收尾的硬兜底 cancel(非 pause、非 kill)取消了本 run → 归为 exhausted(已收尾),
- // 不要误标 blocked。此时 worker 通常已在 settlement 阶段把结果写回。
- if ectx.Err() != nil && e.isSettling(t.ID) {
- _ = t.Store.SetIntentState(intent.ID, "exhausted")
- log.Printf("[worker %s] task %s 意图 #%d 因任务超时收尾结束(exhausted),写回 %s", name, t.ID, intent.ID, wrote)
- e.touch(t.ID)
- continue
- }
- // 任务已判完成(done via 常规路径)→ 上面 cancelExec 取消了本 run。意图结果已无意义,
- // 标 stopped(不是 blocked),别污染已完成任务的意图状态。
- if ectx.Err() != nil && isTerminalStatus(e.m.TaskStatus(t.ID)) {
- _ = t.Store.SetIntentState(intent.ID, "stopped")
- log.Printf("[worker %s] task %s 意图 #%d 因任务已完成而取消(stopped)", name, t.ID, intent.ID)
- e.touch(t.ID)
- continue
- }
- // killed by the planner: mark stopped (don't write back results, don't auto-reclaim).
- if killed {
- _ = t.Store.SetIntentState(intent.ID, "stopped")
- log.Printf("[worker %s] task %s 意图 #%d 被终止(stopped)", name, t.ID, intent.ID)
- e.touch(t.ID)
- t.Notify()
- continue
- }
- if err != nil {
- log.Printf("[worker %s] intent %d: %v", name, intent.ID, err)
- }
- // terminal分流:撞步数上限 ≠ 完成。max_turns→exhausted(规划者据此知道这个方向
- // 试过但没真正做完、需换角度,而非当成已覆盖永久跳过);出错→blocked;正常→done。
- state := "done"
- switch {
- case err != nil:
- state = "blocked"
- case reason == harness.ReasonMaxTurns:
- state = "exhausted"
- log.Printf("[worker %s] intent %d 撞步数上限(exhausted),本次写回 %s", name, intent.ID, wrote)
- case reason == harness.ReasonTimeout:
- state = "exhausted"
- log.Printf("[worker %s] intent %d 运行超时(exhausted),收尾后写回 %s", name, intent.ID, wrote)
- }
- _ = t.Store.SetIntentState(intent.ID, state)
- log.Printf("[worker %s] task %s 意图 #%d 结束: %s (写回 %s)", name, t.ID, intent.ID, state, wrote)
- e.touch(t.ID)
- t.NotifyDone(intent.ID) // results changed the graph -> wake the planner (with the just-finished intent id)
}
}
+// runWorkerStep claims and fully settles at most one intent. Its caller holds one
+// task-operation admission for the whole claim/execute/state-write sequence so a
+// delete cannot observe quiescence between the LLM return and the final DB writes.
+func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker *agent.Worker) bool {
+ intent := e.claimNext(t, name)
+ if intent == nil {
+ return false
+ }
+ log.Printf("[worker %s] task %s 领取意图 #%d", name, t.ID, intent.ID)
+ e.stampFirstRun(t) // 首次真正执行 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
+ e.touch(t.ID)
+ emit := func(r db.Activity) { e.emitActivity(t, r) }
+ ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline
+ if ectx.Err() != nil || e.IsDeleting(t.ID) {
+ _ = t.Store.SetIntentState(intent.ID, "open")
+ return true
+ }
+ // per-work child context so the planner's kill_work can stop just this work.
+ workCtx, workCancel := context.WithCancelCause(ectx)
+ e.registerWork(intent.ID, workCancel)
+ // wrap the guard hooks so steer_work can inject a mid-run course-correction
+ // for THIS intent (drained before the worker's next tool call).
+ iid := intent.ID
+ taskEmit := func(a db.Activity) {
+ nid := iid
+ a.NodeID, a.Worker = &nid, name
+ emit(a)
+ }
+ workCtx = intercept.WithTaskContext(workCtx, t.ID, fmt.Sprintf("%s · #%d", name, iid), taskEmit)
+ hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
+ wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
+ e.BeginLLMCall(t.ID)
+ reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
+ e.EndLLMCall(t.ID)
+ // model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
+ // 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
+ // 再重试,避免退避挤占其他 worker 的优雅收尾窗口)。
+ for attempt := 1; attempt <= modelErrorRetries &&
+ retryableWorkerModelError(reason, err) &&
+ workCtx.Err() == nil && ectx.Err() == nil && !e.IsPaused(t.ID) && !e.isSettling(t.ID); attempt++ {
+ log.Printf("[worker %s] task %s 意图 #%d model_error 收场,%v 后重试 (%d/%d)",
+ name, t.ID, intent.ID, modelErrorRetryBackoff, attempt, modelErrorRetries)
+ if sleepCtx(workCtx, modelErrorRetryBackoff) {
+ break // 退避期间被取消(终止/暂停)→ 交给下方分支处理
+ }
+ e.BeginLLMCall(t.ID)
+ reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
+ e.EndLLMCall(t.ID)
+ }
+ // Capture kill state before detachWork cancels workCtx. kill = this work's
+ // ctx was cancelled (planner kill_work) while the TASK ctx kept running; a
+ // pause cancels the task ctx (ectx) instead. Checking workCtx.Err() AFTER
+ // unregister would always be true (unregister cancels it) → every completed
+ // work would be wrongly marked stopped.
+ killed := workCtx.Err() != nil && ectx.Err() == nil
+ action, completeWork := e.detachWork(intent.ID)
+ defer completeWork()
+ if action == "pause" {
+ _ = t.Store.SetIntentState(intent.ID, "paused")
+ log.Printf("[worker %s] task %s 意图 #%d 已暂停", name, t.ID, intent.ID)
+ e.touch(t.ID)
+ return true
+ }
+ if action == "cancel" {
+ // Leave the node in running until the waiting API handler deletes the intent
+ // and its direct outputs transactionally. No task Notify is emitted here.
+ log.Printf("[worker %s] task %s 意图 #%d 已停止,等待取消清理", name, t.ID, intent.ID)
+ e.touch(t.ID)
+ return true
+ }
+ // if a pause cancelled this run mid-flight, return the intent to the frontier
+ // so it is re-claimed (and re-run from scratch) on resume — not marked done.
+ if ectx.Err() != nil && e.IsPaused(t.ID) {
+ _ = t.Store.SetIntentState(intent.ID, "open")
+ return true
+ }
+ // 任务超时收尾的硬兜底 cancel(非 pause、非 kill)取消了本 run → 归为 exhausted(已收尾),
+ // 不要误标 blocked。此时 worker 通常已在 settlement 阶段把结果写回。
+ if ectx.Err() != nil && e.isSettling(t.ID) {
+ _ = t.Store.SetIntentState(intent.ID, "exhausted")
+ log.Printf("[worker %s] task %s 意图 #%d 因任务超时收尾结束(exhausted),写回 %s", name, t.ID, intent.ID, wrote)
+ e.touch(t.ID)
+ return true
+ }
+ // 任务已判完成(done via 常规路径)→ 上面 cancelExec 取消了本 run。意图结果已无意义,
+ // 标 stopped(不是 blocked),别污染已完成任务的意图状态。
+ if ectx.Err() != nil && isTerminalStatus(e.m.TaskStatus(t.ID)) {
+ _ = t.Store.SetIntentState(intent.ID, "stopped")
+ log.Printf("[worker %s] task %s 意图 #%d 因任务已完成而取消(stopped)", name, t.ID, intent.ID)
+ e.touch(t.ID)
+ return true
+ }
+ // killed by the planner: mark stopped (don't write back results, don't auto-reclaim).
+ if killed {
+ _ = t.Store.SetIntentState(intent.ID, "stopped")
+ log.Printf("[worker %s] task %s 意图 #%d 被终止(stopped)", name, t.ID, intent.ID)
+ e.touch(t.ID)
+ t.Notify()
+ return true
+ }
+ if err != nil {
+ log.Printf("[worker %s] intent %d: %v", name, intent.ID, err)
+ }
+ // terminal分流:撞步数上限 ≠ 完成。max_turns→exhausted(规划者据此知道这个方向
+ // 试过但没真正做完、需换角度,而非当成已覆盖永久跳过);出错→blocked;正常→done。
+ state := "done"
+ switch {
+ case err != nil:
+ state = "blocked"
+ case reason == harness.ReasonMaxTurns:
+ state = "exhausted"
+ log.Printf("[worker %s] intent %d 撞步数上限(exhausted),本次写回 %s", name, intent.ID, wrote)
+ case reason == harness.ReasonTimeout:
+ state = "exhausted"
+ log.Printf("[worker %s] intent %d 运行超时(exhausted),收尾后写回 %s", name, intent.ID, wrote)
+ }
+ if state == "blocked" && isTaskLLMChainExhausted(err) {
+ _ = t.Store.SetIntentBlockedReason(intent.ID, db.IntentBlockedLLMQuota)
+ } else {
+ _ = t.Store.SetIntentState(intent.ID, state)
+ }
+ log.Printf("[worker %s] task %s 意图 #%d 结束: %s (写回 %s)", name, t.ID, intent.ID, state, wrote)
+ e.touch(t.ID)
+ t.NotifyDone(intent.ID) // results changed the graph -> wake the planner (with the just-finished intent id)
+ return true
+}
+
func sleepCtx(ctx context.Context, d time.Duration) (done bool) {
select {
case <-ctx.Done():
diff --git a/server/engine_cancelcause_test.go b/server/engine_cancelcause_test.go
index 0669679..5501821 100644
--- a/server/engine_cancelcause_test.go
+++ b/server/engine_cancelcause_test.go
@@ -7,90 +7,103 @@ import (
"github.com/Autumn-27/artex/agent"
)
-// The engine's cancel paths must hand a NAMED cause to the run being cancelled —
-// that cause is the whole content of the "运行被中断" line in the trace. These
-// exercise the context machinery only (no Manager / DB needed).
-
func TestPauseCancelsWithNamedCause(t *testing.T) {
e := NewEngine(nil)
- ectx := e.execContextFor(context.Background(), "t1")
- // the per-work child the worker loop actually runs under
- workCtx, workCancel := context.WithCancelCause(ectx)
+ execCtx := e.execContextFor(context.Background(), "t1")
+ workCtx, workCancel := context.WithCancelCause(execCtx)
defer workCancel(nil)
-
e.Pause("t1", agent.AbortPausedByUser)
-
if code, _, _, ok := agent.AbortReason(workCtx); !ok || code != "paused_by_user" {
- t.Fatalf("work ctx 拿到的原因 code=%q ok=%v,期望 paused_by_user", code, ok)
+ t.Fatalf("code=%q ok=%v, want paused_by_user", code, ok)
}
}
func TestKillWorkCancelsOnlyThatWorkWithNamedCause(t *testing.T) {
e := NewEngine(nil)
- ectx := e.execContextFor(context.Background(), "t1")
- workCtx, workCancel := context.WithCancelCause(ectx)
+ execCtx := e.execContextFor(context.Background(), "t1")
+ workCtx, workCancel := context.WithCancelCause(execCtx)
e.registerWork(42, workCancel)
-
if err := e.KillWork(42); err != nil {
- t.Fatalf("KillWork: %v", err)
+ t.Fatal(err)
}
if code, _, _, ok := agent.AbortReason(workCtx); !ok || code != "killed_by_planner" {
- t.Fatalf("code=%q ok=%v,期望 killed_by_planner", code, ok)
+ t.Fatalf("code=%q ok=%v, want killed_by_planner", code, ok)
}
- // the task ctx must survive — kill_work stops one work, not the whole task
- if ectx.Err() != nil {
- t.Error("kill_work 不该取消整个任务的 exec ctx")
- }
- if err := e.KillWork(999); err == nil {
- t.Error("对没有在跑的意图 KillWork 应该报错")
+ if execCtx.Err() != nil {
+ t.Fatal("kill_work cancelled the entire task context")
}
+ _, complete := e.detachWork(42)
+ complete()
}
-func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) {
+func TestControlWorkCarriesUserActionCause(t *testing.T) {
for _, tc := range []struct {
- name string
- cause *agent.AbortCause
- want string
+ action string
+ code string
}{
- {"settle", agent.AbortSettleDrainTimeout, "settle_drain_timeout"},
- {"goalMet", agent.AbortGoalMet, "goal_met"},
+ {action: "pause", code: "work_paused_by_user"},
+ {action: "cancel", code: "work_cancelled_by_user"},
} {
- t.Run(tc.name, func(t *testing.T) {
+ t.Run(tc.action, func(t *testing.T) {
e := NewEngine(nil)
- ectx := e.execContextFor(context.Background(), "t1")
- e.cancelExec("t1", tc.cause)
- if code, _, _, _ := agent.AbortReason(ectx); code != tc.want {
- t.Fatalf("code=%q,期望 %q", code, tc.want)
+ ctx, cancel := context.WithCancelCause(context.Background())
+ e.registerWork(42, cancel)
+ done := make(chan error, 1)
+ go func() { done <- e.ControlWork(42, tc.action) }()
+ <-ctx.Done()
+ if code, _, _, _ := agent.AbortReason(ctx); code != tc.code {
+ t.Fatalf("code=%q, want %q", code, tc.code)
+ }
+ action, complete := e.detachWork(42)
+ if action != tc.action {
+ t.Fatalf("action=%q, want %q", action, tc.action)
+ }
+ complete()
+ if err := <-done; err != nil {
+ t.Fatal(err)
}
})
}
}
-// asking for an exec ctx while paused must return an already-cancelled ctx that
-// still SAYS why — this is the claim→Execute race guard, easily mistaken for a
-// mystery abort.
+func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) {
+ for _, tc := range []struct {
+ cause *agent.AbortCause
+ code string
+ }{
+ {agent.AbortSettleDrainTimeout, "settle_drain_timeout"},
+ {agent.AbortGoalMet, "goal_met"},
+ } {
+ e := NewEngine(nil)
+ ctx := e.execContextFor(context.Background(), "t1")
+ e.cancelExec("t1", tc.cause)
+ if code, _, _, _ := agent.AbortReason(ctx); code != tc.code {
+ t.Fatalf("code=%q, want %q", code, tc.code)
+ }
+ }
+}
+
func TestExecContextWhilePausedCarriesRaceGuardCause(t *testing.T) {
e := NewEngine(nil)
e.Pause("t1", agent.AbortPausedByUser)
ctx := e.execContextFor(context.Background(), "t1")
if ctx.Err() == nil {
- t.Fatal("暂停期间不该发出可用的 exec ctx")
+ t.Fatal("paused task received a live execution context")
}
if code, _, _, _ := agent.AbortReason(ctx); code != "paused_race_guard" {
- t.Fatalf("code=%q,期望 paused_race_guard", code)
+ t.Fatalf("code=%q, want paused_race_guard", code)
}
}
-// Resume must hand out a live context again (cancelling is one-shot).
func TestResumeYieldsFreshContext(t *testing.T) {
e := NewEngine(nil)
old := e.execContextFor(context.Background(), "t1")
e.Pause("t1", agent.AbortPausedByUser)
if old.Err() == nil {
- t.Fatal("Pause 没取消旧 ctx")
+ t.Fatal("Pause did not cancel old context")
}
- e.paused.Delete("t1") // Resume() without the Task/Notify plumbing
+ e.paused.Delete("t1")
if fresh := e.execContextFor(context.Background(), "t1"); fresh.Err() != nil {
- t.Fatal("恢复后应拿到全新可用的 exec ctx")
+ t.Fatal("resume did not create a fresh execution context")
}
}
diff --git a/server/engine_llm_calls_test.go b/server/engine_llm_calls_test.go
new file mode 100644
index 0000000..56013e7
--- /dev/null
+++ b/server/engine_llm_calls_test.go
@@ -0,0 +1,43 @@
+package server
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestEngineActiveLLMCallsConcurrent(t *testing.T) {
+ e := NewEngine(nil)
+ const taskID = "42"
+ const calls = 64
+
+ var started sync.WaitGroup
+ var release sync.WaitGroup
+ var finished sync.WaitGroup
+ started.Add(calls)
+ finished.Add(calls)
+ release.Add(1)
+ for range calls {
+ go func() {
+ defer finished.Done()
+ e.BeginLLMCall(taskID)
+ started.Done()
+ release.Wait()
+ e.EndLLMCall(taskID)
+ }()
+ }
+ started.Wait()
+ if got := e.ActiveLLMCalls(taskID); got != calls {
+ t.Fatalf("active calls = %d, want %d", got, calls)
+ }
+ release.Done()
+ finished.Wait()
+ if got := e.ActiveLLMCalls(taskID); got != 0 {
+ t.Fatalf("active calls after completion = %d, want 0", got)
+ }
+
+ // A defensive extra end must never expose a negative UI count.
+ e.EndLLMCall(taskID)
+ if got := e.ActiveLLMCalls(taskID); got != 0 {
+ t.Fatalf("active calls after extra end = %d, want 0", got)
+ }
+}
diff --git a/server/engine_timeout.go b/server/engine_timeout.go
index 751c61d..7d98e27 100644
--- a/server/engine_timeout.go
+++ b/server/engine_timeout.go
@@ -43,7 +43,20 @@ func (e *Engine) inflightCounter(taskID string) *int64 {
v, _ := e.inflight.LoadOrStore(taskID, new(int64))
return v.(*int64)
}
-func (e *Engine) incInflight(taskID string) { atomic.AddInt64(e.inflightCounter(taskID), 1) }
+
+// beginTaskOperation atomically registers a task-owned operation unless deletion
+// has already installed its barrier. The delete handler can therefore wait for
+// inflight==0 without a check-then-start race recreating files after cleanup.
+func (e *Engine) beginTaskOperation(taskID string) bool {
+ e.deleteMu.RLock()
+ defer e.deleteMu.RUnlock()
+ if e.IsDeleting(taskID) {
+ return false
+ }
+ atomic.AddInt64(e.inflightCounter(taskID), 1)
+ return true
+}
+
func (e *Engine) decInflight(taskID string) { atomic.AddInt64(e.inflightCounter(taskID), -1) }
func (e *Engine) inflightCount(taskID string) int64 {
return atomic.LoadInt64(e.inflightCounter(taskID))
@@ -106,10 +119,18 @@ func (e *Engine) startDeadlineCoordinator(ctx context.Context, t *Task) {
if t == nil || t.TimeoutSeconds <= 0 {
return
}
- if _, loaded := e.coordStarted.LoadOrStore(t.ID, true); loaded {
+ e.deleteMu.RLock()
+ if e.IsDeleting(t.ID) {
+ e.deleteMu.RUnlock()
return
}
- go e.deadlineCoordinator(ctx, t)
+ if _, loaded := e.coordStarted.LoadOrStore(t.ID, true); loaded {
+ e.deleteMu.RUnlock()
+ return
+ }
+ rt := e.registerTaskRoutines(ctx, t.ID, 1)
+ e.deleteMu.RUnlock()
+ runTaskRoutine(rt, func(loopCtx context.Context) { e.deadlineCoordinator(loopCtx, t) })
}
// deadlineCoordinator waits until the task's absolute deadline, then runs the settle
@@ -170,6 +191,10 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
// ⑤ 终局一轮 planner(任务超时词,最后目标判定,不产新意图)。
met := e.runFinalPlannerRound(ctx, t)
+ if !e.beginTaskOperation(t.ID) {
+ return
+ }
+ defer e.decInflight(t.ID)
// ⑥ 定终态(带守卫):met → done(completed);否则 timeout。若常规路径已先落 done,
// 守卫(SetTaskStatusGuarded)会拒绝覆盖,保留 completed 语义。
@@ -192,6 +217,9 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
// task-timeout planner words (final goal judgment; no new intents). Waits for the
// LLM to be ready (bounded by ctx) so a completable task isn't mis-judged timeout.
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
+ if e.IsDeleting(t.ID) {
+ return false
+ }
planner, _ := e.snapshotFor(t)
for planner == nil {
if sleepCtx(ctx, deadlinePollInterval) {
@@ -200,17 +228,24 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
if isTerminalStatus(e.m.TaskStatus(t.ID)) {
return false
}
+ if e.IsDeleting(t.ID) {
+ return false
+ }
planner, _ = e.snapshotFor(t)
}
+ // 独立 ctx(不挂 execCancel,避免 pause/硬 cancel 打断这最后一轮),带 Final 注入任务超时词。
+ fctx := e.clockCtx(ctx, t, true)
+ if !e.beginTaskOperation(t.ID) {
+ return false
+ }
+ defer e.decInflight(t.ID)
emit := func(r db.Activity) { e.emitActivity(t, r) }
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: fmt.Sprintf("任务超时收尾·终局判定(第 %d 轮)", e.nextPlannerRound(t.ID))})
- // 独立 ctx(不挂 execCancel,避免 pause/硬 cancel 打断这最后一轮),带 Final 注入任务超时词。
- fctx := e.clockCtx(ctx, t, true)
- e.incInflight(t.ID)
tTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
+ e.BeginLLMCall(t.ID)
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit)
- e.decInflight(t.ID)
+ e.EndLLMCall(t.ID)
if err != nil {
log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err)
} else if met {
diff --git a/server/goals.go b/server/goals.go
index 41c4e99..3d83708 100644
--- a/server/goals.go
+++ b/server/goals.go
@@ -10,7 +10,6 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
- "github.com/Autumn-27/norma/llm"
)
type goalSpec struct {
@@ -27,25 +26,36 @@ type goalSpec struct {
//
// 异步(goroutine)所以调用方立即返回,两条路径行为一致:秒建任务、后台拆目标。
func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) {
+ if !s.engine.beginTaskOperation(t.ID) {
+ return
+ }
s.seed(t, seedText)
if seedFirstIntent {
s.seedFirstIntent(t)
}
- // 并发上限:满了就挂起排队(不做目标拆解、不启动引擎),由 reconcileConcurrency 补位启动。
if s.queueIfAtCapacity(t) {
+ s.engine.decInflight(t.ID)
return
}
- go s.startTaskEngine(t)
+ go func() {
+ defer s.engine.decInflight(t.ID)
+ 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) {
+ ctx := s.engine.execContextFor(s.ctx, t.ID)
+ if ctx.Err() != nil || s.engine.IsDeleting(t.ID) {
+ return
+ }
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: "第 0 轮目标拆解"})
- goals := s.createGoals(s.ctx, t, func(r db.Activity) {
+ goals := s.createGoals(ctx, t, func(r db.Activity) {
s.engine.emitActivity(t, r)
})
+ if ctx.Err() != nil || s.engine.IsDeleting(t.ID) {
+ return
+ }
for _, g := range goals {
summary := g.Text
if g.VulnClass != "" {
@@ -53,37 +63,26 @@ func (s *Server) startTaskEngine(t *Task) {
}
s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text", Summary: summary})
}
+ if ctx.Err() != nil || s.engine.IsDeleting(t.ID) {
+ return
+ }
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)
+func (s *Server) occupiesConcurrencySlot(t *Task) bool {
+ return t != nil && !t.Queued && !t.Paused && !s.engine.IsPaused(t.ID) && !isTerminalStatus(t.Status)
}
-// 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++
+ count := 0
+ for _, task := range s.m.List() {
+ if task.ID != excludeID && s.occupiesConcurrencySlot(task) {
+ count++
}
}
- return n
+ return count
}
-// 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()
@@ -93,52 +92,49 @@ func (s *Server) queueIfAtCapacity(t *Task) bool {
}
if err := s.m.SetTaskQueued(t.ID, true); err != nil {
log.Printf("[concurrency] task %s 置排队失败: %v", t.ID, err)
- return false // 落库失败宁可放它跑,也别静默丢任务
+ return false
}
- t.Queued = true
- s.engine.emitActivity(t, db.Activity{Worker: "planner", Kind: "text",
+ s.engine.emitActivity(t, db.Activity{Worker: "system", 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)
+ queued := []*Task{}
+ for _, task := range s.m.List() {
+ if task.Queued && !isTerminalStatus(task.Status) {
+ queued = append(queued, task)
}
}
- 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 → 全部放行
+ slots := len(queued)
if enabled {
- if !s.engine.Ready() {
- return // 引擎未就绪(无 LLM):继续挂起,别空跑
- }
slots = limit - s.runningTaskCount("")
}
- for _, t := range queued {
+ for _, task := 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)
+ // Keep FIFO order, but do not consume a concurrency slot for a task
+ // whose explicit chain/global provider is unavailable. It will be retried
+ // after the user configures or resets its LLM chain.
+ if task.Paused || !s.engine.ReadyFor(task) {
continue
}
- t.Queued = false
- log.Printf("[concurrency] task %s 出队启动", t.ID)
- go s.startTaskEngine(t)
+ if !s.engine.beginTaskOperation(task.ID) {
+ continue
+ }
+ if err := s.m.SetTaskQueued(task.ID, false); err != nil {
+ s.engine.decInflight(task.ID)
+ continue
+ }
+ go func(t *Task) {
+ defer s.engine.decInflight(t.ID)
+ s.startTaskEngine(t)
+ }(task)
slots--
}
}
@@ -153,7 +149,6 @@ func (s *Server) reviveTask(t *Task) {
if t == nil {
return
}
- // 排队中的任务不复活启动——它本就等待并发空位,交给 reconcileConcurrency。
if t.Queued {
return
}
@@ -184,24 +179,25 @@ func (s *Server) reviveTask(t *Task) {
// Returns the seeded specs so callers can emit activity records for them.
// emit, when non-nil, is forwarded to DecomposeGoals so LLM steps are visible in the UI.
func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity)) []goalSpec {
+ if t == nil {
+ return nil
+ }
// Use the SAME LLM the task runs on (its pinned profile, else the active profile),
// NOT agent.FromEnv() — the LLM is configured via the UI (DB profile), not env vars,
// so FromEnv returned empty and every task silently fell back to the crude rule
// splitter (which shredded URLs / made meaningless 2-way splits).
var specs []goalSpec
- if prov, ok := s.goalsProvider(t); ok {
- var as *db.AssetStore
- if s.m != nil {
- as = s.m.Assets()
- }
- taskID, _ := strconv.ParseInt(t.ID, 10, 64)
- // DecomposeGoals persists each decomposed goal via the set_goals tool straight
- // into t.Store; it returns the specs it wrote so we can emit activity + spot the
- // empty (LLM error / no provider) case below. No write-back needed here anymore.
- for _, g := range agent.DecomposeGoals(ctx, prov, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit) {
- if strings.TrimSpace(g.Text) != "" {
- specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
- }
+ var as *db.AssetStore
+ if s.m != nil {
+ as = s.m.Assets()
+ }
+ taskID, _ := strconv.ParseInt(t.ID, 10, 64)
+ s.engine.BeginLLMCall(t.ID)
+ decomposed := agent.DecomposeGoalsWithProvider(ctx, s.agentsForTask(t).runtime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit)
+ s.engine.EndLLMCall(t.ID)
+ for _, g := range decomposed {
+ if strings.TrimSpace(g.Text) != "" {
+ specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
}
}
if len(specs) == 0 {
@@ -220,23 +216,3 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
}
return specs
}
-
-// goalsProvider resolves the provider for goal decomposition by the standard
-// precedence: the goals agent's own binding → the task's pinned profile → the
-// global active provider (the very instance the engine runs on, so decomposition
-// shares its rate limiter, gets recorded, and follows LLM failover).
-func (s *Server) goalsProvider(t *Task) (llm.Provider, bool) {
- var pin *int64
- if t != nil {
- pin = t.LLMProfileID
- }
- if eff := s.effectiveProfileForAgent("goals", pin); eff != nil {
- if prov, cfg, ok := s.providerForProfile(*eff); ok {
- return s.poolForBinding(*eff, prov, cfg), true
- }
- }
- s.cfgMu.Lock()
- prov, on := s.llmProv, s.llmOn
- s.cfgMu.Unlock()
- return prov, on && prov != nil
-}
diff --git a/server/goals_test.go b/server/goals_test.go
new file mode 100644
index 0000000..057d85b
--- /dev/null
+++ b/server/goals_test.go
@@ -0,0 +1,12 @@
+package server
+
+import (
+ "context"
+ "testing"
+)
+
+func TestCreateGoalsNilTask(t *testing.T) {
+ if got := (&Server{}).createGoals(context.Background(), nil, nil); len(got) != 0 {
+ t.Fatalf("nil task produced goals: %+v", got)
+ }
+}
diff --git a/server/inheritance_api_test.go b/server/inheritance_api_test.go
new file mode 100644
index 0000000..df9fe4f
--- /dev/null
+++ b/server/inheritance_api_test.go
@@ -0,0 +1,100 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) {
+ m, err := NewManager(t.TempDir(), "")
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer m.Close()
+
+ source, err := m.CreateTaskWithOptions("detail source", "source goal", db.TaskCreateOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceID := mustTaskID(t, source.ID)
+ current, err := m.CreateTaskWithOptions("detail current", "current goal", db.TaskCreateOptions{SourceTaskIDs: []int64{sourceID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _, _ = m.DeleteTask(current.ID, DeleteTaskOptions{})
+ _, _ = m.DeleteTask(source.ID, DeleteTaskOptions{})
+ })
+
+ plannerStep, err := source.Store.AppendActivity(db.Activity{
+ Worker: "planner", Kind: "text", Summary: "private planner", Detail: "private planner detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ intentID, err := source.Store.AddIntent(map[string]any{"summary": "completed source work"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := source.Store.SetIntentState(intentID, "done"); err != nil {
+ t.Fatal(err)
+ }
+ workerStep, err := source.Store.AppendActivity(db.Activity{
+ NodeID: &intentID, Worker: "worker", Kind: "result", Summary: "shared result", Detail: "shared worker detail",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
+ token, err := signJWT(s.jwtKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fetchDetail := func(stepID int64) string {
+ req := httptest.NewRequest(http.MethodGet,
+ fmt.Sprintf("/api/exploration/activity/%d?task=%s", stepID, current.ID), nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("activity detail %d: status=%d body=%s", stepID, rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Detail string `json:"detail"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ return body.Detail
+ }
+ if got := fetchDetail(plannerStep); got != "" {
+ t.Fatalf("source planner transcript leaked through API: %q", got)
+ }
+ if got := fetchDetail(workerStep); got != "shared worker detail" {
+ t.Fatalf("terminal inherited worker detail missing: %q", got)
+ }
+
+ if _, err := m.DeleteTask(source.ID, DeleteTaskOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ currentAfter, ok := m.Task(current.ID)
+ if !ok || len(currentAfter.SourceTaskIDs) != 0 {
+ t.Fatalf("deleted source remained in live task DTO state: %+v", currentAfter)
+ }
+}
+
+func mustTaskID(t *testing.T, raw string) int64 {
+ t.Helper()
+ var id int64
+ if _, err := fmt.Sscan(raw, &id); err != nil || id <= 0 {
+ t.Fatalf("bad task id %q: %v", raw, err)
+ }
+ return id
+}
diff --git a/server/inheritance_dto_test.go b/server/inheritance_dto_test.go
new file mode 100644
index 0000000..078d233
--- /dev/null
+++ b/server/inheritance_dto_test.go
@@ -0,0 +1,117 @@
+package server
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/Autumn-27/artex/db"
+)
+
+func TestInheritedDTOProvenance(t *testing.T) {
+ createdAt := time.Date(2026, time.August, 19, 12, 0, 0, 0, time.UTC)
+ node := &db.Node{
+ ID: 41,
+ Kind: db.KindFinding,
+ Payload: json.RawMessage(`{"vulnclass":"xss","severity":"high","summary":"source finding"}`),
+ State: "confirmed",
+ CreatedAt: createdAt,
+ SourceTaskID: 7,
+ Inherited: true,
+ }
+
+ nodeDTO := taskNodeDTO(node)
+ if !nodeDTO.Inherited || nodeDTO.SourceTaskID != "7" {
+ t.Fatalf("node provenance lost: %+v", nodeDTO)
+ }
+ finding := findingDTOsForOwner("7", "source", []*db.Node{node}, nil, nil)
+ if len(finding) != 1 || !finding[0].Inherited || finding[0].SourceTaskID != "7" || finding[0].TaskID != "7" {
+ t.Fatalf("finding provenance lost: %+v", finding)
+ }
+
+ intentID := int64(44)
+ activity := activityDTO(db.Activity{
+ ID: 45,
+ NodeID: &intentID,
+ CreatedAt: createdAt,
+ SourceTaskID: 7,
+ Inherited: true,
+ })
+ if !activity.Inherited || activity.SourceTaskID != "7" || activity.IntentID != "44" {
+ t.Fatalf("activity provenance lost: %+v", activity)
+ }
+ assetRef := coverageAssetRefDTO(db.AssetRef{
+ ID: 46, Kind: db.KindFact, State: "confirmed", Summary: "source fact", SourceTaskID: 7, Inherited: true,
+ })
+ if !assetRef.Inherited || assetRef.SourceTaskID != "7" {
+ t.Fatalf("asset ref provenance lost: %+v", assetRef)
+ }
+}
+
+func TestInheritedIntentResultOnlyAllowsHistory(t *testing.T) {
+ for _, state := range []string{"done", "blocked", "exhausted", "stopped"} {
+ if !inheritedIntentResult(state) {
+ t.Errorf("terminal state %q should be inherited history", state)
+ }
+ }
+ for _, state := range []string{"open", "running", ""} {
+ if inheritedIntentResult(state) {
+ t.Errorf("live state %q must not enter inherited history", state)
+ }
+ }
+}
+
+func TestInheritedGraphSnapshotFiltersLiveIntentsAndEdges(t *testing.T) {
+ nodes := []*db.Node{
+ {ID: 1, Kind: db.KindFact, State: "confirmed"},
+ {ID: 2, Kind: db.KindIntent, State: "running"},
+ {ID: 3, Kind: db.KindIntent, State: "done"},
+ {ID: 4, Kind: db.KindFinding, State: "confirmed"},
+ }
+ edges := []db.Edge{
+ {From: 1, Rel: db.RelDerivedFrom, To: 2},
+ {From: 1, Rel: db.RelDerivedFrom, To: 3},
+ {From: 3, Rel: db.RelYields, To: 4},
+ }
+
+ gotNodes, gotEdges := inheritedGraphSnapshot(nodes, edges, 77)
+ if len(gotNodes) != 3 {
+ t.Fatalf("expected fact, terminal intent and finding; got %+v", gotNodes)
+ }
+ for _, node := range gotNodes {
+ if node.ID == 2 {
+ t.Fatal("running inherited intent leaked into graph snapshot")
+ }
+ if !node.Inherited || node.SourceTaskID != 77 {
+ t.Fatalf("source provenance missing: %+v", node)
+ }
+ }
+ if len(gotEdges) != 2 {
+ t.Fatalf("edge referencing hidden live intent was not removed: %+v", gotEdges)
+ }
+ for _, edge := range gotEdges {
+ if edge.From == 2 || edge.To == 2 {
+ t.Fatalf("live intent id leaked through edge: %+v", edge)
+ }
+ }
+}
+
+func TestFindingProvenanceInTask(t *testing.T) {
+ task := &Task{ID: "10", SourceTaskIDs: []int64{7, 8}}
+
+ local := int64(10)
+ if source, inherited, allowed := findingProvenanceInTask(task, &local); !allowed || inherited || source != "" {
+ t.Fatalf("local finding: source=%q inherited=%v allowed=%v", source, inherited, allowed)
+ }
+ sourceID := int64(7)
+ if source, inherited, allowed := findingProvenanceInTask(task, &sourceID); !allowed || !inherited || source != "7" {
+ t.Fatalf("direct source finding: source=%q inherited=%v allowed=%v", source, inherited, allowed)
+ }
+ unrelated := int64(6)
+ if _, _, allowed := findingProvenanceInTask(task, &unrelated); allowed {
+ t.Fatal("unrelated finding must not be readable through task context")
+ }
+ if _, _, allowed := findingProvenanceInTask(task, nil); allowed {
+ t.Fatal("detached finding must not be readable through task context")
+ }
+}
diff --git a/server/manager.go b/server/manager.go
index 78e403d..5f8dc36 100644
--- a/server/manager.go
+++ b/server/manager.go
@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
+ "errors"
"fmt"
"log"
"os"
@@ -12,38 +13,44 @@ import (
"sync"
"time"
- actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent"
pgdb "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/enrich"
"github.com/Autumn-27/artex/guard"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/artex/traffic"
+ actool "github.com/Autumn-27/norma/tool"
)
// Task is one engagement: a description + goal + its own exploration store,
// sharing the process-wide asset store. ID is the PG task id as a string; ExpID
// is the exploration the task owns.
type Task struct {
- ID string `json:"id"`
- ExpID int64 `json:"exploration_id"`
- Description string `json:"description"`
- Goal string `json:"goal"`
- 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 为终态;空/其它则由运行态推导)
+ ID string `json:"id"`
+ ExpID int64 `json:"exploration_id"`
+ Description string `json:"description"`
+ Goal string `json:"goal"`
+ 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=用全局激活配置
+ LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"`
+ ActiveLLMProfileID *int64 `json:"active_llm_profile_id,omitempty"`
+ LLMFailoverState string `json:"llm_failover_state,omitempty"`
+ LLMFailoverReason string `json:"llm_failover_reason,omitempty"`
+ SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
+ Status string `json:"status"` // persisted lifecycle status (done/failed/timeout 为终态;空/其它则由运行态推导)
// 任务级超时(见 docs/任务级超时与收尾设计.md)。DeadlineAt/FirstRunAt 为 unix 秒,0=未设/未运行。
- TimeoutSeconds int `json:"timeout_seconds"`
- PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
- FirstRunAt int64 `json:"first_run_at,omitempty"`
- DeadlineAt int64 `json:"deadline_at,omitempty"`
- Store *pgdb.ExplorationStore `json:"-"`
- Guard *guard.Guard `json:"-"`
- notify chan struct{}
+ TimeoutSeconds int `json:"timeout_seconds"`
+ PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
+ FirstRunAt int64 `json:"first_run_at,omitempty"`
+ DeadlineAt int64 `json:"deadline_at,omitempty"`
+ Store *pgdb.ExplorationStore `json:"-"`
+ Guard *guard.Guard `json:"-"`
+ notify chan struct{}
+ llmMu sync.RWMutex
// pendingTriggers accumulates the concrete changes (worker done / finding) that
// fired planning rounds since the last one consumed them. The debounce coalesces
@@ -52,14 +59,74 @@ type Task struct {
pendingTriggers []agent.TriggerEvent
}
+type taskLLMState struct {
+ ProfileID *int64
+ ProfileIDs []int64
+ ActiveID *int64
+ FailoverState string
+ FailoverReason string
+}
+
+func cloneInt64Ptr(value *int64) *int64 {
+ if value == nil {
+ return nil
+ }
+ copy := *value
+ return ©
+}
+
+func (t *Task) llmStateSnapshot() taskLLMState {
+ t.llmMu.RLock()
+ defer t.llmMu.RUnlock()
+ return taskLLMState{
+ ProfileID: cloneInt64Ptr(t.LLMProfileID),
+ ProfileIDs: append(make([]int64, 0, len(t.LLMProfileIDs)), t.LLMProfileIDs...),
+ ActiveID: cloneInt64Ptr(t.ActiveLLMProfileID),
+ FailoverState: t.LLMFailoverState,
+ FailoverReason: t.LLMFailoverReason,
+ }
+}
+
+func (t *Task) setLLMState(profileID, activeID *int64, profileIDs []int64, state, reason string) {
+ t.llmMu.Lock()
+ t.LLMProfileID = cloneInt64Ptr(profileID)
+ t.ActiveLLMProfileID = cloneInt64Ptr(activeID)
+ t.LLMProfileIDs = append(t.LLMProfileIDs[:0], profileIDs...)
+ t.LLMFailoverState = state
+ t.LLMFailoverReason = reason
+ t.llmMu.Unlock()
+}
+
+// DeleteTaskOptions controls cleanup of data stored outside the task's own
+// exploration graph. All options default to false for backward compatibility.
+type DeleteTaskOptions struct {
+ DeleteAssets bool `json:"delete_assets"`
+ DeleteTraffic bool `json:"delete_traffic"`
+ DeleteFiles bool `json:"delete_files"`
+ DeleteFindings bool `json:"delete_findings"`
+ DeleteLLMRecords bool `json:"delete_llm_records"`
+}
+
+// DeleteTaskResult makes destructive cleanup auditable to API callers.
+type DeleteTaskResult struct {
+ Deleted string `json:"deleted"`
+ AssetsDeleted int64 `json:"assets_deleted"`
+ AssetsDetached int64 `json:"assets_detached"`
+ TrafficDeleted int64 `json:"traffic_deleted"`
+ FilesDeleted bool `json:"files_deleted"`
+ FindingsDeleted int64 `json:"findings_deleted"`
+ LLMRecordsDeleted int64 `json:"llm_records_deleted"`
+ CleanupWarning string `json:"cleanup_warning,omitempty"`
+}
+
// Manager owns the PostgreSQL data source (asset graph + every task's exploration
// graph + config) and the in-memory set of task handles.
type Manager struct {
dir string
pg *pgdb.DB
assets *pgdb.AssetStore
- traffic *traffic.Traffic // process-wide recording proxy (may be nil)
- enrich *enrich.Engine // engine-side asset auto-completion (DNS/HTTP)
+ traffic *traffic.Traffic // process-wide recording proxy (may be nil)
+ enrich *enrich.Engine // engine-side asset auto-completion (DNS/HTTP)
interceptor *intercept.Interceptor // user-configured tool-call interception rules
mu sync.RWMutex
@@ -529,7 +596,10 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
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,
+ LLMProfileID: pt.LLMProfileID,
+ LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID,
+ LLMFailoverState: pt.LLMFailoverState, LLMFailoverReason: pt.LLMFailoverReason,
+ SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
@@ -539,7 +609,17 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
// CreateTask creates a task + its exploration and makes it active.
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时).
func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
- pt, err := m.pg.CreateTask(description, goal, llmProfileID, timeoutSeconds, planHeartbeatSeconds)
+ var ids []int64
+ if llmProfileID != nil {
+ ids = []int64{*llmProfileID}
+ }
+ return m.CreateTaskWithOptions(description, goal, pgdb.TaskCreateOptions{
+ LLMProfileIDs: ids, TimeoutSeconds: timeoutSeconds, PlanHeartbeatSeconds: planHeartbeatSeconds,
+ })
+}
+
+func (m *Manager) CreateTaskWithOptions(description, goal string, opts pgdb.TaskCreateOptions) (*Task, error) {
+ pt, err := m.pg.CreateTaskWithOptions(description, goal, opts)
if err != nil {
return nil, err
}
@@ -551,6 +631,35 @@ func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, time
return t, nil
}
+// ReplaceTaskLLMProfiles resets a non-terminal task's ordered provider chain and
+// mirrors the committed state onto the live task handle.
+func (m *Manager) ReplaceTaskLLMProfiles(id string, profileIDs []int64, activeProfileID int64) (int64, error) {
+ n, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ if err := m.pg.ReplaceTaskLLMProfiles(n, profileIDs, activeProfileID); err != nil {
+ return 0, err
+ }
+ pt, err := m.pg.GetTask(n)
+ if err != nil || pt == nil {
+ return 0, err
+ }
+ m.mu.Lock()
+ if task := m.tasks[id]; task != nil {
+ task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMFailoverState, pt.LLMFailoverReason)
+ }
+ m.mu.Unlock()
+ if task, ok := m.Task(id); ok {
+ reopened, reopenErr := task.Store.ReopenIntentsByBlockedReason(pgdb.IntentBlockedLLMQuota)
+ if reopenErr != nil {
+ return reopened, reopenErr
+ }
+ return reopened, nil
+ }
+ return 0, nil
+}
+
// LoadExisting rebuilds in-memory task handles from the PG task registry.
func (m *Manager) LoadExisting() []*Task {
pts, err := m.pg.ListTasks()
@@ -706,17 +815,123 @@ func (m *Manager) SetTaskStatus(id, status string) error {
return nil
}
-// DeleteTask removes a task (and its exploration subgraph) from PG + memory.
-func (m *Manager) DeleteTask(id string) error {
+// DeleteTask removes a task and optionally its related global data. Traffic has
+// no task-id column, so related exchanges are resolved by exact hosts from the
+// task's asset rows. Files are staged before the database operation; traffic is
+// staged while PostgreSQL excludes asset/anchor writers. Both are restored on a
+// database failure and purged only after its commit.
+func (m *Manager) DeleteTask(id string, opts DeleteTaskOptions) (DeleteTaskResult, error) {
+ result := DeleteTaskResult{Deleted: id}
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
- return err
+ return result, err
}
- if err := m.pg.DeleteTask(n); err != nil {
- return err
+ registered, err := m.pg.GetTask(n)
+ if err != nil {
+ return result, err
}
+ if registered == nil {
+ m.forgetTask(id, n)
+ return result, nil
+ }
+
+ var fileStage *taskFileDeleteStage
+ if opts.DeleteFiles {
+ fileStage, err = stageTaskFiles(m.dir, id, registered.ExplorationID)
+ if err != nil {
+ return result, err
+ }
+ result.FilesDeleted = fileStage.deleted
+ }
+
+ var trafficStage *traffic.HostDeleteStage
+ var prepare func(pgdb.TaskDeletePreparation) error
+ if opts.DeleteTraffic && m.traffic != nil {
+ prepare = func(p pgdb.TaskDeletePreparation) error {
+ if len(p.TrafficHosts) == 0 {
+ return nil
+ }
+ trafficStage, err = m.traffic.StageDeleteHostsExact(p.TrafficHosts)
+ if err != nil {
+ return err
+ }
+ result.TrafficDeleted = trafficStage.Deleted()
+ return nil
+ }
+ }
+
+ dbResult, err := m.pg.DeleteTaskCascadePrepared(
+ n, opts.DeleteAssets, opts.DeleteFindings, opts.DeleteLLMRecords, prepare,
+ )
+ if err != nil {
+ return result, rollbackTaskDelete(err, trafficStage, fileStage)
+ }
+ result.AssetsDeleted = dbResult.AssetsDeleted
+ result.AssetsDetached = dbResult.AssetsDetached
+ result.FindingsDeleted = dbResult.FindingsDeleted
+ result.LLMRecordsDeleted = dbResult.LLMRecordsDeleted
+
+ // PostgreSQL is now authoritative: finalize the staged external deletion and
+ // forget the live task even if a final purge reports an error. Such errors are
+ // typed so the HTTP layer can still tear down the task runtime instead of
+ // incorrectly reviving a task whose database row is already gone.
+ var finalizeErrs []error
+ if trafficStage != nil {
+ if err := trafficStage.Commit(); err != nil {
+ finalizeErrs = append(finalizeErrs, fmt.Errorf("finalize traffic deletion: %w", err))
+ }
+ }
+ if fileStage != nil {
+ if err := fileStage.commit(); err != nil {
+ finalizeErrs = append(finalizeErrs, fmt.Errorf("finalize task file deletion: %w", err))
+ }
+ }
+ m.forgetTask(id, n)
+ if err := errors.Join(finalizeErrs...); err != nil {
+ return result, &taskDeleteCommittedError{err: err}
+ }
+ return result, nil
+}
+
+// taskDeleteCommittedError means PostgreSQL deletion succeeded but purging one
+// of the recoverable staging directories failed. The task must stay deleted.
+type taskDeleteCommittedError struct{ err error }
+
+func (e *taskDeleteCommittedError) Error() string {
+ return "task deletion committed; external cleanup incomplete: " + e.err.Error()
+}
+
+func (e *taskDeleteCommittedError) Unwrap() error { return e.err }
+
+func rollbackTaskDelete(cause error, trafficStage *traffic.HostDeleteStage, fileStage *taskFileDeleteStage) error {
+ errs := []error{cause}
+ // Reverse the preparation order. Both restorations are attempted even if the
+ // first one fails, and errors.Join preserves the original PostgreSQL error.
+ if trafficStage != nil {
+ if err := trafficStage.Rollback(); err != nil {
+ errs = append(errs, fmt.Errorf("restore traffic after task delete failure: %w", err))
+ }
+ }
+ if fileStage != nil {
+ if err := fileStage.rollback(); err != nil {
+ errs = append(errs, fmt.Errorf("restore task files after task delete failure: %w", err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func (m *Manager) forgetTask(id string, numericID int64) {
m.mu.Lock()
delete(m.tasks, id)
+ for _, task := range m.tasks {
+ kept := task.SourceTaskIDs[:0]
+ for _, sourceID := range task.SourceTaskIDs {
+ if sourceID != numericID {
+ kept = append(kept, sourceID)
+ }
+ }
+ task.SourceTaskIDs = kept
+ }
if m.active == id {
m.active = ""
for _, t := range m.tasks {
@@ -725,7 +940,128 @@ func (m *Manager) DeleteTask(id string) error {
}
}
m.mu.Unlock()
- return nil
+}
+
+type stagedTaskPath struct {
+ source string
+ staged string
+}
+
+type taskFileDeleteStage struct {
+ stageDir string
+ moves []stagedTaskPath
+ deleted bool
+ done bool
+}
+
+// stageTaskFiles atomically renames the task workspace and owned transcripts to
+// a same-filesystem staging directory. The trailing dash in the transcript
+// prefix is significant: exploration 12 must not match exploration 123.
+func stageTaskFiles(dataDir, taskID string, explorationID int64) (*taskFileDeleteStage, error) {
+ stage := &taskFileDeleteStage{}
+ var targets []string
+ taskDir := filepath.Join(dataDir, "tasks", taskID)
+ if _, err := os.Lstat(taskDir); err == nil {
+ targets = append(targets, taskDir)
+ } else if !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ transcriptDir := filepath.Join(dataDir, "transcripts")
+ entries, err := os.ReadDir(transcriptDir)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return nil, err
+ }
+ entries = nil
+ }
+ prefix := fmt.Sprintf("exp%d-", explorationID)
+ for _, entry := range entries {
+ name := entry.Name()
+ if !strings.HasPrefix(name, prefix) || (!entry.IsDir() && !strings.HasSuffix(name, ".jsonl")) {
+ continue
+ }
+ targets = append(targets, filepath.Join(transcriptDir, name))
+ }
+ if len(targets) == 0 {
+ stage.done = true
+ return stage, nil
+ }
+
+ parent := filepath.Join(dataDir, ".delete-staging")
+ if err := os.MkdirAll(parent, 0o700); err != nil {
+ return nil, err
+ }
+ stage.stageDir, err = os.MkdirTemp(parent, "task-"+taskID+"-")
+ if err != nil {
+ return nil, err
+ }
+ for _, source := range targets {
+ staged := filepath.Join(stage.stageDir, fmt.Sprintf("%d-%s", len(stage.moves), filepath.Base(source)))
+ if err := os.Rename(source, staged); err != nil {
+ cause := fmt.Errorf("stage task file %s: %w", source, err)
+ if restoreErr := stage.rollback(); restoreErr != nil {
+ return nil, errors.Join(cause, fmt.Errorf("restore partially staged task files: %w", restoreErr))
+ }
+ return nil, cause
+ }
+ stage.moves = append(stage.moves, stagedTaskPath{source: source, staged: staged})
+ }
+ stage.deleted = true
+ return stage, nil
+}
+
+func (s *taskFileDeleteStage) commit() error {
+ if s == nil || s.done {
+ return nil
+ }
+ err := os.RemoveAll(s.stageDir)
+ s.done = true
+ return err
+}
+
+func (s *taskFileDeleteStage) rollback() error {
+ if s == nil || s.done {
+ return nil
+ }
+ var errs []error
+ for i := len(s.moves) - 1; i >= 0; i-- {
+ move := s.moves[i]
+ if _, err := os.Lstat(move.source); err == nil {
+ errs = append(errs, fmt.Errorf("restore destination already exists: %s", move.source))
+ continue
+ } else if !os.IsNotExist(err) {
+ errs = append(errs, fmt.Errorf("inspect restore destination %s: %w", move.source, err))
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(move.source), 0o755); err != nil {
+ errs = append(errs, fmt.Errorf("create restore parent for %s: %w", move.source, err))
+ continue
+ }
+ if err := os.Rename(move.staged, move.source); err != nil {
+ errs = append(errs, fmt.Errorf("restore %s: %w", move.source, err))
+ }
+ }
+ if len(errs) == 0 && s.stageDir != "" {
+ if err := os.RemoveAll(s.stageDir); err != nil {
+ errs = append(errs, fmt.Errorf("remove task file stage: %w", err))
+ }
+ }
+ s.done = true
+ return errors.Join(errs...)
+}
+
+// deleteTaskFiles retains the standalone helper contract used by focused tests.
+func deleteTaskFiles(dataDir, taskID string, explorationID int64) (bool, error) {
+ stage, err := stageTaskFiles(dataDir, taskID, explorationID)
+ if err != nil {
+ return false, err
+ }
+ deleted := stage.deleted
+ if err := stage.commit(); err != nil {
+ return deleted, err
+ }
+ return deleted, nil
}
func (m *Manager) Task(id string) (*Task, bool) {
diff --git a/server/manager_delete_files_test.go b/server/manager_delete_files_test.go
new file mode 100644
index 0000000..48de983
--- /dev/null
+++ b/server/manager_delete_files_test.go
@@ -0,0 +1,280 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ pgdb "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/traffic"
+)
+
+func TestSeedAssociatesTargetAssetWithTask(t *testing.T) {
+ m, err := NewManager(t.TempDir(), "")
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer m.Close()
+ task, err := m.CreateTask("seed ownership", "seed ownership", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true})
+ })
+
+ host := fmt.Sprintf("seed-%s.example.test", task.ID)
+ s := &Server{m: m}
+ s.seed(task, "https://"+host)
+ taskID := mustTaskID(t, task.ID)
+ assets, err := m.Assets().QueryByTask(taskID, "", 20, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, asset := range assets {
+ if asset.Domain == host || asset.URL == "https://"+host {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("seed target was not associated with task %s: %+v", task.ID, assets)
+ }
+}
+
+func TestDeleteTaskFilesRemovesOnlyOwnedWorkspaceAndTranscripts(t *testing.T) {
+ dataDir := t.TempDir()
+
+ removed := []struct {
+ path string
+ isDir bool
+ }{
+ {path: filepath.Join("tasks", "77"), isDir: true},
+ {path: filepath.Join("transcripts", "exp12-main.jsonl")},
+ {path: filepath.Join("transcripts", "exp12-planner.jsonl")},
+ {path: filepath.Join("transcripts", "exp12-worker-i9.jsonl")},
+ {path: filepath.Join("transcripts", "exp12-main"), isDir: true},
+ {path: filepath.Join("transcripts", "exp12-worker-i9"), isDir: true},
+ }
+ kept := []struct {
+ path string
+ isDir bool
+ }{
+ {path: filepath.Join("tasks", "78"), isDir: true},
+ {path: filepath.Join("transcripts", "exp123-main.jsonl")},
+ {path: filepath.Join("transcripts", "exp123-main"), isDir: true},
+ {path: filepath.Join("transcripts", "exp1-main.jsonl")},
+ {path: filepath.Join("transcripts", "exp12main.jsonl")},
+ {path: filepath.Join("transcripts", "exp12-main.jsonl.bak")},
+ {path: filepath.Join("transcripts", "exp12-main.txt")},
+ {path: filepath.Join("transcripts", "conv-12.jsonl")},
+ {path: filepath.Join("transcripts", "unrelated-session"), isDir: true},
+ }
+
+ for _, fixture := range append(removed, kept...) {
+ createDeleteFixture(t, dataDir, fixture.path, fixture.isDir)
+ }
+
+ deleted, err := deleteTaskFiles(dataDir, "77", 12)
+ if err != nil {
+ t.Fatalf("delete task files: %v", err)
+ }
+ if !deleted {
+ t.Fatal("expected deletion to be reported")
+ }
+ for _, fixture := range removed {
+ assertPathMissing(t, filepath.Join(dataDir, fixture.path))
+ }
+ for _, fixture := range kept {
+ assertPathExists(t, filepath.Join(dataDir, fixture.path))
+ }
+}
+
+func TestDeleteTaskFilesReportsTranscriptOnlyDeletion(t *testing.T) {
+ dataDir := t.TempDir()
+ path := filepath.Join("transcripts", "exp44-main.jsonl")
+ createDeleteFixture(t, dataDir, path, false)
+
+ deleted, err := deleteTaskFiles(dataDir, "missing-task", 44)
+ if err != nil {
+ t.Fatalf("delete task files: %v", err)
+ }
+ if !deleted {
+ t.Fatal("expected transcript deletion to be reported")
+ }
+ assertPathMissing(t, filepath.Join(dataDir, path))
+}
+
+func TestDeleteTaskFilesMissingTargetsIsIdempotent(t *testing.T) {
+ deleted, err := deleteTaskFiles(t.TempDir(), "77", 12)
+ if err != nil {
+ t.Fatalf("delete missing task files: %v", err)
+ }
+ if deleted {
+ t.Fatal("missing targets must not be reported as deleted")
+ }
+}
+
+func TestStageTaskFilesRollbackRestoresWorkspaceAndTranscripts(t *testing.T) {
+ dataDir := t.TempDir()
+ paths := []string{
+ filepath.Join("tasks", "77", "notes.txt"),
+ filepath.Join("transcripts", "exp12-main.jsonl"),
+ filepath.Join("transcripts", "exp12-worker-i9", "sidechain.jsonl"),
+ }
+ for _, path := range paths {
+ createDeleteFixture(t, dataDir, path, false)
+ }
+
+ stage, err := stageTaskFiles(dataDir, "77", 12)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !stage.deleted {
+ t.Fatal("expected files to be staged")
+ }
+ for _, path := range paths {
+ assertPathMissing(t, filepath.Join(dataDir, path))
+ }
+ if err := stage.rollback(); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range paths {
+ assertPathExists(t, filepath.Join(dataDir, path))
+ }
+}
+
+func TestStageTaskFilesRollbackReportsRestoreFailure(t *testing.T) {
+ dataDir := t.TempDir()
+ workspace := filepath.Join(dataDir, "tasks", "77")
+ createDeleteFixture(t, dataDir, filepath.Join("tasks", "77"), true)
+
+ stage, err := stageTaskFiles(dataDir, "77", 12)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(workspace, []byte("conflict"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cause := errors.New("forced PostgreSQL delete failure")
+ err = rollbackTaskDelete(cause, nil, stage)
+ if err == nil || !errors.Is(err, cause) || !strings.Contains(err.Error(), "destination already exists") {
+ t.Fatalf("rollback err=%v, want original and restore errors", err)
+ }
+ if _, statErr := os.Stat(stage.stageDir); statErr != nil {
+ t.Fatalf("staging was removed after failed restore: %v", statErr)
+ }
+}
+
+func TestManagerDeleteTaskRestoresFilesAndTrafficWhenPostgresDeleteFails(t *testing.T) {
+ dataDir := t.TempDir()
+ m, err := NewManager(dataDir, "")
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+
+ task, err := m.CreateTask("delete rollback", "delete rollback", nil, 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ taskID := mustTaskID(t, task.ID)
+ triggerName := fmt.Sprintf("test_fail_task_delete_%d", taskID)
+ functionName := triggerName + "_fn"
+ cleanupDB := func() {
+ _, _ = m.pg.Exec(fmt.Sprintf(`DROP TRIGGER IF EXISTS %s ON tasks`, triggerName))
+ _, _ = m.pg.Exec(fmt.Sprintf(`DROP FUNCTION IF EXISTS %s()`, functionName))
+ _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteAssets: true, DeleteTraffic: true, DeleteFiles: true})
+ }
+ t.Cleanup(cleanupDB)
+
+ host := fmt.Sprintf("delete-rollback-%d.example.test", taskID)
+ if _, err := m.Assets().UpsertHTTPService(pgdb.UpsertHTTPServiceReq{URL: "https://" + host, TaskID: taskID}); err != nil {
+ t.Fatal(err)
+ }
+ createDeleteFixture(t, dataDir, filepath.Join("tasks", task.ID), true)
+ transcriptPath := filepath.Join(dataDir, "transcripts", fmt.Sprintf("exp%d-main.jsonl", task.ExpID))
+ createDeleteFixture(t, dataDir, filepath.Join("transcripts", filepath.Base(transcriptPath)), false)
+
+ tr, err := traffic.Open(filepath.Join(dataDir, "traffic"), "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.traffic = tr
+ trafficTree := filepath.Join(dataDir, "traffic", host, "GET", "1-0001")
+ if err := os.MkdirAll(trafficTree, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ trafficMarker := filepath.Join(trafficTree, "request.http")
+ if err := os.WriteFile(trafficMarker, []byte("original traffic"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`, "1-0001", 1, host, "GET", "/", "https://"+host+"/", 200, "text/plain", 0, 0, host+"/GET/1-0001"); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := m.pg.Exec(fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $body$
+BEGIN RAISE EXCEPTION 'forced task delete failure'; END $body$`, functionName)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.pg.Exec(fmt.Sprintf(`CREATE TRIGGER %s BEFORE DELETE ON tasks
+FOR EACH ROW WHEN (OLD.id = %d) EXECUTE FUNCTION %s()`, triggerName, taskID, functionName)); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = m.DeleteTask(task.ID, DeleteTaskOptions{DeleteTraffic: true, DeleteFiles: true})
+ if err == nil || !strings.Contains(err.Error(), "forced task delete failure") {
+ t.Fatalf("delete err=%v, want injected PostgreSQL failure", err)
+ }
+ if got, err := m.pg.GetTask(taskID); err != nil || got == nil {
+ t.Fatalf("task row was lost after failed delete: task=%+v err=%v", got, err)
+ }
+ assertPathExists(t, filepath.Join(dataDir, "tasks", task.ID))
+ assertPathExists(t, transcriptPath)
+ if got, err := os.ReadFile(trafficMarker); err != nil || string(got) != "original traffic" {
+ t.Fatalf("traffic tree was not restored: content=%q err=%v", got, err)
+ }
+ var trafficRows int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges WHERE host=?`, host).Scan(&trafficRows); err != nil || trafficRows != 1 {
+ t.Fatalf("traffic index was not restored: rows=%d err=%v", trafficRows, err)
+ }
+}
+
+func createDeleteFixture(t *testing.T, root, relative string, isDir bool) {
+ t.Helper()
+ path := filepath.Join(root, relative)
+ if isDir {
+ if err := os.MkdirAll(filepath.Join(path, "subagents"), 0o755); err != nil {
+ t.Fatalf("create fixture directory %s: %v", path, err)
+ }
+ if err := os.WriteFile(filepath.Join(path, "subagents", "agent-test.jsonl"), []byte("test"), 0o644); err != nil {
+ t.Fatalf("create fixture sidechain %s: %v", path, err)
+ }
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("create fixture parent %s: %v", path, err)
+ }
+ if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
+ t.Fatalf("create fixture file %s: %v", path, err)
+ }
+}
+
+func assertPathMissing(t *testing.T, path string) {
+ t.Helper()
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Fatalf("expected %s to be removed, stat err=%v", path, err)
+ }
+}
+
+func assertPathExists(t *testing.T, path string) {
+ t.Helper()
+ if _, err := os.Lstat(path); err != nil {
+ t.Fatalf("expected %s to remain: %v", path, err)
+ }
+}
diff --git a/server/orchestration.go b/server/orchestration.go
index 011b0f6..9ed4446 100644
--- a/server/orchestration.go
+++ b/server/orchestration.go
@@ -199,12 +199,13 @@ func (s *Server) toolListTasks() actool.CoreTool {
if t.ParentRef != "" {
row["parent_ref"] = t.ParentRef
}
- if t.LLMProfileID == nil {
+ llmState := t.llmStateSnapshot()
+ if llmState.ProfileID == nil {
row["llm_profile"] = "(激活配置)"
- } else if n, ok := profName[*t.LLMProfileID]; ok {
+ } else if n, ok := profName[*llmState.ProfileID]; ok {
row["llm_profile"] = n
} else {
- row["llm_profile"] = fmt.Sprintf("#%d(已删除)", *t.LLMProfileID)
+ row["llm_profile"] = fmt.Sprintf("#%d(已删除)", *llmState.ProfileID)
}
out = append(out, row)
}
@@ -305,6 +306,7 @@ func (s *Server) toolPauseTask() actool.CoreTool {
return actool.Errorf("task 不存在: " + a.TaskID), nil
}
s.engine.Pause(a.TaskID, agent.AbortPausedByOrchestrator)
+ s.cancelTaskChat(a.TaskID, agent.AbortChatPausedWithTask)
return actool.Text("task paused: " + a.TaskID), nil
})
}
@@ -419,7 +421,7 @@ func (s *Server) deriveTaskStatus(t *Task) string {
return t.Status
case t.Paused || s.engine.IsPaused(t.ID):
return "paused"
- case s.engine.Ready() && s.engine.Started(t.ID):
+ case s.engine.ReadyFor(t) && s.engine.Started(t.ID):
return "running"
}
return "created"
diff --git a/server/server.go b/server/server.go
index 2fe0d1d..2f44d26 100644
--- a/server/server.go
+++ b/server/server.go
@@ -12,6 +12,7 @@ import (
"net/url"
"path/filepath"
"regexp"
+ "sort"
"strconv"
"strings"
"sync"
@@ -51,14 +52,12 @@ type Server struct {
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
- llmCfg agent.Config // current LLM config (key not exposed)
- llmOn bool
- llmProf string // active LLM profile name (for llmrec tagging)
// llmProv is the fully-decorated global provider (recorder + failover chain)
- // applyLLM installed. Kept so one-off callers (goal decomposition) reuse the
- // same instance instead of building a private one that skips the rate limiter,
- // the recorder and failover.
+ // installed by applyLLM. Task routers use it after an explicit chain is cleared.
llmProv llm.Provider
+ llmCfg agent.Config // current LLM config (key not exposed)
+ llmOn bool
+ llmProf string // active LLM profile name (for llmrec tagging)
// chatBusy guards the per-task main-agent run: the chat handler launches the
// agent on the server's background ctx (not the request ctx) and returns
@@ -97,18 +96,25 @@ type Server struct {
// provByProfile caches ONE provider per LLM profile id so every agent bound or
// pinned to the same profile shares a single provider instance — hence one rate
- // limiter. Separate from profAgents (whole planner/worker pairs). The globally-active
- // provider (built in applyLLM) lives OUTSIDE this cache, so binding an agent to the
- // currently-active profile builds a second instance — harmless (that binding equals
- // leaving it unset), just not deduped. Cleared alongside profAgents on profile edits.
+ // limiter. Separate from profAgents (whole planner/worker pairs). applyLLM also
+ // resolves the persisted global-active profile through this cache, so global
+ // fallback and task chains do not accidentally double the configured request rate.
+ // Cleared alongside profAgents on profile edits, then repopulated by active reapply.
provCacheMu sync.Mutex
provByProfile map[int64]*provEntry
+ provCacheGen uint64
// llmHealth is the process-wide circuit-breaker state for LLM failover (轮询).
// It deliberately lives OUTSIDE the provider caches: rebuilding the chain
// (saving an unrelated profile, flipping a setting) must not erase what we
// learned about which backends are out of credit / rate-limited.
llmHealth *llmpool.Registry
+
+ // Explicit task chains use one task-scoped dynamic provider shared by goals,
+ // planner, workers, and the main agent. Bundles are stable; their runtime reads
+ // the persisted current profile at every new LLM call.
+ taskAgentMu sync.Mutex
+ taskAgents map[string]*taskAgentBundle
}
// profBundle is a planner/worker pair built from one LLM profile.
@@ -143,14 +149,17 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{},
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{},
- provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg)}
- // per-task LLM: a task pinned to a specific profile runs on that profile's
- // dedicated planner/worker; unpinned tasks fall back to the global active pair.
- s.engine.SetAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) {
- if t.LLMProfileID == nil {
+ provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
+ taskAgents: map[string]*taskAgentBundle{}}
+ // Every task uses a stable task router. An empty explicit chain is resolved by
+ // that router through Agent bindings and then the global provider, so adding a
+ // first chain to a running task takes effect on its very next LLM call.
+ s.engine.SetAuthoritativeAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) {
+ if !s.taskRuntimeAvailable(t, "planner", "worker") {
return nil, nil
}
- return s.agentsForProfile(*t.LLMProfileID)
+ b := s.agentsForTask(t)
+ return b.pl, b.wk
})
// Wire DB-stored prompt templates into the agents (新版方案 §3.3 / §5a). With no
// override row, agents keep their built-in defaults — behavior is unchanged.
@@ -297,7 +306,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
}
// saveLLMConfig persists the LLM config as the active "default" profile in PG.
-func (s *Server) saveLLMConfig(cfg agent.Config) {
+func (s *Server) saveLLMConfig(cfg agent.Config) error {
format := cfg.Provider()
if format != "anthropic" {
format = "openai"
@@ -322,9 +331,10 @@ func (s *Server) saveLLMConfig(cfg agent.Config) {
ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude,
})
- if err == nil {
- _ = s.m.pg.SetActiveProfile(newID)
+ if err != nil {
+ return err
}
+ return s.m.pg.SetActiveProfile(newID)
}
// reapplyActiveProfile hot-reloads the engine from the active DB profile so that
@@ -380,15 +390,26 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
func (s *Server) applyLLM(cfg agent.Config) error {
- prov, err := cfg.NewProvider()
- if err != nil {
- return err
+ var prov llm.Provider
+ // A persisted active profile must use the same cached provider as task chains
+ // and Agent bindings, otherwise each path owns a separate rate limiter.
+ if active, _ := s.m.pg.ActiveProfile(); active != nil {
+ if activeCfg, ok := s.loadProfileConfig(active.ID); ok && activeCfg == cfg {
+ prov, _, _ = s.providerForProfile(active.ID)
+ }
+ }
+ if prov == nil {
+ var err error
+ prov, err = cfg.NewProvider()
+ if err != nil {
+ return err
+ }
+ // Non-persisted/env configs do not have a profile cache key.
+ s.cfgMu.Lock()
+ profName := s.llmProf
+ s.cfgMu.Unlock()
+ prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
}
- // Wrap provider with LLM call recorder (persists request/response to PG).
- s.cfgMu.Lock()
- profName := s.llmProf
- s.cfgMu.Unlock()
- prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
// LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。
// 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile,
// 默认仍然独占该配置(见 poolForBinding)。关闭或无备选时返回原 provider,行为不变。
@@ -414,10 +435,11 @@ func (s *Server) applyLLM(cfg agent.Config) error {
s.chatAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
s.chatAgent.SetGuard(s.chatGuard())
- s.llmCfg = cfg
s.llmProv = prov
+ s.llmCfg = cfg
s.llmOn = true
s.cfgMu.Unlock()
+ s.invalidateTaskAgents()
// wake the active task so a task created while idle starts exploring.
if t := s.m.ActiveTask(); t != nil {
@@ -466,6 +488,7 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
s.provCacheMu.Unlock()
return e.prov, e.cfg, true
}
+ generation := s.provCacheGen
s.provCacheMu.Unlock()
cfg, ok := s.loadProfileConfig(id)
if !ok {
@@ -481,6 +504,10 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool)
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
}
s.provCacheMu.Lock()
+ if generation != s.provCacheGen {
+ s.provCacheMu.Unlock()
+ return s.providerForProfile(id)
+ }
if e := s.provByProfile[id]; e != nil { // lost the race → keep the winner
prov, cfg = e.prov, e.cfg
} else {
@@ -571,8 +598,10 @@ func (s *Server) invalidateProfileAgents() {
s.profChatAgents = map[int64]*agent.ChatAgent{}
s.profMu.Unlock()
s.provCacheMu.Lock()
+ s.provCacheGen++
s.provByProfile = map[int64]*provEntry{}
s.provCacheMu.Unlock()
+ s.invalidateTaskAgents()
}
func (s *Server) mainAgentRef() *agent.MainAgent {
@@ -619,6 +648,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
+ mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
+ mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent)
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
mux.HandleFunc("POST /api/active", s.setActive)
@@ -821,6 +852,7 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, out) // no task selected: only global fields
return
}
+ out["llm_configured"] = s.engine.ReadyFor(t)
st, _ := t.Store.Stats()
out["exploration"] = st
@@ -842,27 +874,31 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
}
last := s.engine.LastActivity(t.ID)
paused := s.engine.IsPaused(t.ID)
- running := s.engine.Ready() && s.engine.Started(t.ID) && !paused
- // stalled: running but no activity for a while and nothing in flight.
- stalled := running && inFlight == 0 && last > 0 && time.Now().Unix()-last > 120
+ activeCalls := s.engine.ActiveLLMCalls(t.ID)
+ running := s.engine.ReadyFor(t) && s.engine.Started(t.ID) && !paused
+ // A live event-driven engine with no work is healthy idle. Only a persisted
+ // running intent without any corresponding LLM call can be considered stalled.
+ stalled := running && activeCalls == 0 && inFlight > 0 && last > 0 && time.Now().Unix()-last > 120
// engine_mode follows the spec enum (exploring|paused|stalled|idle).
+ engineMode := "idle"
switch {
case paused:
- out["engine_mode"] = "paused"
+ engineMode = "paused"
case stalled:
- out["engine_mode"] = "stalled"
- case running:
- out["engine_mode"] = "exploring"
- default:
- out["engine_mode"] = "idle"
+ engineMode = "stalled"
+ case running && activeCalls > 0:
+ engineMode = "exploring"
}
+ out["engine_mode"] = engineMode
out["active_task"] = map[string]any{
"id": t.ID, "description": t.Description, "goal": t.Goal,
"running": running,
"paused": paused,
+ "engine_mode": engineMode,
"in_flight": inFlight,
+ "llm_in_flight": activeCalls,
"last_activity": last,
"stalled": stalled,
"goals_total": len(goals),
@@ -882,18 +918,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query
dtos := make([]TaskDTO, 0, len(list))
for _, t := range list {
- status := "created"
- 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):
- status = "running"
- }
- dto := taskDTO(t, status)
+ dto := taskDTO(t, s.resolvedTaskStatus(t))
dto.Tokens = tokenTotalDTO(toks[t.ExpID])
// prefer the live in-memory heartbeat (fresher) and fall back to the
// persisted max activity time (survives restarts) for run-duration display.
@@ -909,6 +934,28 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active})
}
+func (s *Server) resolvedTaskStatus(t *Task) string {
+ if t == nil {
+ return "created"
+ }
+ switch {
+ case isTerminalStatus(t.Status):
+ return t.Status
+ case t.Queued:
+ return "queued"
+ case t.Paused || s.engine.IsPaused(t.ID):
+ return "paused"
+ case t.llmStateSnapshot().FailoverState == "chain_exhausted" && s.engine.Started(t.ID):
+ // LLM readiness is an execution dependency, not lifecycle state. Keeping
+ // an exhausted task running lets the user edit/reset its chain directly.
+ return "running"
+ case s.engine.ReadyFor(t) && s.engine.Started(t.ID):
+ return "running"
+ default:
+ return "created"
+ }
+}
+
func (s *Server) setActive(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
@@ -946,6 +993,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
switch req.Action {
case "pause":
s.engine.Pause(t.ID, agent.AbortPausedByUser)
+ // Main Agent turns run outside Engine's planner/worker context so they need
+ // their own cancellation. New turns are rejected while the task is paused.
+ s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
case "resume":
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
s.engine.Resume(t)
@@ -962,6 +1012,99 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"id": t.ID, "paused": paused})
}
+// controlIntent pauses, resumes, or cancels one local worker intent. A running
+// worker is always stopped before state mutation/cleanup, preventing tool output
+// that arrives after the user action from recreating deleted blackboard records.
+func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) {
+ t, ok := s.m.Task(r.PathValue("id"))
+ if !ok {
+ writeErr(w, 404, "task not found")
+ return
+ }
+ if !s.engine.beginTaskOperation(t.ID) {
+ writeErr(w, 409, "任务正在删除,无法控制意图")
+ return
+ }
+ defer s.engine.decInflight(t.ID)
+
+ iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64)
+ if err != nil || iid <= 0 {
+ writeErr(w, 400, "bad intent id")
+ return
+ }
+ var req struct {
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, 400, "bad json: "+err.Error())
+ return
+ }
+ if req.Action != "pause" && req.Action != "resume" && req.Action != "cancel" {
+ writeErr(w, 400, "action must be pause|resume|cancel")
+ return
+ }
+
+ node, err := t.Store.GetNode(iid)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ if node == nil {
+ if inherited, sourceErr := t.Store.GetNodeWithSources(iid); sourceErr == nil && inherited != nil && inherited.Inherited {
+ writeErr(w, 409, "继承意图为只读,不能控制")
+ } else {
+ writeErr(w, 404, "intent not found")
+ }
+ return
+ }
+ if node.Kind != db.KindIntent {
+ writeErr(w, 409, "node is not an intent")
+ return
+ }
+
+ switch req.Action {
+ case "pause":
+ if node.State != "running" {
+ writeErr(w, 409, "仅运行中的意图可以暂停")
+ return
+ }
+ if err := s.engine.ControlWork(iid, "pause"); err != nil {
+ writeErr(w, 409, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"id": iid, "state": "paused"})
+ case "resume":
+ if node.State != "paused" {
+ writeErr(w, 409, "仅已暂停的意图可以恢复")
+ return
+ }
+ if err := t.Store.SetIntentState(iid, "open"); err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ t.Notify()
+ writeJSON(w, 200, map[string]any{"id": iid, "state": "open"})
+ case "cancel":
+ if node.State != "running" && node.State != "paused" {
+ writeErr(w, 409, "仅运行中或已暂停的意图可以取消")
+ return
+ }
+ if node.State == "running" {
+ if err := s.engine.ControlWork(iid, "cancel"); err != nil {
+ writeErr(w, 409, err.Error())
+ return
+ }
+ }
+ deleted, err := t.Store.CancelIntent(iid)
+ if err != nil {
+ writeErr(w, 409, err.Error())
+ return
+ }
+ t.Notify()
+ writeJSON(w, 200, map[string]any{"id": iid, "state": "cancelled", "deleted": deleted})
+ }
+}
+
// rerunIntent 重跑一条没跑成功的意图(blocked/exhausted/stopped):把它置回 open,worker
// 会重新认领、从头再跑(已写回图谱的 fact/finding/asset 保留);若任务已终态/暂停则顺带复活。
// 用于「出错的 work 点击继续运行」——网络/LLM 抖动导致 blocked 后可一键重试。
@@ -1063,11 +1206,24 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "api_key required")
return
}
+ // Validate provider construction before persisting it as the active profile.
+ if _, err := cfg.NewProvider(); err != nil {
+ writeErr(w, 400, "provider init failed: "+err.Error())
+ return
+ }
+ if err := s.saveLLMConfig(cfg); err != nil {
+ writeErr(w, 500, "persist provider failed: "+err.Error())
+ return
+ }
+ s.invalidateProfileAgents()
+ if _, ok := s.loadLLMConfig(); !ok {
+ writeErr(w, 500, "saved provider is unavailable")
+ return
+ }
if err := s.applyLLM(cfg); err != nil {
writeErr(w, 400, "provider init failed: "+err.Error())
return
}
- s.saveLLMConfig(cfg) // persist to DB
log.Printf("[engine] LLM configured via UI: %s / %s", cfg.Provider(), cfg.Model)
s.getLLM(w, r)
}
@@ -1116,12 +1272,14 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
}
type createTaskReq struct {
- Description string `json:"description"`
- Goal string `json:"goal"`
- LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
- TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
- PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
- SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
+ Description string `json:"description"`
+ Goal string `json:"goal"`
+ LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
+ LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效
+ SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务
+ TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
+ PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
+ SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
@@ -1133,18 +1291,39 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(req.Description) == "" {
req.Description = "未命名任务"
}
- // validate the pinned profile up front (must exist + be usable), so a bad id fails
- // task creation instead of silently falling back to the active profile at run time.
- if req.LLMProfileID != nil {
- if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
- writeErr(w, 400, "所选 LLM 配置不存在或未设置 API Key")
- return
- }
+ if len(req.LLMProfileIDs) == 0 && req.LLMProfileID != nil {
+ req.LLMProfileIDs = []int64{*req.LLMProfileID}
+ }
+ if err := s.validateTaskProfileIDs(req.LLMProfileIDs); err != nil {
+ writeErr(w, 400, err.Error())
+ return
}
if req.TimeoutSeconds < 0 {
req.TimeoutSeconds = 0
}
- t, err := s.m.CreateTask(req.Description, req.Goal, req.LLMProfileID, req.TimeoutSeconds, req.PlanHeartbeatSeconds)
+ if len(req.SourceTaskIDs) > db.MaxTaskSourceCount {
+ writeErr(w, 400, fmt.Sprintf("关联任务最多选择 %d 个", db.MaxTaskSourceCount))
+ return
+ }
+ sourceIDs := make([]int64, 0, len(req.SourceTaskIDs))
+ seenSources := map[int64]bool{}
+ for _, raw := range req.SourceTaskIDs {
+ id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ if err != nil || id <= 0 || seenSources[id] {
+ writeErr(w, 400, "关联任务 id 无效或重复")
+ return
+ }
+ if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok {
+ writeErr(w, 400, fmt.Sprintf("关联任务 #%d 不存在", id))
+ return
+ }
+ seenSources[id] = true
+ sourceIDs = append(sourceIDs, id)
+ }
+ t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
+ SourceTaskIDs: sourceIDs, LLMProfileIDs: req.LLMProfileIDs,
+ TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
+ })
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -1153,7 +1332,73 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
// 共享的建后流程(seed + 种子意图 + 后台目标分解 + engine.Run),与 spawn_task 复用同一段。
// launchTask 内部异步,不阻塞 UI —— 目标分解在后台可见地进行。
s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent)
- writeJSON(w, 201, t)
+ writeJSON(w, 201, taskDTO(t, s.resolvedTaskStatus(t)))
+}
+
+func (s *Server) validateTaskProfileIDs(ids []int64) error {
+ seen := map[int64]bool{}
+ for _, id := range ids {
+ if id <= 0 || seen[id] {
+ return fmt.Errorf("LLM 配置 id 无效或重复")
+ }
+ seen[id] = true
+ if _, ok := s.loadProfileConfig(id); !ok {
+ return fmt.Errorf("LLM 配置 #%d 不存在或未设置 API Key", id)
+ }
+ }
+ return nil
+}
+
+func (s *Server) updateTaskLLMProfiles(w http.ResponseWriter, r *http.Request) {
+ t, ok := s.m.Task(r.PathValue("id"))
+ if !ok {
+ writeErr(w, 404, "task not found")
+ return
+ }
+ if isTerminalStatus(t.Status) {
+ writeErr(w, 409, "已结束的任务不能修改 LLM 配置")
+ return
+ }
+ before := t.llmStateSnapshot()
+ var req struct {
+ LLMProfileIDs []int64 `json:"llm_profile_ids"`
+ ActiveLLMProfileID *int64 `json:"active_llm_profile_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, 400, "bad json: "+err.Error())
+ return
+ }
+ if err := s.validateTaskProfileIDs(req.LLMProfileIDs); err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ active := int64(0)
+ if req.ActiveLLMProfileID != nil {
+ active = *req.ActiveLLMProfileID
+ }
+ 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())
+ }
+ return
+ }
+ // Profile edits affect only subsequent LLM calls. Existing in-flight calls
+ // retain their provider; a nudge lets an idle running task resume promptly.
+ t.Notify()
+ llmState := t.llmStateSnapshot()
+ result := map[string]any{
+ "id": t.ID, "llm_profile_ids": llmState.ProfileIDs,
+ "active_llm_profile_id": llmState.ActiveID,
+ "llm_failover_state": llmState.FailoverState, "reopened_intents": reopened,
+ }
+ if !sameOptionalID(before.ActiveID, llmState.ActiveID) {
+ event := s.emitManualTaskLLMSwitch(t, before.ActiveID, llmState.ActiveID)
+ result["switch_event"] = activityDTO(event)
+ }
+ writeJSON(w, 200, result)
}
var (
@@ -1241,12 +1486,13 @@ func (s *Server) seed(t *Task, text string) {
}
var rootID int64
if as := s.m.Assets(); as != nil {
+ taskID, _ := strconv.ParseInt(t.ID, 10, 64)
if net.ParseIP(host) != nil {
- rootID, _ = as.UpsertIP(db.UpsertIPReq{IP: host})
+ rootID, _ = as.UpsertIP(db.UpsertIPReq{IP: host, TaskID: taskID})
} else if scheme == "https" || scheme == "http" {
- rootID, _ = as.UpsertHTTPService(db.UpsertHTTPServiceReq{URL: u})
+ rootID, _ = as.UpsertHTTPService(db.UpsertHTTPServiceReq{URL: u, TaskID: taskID})
} else {
- rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host})
+ rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: taskID})
}
}
// anchor the seeded assets to this task's begin root as lineage/provenance
@@ -1286,7 +1532,7 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "task not found")
return
}
- writeJSON(w, 200, t)
+ writeJSON(w, 200, taskDTO(t, s.resolvedTaskStatus(t)))
}
// taskCoverage returns a task's rough asset test coverage (denominator/tested/backlog).
@@ -1302,7 +1548,7 @@ func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) {
return
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
- cov, err := as.TaskCoverage(taskID, t.ExpID)
+ cov, err := as.TaskCoverageWithSources(taskID)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -1345,22 +1591,23 @@ func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "需要 asset_id")
return
}
- refs, err := t.Store.AssetRefs(assetID)
+ refs, err := t.Store.AssetRefsWithSources(assetID)
if err != nil {
writeErr(w, 500, err.Error())
return
}
- intents := []db.AssetRef{}
- facts := []db.AssetRef{}
- findings := []db.AssetRef{}
+ intents := []CoverageAssetRefDTO{}
+ facts := []CoverageAssetRefDTO{}
+ findings := []CoverageAssetRefDTO{}
for _, ref := range refs {
+ dto := coverageAssetRefDTO(ref)
switch ref.Kind {
case "intent":
- intents = append(intents, ref)
+ intents = append(intents, dto)
case "fact":
- facts = append(facts, ref)
+ facts = append(facts, dto)
case "finding":
- findings = append(findings, ref)
+ findings = append(findings, dto)
}
}
writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings})
@@ -1379,7 +1626,7 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
return
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
- rows, err := as.ListTaskScope(taskID)
+ rows, err := as.ListTaskScopeWithSources(taskID)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -1443,7 +1690,11 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, []any{})
return
}
- f, _ := t.Store.ListByKind(db.KindFinding, 200)
+ f, err := t.Store.ListByKind(db.KindFinding, 200)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
tid, _ := strconv.ParseInt(t.ID, 10, 64)
meta, err := s.m.pg.FindingMetaByNodeID(tid)
if err != nil {
@@ -1454,7 +1705,42 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
aidSet = append(aidSet, m.AssetIDs...)
}
assets := s.resolveAssetIDs(aidSet)
- writeJSON(w, 200, findingDTOsForTask(t, f, meta, assets))
+ out := findingDTOsForTask(t, f, meta, assets)
+
+ // Related-task findings are a live, read-only view. Provenance metadata lets
+ // the task UI suppress mutation affordances while retaining stable ids.
+ sources, err := t.Store.DirectSourceStores()
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ for _, source := range sources {
+ nodes, listErr := source.Store.ListByKind(db.KindFinding, 200)
+ if listErr != nil {
+ writeErr(w, 500, listErr.Error())
+ return
+ }
+ for _, node := range nodes {
+ node.SourceTaskID = source.Task.TaskID
+ node.Inherited = true
+ }
+ sourceMeta, metaErr := s.m.pg.FindingMetaByNodeID(source.Task.TaskID)
+ if metaErr != nil {
+ log.Printf("[findings] source_task=%d meta: %v", source.Task.TaskID, metaErr)
+ }
+ var sourceAssetIDs []int64
+ for _, item := range sourceMeta {
+ sourceAssetIDs = append(sourceAssetIDs, item.AssetIDs...)
+ }
+ out = append(out, findingDTOsForOwner(
+ i64s(source.Task.TaskID),
+ source.Task.Description,
+ nodes,
+ sourceMeta,
+ s.resolveAssetIDs(sourceAssetIDs),
+ )...)
+ }
+ writeJSON(w, 200, out)
}
// normFilter maps the frontend's "all" sentinel (and empty) to "" so the DB layer
@@ -1529,7 +1815,41 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
return
}
assets := s.resolveAssetIDs(f.AssetIDs)
- writeJSON(w, 200, findingFromDB(f, assets))
+ dto := findingFromDB(f, assets)
+ if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" {
+ contextTask := s.m.ResolveTask(contextTaskID)
+ if contextTask == nil {
+ writeErr(w, 404, "context task not found")
+ return
+ }
+ sourceTaskID, inherited, allowed := findingProvenanceInTask(contextTask, f.TaskID)
+ if !allowed {
+ writeErr(w, 404, "finding not available in task context")
+ return
+ }
+ dto.SourceTaskID = sourceTaskID
+ dto.Inherited = inherited
+ }
+ writeJSON(w, 200, dto)
+}
+
+func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) {
+ if contextTask == nil || findingTaskID == nil {
+ return "", false, false
+ }
+ contextID, err := strconv.ParseInt(contextTask.ID, 10, 64)
+ if err != nil {
+ return "", false, false
+ }
+ if *findingTaskID == contextID {
+ return "", false, true
+ }
+ for _, sourceID := range contextTask.SourceTaskIDs {
+ if sourceID == *findingTaskID {
+ return i64s(sourceID), true, true
+ }
+ }
+ return "", false, false
}
// findingLineage returns the exploration sub-DAG from the task root to this
@@ -1699,17 +2019,108 @@ func (s *Server) intents(w http.ResponseWriter, r *http.Request) {
return
}
// Paged form: ?before= (or ?page as a marker) → {items, has_more} so the
- // worker session list can reach past the old fixed 300 boundary on scroll.
+ // worker session list can reach past the old fixed 300 boundary on scroll. It
+ // also includes immutable intent results from directly related tasks; those
+ // rows never participate in this task's frontier or claim path.
before := int64(atoiDefault(q.Get("before"), 0))
- in, hasMore, err := t.Store.ListByKindPage(db.KindIntent, before, limit)
+ in, hasMore, err := taskIntentHistoryPage(t.Store, before, limit, false, 0)
if err != nil {
log.Printf("[intents] task=%s before=%d limit=%d: %v", t.ID, before, limit, err)
writeErr(w, 500, err.Error())
return
}
+ sources, err := t.Store.DirectSourceStores()
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ for _, source := range sources {
+ items, sourceMore, sourceErr := taskIntentHistoryPage(source.Store, before, limit, true, source.Task.TaskID)
+ if sourceErr != nil {
+ writeErr(w, 500, sourceErr.Error())
+ return
+ }
+ in = append(in, items...)
+ hasMore = hasMore || sourceMore
+ }
+ sort.Slice(in, func(i, j int) bool { return in[i].ID > in[j].ID })
+ if len(in) > limit {
+ in = in[:limit]
+ hasMore = true
+ }
writeJSON(w, 200, map[string]any{"items": taskNodeDTOs(in), "has_more": hasMore})
}
+func inheritedIntentResult(state string) bool {
+ switch state {
+ case "done", "blocked", "exhausted", "stopped":
+ return true
+ default:
+ return false
+ }
+}
+
+// inheritedGraphSnapshot exposes immutable source-task history without leaking
+// work that is still open or running. Edges are filtered with the nodes so the
+// response cannot contain dangling ids that reveal hidden live intent topology.
+func inheritedGraphSnapshot(nodes []*db.Node, edges []db.Edge, sourceTaskID int64) ([]*db.Node, []db.Edge) {
+ visible := make(map[int64]struct{}, len(nodes))
+ filteredNodes := make([]*db.Node, 0, len(nodes))
+ for _, node := range nodes {
+ if node == nil || (node.Kind == db.KindIntent && !inheritedIntentResult(node.State)) {
+ continue
+ }
+ node.SourceTaskID = sourceTaskID
+ node.Inherited = true
+ visible[node.ID] = struct{}{}
+ filteredNodes = append(filteredNodes, node)
+ }
+ filteredEdges := make([]db.Edge, 0, len(edges))
+ for _, edge := range edges {
+ if _, ok := visible[edge.From]; !ok {
+ continue
+ }
+ if _, ok := visible[edge.To]; !ok {
+ continue
+ }
+ filteredEdges = append(filteredEdges, edge)
+ }
+ return filteredNodes, filteredEdges
+}
+
+// taskIntentHistoryPage returns one newest-first page for an exploration.
+// A source may have many open/running intents, so keep paging until enough
+// historical results are collected instead of leaking its frontier into the UI.
+func taskIntentHistoryPage(store *db.ExplorationStore, before int64, limit int, inherited bool, sourceTaskID int64) ([]*db.Node, bool, error) {
+ if !inherited {
+ return store.ListByKindPage(db.KindIntent, before, limit)
+ }
+ batch := max(limit, 300)
+ cursor := before
+ out := make([]*db.Node, 0, limit+1)
+ for {
+ page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, node := range page {
+ if !inheritedIntentResult(node.State) {
+ continue
+ }
+ node.SourceTaskID = sourceTaskID
+ node.Inherited = true
+ out = append(out, node)
+ if len(out) > limit {
+ return out[:limit], true, nil
+ }
+ }
+ if !more || len(page) == 0 {
+ return out, false, nil
+ }
+ cursor = page[len(page)-1].ID
+ }
+}
+
// explorationGraph returns the whole exploration chain (task graph) as nodes+edges.
func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) {
t := s.m.ResolveTask(r.URL.Query().Get("task"))
@@ -1717,8 +2128,36 @@ func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"nodes": []any{}, "edges": []any{}})
return
}
- nodes, _ := t.Store.Nodes(2000)
- edges, _ := t.Store.Edges(5000)
+ nodes, err := t.Store.Nodes(2000)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ edges, err := t.Store.Edges(5000)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ sources, err := t.Store.DirectSourceStores()
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ for _, source := range sources {
+ sourceNodes, nodeErr := source.Store.Nodes(2000)
+ if nodeErr != nil {
+ writeErr(w, 500, nodeErr.Error())
+ return
+ }
+ sourceEdges, edgeErr := source.Store.Edges(5000)
+ if edgeErr != nil {
+ writeErr(w, 500, edgeErr.Error())
+ return
+ }
+ sourceNodes, sourceEdges = inheritedGraphSnapshot(sourceNodes, sourceEdges, source.Task.TaskID)
+ nodes = append(nodes, sourceNodes...)
+ edges = append(edges, sourceEdges...)
+ }
writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)})
}
@@ -1737,7 +2176,16 @@ func (s *Server) activity(w http.ResponseWriter, r *http.Request) {
intentPtr = &n
}
}
- items, cursor, err := t.Store.ActivityList(intentPtr, since, limit)
+ var (
+ items []db.Activity
+ cursor int64
+ err error
+ )
+ if intentPtr != nil {
+ items, cursor, err = t.Store.ActivityListWithSources(*intentPtr, since, limit)
+ } else {
+ items, cursor, err = t.Store.ActivityList(nil, since, limit)
+ }
if err != nil {
log.Printf("[activity] task=%s since=%d limit=%d intent=%v: %v", t.ID, since, limit, intentPtr, err)
writeErr(w, 500, err.Error())
@@ -1765,6 +2213,38 @@ func parseActivitySession(sess string) (db.ActivitySessionFilter, bool) {
return db.ActivitySessionFilter{}, false
}
+// activitySessionStore resolves a worker session against the current task and
+// its direct sources. Planner/main always stay local; inherited intent sessions
+// are immutable history and use the source exploration only for reads.
+func activitySessionStore(t *Task, filter db.ActivitySessionFilter) (*db.ExplorationStore, int64, error) {
+ if filter.NodeID == nil {
+ return t.Store, 0, nil
+ }
+ node, err := t.Store.GetNodeWithSources(*filter.NodeID)
+ if err != nil {
+ return nil, 0, err
+ }
+ if node == nil || node.Kind != db.KindIntent {
+ return nil, 0, nil
+ }
+ if !node.Inherited {
+ return t.Store, 0, nil
+ }
+ if !inheritedIntentResult(node.State) {
+ return nil, 0, nil
+ }
+ sources, err := t.Store.DirectSourceStores()
+ if err != nil {
+ return nil, 0, err
+ }
+ for _, source := range sources {
+ if source.Task.TaskID == node.SourceTaskID {
+ return source.Store, source.Task.TaskID, nil
+ }
+ }
+ return nil, 0, nil
+}
+
// activityHistory serves one reverse-paginated page of a session's activity history.
// The latest page (no ?before) opens a session; ?before= pulls the older page on
// scroll-up. snapshot_cursor is the TASK-level max id at query time — the client uses
@@ -1785,18 +2265,43 @@ func (s *Server) activityHistory(w http.ResponseWriter, r *http.Request) {
}
before := int64(atoiDefault(q.Get("before"), 0))
limit := min(atoiDefault(q.Get("limit"), 200), 500) // cap so one request can't pull an unbounded slice
+ store, sourceTaskID, err := activitySessionStore(t, filter)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ if store == nil {
+ writeErr(w, 404, "session not found")
+ return
+ }
+ // The browser tails only this task's broadcaster. Keep the SSE join cursor
+ // local even when the opened worker transcript comes from a related task.
snapshot, err := t.Store.ActivityMaxID()
if err != nil {
log.Printf("[activity/history] task=%s session=%s snapshot: %v", t.ID, sess, err)
writeErr(w, 500, err.Error())
return
}
- items, hasMore, err := t.Store.ActivityPage(filter, before, limit)
+ var items []db.Activity
+ var hasMore bool
+ if sourceTaskID > 0 {
+ // Source sessions are readable only while the intent remains terminal. The
+ // DB query also removes model reasoning/accounting rows from inherited data.
+ items, hasMore, err = store.ActivityPageForTerminalIntent(*filter.NodeID, before, limit)
+ } else {
+ items, hasMore, err = store.ActivityPage(filter, before, limit)
+ }
if err != nil {
log.Printf("[activity/history] task=%s session=%s before=%d limit=%d: %v", t.ID, sess, before, limit, err)
writeErr(w, 500, err.Error())
return
}
+ if sourceTaskID > 0 {
+ for i := range items {
+ items[i].SourceTaskID = sourceTaskID
+ items[i].Inherited = true
+ }
+ }
earliest := before
if len(items) > 0 {
earliest = items[0].ID
@@ -2072,7 +2577,7 @@ func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) {
return
}
seq, _ := strconv.ParseInt(r.PathValue("seq"), 10, 64)
- d, err := t.Store.ActivityDetail(seq)
+ d, err := taskActivityDetail(t, seq)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -2080,6 +2585,14 @@ func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"detail": d})
}
+// taskActivityDetail keeps the legacy local-task behavior (including thinking
+// rows), while inherited details are restricted to terminal worker intents.
+// This prevents a guessed global activity id from exposing source planner/main
+// or in-flight transcripts.
+func taskActivityDetail(t *Task, seq int64) (string, error) {
+ return t.Store.ActivityDetailWithSources(seq)
+}
+
func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) {
tr := s.m.Traffic()
if tr == nil {
@@ -2423,6 +2936,14 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "no active task")
return
}
+ if s.engine.IsDeleting(t.ID) {
+ writeErr(w, 409, "任务正在删除,无法发送新消息")
+ return
+ }
+ if s.engine.IsPaused(t.ID) {
+ writeErr(w, 409, "任务已暂停,无法发送新消息")
+ return
+ }
var req struct {
Message string `json:"message"`
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
@@ -2431,29 +2952,36 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, err.Error())
return
}
+ // Admission is serialized with deletion's chat cancellation. Mark every chat
+ // turn busy before its first activity/file write, including rule-mode turns.
+ // If deletion wins the race, the second barrier check rejects this request.
+ s.chatMu.Lock()
+ if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) {
+ s.chatMu.Unlock()
+ writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息")
+ return
+ }
+ if s.chatBusy[t.ID] {
+ s.chatMu.Unlock()
+ writeErr(w, 409, "主 Agent 正在处理上一条消息,请稍候")
+ return
+ }
+ ctx, cancel := context.WithCancelCause(s.ctx)
+ s.chatBusy[t.ID] = true
+ s.chatCancel[t.ID] = cancel
+ s.chatMu.Unlock()
+
// Persist + broadcast the human turn so the 主 Agent 编排会话 survives page
// reloads and updates live: the conversation lives in the activity stream as
// worker="mainagent" (the per-task activity table, replayed via SSE). With
// attachments, the activity's Detail carries {text, attachments} so the transcript
// renders attachment cards.
s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments))
- if ma := s.mainAgentRef(); ma != nil {
- // Serialize per task: one main-agent run at a time so concurrent messages
- // don't corrupt the shared exp-main transcript. If a prior turn is still
- // running, tell the caller to wait rather than starting a racing run.
- s.chatMu.Lock()
- if s.chatBusy[t.ID] {
- s.chatMu.Unlock()
- writeErr(w, 409, "主 Agent 正在处理上一条消息,请稍候")
- return
- }
- // Cancellable context so stopChat can abort this turn without affecting
- // the server's root context (mirrors the conversation stop pattern).
- ctx, cancel := context.WithCancelCause(s.ctx)
- s.chatBusy[t.ID] = true
- s.chatCancel[t.ID] = cancel
- s.chatMu.Unlock()
-
+ var ma *agent.MainAgent
+ if s.taskRuntimeAvailable(t, "mainagent") {
+ ma = s.agentsForTask(t).main
+ }
+ if ma != nil {
// Run the agent on a per-turn cancellable ctx (NOT r.Context()): the turn can
// take minutes (multi-tool loop), and binding it to the request lifecycle meant
// a page reload / proxy timeout cancelled it mid-run ("context canceled"). The
@@ -2461,11 +2989,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// handler returns immediately and the browser never needs to hold the request.
go func() {
defer func() {
- cancel(agent.AbortChatTurnFinished)
- s.chatMu.Lock()
- delete(s.chatBusy, t.ID)
- delete(s.chatCancel, t.ID)
- s.chatMu.Unlock()
+ s.finishTaskChat(t.ID, cancel)
}()
// emit every step (thinking/tool_use/tool_result/text/result) so the main
// agent session shows its work live, like worker/planner. The final answer
@@ -2477,7 +3001,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// taskDir = agent 的工作目录(CWD),与 chatUpload 落盘、ensureRunDir 一致。
taskDir := filepath.Join(s.m.dir, "tasks", t.ID)
agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir)
- if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil {
+ s.engine.BeginLLMCall(t.ID)
+ _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal)
+ s.engine.EndLLMCall(t.ID)
+ if err != nil && ctx.Err() == nil {
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
}
}()
@@ -2486,9 +3013,28 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
}
reply := s.fallbackChat(t, req.Message)
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", Summary: reply})
+ s.finishTaskChat(t.ID, cancel)
writeJSON(w, 200, map[string]any{"reply": reply, "mode": "rule"})
}
+func (s *Server) cancelTaskChat(taskID string, cause error) bool {
+ s.chatMu.Lock()
+ cancel := s.chatCancel[taskID]
+ if cancel != nil {
+ cancel(cause)
+ }
+ s.chatMu.Unlock()
+ return cancel != nil
+}
+
+func (s *Server) finishTaskChat(taskID string, cancel context.CancelCauseFunc) {
+ cancel(agent.AbortChatTurnFinished)
+ s.chatMu.Lock()
+ delete(s.chatBusy, taskID)
+ delete(s.chatCancel, taskID)
+ s.chatMu.Unlock()
+}
+
// stopChat aborts the in-flight main-agent turn for a task (manual stop button).
// Mirrors pgStopConversation: cancels only the current turn; planner/workers are
// unaffected and continue running. The user can send a new message immediately.
@@ -2498,14 +3044,10 @@ func (s *Server) stopChat(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "task not found")
return
}
- s.chatMu.Lock()
- cancel := s.chatCancel[t.ID]
- s.chatMu.Unlock()
- if cancel == nil {
+ if !s.cancelTaskChat(t.ID, agent.AbortChatStoppedByUser) {
writeJSON(w, 200, map[string]any{"status": "idle"})
return
}
- cancel(agent.AbortChatStoppedByUser)
writeJSON(w, 200, map[string]any{"status": "stopping"})
}
diff --git a/server/server_mgmt.go b/server/server_mgmt.go
index eef78bb..9732b9d 100644
--- a/server/server_mgmt.go
+++ b/server/server_mgmt.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"log"
@@ -66,61 +67,97 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod
// ---------- tasks (delete) ----------
-// pgDeleteTask removes a task and, per opt-in flags in the (optional) JSON body,
-// its associated data: assets (task-owned), findings, recorded traffic (by the
-// task's hosts) and the per-task working directory. Empty body → all flags false →
-// only the task + its exploration subgraph are removed (legacy behavior).
+const taskDeleteDrainTimeout = 10 * time.Second
+
+func canonicalTaskID(raw string) (string, bool) {
+ n, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || n <= 0 {
+ return "", false
+ }
+ return strconv.FormatInt(n, 10), true
+}
+
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
- id := r.PathValue("id")
- var opts struct {
- Assets bool `json:"assets"`
- Findings bool `json:"findings"`
- Traffic bool `json:"traffic"`
- Files bool `json:"files"`
+ id, ok := canonicalTaskID(r.PathValue("id"))
+ if !ok {
+ writeErr(w, http.StatusBadRequest, "任务 id 无效")
+ return
}
- _ = json.NewDecoder(r.Body).Decode(&opts) // optional body; ignore EOF/parse errors
-
- result := map[string]any{"deleted": id}
- nid, _ := strconv.ParseInt(id, 10, 64)
- as := s.m.Assets()
-
- // Traffic first: it needs the task's hosts, which come from assets that a later
- // asset-delete would remove. Traffic is host-keyed (no per-task tag), so this
- // purges ALL recorded traffic for those hosts, not just this task's.
- if opts.Traffic && nid > 0 && as != nil {
- if tr := s.m.Traffic(); tr != nil {
- if hosts, err := as.HostsByTask(nid); err == nil && len(hosts) > 0 {
- if n, err := tr.DeleteHostsExact(hosts); err == nil {
- result["traffic_deleted"] = n
- }
- }
+ var opts DeleteTaskOptions
+ if err := decode(r, &opts); err != nil && err != io.EOF {
+ writeErr(w, 400, "invalid JSON: "+err.Error())
+ return
+ }
+ if !s.engine.BeginDelete(id) {
+ writeErr(w, http.StatusConflict, "任务正在删除")
+ return
+ }
+ deleted := false
+ defer func() {
+ if !deleted {
+ s.engine.AbortDelete(id)
}
- }
- if opts.Findings && nid > 0 {
- if n, err := s.m.pg.DeleteFindingsByTask(nid); err == nil {
- result["findings_deleted"] = n
- }
- }
- if opts.Assets && nid > 0 && as != nil {
- if n, err := as.DeleteByTaskID(nid); err == nil {
- result["assets_deleted"] = n
- }
- }
+ }()
- s.engine.Pause(id, agent.AbortTaskDeleted) // cancel running loops before removing
- if err := s.m.DeleteTask(id); err != nil {
- writeErr(w, 500, err.Error())
+ // Main-agent runs use a separate context from planner/workers. Cancel it, then
+ // wait until both execution domains have returned before removing transcripts.
+ s.cancelTaskChat(id, agent.AbortTaskDeleted)
+ drainCtx, cancelDrain := context.WithTimeout(r.Context(), taskDeleteDrainTimeout)
+ defer cancelDrain()
+ if err := s.waitTaskQuiescent(drainCtx, id); err != nil {
+ writeErr(w, http.StatusConflict, "任务仍有运行中的 Agent,删除已取消")
return
}
- // Files last: the per-task working dir /tasks/ (worker artifacts +
- // uploads). Confined to the work dir; id is numeric so no traversal risk.
- if opts.Files && nid > 0 {
- if err := os.RemoveAll(filepath.Join(s.m.dir, "tasks", id)); err == nil {
- result["files_deleted"] = true
+ result, err := s.m.DeleteTask(id, opts)
+ if err != nil {
+ var committed *taskDeleteCommittedError
+ if errors.As(err, &committed) {
+ // PostgreSQL is already gone. Complete runtime teardown and return the
+ // auditable counts together with the post-commit cleanup warning.
+ s.engine.StopTask(id)
+ s.taskAgentMu.Lock()
+ delete(s.taskAgents, id)
+ s.taskAgentMu.Unlock()
+ deleted = true
+ writeCommittedTaskDelete(w, result, err)
+ return
+ }
+ writeErr(w, 500, err.Error())
+ return
+ }
+ // Manager has removed the task from the registry, so no new API operation can
+ // resolve it. Now stop and join every task-owned Engine goroutine and clear its
+ // lifecycle maps before releasing the delete barrier.
+ s.engine.StopTask(id)
+ s.taskAgentMu.Lock()
+ delete(s.taskAgents, id)
+ s.taskAgentMu.Unlock()
+ deleted = true
+ writeJSON(w, 200, result)
+}
+
+func writeCommittedTaskDelete(w http.ResponseWriter, result DeleteTaskResult, err error) {
+ result.CleanupWarning = err.Error()
+ writeJSON(w, http.StatusOK, result)
+}
+
+func (s *Server) waitTaskQuiescent(ctx context.Context, taskID string) error {
+ ticker := time.NewTicker(10 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ s.chatMu.Lock()
+ chatBusy := s.chatBusy[taskID]
+ s.chatMu.Unlock()
+ if s.engine.inflightCount(taskID) == 0 && !chatBusy {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
}
}
- writeJSON(w, 200, result)
}
// ---------- agents ----------
@@ -352,6 +389,9 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
if profileChanged {
s.invalidateProfileAgents()
}
+ // Task bundles capture operational Agent settings (turn/time budgets, tools,
+ // web search). Rebuild them even when no global profile is configured.
+ s.invalidateTaskAgents()
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
s.cfgMu.Lock()
cfg, on := s.llmCfg, s.llmOn
@@ -396,8 +436,8 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, 200, map[string]any{
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
- "visibility": map[string]any{"mcp": mcp, "skill": sk},
- "llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
+ "visibility": map[string]any{"mcp": mcp, "skill": sk},
+ "llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
@@ -1607,7 +1647,9 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
- // Editing a profile rebuilds any task pinned to it on its next round.
+ // Editing a profile rebuilds any task pinned to it on its next round. Reapply
+ // the active profile too: the global fallback and explicit task chains must
+ // repopulate the same provider-cache entry and therefore share one limiter.
s.invalidateProfileAgents()
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
@@ -1622,16 +1664,50 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
return
}
id, _ := pathInt(r, "id")
- if err := pg.DeleteProfile(id); err != nil {
- writeErr(w, 500, err.Error())
+ if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
+ switch {
+ case errors.Is(err, db.ErrActiveLLMProfileDelete):
+ writeErr(w, 409, "当前激活的 LLM 配置不能删除,请先激活其他配置")
+ case errors.Is(err, db.ErrLLMProfileReferencesChanged):
+ writeErr(w, 409, "LLM 配置正在被任务或会话修改,请重试")
+ case errors.Is(err, context.DeadlineExceeded):
+ writeErr(w, 409, "等待 LLM 配置引用释放超时,请重试")
+ case errors.Is(err, db.ErrLLMProfileNotFound):
+ writeErr(w, 404, err.Error())
+ default:
+ writeErr(w, 500, err.Error())
+ }
return
}
s.invalidateProfileAgents() // drop cached agents for the removed profile
- s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
- s.reapplyActiveProfile() // the deleted profile may have been in the failover chain
+ s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
+ s.restoreTasksAfterProfileDelete(pg)
+ // Cache invalidation also removed the active profile's shared provider entry.
+ // Reapply after task-state sync so the wake-up observes the post-delete chain.
+ s.reapplyActiveProfile()
writeJSON(w, 200, map[string]any{"deleted": id})
}
+func (s *Server) restoreTasksAfterProfileDelete(pg *db.DB) {
+ for _, task := range s.m.List() {
+ if taskID, err := strconv.ParseInt(task.ID, 10, 64); err == nil {
+ if pt, err := pg.GetTask(taskID); err == nil && pt != nil {
+ s.syncTaskLLMState(pt)
+ // Deleting the active entry may advance the task to a ready successor,
+ // or clear the explicit chain and restore its Agent/global fallback.
+ // Resume only intents that were blocked by the exhausted chain; a
+ // paused task remains paused and terminal tasks remain immutable.
+ if !isTerminalStatus(task.Status) && s.taskRuntimeAvailable(task, "planner", "worker") {
+ if _, reopenErr := task.Store.ReopenIntentsByBlockedReason(db.IntentBlockedLLMQuota); reopenErr != nil {
+ log.Printf("[llm-profile] task %s reopen quota-blocked intents after profile delete: %v", task.ID, reopenErr)
+ }
+ }
+ task.Notify()
+ }
+ }
+ }
+}
+
func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
diff --git a/server/task_delete_barrier_test.go b/server/task_delete_barrier_test.go
new file mode 100644
index 0000000..4b95192
--- /dev/null
+++ b/server/task_delete_barrier_test.go
@@ -0,0 +1,220 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Autumn-27/artex/agent"
+)
+
+func TestEngineDeleteBarrierRejectsNewTaskOperations(t *testing.T) {
+ e := NewEngine(nil)
+ const taskID = "42"
+
+ if !e.beginTaskOperation(taskID) {
+ t.Fatal("first task operation should be admitted")
+ }
+ if !e.BeginDelete(taskID) {
+ t.Fatal("first delete should install the barrier")
+ }
+ if !e.IsDeleting(taskID) {
+ t.Fatal("delete barrier should be visible")
+ }
+ if !e.IsPaused(taskID) {
+ t.Fatal("delete should temporarily pause task execution")
+ }
+ if e.beginTaskOperation(taskID) {
+ t.Fatal("operation started after BeginDelete returned")
+ }
+ if got := e.inflightCount(taskID); got != 1 {
+ t.Fatalf("existing operation must remain drainable, inflight=%d", got)
+ }
+ e.decInflight(taskID)
+
+ if e.BeginDelete(taskID) {
+ t.Fatal("duplicate delete must not acquire the barrier")
+ }
+ e.AbortDelete(taskID)
+ if e.IsPaused(taskID) {
+ t.Fatal("aborted delete should restore a previously running task")
+ }
+ if !e.beginTaskOperation(taskID) {
+ t.Fatal("aborting deletion should reopen operation admission")
+ }
+ e.decInflight(taskID)
+}
+
+func TestAbortDeletePreservesExistingPause(t *testing.T) {
+ e := NewEngine(nil)
+ e.Pause("42", agent.AbortPausedByUser)
+ if !e.BeginDelete("42") {
+ t.Fatal("delete barrier should be installed")
+ }
+ e.AbortDelete("42")
+ if !e.IsPaused("42") {
+ t.Fatal("aborted delete must preserve a pre-existing user pause")
+ }
+}
+
+func TestStopTaskCancelsRuntimeAndClearsLifecycleState(t *testing.T) {
+ e := NewEngine(nil)
+ const taskID = "42"
+ e.deleteMu.RLock()
+ rt := e.registerTaskRoutines(context.Background(), taskID, 1)
+ e.started.Store(taskID, true)
+ e.lastAct.Store(taskID, int64(1))
+ e.paused.Store(taskID, true)
+ e.plannerRound.Store(taskID, 3)
+ e.coordStarted.Store(taskID, true)
+ e.deleteMu.RUnlock()
+
+ done := make(chan struct{})
+ runTaskRoutine(rt, func(ctx context.Context) {
+ <-ctx.Done()
+ close(done)
+ })
+ e.StopTask(taskID)
+
+ select {
+ case <-done:
+ default:
+ t.Fatal("task runtime was not cancelled before StopTask returned")
+ }
+ if e.Started(taskID) || e.IsPaused(taskID) || e.IsDeleting(taskID) {
+ t.Fatalf("task lifecycle maps were not cleared: started=%v paused=%v deleting=%v",
+ e.Started(taskID), e.IsPaused(taskID), e.IsDeleting(taskID))
+ }
+ if _, ok := e.lastAct.Load(taskID); ok {
+ t.Fatal("last activity state was retained")
+ }
+ if _, ok := e.plannerRound.Load(taskID); ok {
+ t.Fatal("planner round state was retained")
+ }
+ if _, ok := e.coordStarted.Load(taskID); ok {
+ t.Fatal("deadline coordinator state was retained")
+ }
+ e.runtimeMu.Lock()
+ _, retained := e.runtimes[taskID]
+ e.runtimeMu.Unlock()
+ if retained {
+ t.Fatal("task runtime retained after StopTask")
+ }
+}
+
+func TestCanonicalTaskID(t *testing.T) {
+ for raw, want := range map[string]string{"42": "42", "00042": "42", "+42": "42"} {
+ if got, ok := canonicalTaskID(raw); !ok || got != want {
+ t.Fatalf("canonicalTaskID(%q)=(%q,%v), want (%q,true)", raw, got, ok, want)
+ }
+ }
+ for _, raw := range []string{"", "0", "-1", "abc", "42x"} {
+ if got, ok := canonicalTaskID(raw); ok {
+ t.Fatalf("canonicalTaskID(%q)=(%q,true), want invalid", raw, got)
+ }
+ }
+}
+
+func TestCancelChatThenWaitTaskQuiescent(t *testing.T) {
+ e := NewEngine(nil)
+ chatCtx, chatCancel := context.WithCancelCause(context.Background())
+ s := &Server{
+ engine: e,
+ chatBusy: map[string]bool{"7": true},
+ chatCancel: map[string]context.CancelCauseFunc{"7": chatCancel},
+ }
+ if !e.beginTaskOperation("7") {
+ t.Fatal("task operation should be admitted")
+ }
+
+ cancelObserved := make(chan struct{})
+ releaseChat := make(chan struct{})
+ go func() {
+ <-chatCtx.Done()
+ close(cancelObserved)
+ <-releaseChat
+ s.finishTaskChat("7", chatCancel)
+ }()
+ if !s.cancelTaskChat("7", agent.AbortTaskDeleted) {
+ t.Fatal("expected active chat to be cancelled")
+ }
+ <-cancelObserved
+
+ waitDone := make(chan error, 1)
+ waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
+ defer waitCancel()
+ go func() { waitDone <- s.waitTaskQuiescent(waitCtx, "7") }()
+
+ select {
+ case err := <-waitDone:
+ t.Fatalf("wait returned while chat and engine operation were active: %v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ close(releaseChat)
+ select {
+ case err := <-waitDone:
+ t.Fatalf("wait returned while engine operation was active: %v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ e.decInflight("7")
+ if err := <-waitDone; err != nil {
+ t.Fatalf("wait for quiescence: %v", err)
+ }
+}
+
+func TestWaitTaskQuiescentHonorsContext(t *testing.T) {
+ s := &Server{
+ engine: NewEngine(nil),
+ chatBusy: map[string]bool{"9": true},
+ chatCancel: map[string]context.CancelCauseFunc{},
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if err := s.waitTaskQuiescent(ctx, "9"); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected deadline exceeded, got %v", err)
+ }
+}
+
+func TestTaskChatUploadRejectsDeleteBarrierBeforeCreatingDirectory(t *testing.T) {
+ dataDir := t.TempDir()
+ m := &Manager{dir: dataDir, tasks: map[string]*Task{"7": {ID: "7"}}}
+ s := &Server{m: m, engine: NewEngine(m)}
+ if !s.engine.BeginDelete("7") {
+ t.Fatal("delete barrier should be installed")
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/chat/upload?scope=task&id=7", nil)
+ rec := httptest.NewRecorder()
+ s.chatUpload(rec, req)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("expected 409 while deleting, got %d: %s", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(dataDir, "tasks", "7")); !os.IsNotExist(err) {
+ t.Fatalf("upload must not recreate task directory, stat err=%v", err)
+ }
+}
+
+func TestCommittedTaskDeleteReturnsSuccessWithCleanupWarning(t *testing.T) {
+ rec := httptest.NewRecorder()
+ result := DeleteTaskResult{Deleted: "7", FilesDeleted: true}
+ err := &taskDeleteCommittedError{err: errors.New("remove staged task files: permission denied")}
+
+ writeCommittedTaskDelete(rec, result, err)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String())
+ }
+ var got DeleteTaskResult
+ if decodeErr := json.NewDecoder(rec.Body).Decode(&got); decodeErr != nil {
+ t.Fatal(decodeErr)
+ }
+ if got.Deleted != "7" || !got.FilesDeleted || !strings.Contains(got.CleanupWarning, "cleanup incomplete") {
+ t.Fatalf("unexpected response: %+v", got)
+ }
+}
diff --git a/server/task_llm.go b/server/task_llm.go
new file mode 100644
index 0000000..c1dbe88
--- /dev/null
+++ b/server/task_llm.go
@@ -0,0 +1,462 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "iter"
+ "log"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Autumn-27/artex/agent"
+ "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/artex/llmrec"
+ "github.com/Autumn-27/norma/llm"
+ "github.com/Autumn-27/norma/memory"
+ "github.com/Autumn-27/norma/transcript"
+)
+
+type taskAgentBundle struct {
+ runtime *taskLLMRuntime // goal decomposition runtime
+ plannerRuntime *taskLLMRuntime
+ workerRuntime *taskLLMRuntime
+ mainRuntime *taskLLMRuntime
+ pl *agent.Planner
+ wk *agent.Worker
+ main *agent.MainAgent
+}
+
+type llmAuditProfile struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Format string `json:"format"`
+ Model string `json:"model"`
+}
+
+type llmTransitionAudit struct {
+ Mode string `json:"mode"` // automatic | manual | exhausted
+ Reason string `json:"reason"`
+ Previous *llmAuditProfile `json:"previous,omitempty"`
+ Next *llmAuditProfile `json:"next,omitempty"`
+}
+
+type llmActivityMetadata struct {
+ LLMTransition llmTransitionAudit `json:"llm_transition"`
+}
+
+type taskLLMRuntime struct {
+ s *Server
+ taskID string
+ agentKey string
+}
+
+type taskLLMError struct {
+ taskID string
+ chainExhausted bool
+ cause error
+}
+
+func (e *taskLLMError) Error() string {
+ if e.chainExhausted {
+ return fmt.Sprintf("task %s LLM profile chain exhausted: %v", e.taskID, e.cause)
+ }
+ return e.cause.Error()
+}
+
+func (e *taskLLMError) Unwrap() error { return e.cause }
+
+func isTaskLLMRuntimeError(err error) bool {
+ var target *taskLLMError
+ return errors.As(err, &target)
+}
+
+func isTaskLLMChainExhausted(err error) bool {
+ var target *taskLLMError
+ return errors.As(err, &target) && target.chainExhausted
+}
+
+// isQuotaExhaustedError is intentionally strict. A generic 429, auth error,
+// network failure, or 5xx does not rotate providers; the response must explicitly
+// identify quota, credits, billing balance, or payment exhaustion.
+func isQuotaExhaustedError(err error) bool {
+ return err != nil && agent.IsQuotaExhaustedMessage(err.Error())
+}
+
+type taskLLMSelection struct {
+ task *Task
+ profileID int64
+ revision int64
+ provider llm.Provider
+}
+
+type taskLLMStreamHooks struct {
+ current func() (taskLLMSelection, error)
+ exhaust func(taskLLMSelection, error) (db.TaskLLMTransition, error)
+ transition func(taskLLMSelection, db.TaskLLMTransition, error)
+}
+
+func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) {
+ taskNum, err := parseTaskID(r.taskID)
+ if err != nil {
+ return nil, 0, 0, nil, err
+ }
+ pt, err := r.s.m.pg.GetTask(taskNum)
+ if err != nil {
+ return nil, 0, 0, nil, err
+ }
+ if pt == nil {
+ return nil, 0, 0, nil, fmt.Errorf("task %s not found", r.taskID)
+ }
+ r.s.syncTaskLLMState(pt)
+ t, _ := r.s.m.Task(r.taskID)
+ if len(pt.LLMProfileIDs) == 0 {
+ prov, _, ok := r.s.fallbackProviderForAgent(r.agentKey)
+ if !ok {
+ return t, 0, pt.LLMChainRevision, nil, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID)
+ }
+ return t, 0, pt.LLMChainRevision, prov, nil
+ }
+ if pt.ActiveLLMProfileID == nil {
+ return t, 0, pt.LLMChainRevision, nil, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")}
+ }
+ prov, _, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID)
+ if !ok {
+ return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, nil, fmt.Errorf("LLM profile #%d is missing or invalid", *pt.ActiveLLMProfileID)
+ }
+ return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, prov, nil
+}
+
+func parseTaskID(id string) (int64, error) {
+ n, err := strconv.ParseInt(id, 10, 64)
+ if err != nil || n <= 0 {
+ return 0, fmt.Errorf("invalid task id %q", id)
+ }
+ return n, nil
+}
+
+func (r *taskLLMRuntime) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
+ ctx = llmrec.WithTaskID(ctx, r.taskID)
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ task, profileID, revision, provider, err := r.current()
+ return taskLLMSelection{task: task, profileID: profileID, revision: revision, provider: provider}, err
+ },
+ exhaust: func(selection taskLLMSelection, cause error) (db.TaskLLMTransition, error) {
+ taskNum, _ := parseTaskID(r.taskID)
+ transition, err := r.s.m.pg.MarkTaskLLMProfileQuotaExhaustedAtRevision(taskNum, selection.profileID, selection.revision, cause.Error())
+ if err != nil {
+ return transition, err
+ }
+ if pt, getErr := r.s.m.pg.GetTask(taskNum); getErr == nil && pt != nil {
+ r.s.syncTaskLLMState(pt)
+ }
+ return transition, nil
+ },
+ transition: func(selection taskLLMSelection, transition db.TaskLLMTransition, cause error) {
+ r.s.emitTaskLLMTransition(selection.task, transition, cause)
+ },
+ }
+ return streamTaskLLM(ctx, r.taskID, req, hooks)
+}
+
+func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) iter.Seq2[llm.StreamEvent, error] {
+ return func(yield func(llm.StreamEvent, error) bool) {
+ for {
+ selection, err := hooks.current()
+ if err != nil {
+ yield(llm.StreamEvent{}, err)
+ return
+ }
+ committed := false
+ var pending []llm.StreamEvent
+ var streamErr error
+ for event, err := range selection.provider.Stream(ctx, req) {
+ if err != nil {
+ streamErr = err
+ break
+ }
+ if !committed && !streamEventCommitsOutput(event) {
+ pending = append(pending, event)
+ continue
+ }
+ if !committed {
+ for _, buffered := range pending {
+ if !yield(buffered, nil) {
+ return
+ }
+ }
+ pending = nil
+ committed = true
+ }
+ if !yield(event, nil) {
+ return
+ }
+ }
+ if streamErr == nil {
+ for _, buffered := range pending {
+ if !yield(buffered, nil) {
+ return
+ }
+ }
+ return
+ }
+ // profileID=0 means the explicit chain was cleared while this stable task
+ // bundle was still in use. Agent/global fallback errors follow the legacy
+ // behavior and never mutate task failover state.
+ if selection.profileID == 0 || !isQuotaExhaustedError(streamErr) {
+ for _, buffered := range pending {
+ if !yield(buffered, nil) {
+ return
+ }
+ }
+ yield(llm.StreamEvent{}, streamErr)
+ return
+ }
+ transition, markErr := hooks.exhaust(selection, streamErr)
+ if markErr != nil {
+ cause := fmt.Errorf("mark profile quota exhausted after %v: %w", streamErr, markErr)
+ if committed {
+ // Output may already have driven tool execution. Report the persistence
+ // failure, but classify it as router-handled so the worker does not
+ // replay the entire intent and duplicate those side effects.
+ yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, cause: cause})
+ } else {
+ yield(llm.StreamEvent{}, cause)
+ }
+ return
+ }
+ if transition.Advanced && !transition.Stale && hooks.transition != nil {
+ hooks.transition(selection, transition, streamErr)
+ }
+ if committed || (!transition.Stale && transition.NextProfileID == nil) {
+ yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: streamErr})
+ return
+ }
+ // No event reached the caller, so replaying the same logical request on
+ // the next profile cannot duplicate model output or tool execution.
+ }
+ }
+}
+
+func streamEventCommitsOutput(event llm.StreamEvent) bool {
+ switch event.Type {
+ case llm.SETextDelta, llm.SEThinkingDelta, llm.SEToolInputJSON:
+ return event.Text != ""
+ case llm.SEToolUseStart, llm.SEMessageDelta, llm.SEMessageStop:
+ return true
+ default:
+ return false
+ }
+}
+
+func (r *taskLLMRuntime) CompactionWindow() int {
+ taskNum, err := parseTaskID(r.taskID)
+ if err != nil {
+ return (agent.Config{}).CompactionWindow()
+ }
+ chain, err := r.s.m.pg.TaskLLMProfiles(taskNum)
+ if err != nil {
+ return (agent.Config{}).CompactionWindow()
+ }
+ if len(chain) == 0 {
+ if _, cfg, ok := r.s.fallbackProviderForAgent(r.agentKey); ok {
+ return cfg.CompactionWindow()
+ }
+ return (agent.Config{}).CompactionWindow()
+ }
+ minimum := 0
+ for _, entry := range chain {
+ if cfg, ok := r.s.loadProfileConfig(entry.ProfileID); ok {
+ window := cfg.CompactionWindow()
+ if minimum == 0 || window < minimum {
+ minimum = window
+ }
+ }
+ }
+ if minimum == 0 {
+ return (agent.Config{}).CompactionWindow()
+ }
+ return minimum
+}
+
+// fallbackProviderForAgent resolves the pre-existing precedence used by tasks
+// without an explicit chain: the Agent's saved binding first, then the current
+// global provider (which may come from the default profile or environment).
+func (s *Server) fallbackProviderForAgent(agentKey string) (llm.Provider, agent.Config, bool) {
+ if id := s.effectiveProfileForAgent(agentKey, nil); id != nil {
+ if prov, cfg, ok := s.providerForProfile(*id); ok {
+ return s.poolForBinding(*id, prov, cfg), cfg, true
+ }
+ }
+ s.cfgMu.Lock()
+ defer s.cfgMu.Unlock()
+ if !s.llmOn || s.llmProv == nil {
+ return nil, agent.Config{}, false
+ }
+ return s.llmProv, s.llmCfg, true
+}
+
+func (s *Server) taskRuntimeAvailable(t *Task, agentKeys ...string) bool {
+ if t == nil {
+ return false
+ }
+ state := t.llmStateSnapshot()
+ if len(state.ProfileIDs) > 0 {
+ if state.ActiveID == nil {
+ return false
+ }
+ _, _, ok := s.providerForProfile(*state.ActiveID)
+ return ok
+ }
+ s.cfgMu.Lock()
+ globallyReady := s.llmOn && s.llmProv != nil
+ s.cfgMu.Unlock()
+ if globallyReady {
+ return true
+ }
+ for _, key := range agentKeys {
+ if _, _, ok := s.fallbackProviderForAgent(key); !ok {
+ return false
+ }
+ }
+ return len(agentKeys) > 0
+}
+
+func (s *Server) invalidateTaskAgents() {
+ s.taskAgentMu.Lock()
+ s.taskAgents = map[string]*taskAgentBundle{}
+ s.taskAgentMu.Unlock()
+}
+
+func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
+ s.taskAgentMu.Lock()
+ defer s.taskAgentMu.Unlock()
+ if bundle := s.taskAgents[t.ID]; bundle != nil {
+ return bundle
+ }
+ goalRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "goals"}
+ plannerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "planner"}
+ workerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "worker"}
+ mainRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "mainagent"}
+ tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
+ window := workerRuntime.CompactionWindow()
+ wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
+ wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
+ wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
+ wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
+ wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
+ wk.SetWebSearch(s.webSearchFor("worker"))
+ pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
+ pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
+ pl.SetKillWork(s.engine.KillWork)
+ pl.SetSteerWork(s.engine.SteerWork)
+ pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
+ pl.SetWebSearch(s.webSearchFor("planner"))
+ main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
+ main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
+ main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
+ main.SetWebSearch(s.webSearchFor("mainagent"))
+ bundle := &taskAgentBundle{
+ runtime: goalRuntime, plannerRuntime: plannerRuntime, workerRuntime: workerRuntime,
+ mainRuntime: mainRuntime, pl: pl, wk: wk, main: main,
+ }
+ s.taskAgents[t.ID] = bundle
+ return bundle
+}
+
+func (s *Server) syncTaskLLMState(pt *db.Task) {
+ if pt == nil {
+ return
+ }
+ id := fmt.Sprintf("%d", pt.ID)
+ s.m.mu.Lock()
+ if task := s.m.tasks[id]; task != nil {
+ task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMFailoverState, pt.LLMFailoverReason)
+ }
+ s.m.mu.Unlock()
+}
+
+func (s *Server) emitTaskLLMTransition(t *Task, transition db.TaskLLMTransition, cause error) {
+ if t == nil {
+ return
+ }
+ previous := s.llmAuditProfile(transition.PreviousProfileID)
+ var next *llmAuditProfile
+ if transition.NextProfileID != nil {
+ next = s.llmAuditProfile(*transition.NextProfileID)
+ }
+ mode := "automatic"
+ kind := "llm_switch"
+ summary := fmt.Sprintf("%s 额度不足", llmAuditProfileLabel(previous))
+ if transition.NextProfileID != nil {
+ summary += fmt.Sprintf(",后续调用切换到 %s", llmAuditProfileLabel(next))
+ } else {
+ mode = "exhausted"
+ kind = "llm_failover"
+ summary += ",配置链已耗尽"
+ }
+ metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
+ Mode: mode, Reason: cause.Error(), Previous: previous, Next: next,
+ }})
+ s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: kind, IsError: transition.ChainExhausted, Summary: summary, Detail: cause.Error(), Metadata: metadata})
+ log.Printf("[llm-failover] task %s: %s", t.ID, summary)
+}
+
+func (s *Server) llmAuditProfile(id int64) *llmAuditProfile {
+ if id <= 0 || s.m == nil || s.m.pg == nil {
+ return nil
+ }
+ p, err := s.m.pg.ProfileByID(id)
+ if err != nil || p == nil {
+ return &llmAuditProfile{ID: id, Name: fmt.Sprintf("配置 #%d", id)}
+ }
+ return &llmAuditProfile{ID: p.ID, Name: p.Name, Format: p.Format, Model: p.Model}
+}
+
+func llmAuditProfileLabel(profile *llmAuditProfile) string {
+ if profile == nil {
+ return "默认配置"
+ }
+ name := profile.Name
+ if name == "" {
+ name = fmt.Sprintf("配置 #%d", profile.ID)
+ }
+ detail := []string{}
+ if profile.Format != "" {
+ detail = append(detail, profile.Format)
+ }
+ if profile.Model != "" {
+ detail = append(detail, profile.Model)
+ }
+ if len(detail) == 0 {
+ return name
+ }
+ return fmt.Sprintf("%s(%s)", name, strings.Join(detail, " / "))
+}
+
+func sameOptionalID(a, b *int64) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return *a == *b
+}
+
+func (s *Server) emitManualTaskLLMSwitch(t *Task, previousID, nextID *int64) db.Activity {
+ previous := (*llmAuditProfile)(nil)
+ next := (*llmAuditProfile)(nil)
+ if previousID != nil {
+ previous = s.llmAuditProfile(*previousID)
+ }
+ if nextID != nil {
+ next = s.llmAuditProfile(*nextID)
+ }
+ summary := fmt.Sprintf("用户手动将任务 LLM 从 %s 切换到 %s", llmAuditProfileLabel(previous), llmAuditProfileLabel(next))
+ metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
+ Mode: "manual", Reason: "用户手动切换任务 LLM", Previous: previous, Next: next,
+ }})
+ return s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: "llm_switch", Summary: summary, Detail: summary, Metadata: metadata})
+}
diff --git a/server/task_llm_test.go b/server/task_llm_test.go
new file mode 100644
index 0000000..faf1cfd
--- /dev/null
+++ b/server/task_llm_test.go
@@ -0,0 +1,507 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "iter"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Autumn-27/artex/agent"
+ "github.com/Autumn-27/artex/db"
+ "github.com/Autumn-27/norma/harness"
+ "github.com/Autumn-27/norma/llm"
+)
+
+type scriptedLLMProvider struct {
+ events []llm.StreamEvent
+ err error
+ calls int
+}
+
+func (p *scriptedLLMProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
+ return func(yield func(llm.StreamEvent, error) bool) {
+ p.calls++
+ for _, event := range p.events {
+ if !yield(event, nil) {
+ return
+ }
+ }
+ if p.err != nil {
+ yield(llm.StreamEvent{}, p.err)
+ }
+ }
+}
+
+func collectTaskLLMStream(seq iter.Seq2[llm.StreamEvent, error]) ([]llm.StreamEvent, error) {
+ var events []llm.StreamEvent
+ var streamErr error
+ for event, err := range seq {
+ if err != nil {
+ streamErr = err
+ break
+ }
+ events = append(events, event)
+ }
+ return events, streamErr
+}
+
+func TestIsQuotaExhaustedError(t *testing.T) {
+ t.Parallel()
+ positive := []string{
+ `openai: code=insufficient_quota status=429`,
+ `provider error: quota_exceeded`,
+ `grpc code=RESOURCE_EXHAUSTED`,
+ `You exceeded your current quota, please check your plan and billing details.`,
+ `billing_hard_limit_reached`,
+ `billing_not_active`,
+ `HTTP status 402 Payment Required`,
+ `账户余额不足,请充值`,
+ `credit balance is too low`,
+ }
+ for _, message := range positive {
+ if !isQuotaExhaustedError(errors.New(message)) {
+ t.Errorf("expected quota classification for %q", message)
+ }
+ }
+
+ negative := []string{
+ `HTTP status 429: rate limit exceeded`,
+ `RESOURCE_EXHAUSTED: rate limit exceeded`,
+ `HTTP status 429: quota exceeded for quota metric GenerateRequestsPerMinutePerProjectPerBaseModel`,
+ `RESOURCE_EXHAUSTED: TPM quota exceeded`,
+ `HTTP status 401: invalid api key`,
+ `HTTP status 401: insufficient_quota`,
+ `HTTP status 403: forbidden`,
+ `HTTP status 403: billing_hard_limit_reached`,
+ `HTTP status 500: internal server error`,
+ `HTTP status 503: quota_exceeded`,
+ `dial tcp: network is unreachable`,
+ `context length exceeded`,
+ `invalid request parameter`,
+ }
+ for _, message := range negative {
+ if isQuotaExhaustedError(errors.New(message)) {
+ t.Errorf("unexpected quota classification for %q", message)
+ }
+ }
+ if isQuotaExhaustedError(nil) {
+ t.Error("nil error must not be classified as quota exhaustion")
+ }
+}
+
+func TestTaskDTOUsesEmptyArrays(t *testing.T) {
+ t.Parallel()
+ dto := taskDTO(&Task{ID: "7"}, "created")
+ raw, err := json.Marshal(dto)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ t.Fatal(err)
+ }
+ for _, field := range []string{"source_task_ids", "llm_profile_ids"} {
+ value, ok := payload[field].([]any)
+ if !ok || len(value) != 0 {
+ t.Fatalf("%s must serialize as [], payload=%s", field, raw)
+ }
+ }
+}
+
+func TestTaskLLMStateSnapshotIsConcurrentAndDetached(t *testing.T) {
+ t.Parallel()
+ task := &Task{ID: "7"}
+ var wg sync.WaitGroup
+ for writer := 0; writer < 2; writer++ {
+ wg.Add(1)
+ go func(offset int) {
+ defer wg.Done()
+ for i := 0; i < 500; i++ {
+ id := int64(offset*1000 + i + 1)
+ task.setLLMState(&id, &id, []int64{id, id + 1}, "ready", "")
+ }
+ }(writer)
+ }
+ for reader := 0; reader < 4; reader++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 500; i++ {
+ state := task.llmStateSnapshot()
+ if len(state.ProfileIDs) > 0 {
+ state.ProfileIDs[0] = -1
+ }
+ _ = taskDTO(task, "running")
+ }
+ }()
+ }
+ wg.Wait()
+ state := task.llmStateSnapshot()
+ if len(state.ProfileIDs) != 2 || state.ProfileIDs[0] <= 0 {
+ t.Fatalf("snapshot mutation leaked into task state: %+v", state)
+ }
+}
+
+func TestTaskLLMStreamRetriesPreStreamQuotaOnNextProfile(t *testing.T) {
+ t.Parallel()
+ quota := errors.New("openai: status 402: insufficient_quota")
+ first := &scriptedLLMProvider{err: quota}
+ second := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "fallback"}}}
+ active := int64(11)
+ transitions := 0
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ if active == 11 {
+ return taskLLMSelection{profileID: 11, provider: first}, nil
+ }
+ return taskLLMSelection{profileID: 22, provider: second}, nil
+ },
+ exhaust: func(selection taskLLMSelection, _ error) (db.TaskLLMTransition, error) {
+ if selection.profileID != 11 {
+ t.Fatalf("unexpected exhausted profile %d", selection.profileID)
+ }
+ active = 22
+ next := active
+ return db.TaskLLMTransition{PreviousProfileID: 11, NextProfileID: &next, Advanced: true}, nil
+ },
+ transition: func(taskLLMSelection, db.TaskLLMTransition, error) { transitions++ },
+ }
+
+ events, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 1 || events[0].Text != "fallback" {
+ t.Fatalf("unexpected fallback events: %+v", events)
+ }
+ if first.calls != 1 || second.calls != 1 || transitions != 1 {
+ t.Fatalf("calls first=%d second=%d transitions=%d", first.calls, second.calls, transitions)
+ }
+}
+
+func TestTaskLLMStreamRetriesAfterNonContentStartEvent(t *testing.T) {
+ t.Parallel()
+ quota := errors.New("anthropic: status 402: insufficient credit balance")
+ first := &scriptedLLMProvider{
+ events: []llm.StreamEvent{{Type: llm.SEMessageStart}},
+ err: quota,
+ }
+ second := &scriptedLLMProvider{events: []llm.StreamEvent{
+ {Type: llm.SEMessageStart},
+ {Type: llm.SETextDelta, Text: "fallback"},
+ }}
+ active := int64(11)
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ if active == 11 {
+ return taskLLMSelection{profileID: 11, provider: first}, nil
+ }
+ return taskLLMSelection{profileID: 22, provider: second}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ active = 22
+ next := active
+ return db.TaskLLMTransition{PreviousProfileID: 11, NextProfileID: &next, Advanced: true}, nil
+ },
+ }
+
+ events, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 || events[0].Type != llm.SEMessageStart || events[1].Text != "fallback" {
+ t.Fatalf("non-content event from failed attempt leaked or fallback missing: %+v", events)
+ }
+ if first.calls != 1 || second.calls != 1 {
+ t.Fatalf("calls first=%d second=%d", first.calls, second.calls)
+ }
+}
+
+func TestTaskLLMStreamRetriesStalePreStreamFailureAgainstReplacementChain(t *testing.T) {
+ t.Parallel()
+ first := &scriptedLLMProvider{err: errors.New("status 402: insufficient_quota")}
+ second := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "replacement"}}}
+ current := first
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ profileID := int64(11)
+ if current == second {
+ profileID = 22
+ }
+ return taskLLMSelection{profileID: profileID, provider: current}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ current = second
+ return db.TaskLLMTransition{PreviousProfileID: 11, Stale: true}, nil
+ },
+ }
+
+ events, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if err != nil || len(events) != 1 || events[0].Text != "replacement" {
+ t.Fatalf("stale call did not continue on replacement chain: events=%+v err=%v", events, err)
+ }
+}
+
+func TestTaskLLMStreamDoesNotSwitchForOrdinaryErrors(t *testing.T) {
+ t.Parallel()
+ tests := []string{
+ "openai: status 429: rate limit exceeded",
+ "openai: status 401: invalid api key",
+ "openai: status 500: internal server error",
+ "dial tcp: network is unreachable",
+ }
+ for _, message := range tests {
+ t.Run(message, func(t *testing.T) {
+ provider := &scriptedLLMProvider{err: errors.New(message)}
+ exhausted := 0
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ return taskLLMSelection{profileID: 11, provider: provider}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ exhausted++
+ return db.TaskLLMTransition{}, nil
+ },
+ }
+ _, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if err == nil || err.Error() != message {
+ t.Fatalf("error=%v, want %q", err, message)
+ }
+ if exhausted != 0 {
+ t.Fatalf("ordinary error changed failover state %d times", exhausted)
+ }
+ })
+ }
+}
+
+func TestTaskLLMStreamAdvancesAfterMidStreamQuotaWithoutReplay(t *testing.T) {
+ t.Parallel()
+ quota := errors.New("anthropic: status 402: credit balance is too low")
+ first := &scriptedLLMProvider{
+ events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "partial"}},
+ err: quota,
+ }
+ second := &scriptedLLMProvider{events: []llm.StreamEvent{{Type: llm.SETextDelta, Text: "next-call"}}}
+ active := int64(11)
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ if active == 11 {
+ return taskLLMSelection{profileID: 11, provider: first}, nil
+ }
+ return taskLLMSelection{profileID: 22, provider: second}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ active = 22
+ next := active
+ return db.TaskLLMTransition{PreviousProfileID: 11, NextProfileID: &next, Advanced: true}, nil
+ },
+ }
+
+ events, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if len(events) != 1 || events[0].Text != "partial" || err == nil {
+ t.Fatalf("mid-stream result events=%+v err=%v", events, err)
+ }
+ if first.calls != 1 || second.calls != 0 {
+ t.Fatalf("mid-stream call was replayed: first=%d second=%d", first.calls, second.calls)
+ }
+
+ nextEvents, nextErr := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if nextErr != nil || len(nextEvents) != 1 || nextEvents[0].Text != "next-call" || second.calls != 1 {
+ t.Fatalf("next call did not use fallback: events=%+v err=%v calls=%d", nextEvents, nextErr, second.calls)
+ }
+}
+
+func TestTaskLLMStreamMarkFailureAfterToolStartSkipsWorkerReplay(t *testing.T) {
+ t.Parallel()
+ quota := errors.New("openai: status 402: insufficient_quota")
+ markFailure := errors.New("database temporarily unavailable")
+ provider := &scriptedLLMProvider{
+ events: []llm.StreamEvent{{Type: llm.SEToolUseStart}},
+ err: quota,
+ }
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ return taskLLMSelection{profileID: 11, provider: provider}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ return db.TaskLLMTransition{}, markFailure
+ },
+ }
+
+ events, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if len(events) != 1 || events[0].Type != llm.SEToolUseStart || err == nil {
+ t.Fatalf("committed tool stream events=%+v err=%v", events, err)
+ }
+ if !isTaskLLMRuntimeError(err) {
+ t.Fatalf("committed mark failure must be router-classified: %T %v", err, err)
+ }
+ if retryableWorkerModelError(harness.ReasonModelError, err) {
+ t.Fatal("worker must not replay after a committed tool stream when quota persistence fails")
+ }
+ if provider.calls != 1 {
+ t.Fatalf("provider calls=%d, want 1", provider.calls)
+ }
+}
+
+func TestTaskLLMRuntimeErrorSkipsWorkerReplay(t *testing.T) {
+ partialStreamErr := &taskLLMError{
+ taskID: "7",
+ chainExhausted: false,
+ cause: errors.New("insufficient_quota after partial output"),
+ }
+ if retryableWorkerModelError(harness.ReasonModelError, partialStreamErr) {
+ t.Fatal("worker must not replay an intent after the task router handled a mid-stream quota error")
+ }
+ if !retryableWorkerModelError(harness.ReasonModelError, errors.New("temporary network error")) {
+ t.Fatal("ordinary model errors should retain the existing worker retry behavior")
+ }
+}
+
+func TestEngineReadyForTaskOverride(t *testing.T) {
+ t.Parallel()
+ e := &Engine{}
+ if e.Ready() {
+ t.Fatal("engine without a global provider must not be globally ready")
+ }
+ e.SetAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) {
+ return &agent.Planner{}, &agent.Worker{}
+ })
+ if !e.ReadyFor(&Task{ID: "7"}) {
+ t.Fatal("task-specific provider chain must make that task ready")
+ }
+}
+
+func TestResolvedTaskStatusKeepsExhaustedStartedTaskRunning(t *testing.T) {
+ t.Parallel()
+ engine := NewEngine(nil)
+ engine.started.Store("7", true)
+ task := &Task{ID: "7", Status: "running"}
+ profileID := int64(11)
+ task.setLLMState(&profileID, nil, []int64{profileID}, "chain_exhausted", "quota exhausted")
+ s := &Server{engine: engine}
+ if got := s.resolvedTaskStatus(task); got != "running" {
+ t.Fatalf("exhausted started status=%q, want running", got)
+ }
+ task.Paused = true
+ if got := s.resolvedTaskStatus(task); got != "paused" {
+ t.Fatalf("paused exhausted status=%q, want paused", got)
+ }
+}
+
+func TestAuthoritativeTaskResolverDoesNotFallBackWhenChainUnavailable(t *testing.T) {
+ t.Parallel()
+ e := &Engine{}
+ e.UseLLM(&agent.Planner{}, &agent.Worker{})
+ if !e.ReadyFor(&Task{ID: "7"}) {
+ t.Fatal("global provider should initially make task ready")
+ }
+ e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) {
+ return nil, nil
+ })
+ if e.ReadyFor(&Task{ID: "7"}) {
+ t.Fatal("unavailable authoritative task chain must not fall back to global provider")
+ }
+}
+
+func TestTaskLLMStreamStopsWhenChainExhausted(t *testing.T) {
+ t.Parallel()
+ provider := &scriptedLLMProvider{err: errors.New("billing_not_active")}
+ transitions := 0
+ hooks := taskLLMStreamHooks{
+ current: func() (taskLLMSelection, error) {
+ return taskLLMSelection{profileID: 11, provider: provider}, nil
+ },
+ exhaust: func(taskLLMSelection, error) (db.TaskLLMTransition, error) {
+ return db.TaskLLMTransition{PreviousProfileID: 11, Advanced: true, ChainExhausted: true}, nil
+ },
+ transition: func(taskLLMSelection, db.TaskLLMTransition, error) { transitions++ },
+ }
+
+ _, err := collectTaskLLMStream(streamTaskLLM(context.Background(), "7", llm.CompletionRequest{}, hooks))
+ if !isTaskLLMChainExhausted(err) {
+ t.Fatalf("expected chain-exhausted error, got %v", err)
+ }
+ if provider.calls != 1 || transitions != 1 {
+ t.Fatalf("calls=%d transitions=%d", provider.calls, transitions)
+ }
+}
+
+func TestProfileDeleteRestoresQuotaBlockedIntentWhenFallbackAvailable(t *testing.T) {
+ m, err := NewManager(t.TempDir(), "")
+ if err != nil {
+ t.Skipf("postgres unavailable (%v) - skipping", err)
+ }
+ defer m.Close()
+
+ profileID, err := m.pg.SaveProfile(&db.LLMProfile{
+ Name: fmt.Sprintf("delete-quota-fallback-%d", time.Now().UnixNano()),
+ Format: "openai",
+ Model: "test-model",
+ APIKey: "test-key",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ task, err := m.CreateTaskWithOptions("quota fallback", "resume intent", db.TaskCreateOptions{
+ LLMProfileIDs: []int64{profileID},
+ })
+ if err != nil {
+ _ = m.pg.DeleteProfile(profileID)
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{})
+ _ = m.pg.DeleteProfile(profileID)
+ })
+
+ intentID, err := task.Store.AddIntent(map[string]any{"action": "retry after fallback"}, 1, nil, "planner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ taskID, err := parseTaskID(task.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if transition, err := m.pg.MarkTaskLLMProfileQuotaExhausted(taskID, profileID, "insufficient_quota"); err != nil || !transition.ChainExhausted {
+ t.Fatalf("exhaust task chain: transition=%+v err=%v", transition, err)
+ }
+ if err := task.Store.SetIntentBlockedReason(intentID, db.IntentBlockedLLMQuota); err != nil {
+ t.Fatal(err)
+ }
+ task.Paused = true
+ if err := m.SetTaskPaused(task.ID, true); err != nil {
+ t.Fatal(err)
+ }
+
+ // Removing the final explicit entry restores the legacy/global fallback. The
+ // post-delete sync must reopen only quota-blocked work without unpausing it.
+ if err := m.pg.DeleteProfile(profileID); err != nil {
+ t.Fatal(err)
+ }
+ s := &Server{
+ m: m,
+ llmOn: true,
+ llmProv: &scriptedLLMProvider{},
+ provByProfile: map[int64]*provEntry{},
+ }
+ s.restoreTasksAfterProfileDelete(m.pg)
+
+ node, err := task.Store.GetNode(intentID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if node == nil || node.State != "open" || node.BlockedReason != "" {
+ t.Fatalf("quota-blocked intent was not reopened: %+v", node)
+ }
+ if !task.Paused {
+ t.Fatal("profile deletion must not resume a paused task")
+ }
+ state := task.llmStateSnapshot()
+ if len(state.ProfileIDs) != 0 || state.FailoverState != "default" {
+ t.Fatalf("task did not return to fallback state: %+v", state)
+ }
+}
diff --git a/traffic/traffic.go b/traffic/traffic.go
index ff1d5a3..49675d2 100644
--- a/traffic/traffic.go
+++ b/traffic/traffic.go
@@ -10,6 +10,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"io/fs"
"log"
@@ -24,9 +25,9 @@ import (
"sync/atomic"
"time"
+ "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
- "github.com/Autumn-27/artex/db"
mproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
_ "modernc.org/sqlite"
)
@@ -471,28 +472,205 @@ func (t *Traffic) DeleteHost(host string) (int64, error) {
// so picking "api.example.com" never sweeps "api.example.com.cn". Returns rows
// deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass).
func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) {
- t.wmu.Lock()
- defer t.wmu.Unlock()
- var deleted int64
- removed := make(map[string]bool)
- for _, h := range hosts {
- res, err := t.db.Exec(`DELETE FROM exchanges WHERE host=?`, h)
- if err != nil {
- return deleted, err
- }
- n, _ := res.RowsAffected()
- deleted += n
- if !removed[h] {
- os.RemoveAll(filepath.Join(t.dir, sanitize(h)))
- removed[h] = true
- }
+ stage, err := t.StageDeleteHostsExact(hosts)
+ if err != nil {
+ return 0, err
}
- if deleted > 0 {
- _ = t.gcBlobs()
+ deleted := stage.Deleted()
+ if err := stage.Commit(); err != nil {
+ return deleted, err
}
return deleted, nil
}
+type stagedTrafficPath struct {
+ source string
+ staged string
+}
+
+// HostDeleteStage keeps the SQLite delete transaction open and moves matching
+// host trees to a same-filesystem staging directory. The Traffic write lock is
+// held until Commit or Rollback, so no recorder can recreate a staged tree or a
+// blob reference while a task deletion is being coordinated with PostgreSQL.
+type HostDeleteStage struct {
+ traffic *Traffic
+ tx *sql.Tx
+ stageDir string
+ moves []stagedTrafficPath
+ deleted int64
+ done bool
+}
+
+// Deleted reports the number of exchange rows selected by this stage.
+func (s *HostDeleteStage) Deleted() int64 {
+ if s == nil {
+ return 0
+ }
+ return s.deleted
+}
+
+// StageDeleteHostsExact prepares a reversible exact-host deletion. Callers must
+// finish every successful stage with Commit or Rollback.
+func (t *Traffic) StageDeleteHostsExact(hosts []string) (*HostDeleteStage, error) {
+ t.wmu.Lock()
+ stage := &HostDeleteStage{traffic: t}
+ fail := func(cause error) (*HostDeleteStage, error) {
+ if rollbackErr := stage.rollbackLocked(); rollbackErr != nil {
+ return nil, errors.Join(cause, fmt.Errorf("restore staged traffic: %w", rollbackErr))
+ }
+ return nil, cause
+ }
+
+ unique := make([]string, 0, len(hosts))
+ seen := make(map[string]struct{}, len(hosts))
+ for _, host := range hosts {
+ if _, ok := seen[host]; ok {
+ continue
+ }
+ seen[host] = struct{}{}
+ unique = append(unique, host)
+ }
+
+ tx, err := t.db.Begin()
+ if err != nil {
+ t.wmu.Unlock()
+ stage.done = true
+ return nil, err
+ }
+ stage.tx = tx
+ for _, h := range unique {
+ res, err := tx.Exec(`DELETE FROM exchanges WHERE host=?`, h)
+ if err != nil {
+ return fail(err)
+ }
+ n, _ := res.RowsAffected()
+ stage.deleted += n
+ }
+
+ stagedSources := make(map[string]struct{}, len(unique))
+ for _, host := range unique {
+ source := filepath.Join(t.dir, sanitize(host))
+ if _, duplicatePath := stagedSources[source]; duplicatePath {
+ continue
+ }
+ stagedSources[source] = struct{}{}
+ if _, err := os.Lstat(source); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return fail(fmt.Errorf("inspect traffic tree for %q: %w", host, err))
+ }
+ if stage.stageDir == "" {
+ parent := filepath.Join(t.dir, "_delete_staging")
+ if err := os.MkdirAll(parent, 0o700); err != nil {
+ return fail(fmt.Errorf("create traffic staging directory: %w", err))
+ }
+ stage.stageDir, err = os.MkdirTemp(parent, "hosts-")
+ if err != nil {
+ return fail(fmt.Errorf("create traffic stage: %w", err))
+ }
+ }
+ staged := filepath.Join(stage.stageDir, fmt.Sprintf("%d-%s", len(stage.moves), filepath.Base(source)))
+ if err := os.Rename(source, staged); err != nil {
+ return fail(fmt.Errorf("stage traffic tree for %q: %w", host, err))
+ }
+ stage.moves = append(stage.moves, stagedTrafficPath{source: source, staged: staged})
+ }
+ return stage, nil
+}
+
+// Rollback restores both the SQLite rows and every staged host tree.
+func (s *HostDeleteStage) Rollback() error {
+ if s == nil || s.done {
+ return nil
+ }
+ return s.rollbackLocked()
+}
+
+func (s *HostDeleteStage) rollbackLocked() error {
+ if s == nil || s.done {
+ return nil
+ }
+ var errs []error
+ if s.tx != nil {
+ if err := s.tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
+ errs = append(errs, fmt.Errorf("rollback traffic index: %w", err))
+ }
+ }
+ for i := len(s.moves) - 1; i >= 0; i-- {
+ move := s.moves[i]
+ if _, err := os.Lstat(move.source); err == nil {
+ errs = append(errs, fmt.Errorf("restore destination already exists: %s", move.source))
+ continue
+ } else if !os.IsNotExist(err) {
+ errs = append(errs, fmt.Errorf("inspect restore destination %s: %w", move.source, err))
+ continue
+ }
+ if err := os.Rename(move.staged, move.source); err != nil {
+ errs = append(errs, fmt.Errorf("restore %s: %w", move.source, err))
+ }
+ }
+ if len(errs) == 0 && s.stageDir != "" {
+ if err := os.RemoveAll(s.stageDir); err != nil {
+ errs = append(errs, fmt.Errorf("remove traffic stage: %w", err))
+ }
+ }
+ s.done = true
+ s.traffic.wmu.Unlock()
+ return errors.Join(errs...)
+}
+
+// Commit makes the staged deletion permanent. SQLite is committed only after
+// the caller has committed its PostgreSQL task deletion.
+func (s *HostDeleteStage) Commit() error {
+ if s == nil || s.done {
+ return nil
+ }
+ if err := s.tx.Commit(); err != nil {
+ // A failed SQLite commit normally rolls the transaction back. Restore the
+ // trees so the traffic store remains internally consistent and recoverable.
+ restoreErr := s.restoreTreesLocked()
+ s.done = true
+ s.traffic.wmu.Unlock()
+ return errors.Join(fmt.Errorf("commit traffic index deletion: %w", err), restoreErr)
+ }
+ var errs []error
+ if s.stageDir != "" {
+ if err := os.RemoveAll(s.stageDir); err != nil {
+ errs = append(errs, fmt.Errorf("remove staged traffic trees: %w", err))
+ }
+ }
+ if err := s.traffic.gcBlobs(); err != nil {
+ errs = append(errs, fmt.Errorf("garbage collect traffic blobs: %w", err))
+ }
+ s.done = true
+ s.traffic.wmu.Unlock()
+ return errors.Join(errs...)
+}
+
+func (s *HostDeleteStage) restoreTreesLocked() error {
+ var errs []error
+ for i := len(s.moves) - 1; i >= 0; i-- {
+ move := s.moves[i]
+ if _, err := os.Lstat(move.source); err == nil {
+ errs = append(errs, fmt.Errorf("restore destination already exists: %s", move.source))
+ continue
+ } else if !os.IsNotExist(err) {
+ errs = append(errs, fmt.Errorf("inspect restore destination %s: %w", move.source, err))
+ continue
+ }
+ if err := os.Rename(move.staged, move.source); err != nil {
+ errs = append(errs, fmt.Errorf("restore %s: %w", move.source, err))
+ }
+ }
+ if len(errs) == 0 && s.stageDir != "" {
+ if err := os.RemoveAll(s.stageDir); err != nil {
+ errs = append(errs, fmt.Errorf("remove traffic stage: %w", err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
// gcBlobs removes content-addressed blobs in _blobs that no remaining exchange
// tree references. References appear as "@blob sha256:" lines inside the
// tree's request.http/response.http files (see bodyOrBlob). Best-effort: walk
diff --git a/traffic/traffic_test.go b/traffic/traffic_test.go
index 25d95b9..497bcf5 100644
--- a/traffic/traffic_test.go
+++ b/traffic/traffic_test.go
@@ -162,6 +162,152 @@ VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
}
}
+func TestDeleteHostsExactReportsTreeRemovalFailure(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ const host = "api.example.com"
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ "1-0001", 1, host, "GET", "/", "http://"+host+"/", 200, "text/html", 0, 0, host+"/GET/1-0001"); err != nil {
+ t.Fatal(err)
+ }
+
+ notDir := filepath.Join(dir, "not-a-directory")
+ if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tr.dir = notDir
+
+ n, err := tr.DeleteHostsExact([]string{host})
+ if err == nil {
+ t.Fatal("DeleteHostsExact returned nil after traffic tree removal failed")
+ }
+ if n != 0 {
+ t.Fatalf("deleted=%d, want 0 after atomic rollback", n)
+ }
+ var count int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges WHERE host=?`, host).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("rolled-back index count=%d err=%v, want 1", count, err)
+ }
+}
+
+func TestDeleteHostsExactRollsBackWholeIndexBatch(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ for i, host := range []string{"a.example.com", "b.example.com"} {
+ id := fmt.Sprintf("1-%04d", i+1)
+ if err := os.MkdirAll(filepath.Join(dir, host, "GET", id), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, 1, host, "GET", "/", "http://"+host+"/", 200, "text/html", 0, 0, host+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := tr.DB().Exec(`CREATE TRIGGER fail_second_host BEFORE DELETE ON exchanges
+WHEN OLD.host='b.example.com' BEGIN SELECT RAISE(ABORT, 'forced delete failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+
+ if n, err := tr.DeleteHostsExact([]string{"a.example.com", "b.example.com"}); err == nil || n != 0 {
+ t.Fatalf("DeleteHostsExact failure=(%d,%v), want (0,error)", n, err)
+ }
+ var count int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&count); err != nil || count != 2 {
+ t.Fatalf("rolled-back index count=%d err=%v, want 2", count, err)
+ }
+ for _, host := range []string{"a.example.com", "b.example.com"} {
+ if _, err := os.Stat(filepath.Join(dir, host)); err != nil {
+ t.Fatalf("tree %s changed despite index rollback: %v", host, err)
+ }
+ }
+}
+
+func TestStageDeleteHostsExactRollbackRestoresIndexAndTree(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ const host = "rollback.example.com"
+ const id = "1-0001"
+ tree := filepath.Join(dir, host, "GET", id)
+ if err := os.MkdirAll(tree, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ marker := filepath.Join(tree, "request.http")
+ if err := os.WriteFile(marker, []byte("original"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, 1, host, "GET", "/", "http://"+host+"/", 200, "text/html", 0, 0, host+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+
+ stage, err := tr.StageDeleteHostsExact([]string{host})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stage.Deleted() != 1 {
+ t.Fatalf("staged deleted=%d, want 1", stage.Deleted())
+ }
+ if _, err := os.Stat(filepath.Join(dir, host)); !os.IsNotExist(err) {
+ t.Fatalf("host tree was not staged: %v", err)
+ }
+ if err := stage.Rollback(); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(marker); err != nil || string(got) != "original" {
+ t.Fatalf("restored tree content=%q err=%v", got, err)
+ }
+ var count int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges WHERE host=?`, host).Scan(&count); err != nil || count != 1 {
+ t.Fatalf("restored index count=%d err=%v, want 1", count, err)
+ }
+}
+
+func TestStageDeleteHostsExactRollbackReportsRestoreFailure(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ const host = "conflict.example.com"
+ hostDir := filepath.Join(dir, host)
+ if err := os.MkdirAll(hostDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ stage, err := tr.StageDeleteHostsExact([]string{host})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Simulate an out-of-band conflicting destination. Rollback must surface the
+ // failed rename instead of claiming the external data was restored.
+ if err := os.WriteFile(hostDir, []byte("conflict"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := stage.Rollback(); err == nil || !strings.Contains(err.Error(), "restore") {
+ t.Fatalf("rollback err=%v, want restore failure", err)
+ }
+ if _, err := os.Stat(stage.stageDir); err != nil {
+ t.Fatalf("staging was removed after failed restore: %v", err)
+ }
+}
+
// TestDeleteHostGCBlobs verifies blob garbage collection: after a host's trees
// are removed, blobs referenced by no remaining exchange are deleted, while
// blobs still referenced (including shared ones) survive.
diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx
index 3c5a39c..c320a19 100644
--- a/web/src/app/(main)/_components/main-content.tsx
+++ b/web/src/app/(main)/_components/main-content.tsx
@@ -6,9 +6,9 @@ import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
+import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
-import { useCurrentUser } from "@/hooks/use-current-user";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
@@ -62,7 +62,7 @@ export function MainContent({ children }: { children: ReactNode }) {
{version && (
-
版本 · {version}
+
版本 · {version}
)}
diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx
index 19f1c2a..d5590e0 100644
--- a/web/src/app/(main)/function/findings/detail/page.tsx
+++ b/web/src/app/(main)/function/findings/detail/page.tsx
@@ -2,29 +2,20 @@
import * as React from "react";
import Link from "next/link";
-import { useRouter, useSearchParams } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
-import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react";
+import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
+ SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
@@ -72,9 +63,9 @@ function FieldRow({
}
function FindingDetailInner() {
- const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
+ const contextTaskId = searchParams.get("context_task") ?? "";
const [finding, setFinding] = React.useState
(null);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview");
@@ -85,18 +76,18 @@ function FindingDetailInner() {
return;
}
api
- .getFinding(id)
+ .getFinding(id, contextTaskId || undefined)
.then((f) => setFinding(f))
.catch(() => setFinding(null))
.finally(() => setLoaded(true));
- }, [id]);
+ }, [contextTaskId, id]);
React.useEffect(() => {
load();
}, [load]);
const changeSeverity = React.useCallback(
async (next: Severity) => {
- if (!finding || next === finding.severity) return;
+ if (!finding || finding.inherited || next === finding.severity) return;
const prev = finding.severity;
setFinding({ ...finding, severity: next });
try {
@@ -113,7 +104,7 @@ function FindingDetailInner() {
const changeStatus = React.useCallback(
async (next: FindingStatus) => {
- if (!finding || next === finding.status) return;
+ if (!finding || finding.inherited || next === finding.status) return;
const prev = finding.status;
setFinding({ ...finding, status: next });
try {
@@ -128,18 +119,6 @@ function FindingDetailInner() {
[finding, id],
);
- // deleteFinding 删除当前漏洞(需二次确认),成功后返回发现列表。
- const deleteFinding = React.useCallback(async () => {
- if (!id) return;
- try {
- await api.deleteFinding(id);
- toast.success("已删除漏洞");
- router.push("/function/findings");
- } catch (e) {
- toast.error("删除失败:" + (e as Error).message);
- }
- }, [id, router]);
-
if (!finding) {
return (
@@ -184,25 +163,11 @@ function FindingDetailInner() {
-
-
-
-
-
-
- 确认删除该漏洞?
-
- 「{title}」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
-
-
-
- 取消
- 删除
-
-
-
+ {finding.inherited && finding.source_task_id && (
+
+ 来源任务 #{finding.source_task_id} · 只读
+
+ )}
概览
@@ -271,48 +236,60 @@ function FindingDetailInner() {
- {/* 严重等级(可改) */}
+ {/* 严重等级 */}
-
+ )}
- {/* 处理状态(可改) */}
+ {/* 处理状态 */}
- changeStatus(v as FindingStatus)}
- >
-
+ ) : (
+ changeStatus(v as FindingStatus)}
>
-
-
-
- {FINDING_STATUSES.map((st) => (
-
- {statusMeta("finding", st).label}
-
- ))}
-
-
+
+
+
+
+
+ {FINDING_STATUSES.map((st) => (
+
+ {statusMeta("finding", st).label}
+
+ ))}
+
+
+
+ )}
{/* 漏洞类型 */}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx
index 7d066c6..c890352 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/assets-tab.tsx
@@ -152,7 +152,6 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [
];
export function AssetsTab({ taskId }: { taskId: string }) {
- // 服务端分页:只取当前分类的当前页,分类总数走 /assets/counts?task_id=,不做数量截断
const [rows, setRows] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [counts, setCounts] = React.useState>({});
@@ -161,7 +160,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
- // 换分类/换页大小:回到第一页,并清掉旧行(各分类列不同,不能串用)
React.useEffect(() => {
setPage(0);
setRows([]);
@@ -171,17 +169,16 @@ export function AssetsTab({ taskId }: { taskId: string }) {
let alive = true;
const load = async () => {
try {
- const [cur, c] = await Promise.all([
+ const [cur, nextCounts] = await Promise.all([
api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId),
]);
if (!alive) return;
setRows(cur.assets);
setTotal(cur.total);
- setCounts(c ?? {});
+ setCounts(nextCounts ?? {});
+ setLoaded(true);
} catch {
- // 保留上一次的数据
- } finally {
if (alive) setLoaded(true);
}
};
@@ -190,7 +187,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
return () => { alive = false; clearInterval(timer); };
}, [taskId, tab, page, size]);
- // 轮询期间资产变少时,别停在越界的空页上
React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
if (page > maxPage) setPage(maxPage);
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
index 14201fe..e5cfdda 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/coverage-graph-tab.tsx
@@ -14,9 +14,9 @@ import {
Waypoints,
} from "lucide-react";
-import { cn } from "@/lib/utils";
-import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
+import { cn } from "@/lib/utils";
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
const FOLD_LIMIT = 20;
@@ -274,10 +275,18 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] })
{items.map((r) => (
-
-
#{r.id}
- {r.state &&
{r.state}}
-
{r.summary || "—"}
+
+ #{r.id}
+ {r.state && {r.state}}
+ {r.summary || "—"}
+ {r.inherited && r.source_task_id && (
+
+ 来源 #{r.source_task_id} · 只读
+
+ )}
))}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
index d4faf24..07d9449 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
@@ -2,25 +2,15 @@
import * as React from "react";
import Link from "next/link";
-import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react";
+import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-badge";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
+ SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
@@ -42,12 +32,12 @@ const FINDING_STATUSES: FindingStatus[] = [
function Row({
f,
+ contextTaskId,
onStatus,
- onDelete,
}: {
f: Finding;
+ contextTaskId: string;
onStatus: (f: Finding, next: FindingStatus) => void;
- onDelete: (f: Finding) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
@@ -65,10 +55,17 @@ function Row({
)}
/>
-
-
- {f.name || f.vulnclass || "未分类"}
-
+
+
+
+ {f.name || f.vulnclass || "未分类"}
+
+ {f.inherited && f.source_task_id && (
+
+ 来源 #{f.source_task_id} · 只读
+
+ )}
+
{f.summary}
@@ -92,7 +89,7 @@ function Row({
)}
)}
- {f.finding_id ? (
+ {f.finding_id && !f.inherited ? (
onStatus(f, v as FindingStatus)}
@@ -104,11 +101,13 @@ function Row({
- {FINDING_STATUSES.map((st) => (
-
- {statusMeta("finding", st).label}
-
- ))}
+
+ {FINDING_STATUSES.map((st) => (
+
+ {statusMeta("finding", st).label}
+
+ ))}
+
) : (
@@ -119,7 +118,11 @@ function Row({
{f.finding_id && (
@@ -130,30 +133,8 @@ function Row({
{open && (
-
-
证据 / PoC
- {f.finding_id && (
-
-
-
-
-
-
- 确认删除该漏洞?
-
- 「{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}」将被永久删除,
- 同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
-
-
-
- 取消
- onDelete(f)}>删除
-
-
-
- )}
+
+ 证据 / PoC
{f.evidence}
@@ -187,7 +168,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
const onStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
- if (!f.finding_id || next === f.status) return;
+ if (f.inherited || !f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
@@ -205,19 +186,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
[],
);
- const onDelete = React.useCallback(async (f: Finding) => {
- if (!f.finding_id) return;
- try {
- await api.deleteFinding(f.finding_id);
- setFindings((cur) => cur.filter((x) => x.id !== f.id));
- toast.success("已删除漏洞");
- } catch (e) {
- toast.error("删除失败:" + (e as Error).message);
- }
- }, []);
-
const items = findings
- .filter((f) => f.task_id === taskId)
+ .filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => {
const order = { critical: 0, high: 1, medium: 2, low: 3 };
return order[a.severity] - order[b.severity];
@@ -227,11 +197,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
{items.map((f) => (
-
+
))}
{items.length === 0 && (
- 本任务暂无确认发现。
+ 本任务及直接关联任务暂无确认发现。
)}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx
index 090adb3..056b786 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx
@@ -1,8 +1,11 @@
"use client";
import * as React from "react";
-import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
+
+import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
import { toast } from "sonner";
+
+import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
@@ -39,6 +42,29 @@ export function ReportTab({ taskId }: { taskId: string }) {
setTimeout(() => setCopied(false), 1500);
}
+ let content: React.ReactNode;
+ if (loading) {
+ content = (
+
+
+ 加载中…
+
+ );
+ } else if (report) {
+ content = (
+
+
+
+ );
+ } else {
+ content = (
+
+
+ 暂无报告
+
+ );
+ }
+
return (
@@ -53,23 +79,7 @@ export function ReportTab({ taskId }: { taskId: string }) {
)}
-
- {loading ? (
-
-
- 加载中…
-
- ) : report ? (
-
- {report}
-
- ) : (
-
-
- 暂无报告
-
- )}
-
+
{content}
);
}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
index 1365089..1e18d1b 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx
@@ -1,30 +1,46 @@
"use client";
import * as React from "react";
+
import {
- RadioIcon,
BrainIcon,
- UserIcon,
- Loader2Icon,
- ZapOffIcon,
- SendIcon,
- SquareIcon,
- ClockIcon,
CircleCheckIcon,
- CircleXIcon,
CircleSlashIcon,
- ShieldAlertIcon,
- WifiOffIcon,
- RotateCwIcon,
+ CircleXIcon,
+ ClockIcon,
+ HistoryIcon,
+ Loader2Icon,
PaperclipIcon,
+ PauseIcon,
+ PlayIcon,
+ RadioIcon,
+ RotateCwIcon,
+ SendIcon,
+ ShieldAlertIcon,
+ SquareIcon,
+ UserIcon,
+ WifiOffIcon,
XIcon,
+ ZapOffIcon,
+ Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
-import { cn } from "@/lib/utils";
-import { Transcript } from "@/components/transcript";
+
import { TodoPopover } from "@/components/todo-popover";
+import { Transcript } from "@/components/transcript";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
@@ -43,6 +59,7 @@ import type {
TaskNode,
TokenTotal,
} from "@/lib/types";
+import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
function fmtBytes(n: number): string {
@@ -63,6 +80,7 @@ function fmtBytes(n: number): string {
// so refresh / tab-switch / sleep / reconnect never drop the newest records.
const PAGE = 200; // history page size
+const SYSTEM_SCAN_PAGE = 500; // generic activity pages scanned to recover sparse system audit events
const MAX_KEEP = 4000; // per-session in-memory cap; older pages re-fetched on scroll-up
const STREAM_WINDOW_MS = 5000; // "live" = activity seen within this window
@@ -98,6 +116,7 @@ function emptyState(): SessionState {
// sessionKeyOf routes an activity to its stable session key. worker="planner" covers
// BOTH the Goal Agent's round-0 decomposition and the Planner (single Plan session).
function sessionKeyOf(a: Activity): string {
+ if (a.worker === "system" || a.kind === "llm_switch" || a.kind === "llm_failover") return "system";
if (a.worker === "mainagent") return "main";
if (a.worker === "planner") return "plan";
if (a.intent_id) return `intent:${a.intent_id}`;
@@ -122,6 +141,8 @@ function statusIcon(status: SessionStatus) {
switch (status) {
case "running": // 执行中
return
;
+ case "paused":
+ return
;
case "pending": // 待领取(open intent)
return
;
case "done": // 完成
@@ -156,6 +177,7 @@ const roleMeta = {
mainagent: { label: "主 Agent", icon: UserIcon },
planner: { label: "规划 Planner", icon: BrainIcon },
worker: { label: "Workers", icon: RadioIcon },
+ system: { label: "系统审计", icon: HistoryIcon },
} as const;
// The main-agent session is the interactive entry point of this tab and has no
@@ -185,10 +207,24 @@ const PLANNER_SESSION: Session = {
last_activity: "",
};
+// LLM provider switches are task-level audit events rather than agent output. The
+// history endpoint has no "system" filter, so this fixed session is populated by
+// scanning the generic incremental activity endpoint and then tailed by task SSE.
+const SYSTEM_ID = "s-system";
+const SYSTEM_SESSION: Session = {
+ id: SYSTEM_ID,
+ role: "system",
+ title: "系统事件 · LLM 故障转移",
+ status: "done",
+ live: false,
+ last_activity: "",
+};
+
// keyForSession maps a UI Session → its stable store key (main | plan | intent:
).
function keyForSession(s: Session): string {
if (s.role === "mainagent") return "main";
if (s.role === "planner") return "plan";
+ if (s.role === "system") return "system";
return `intent:${s.intent_id}`;
}
@@ -203,6 +239,8 @@ function intentStatus(state: string): SessionStatus {
return "exhausted";
case "stopped":
return "stopped";
+ case "paused":
+ return "paused";
case "open": // 待领取,区别于执行中
return "pending";
default: // running
@@ -220,9 +258,11 @@ function intentToSession(n: TaskNode): Session {
role: "worker",
title: label || `Intent ${n.id}`,
status: state,
- live: state === "running",
+ live: !n.inherited && state === "running",
last_activity: n.ts,
intent_id: n.id,
+ source_task_id: n.source_task_id,
+ inherited: n.inherited,
};
}
@@ -233,6 +273,10 @@ function SessionItem({
hasPending,
unread,
onClick,
+ onPause,
+ onResume,
+ onCancel,
+ controlling,
}: {
s: Session;
active: boolean;
@@ -240,6 +284,10 @@ function SessionItem({
hasPending?: boolean;
unread?: number;
onClick: () => void;
+ onPause?: () => void;
+ onResume?: () => void;
+ onCancel?: () => void;
+ controlling?: boolean;
}) {
const icon =
s.role === "worker" ? (
@@ -248,36 +296,57 @@ function SessionItem({
) : null;
+ const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
return (
-
+
);
}
@@ -295,6 +364,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
+ const [controllingIntent, setControllingIntent] = React.useState
(null);
+ const [cancelIntent, setCancelIntent] = React.useState(null);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
@@ -313,6 +384,42 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
+
+ const patchIntentState = React.useCallback((intentId: string, state?: string) => {
+ const patch = (rows: TaskNode[]) =>
+ state ? rows.map((row) => (row.id === intentId ? { ...row, state } : row)) : rows.filter((row) => row.id !== intentId);
+ setIntents(patch);
+ setOlderIntents(patch);
+ }, []);
+
+ const controlWorker = React.useCallback(async (session: Session, action: "pause" | "resume" | "cancel") => {
+ if (!session.intent_id || session.inherited || controllingIntent) return;
+ setControllingIntent(session.intent_id);
+ try {
+ const result = await api.controlIntent(taskId, session.intent_id, action);
+ if (action === "pause") {
+ patchIntentState(session.intent_id, "paused");
+ toast.success(`Worker #${session.intent_id} 已暂停`);
+ } else if (action === "resume") {
+ patchIntentState(session.intent_id, "open");
+ toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
+ } else {
+ patchIntentState(session.intent_id);
+ if (activeId === session.id) setActiveId(MAIN_ID);
+ const deleted = result.deleted;
+ toast.success(
+ deleted
+ ? `Worker #${session.intent_id} 已取消,清理 ${deleted.facts} 条事实、${deleted.findings} 个漏洞`
+ : `Worker #${session.intent_id} 已取消`,
+ );
+ }
+ } catch (error) {
+ toast.error("Worker 操作失败:" + (error as Error).message);
+ } finally {
+ setControllingIntent(null);
+ setCancelIntent(null);
+ }
+ }, [activeId, controllingIntent, patchIntentState, taskId]);
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
const [sseLive, setSseLive] = React.useState(false);
@@ -326,6 +433,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const esRef = React.useRef(null);
const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread)
const atBottomRef = React.useRef(true); // transcript pinned to bottom?
+ const llmToastSeqRef = React.useRef>(new Set());
// Per-key request token: a stale response for a key is ignored (guards fast
// latest/older interleaving). Writes are ALWAYS keyed, so a late response can only
// touch its own session cache — never the currently-viewed one (see §7.5).
@@ -353,6 +461,48 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const token = (reqTokenRef.current[key] ?? 0) + 1;
reqTokenRef.current[key] = token;
patchStore(key, (s) => ({ ...s, loading: true, error: undefined }));
+
+ if (key === "system") {
+ void (async () => {
+ let since = 0;
+ let systemItems: Activity[] = [];
+ for (;;) {
+ const page = await api.activity(taskId, { since, limit: SYSTEM_SCAN_PAGE });
+ if (reqTokenRef.current[key] !== token) return;
+ systemItems = mergeBySeq(
+ systemItems,
+ page.items.filter((item) => item.worker === "system" || item.kind === "llm_failover"),
+ );
+ if (page.items.length < SYSTEM_SCAN_PAGE || page.cursor <= since) break;
+ since = page.cursor;
+ }
+ patchStore(key, (s) => {
+ const items = mergeBySeq(systemItems, s.items);
+ return {
+ ...s,
+ items,
+ loaded: true,
+ loading: false,
+ hasMore: false,
+ earliestSeq: items.length ? items[0].seq : 0,
+ unread: 0,
+ lastTs: items.length ? items[items.length - 1].ts : s.lastTs,
+ error: undefined,
+ };
+ });
+ })()
+ .catch((error) => {
+ if (reqTokenRef.current[key] !== token) return;
+ patchStore(key, (s) => ({
+ ...s,
+ loading: false,
+ error: (error as Error).message || "加载失败",
+ }));
+ })
+ .finally(() => loadingKeysRef.current.delete(key));
+ return;
+ }
+
api
.activityHistory(taskId, key, 0, PAGE)
.then((r) => {
@@ -424,8 +574,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
.then((r) => {
if (alive) setTaskTokens(r.total);
})
- .catch(() => {});
- load();
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
+ void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
@@ -438,8 +590,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const load = () =>
api.interceptTask(taskId)
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
- .catch(() => {});
- load();
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
+ void load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [taskId]);
@@ -490,9 +644,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : "";
}
buckets.main ??= { ...emptyState(), loaded: true };
+ buckets.system ??= { ...emptyState(), loaded: true };
setStore(buckets);
})
- .catch(() => setStore({ main: { ...emptyState(), loaded: true } }));
+ .catch(() =>
+ setStore({
+ main: { ...emptyState(), loaded: true },
+ system: { ...emptyState(), loaded: true },
+ }),
+ );
return () => {
alive = false;
};
@@ -532,6 +692,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} catch {
return; // ignore malformed frame
}
+ if ((a.kind === "llm_switch" || a.kind === "llm_failover") && !llmToastSeqRef.current.has(a.seq)) {
+ llmToastSeqRef.current.add(a.seq);
+ const transition = a.metadata?.llm_transition;
+ if (transition?.mode === "exhausted" || a.is_error) {
+ toast.error(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
+ } else if (transition?.mode === "automatic") {
+ toast.success(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
+ } else {
+ toast.info(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
+ }
+ }
const k = sessionKeyOf(a);
setStore((prev) => {
const cur = prev[k] ?? emptyState();
@@ -569,8 +740,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
esRef.current?.close();
esRef.current = null;
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [taskId]);
+ }, [taskId, patchStore]);
// ── worker (intent) session list — paged, poll first page lightly ───────────────
React.useEffect(() => {
@@ -583,8 +753,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setIntents(r.items);
setIntentsHasMore(r.hasMore);
})
- .catch(() => {});
- load();
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
+ void load();
const t = setInterval(load, 5000);
return () => {
active = false;
@@ -607,7 +779,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
});
setIntentsHasMore(r.hasMore);
})
- .catch(() => {})
+ .catch(() => {
+ // A later manual retry can fetch this page again.
+ })
.finally(() => setLoadingOlderIntents(false));
}, [taskId, intents, olderIntents, loadingOlderIntents]);
@@ -650,6 +824,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
const hasPendingForSession = React.useCallback(
(s: Session): boolean => {
+ if (s.inherited) return false;
if (!pendingIntercepts.length) return false;
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner");
@@ -680,6 +855,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
{ ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive },
...workerSessions,
+ SYSTEM_SESSION,
],
[workerSessions, mainLive, plannerLive],
);
@@ -688,16 +864,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
mainagent: sessions.filter((s) => s.role === "mainagent"),
planner: sessions.filter((s) => s.role === "planner"),
worker: sessions.filter((s) => s.role === "worker"),
+ system: sessions.filter((s) => s.role === "system"),
};
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner";
+ const isSystem = active.role === "system";
const activeKey = keyForSession(active);
const activeState = store[activeKey];
// Keep the SSE dispatcher's notion of the active session current, and lazily load
// + clear unread whenever the active session changes.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: cache updates must not reactivate the current session.
React.useEffect(() => {
activeKeyRef.current = activeKey;
const st = store[activeKey];
@@ -706,7 +885,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} else if (st.unread) {
patchStore(activeKey, (s) => ({ ...s, unread: 0 }));
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey]);
// Main agent is the human↔orchestrator CONSOLE: only the conversation (user msgs +
@@ -721,7 +899,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
}
- if (isPlanner) return items;
+ if (isPlanner || isSystem) return items;
// Worker session: the intent leads the transcript as a right-aligned "user"-style
// message (the task handed to this worker), followed by its execution steps.
const intentTitle = sessionMeta.get(active.id)?.title ?? active.title;
@@ -731,9 +909,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
ts: active.last_activity || "",
kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble
summary: intentTitle,
+ source_task_id: active.source_task_id,
+ inherited: active.inherited,
};
return [intentMsg, ...items];
- }, [isMain, isPlanner, activeState, chatExtra, active.id, active.title, active.last_activity, sessionMeta]);
+ }, [
+ isMain,
+ isPlanner,
+ isSystem,
+ activeState,
+ chatExtra,
+ active.id,
+ active.title,
+ active.last_activity,
+ active.source_task_id,
+ active.inherited,
+ sessionMeta,
+ ]);
// seq of this session's most-recent TodoWrite call — for the Todo popover.
const latestTodoSeq = React.useMemo(() => {
@@ -791,6 +983,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
null,
[],
);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the scroll listener.
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
@@ -802,6 +995,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, activeId, loadEarlier]);
// open/switch a session → jump to the latest (bottom)
+ // biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally scrolls a newly selected session.
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
@@ -810,6 +1004,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
}, [activeId, viewport]);
// new activity → stick to bottom only if the user is already pinned there
+ // biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
@@ -819,7 +1014,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
function stop() {
if (stopping) return;
setStopping(true);
- api.stopChat(taskId).finally(() => setStopping(false));
+ void api.stopChat(taskId).finally(() => setStopping(false));
}
function send() {
@@ -906,7 +1101,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
- {(["mainagent", "planner", "worker"] as const).map((role) => {
+ {(["mainagent", "planner", "system", "worker"] as const).map((role) => {
const items = grouped[role];
if (!items.length) return null;
const Meta = roleMeta[role];
@@ -927,11 +1122,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
+ controlling={controllingIntent === s.intent_id}
+ onPause={() => void controlWorker(s, "pause")}
+ onResume={() => void controlWorker(s, "resume")}
+ onCancel={() => setCancelIntent(s)}
/>
);
})}
{role === "worker" && intentsHasMore && (
) : titleEl;
})()}
+ {active.inherited && active.source_task_id && (
+
+ 来源任务 #{active.source_task_id} · 只读历史
+
+ )}
{active.live && (
@@ -1070,48 +1275,66 @@ export function SessionsTab({ taskId }: { taskId: string }) {
))}
)}
-
-
void pickFiles(e.target.files)}
- />
-
fileInputRef.current?.click()}
- disabled={mainLive || uploading}
- title="上传文件"
- >
- {uploading ? : }
-
-
+
+ {mainLive ? (
+
+ {stopping ? : }
+
+ ) : (
+
+ {sending ? : }
+
+ )}
+
+
) : (
@@ -1122,6 +1345,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
)}
+ !open && setCancelIntent(null)}>
+
+
+ 取消 Worker #{cancelIntent?.intent_id}?
+
+ 取消后将删除该意图、执行记录,以及该意图直接登记的事实和漏洞;全局资产、流量和其他 Planner 意图不会删除。此操作不可撤销。
+
+
+
+ 返回
+ cancelIntent && void controlWorker(cancelIntent, "cancel")}
+ >
+ {controllingIntent ? : }
+ 确认取消并清理
+
+
+
+
);
diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx
index ac520e6..c6acd59 100644
--- a/web/src/app/(main)/function/tasks/detail/page.tsx
+++ b/web/src/app/(main)/function/tasks/detail/page.tsx
@@ -9,15 +9,28 @@ import {
PauseIcon,
PlayIcon,
BrainIcon,
+ CheckIcon,
+ CircleAlertIcon,
} from "lucide-react";
+import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
-import type { Task } from "@/lib/types";
+import type { LLMProfile, Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
@@ -39,6 +52,152 @@ const TABS = [
{ value: "report", label: "报告" },
];
+function taskProfileIDs(task: Task): string[] {
+ if (task.llm_profile_ids && task.llm_profile_ids.length > 0) {
+ return task.llm_profile_ids.map(String);
+ }
+ return task.llm_profile_id ? [String(task.llm_profile_id)] : [];
+}
+
+function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) {
+ const [open, setOpen] = React.useState(false);
+ const [profileIDs, setProfileIDs] = React.useState(() => taskProfileIDs(task));
+ const [activeProfileID, setActiveProfileID] = React.useState(
+ task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""),
+ );
+ const [saving, setSaving] = React.useState(false);
+ const popoverContentRef = React.useRef(null);
+
+ const chain = taskProfileIDs(task);
+ const exhausted = task.llm_failover_state === "chain_exhausted";
+ 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.
+ let activeID = chain[0] ?? "";
+ if (exhausted) activeID = "";
+ if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id);
+ const activeProfile = profiles.find((profile) => profile.id === activeID);
+ const currentLabel = exhausted
+ ? "配置链已耗尽"
+ : activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置");
+ const activeIndex = chain.indexOf(activeID);
+ const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
+ const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
+ .filter(Boolean)
+ .join(" · ");
+ let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。";
+ if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。";
+ if (terminal) editorDescription = "已结束的任务仅支持查看配置。";
+ let saveLabel = "保存";
+ if (exhausted) saveLabel = "保存并重置";
+ if (saving) saveLabel = "保存中";
+
+ const syncDraft = React.useCallback(() => {
+ const next = taskProfileIDs(task);
+ setProfileIDs(next);
+ setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? ""));
+ }, [task]);
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (next) syncDraft();
+ };
+
+ const handleProfileIDsChange = (next: string[]) => {
+ setProfileIDs(next);
+ setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? "")));
+ };
+
+ const save = async () => {
+ if (!editable) return;
+ setSaving(true);
+ try {
+ const result = await api.updateTaskLLMProfiles(
+ task.id,
+ profileIDs.map(Number),
+ activeProfileID ? Number(activeProfileID) : undefined,
+ );
+ if (result.switch_event) {
+ toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` });
+ } else {
+ toast.success(
+ result.reopened_intents > 0
+ ? `LLM 配置已更新,并恢复 ${result.reopened_intents} 条额度阻塞意图`
+ : "LLM 配置已更新",
+ );
+ }
+ setOpen(false);
+ onUpdated();
+ } catch (error) {
+ toast.error("更新失败:" + (error as Error).message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {currentLabel}
+ {backupCount > 0 && +{backupCount}}
+
+
+
+
+ 任务 LLM 配置链
+ {editorDescription}
+
+
+ {exhausted && (
+
+
+ 配置链额度已耗尽
+
+ {task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"}
+
+
+ )}
+
+
+
+
+ setOpen(false)}>
+ 关闭
+
+ {editable && (
+
+ {saving && }
+ {saveLabel}
+
+ )}
+
+
+
+ );
+}
+
function TaskDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
@@ -47,42 +206,62 @@ function TaskDetailInner() {
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
+ const [profiles, setProfiles] = React.useState([]);
+
+ React.useEffect(() => {
+ api
+ .llmProfiles()
+ .then(setProfiles)
+ .catch(() => setProfiles([]));
+ }, []);
React.useEffect(() => {
let alive = true;
const load = () =>
- api.interceptTask(id)
- .then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
- .catch(() => {});
- load();
+ api
+ .interceptTask(id)
+ .then((rows) => {
+ if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length);
+ })
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
+ void load();
const t = setInterval(load, 5000);
- return () => { alive = false; clearInterval(t); };
+ return () => {
+ alive = false;
+ clearInterval(t);
+ };
}, [id]);
const load = React.useCallback(() => {
- Promise.all([
- api.tasks(),
- api.stats(id).catch(() => null),
- ])
+ Promise.all([api.tasks(), api.stats(id).catch(() => null)])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
- const at = (s as { active_task?: Partial & { paused?: boolean } } | null)?.active_task;
+ const at = s?.active_task;
if (base && at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
- base.engine_mode = at.engine_mode;
+ base.engine_mode = s?.engine_mode ?? at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
})
- .catch(() => {})
+ .catch(() => {
+ // Keep the last rendered task state during a transient poll failure.
+ })
.finally(() => setLoaded(true));
}, [id]);
- React.useEffect(() => { load(); }, [load]);
+ React.useEffect(() => {
+ load();
+ const timer = setInterval(load, 5000);
+ return () => clearInterval(timer);
+ }, [load]);
async function togglePause() {
+ if (task && ["done", "failed", "timeout"].includes(task.status)) return;
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
@@ -108,14 +287,24 @@ function TaskDetailInner() {
);
}
- const engineMode = paused ? "paused" : task.engine_mode ?? "idle";
+ const completed = task.status === "done";
+ const terminal = ["done", "failed", "timeout"].includes(task.status);
+ const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
+ let controlVariant: "default" | "secondary" | "outline" = "outline";
+ let controlIcon = ;
+ let controlLabel = "暂停";
+ if (terminal) {
+ controlVariant = "secondary";
+ controlIcon = ;
+ controlLabel = completed ? "已完成" : "已结束";
+ } else if (paused) {
+ controlVariant = "default";
+ controlIcon = ;
+ controlLabel = "恢复";
+ }
return (
-
+
{/* Top fixed area */}
@@ -125,43 +314,36 @@ function TaskDetailInner() {
-
+
{task.description}
-
+
{task.id}
-
- {/* status pills */}
-
- LLM 已配置
-
-
-
-
- {paused ? : }
- {paused ? "恢复" : "暂停"}
-
-
+
+
+
+
+ {controlIcon}
+ {controlLabel}
+
{task.goal}
{/* Tabs */}
-
- {TABS.map((t) => (
-
- {t.label}
- {t.value === "intercept" && interceptPendingCount > 0 && (
-
- {interceptPendingCount > 99 ? "99+" : interceptPendingCount}
-
- )}
-
- ))}
-
+
+
+ {TABS.map((t) => (
+
+ {t.label}
+ {t.value === "intercept" && interceptPendingCount > 0 && (
+
+ {interceptPendingCount > 99 ? "99+" : interceptPendingCount}
+
+ )}
+
+ ))}
+
+
{/* Tab content */}
diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx
index 718b6de..ce6b5a9 100644
--- a/web/src/app/(main)/function/tasks/page.tsx
+++ b/web/src/app/(main)/function/tasks/page.tsx
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
+import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import {
AlertDialog,
AlertDialogAction,
@@ -41,8 +42,29 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxValue,
+} from "@/components/ui/combobox";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -71,13 +93,21 @@ import {
import {
Select,
SelectContent,
+ SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api";
-import type { Task, TaskStatus, LLMProfile, ChatAttachment } from "@/lib/types";
+import type {
+ Task,
+ TaskStatus,
+ LLMProfile,
+ ChatAttachment,
+ DeleteTaskOptions,
+ DeleteTaskResult,
+} from "@/lib/types";
// fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string {
@@ -102,12 +132,10 @@ function appendUploads(desc: string, atts: ChatAttachment[]): string {
return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`;
}
-// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
-const ACTIVE_PROFILE = "__active__";
-
// POLL_MS is the task-list refresh interval. Task state moves on the server (planner /
// worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化.
const POLL_MS = 10_000;
+const MAX_SOURCE_TASKS = 8;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
@@ -144,6 +172,17 @@ function taskDuration(task: Task, nowSec: number): number {
return end > start ? end - start : 0;
}
+function deleteSummary(result: DeleteTaskResult): string {
+ const details: string[] = [];
+ if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`);
+ if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`);
+ if (result.traffic_deleted > 0) details.push(`删除流量 ${result.traffic_deleted} 条`);
+ if (result.files_deleted) details.push("删除任务文件");
+ if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`);
+ if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`);
+ return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除";
+}
+
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
@@ -185,6 +224,7 @@ export default function TasksPage() {
}, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change
+ // biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo(
@@ -206,7 +246,9 @@ export default function TasksPage() {
lastRef.current = sig;
setTasks(next);
})
- .catch(() => {});
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
}, []);
React.useEffect(() => {
@@ -227,18 +269,20 @@ export default function TasksPage() {
return () => clearInterval(i);
}, [hasRunning]);
- const deleteTask = React.useCallback(
- async (id: string, opts: DeleteOpts) => {
- try {
- await api.deleteTask(id, opts);
- toast.success("任务已删除");
- load();
- } catch (e) {
- toast.error("删除失败:" + (e as Error).message);
+ const deleteTask = React.useCallback(async (id: string, options: DeleteTaskOptions) => {
+ try {
+ const result = await api.deleteTask(id, options);
+ if (result.cleanup_warning) {
+ toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`);
+ } else {
+ toast.success(deleteSummary(result));
}
- },
- [load],
- );
+ load();
+ } catch (e) {
+ toast.error("删除失败:" + (e as Error).message);
+ throw e;
+ }
+ }, [load]);
return (
@@ -268,19 +312,21 @@ export default function TasksPage() {
- 全部状态
- {STATUS_OPTIONS.map((s) => (
-
- {s.label}
-
- ))}
+
+ 全部状态
+ {STATUS_OPTIONS.map((s) => (
+
+ {s.label}
+
+ ))}
+
{filtered.length}/{tasks.length} 条
-
+
{tasks.length === 0 ? (
@@ -332,19 +378,85 @@ export default function TasksPage() {
);
}
+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((settings) => {
+ setEnabled(!!settings.task_concurrency_enabled);
+ setLimit(String(settings.task_concurrency_limit ?? 5));
+ })
+ .catch(() => {
+ // Keep the dialog usable with defaults; reopening retries the request.
+ })
+ .finally(() => setLoading(false));
+ }, [open]);
+
+ async function save() {
+ const nextLimit = Math.max(1, Math.floor(Number(limit) || 5));
+ setSaving(true);
+ try {
+ await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: nextLimit });
+ toast.success(enabled ? `已开启并发限制:最多同时运行 ${nextLimit} 个任务` : "已关闭任务并发限制");
+ setOpen(false);
+ } catch (error) {
+ toast.error(`保存失败:${(error as Error).message}`);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and
// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page
// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render.
-type DeleteOpts = { assets: boolean; findings: boolean; traffic: boolean; files: boolean };
-
-const DELETE_EXTRAS: { key: keyof DeleteOpts; label: string; hint: string }[] = [
- { key: "assets", label: "删除关联的资产", hint: "仅本任务独有的资产;与其他任务共享的仅解除关联" },
- { key: "findings", label: "删除发现的漏洞", hint: "本任务记录的全部漏洞" },
- { key: "traffic", label: "删除测试的流量", hint: "按本任务涉及的 host 清除录制流量" },
- { key: "files", label: "删除测试过程中写的文件", hint: "本任务工作目录 tasks/(产物 + 上传)" },
-];
-
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
@@ -352,14 +464,8 @@ const TaskRow = React.memo(function TaskRow({
}: {
task: Task;
nowSec: number;
- onDelete: (id: string, opts: DeleteOpts) => void;
+ onDelete: (id: string, options: DeleteTaskOptions) => Promise;
}) {
- const [delOpts, setDelOpts] = React.useState({
- assets: false,
- findings: false,
- traffic: false,
- files: false,
- });
return (
@@ -425,159 +531,183 @@ const TaskRow = React.memo(function TaskRow({
进入
-
-
-
-
-
-
-
-
- 确认删除任务 #{task.id}?
-
- {task.description ? (
- <>
- 「
-
- {task.description.length > 80
- ? task.description.slice(0, 80) + "…"
- : task.description}
-
- 」
- >
- ) : (
- "该任务"
- )}
- 的执行记录、会话与产物将被删除,此操作不可撤销。
-
-
-
- {/* 额外清理项:默认都不选,勾选后连带删除。全局资产图默认保留。 */}
-
-
- 同时删除以下关联数据(默认不删):
-
- {DELETE_EXTRAS.map((opt) => (
-
- ))}
-
-
-
- 取消
- onDelete(task.id, delOpts)}>
- 删除
-
-
-
-
+
);
});
-// 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 emptyDeleteOptions = (): DeleteTaskOptions => ({
+ delete_assets: false,
+ delete_traffic: false,
+ delete_files: false,
+ delete_findings: false,
+ delete_llm_records: false,
+});
+
+const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
+ "delete_assets",
+ "delete_traffic",
+ "delete_files",
+ "delete_findings",
+ "delete_llm_records",
+];
+
+function DeleteTaskDialog({
+ task,
+ onDelete,
+}: {
+ task: Task;
+ onDelete: (id: string, options: DeleteTaskOptions) => Promise;
+}) {
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);
+ const [deleting, setDeleting] = React.useState(false);
+ const [options, setOptions] = React.useState(emptyDeleteOptions);
- // 每次打开都拉取当前值,避免显示陈旧状态。
- 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);
- }
+ const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
+ const allSelected = selectedCount === deleteOptionKeys.length;
+ let allChecked: boolean | "indeterminate" = false;
+ if (allSelected) {
+ allChecked = true;
+ } else if (selectedCount > 0) {
+ allChecked = "indeterminate";
}
+ const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
+ setOptions((current) => ({ ...current, [key]: checked }));
+ };
+
+ const updateAllOptions = (checked: boolean) => {
+ setOptions({
+ delete_assets: checked,
+ delete_traffic: checked,
+ delete_files: checked,
+ delete_findings: checked,
+ delete_llm_records: checked,
+ });
+ };
+
+ const handleOpenChange = (next: boolean) => {
+ if (deleting) return;
+ setOpen(next);
+ if (next) setOptions(emptyDeleteOptions());
+ };
+
+ const handleDelete = async () => {
+ setDeleting(true);
+ try {
+ await onDelete(task.id, options);
+ setOpen(false);
+ } finally {
+ setDeleting(false);
+ }
+ };
+
return (
-
+
+ 全部删除
+ 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。
+
+
+
+ updateOption("delete_assets", checked === true)}
+ />
+
+ 关联资产
+ 删除仅属于该任务的资产;共享资产只解除当前任务关联。
+
+
+
+ updateOption("delete_traffic", checked === true)}
+ />
+
+ 关联流量
+ 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。
+
+
+
+ updateOption("delete_files", checked === true)}
+ />
+
+ 任务文件
+ 删除该任务工作目录中的上传文件、命令输出和其他产物。
+
+
+
+ updateOption("delete_findings", checked === true)}
+ />
+
+ 关联漏洞
+ 永久删除该任务产生的独立漏洞记录与漏洞报告。
+
+
+
+ updateOption("delete_llm_records", checked === true)}
+ />
+
+ LLM 请求/响应记录
+ 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。
+
+
+
+
+
+ 取消
+ {
+ event.preventDefault();
+ void handleDelete();
+ }}
+ >
+ {deleting && }
+ {deleting ? "删除中" : "删除"}
+
+
+
+
);
}
@@ -585,12 +715,81 @@ function ConcurrencySettingsDialog() {
// 描述/目标 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
// input lag. Now typing only re-renders the drawer.
-function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
+function SourceTaskPicker({
+ tasks,
+ value,
+ onValueChange,
+ portalContainer,
+}: {
+ tasks: Task[];
+ value: string[];
+ onValueChange: (value: string[]) => void;
+ portalContainer?: React.RefObject;
+}) {
+ const tasksByID = React.useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
+ const taskIDs = React.useMemo(() => tasks.map((task) => task.id), [tasks]);
+ const atLimit = value.length >= MAX_SOURCE_TASKS;
+
+ const handleValueChange = (next: string[]) => {
+ onValueChange(next.slice(0, MAX_SOURCE_TASKS));
+ };
+
+ return (
+ {
+ const task = tasksByID.get(taskID);
+ return task ? `${task.id} ${task.description} ${task.goal}` : taskID;
+ }}
+ multiple
+ value={value}
+ onValueChange={handleValueChange}
+ >
+
+
+ {value.map((taskID) => (
+ #{taskID}
+ ))}
+
+
+
+
+ 没有匹配的任务
+
+ {(taskID) => {
+ const task = tasksByID.get(taskID);
+ return (
+
+
+
+
+ #{taskID} · {task?.description ?? "未知任务"}
+
+ {task?.goal &&
{task.goal}
}
+
+ {task &&
}
+
+
+ );
+ }}
+
+
+
+ );
+}
+
+function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => void }) {
const [open, setOpen] = React.useState(false);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState([]);
- const [llmProfile, setLlmProfile] = React.useState(ACTIVE_PROFILE); // sentinel = active
+ const [sourceTaskIDs, setSourceTaskIDs] = React.useState([]);
+ const [llmProfileIDs, setLLMProfileIDs] = React.useState([]);
+ const [creating, setCreating] = React.useState(false);
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
@@ -599,6 +798,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef("");
const fileInputRef = React.useRef(null);
+ const sheetContentRef = React.useRef(null);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
@@ -633,15 +833,28 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
toast.error("请填写描述与目标");
return;
}
+ if (sourceTaskIDs.length > MAX_SOURCE_TASKS) {
+ toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`);
+ return;
+ }
+ setCreating(true);
try {
- const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
- await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec);
+ await api.createTask({
+ description: description.trim(),
+ goal: goal.trim(),
+ llmProfileIds: llmProfileIDs.map(Number),
+ sourceTaskIds: sourceTaskIDs,
+ timeoutSeconds: timeoutSec,
+ seedFirstIntent,
+ planHeartbeatSeconds: heartbeatSec,
+ });
toast.success("任务已创建");
setDescription("");
setGoal("");
- setLlmProfile(ACTIVE_PROFILE);
+ setSourceTaskIDs([]);
+ setLLMProfileIDs([]);
setTimeoutMin("");
setHeartbeatMin("10");
setSeedFirstIntent(false);
@@ -651,19 +864,22 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onCreated();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
+ } finally {
+ setCreating(false);
}
}
return (
-
+
新建任务
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
@@ -723,26 +939,31 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onChange={(e) => setGoal(e.target.value)}
/>
-
-
-
-
-
-
-
- 使用激活配置(默认)
- {profiles.map((p) => (
-
- {p.name}
- {p.is_default ? "(激活)" : ""} · {p.model}
-
- ))}
-
-
-
- 本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
-
-
+
+ 关联任务
+
+
+ 最多关联 {MAX_SOURCE_TASKS} 个任务。实时只读继承所选任务的持久化黑板、资产范围及相关流量;新任务写入独立黑板。
+
+
+
+ LLM 配置链
+
+
+ 按列表顺序故障转移;第一项为当前配置,仅在明确额度不足时切换下一项。
+
+
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
@@ -785,8 +1006,12 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
-
@@ -355,6 +365,11 @@ function NodeDetailSheet({
{node.origin}
+ {node.inherited && node.source_task_id && (
+
+ #{node.source_task_id}(继承,只读)
+
+ )}
{new Date(node.ts).toLocaleString("zh-CN")}
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
React.useEffect(() => {
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
const liveTargets = new Set(
- nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
+ nodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id),
);
const layout = computeLayout(nodes, edges);
setRfNodes((prev) => {
diff --git a/web/src/components/task-llm-profile-chain.tsx b/web/src/components/task-llm-profile-chain.tsx
new file mode 100644
index 0000000..11bde2a
--- /dev/null
+++ b/web/src/components/task-llm-profile-chain.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import * as React from "react";
+
+import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxValue,
+} from "@/components/ui/combobox";
+import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import type { LLMProfile } from "@/lib/types";
+
+interface TaskLLMProfileChainProps {
+ profiles: LLMProfile[];
+ value: string[];
+ onValueChange: (value: string[]) => void;
+ activeProfileId?: string;
+ onActiveProfileChange?: (value: string) => void;
+ disabled?: boolean;
+ inputId?: string;
+ portalContainer?: React.RefObject;
+}
+
+function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
+ if (index === currentIndex) return 当前;
+ if (index < currentIndex) {
+ return (
+
+ 已跳过
+
+ );
+ }
+ return 备用 {index - currentIndex};
+}
+
+export function TaskLLMProfileChain({
+ profiles,
+ value,
+ onValueChange,
+ activeProfileId,
+ onActiveProfileChange,
+ disabled = false,
+ inputId,
+ portalContainer,
+}: TaskLLMProfileChainProps) {
+ const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
+ const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
+ const profilesUnavailable = profiles.length === 0;
+ const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
+ const currentIndex = value.indexOf(currentProfileID);
+ // api_key_hint is display-only and is empty for valid keys shorter than four
+ // characters. The task API remains authoritative for profile validation.
+
+ const profileLabel = React.useCallback(
+ (id: string) => {
+ const profile = profilesByID.get(id);
+ return profile ? `${profile.name} ${profile.model}` : `配置 #${id}`;
+ },
+ [profilesByID],
+ );
+
+ const move = (index: number, offset: -1 | 1) => {
+ const target = index + offset;
+ if (target < 0 || target >= value.length) return;
+ const next = [...value];
+ [next[index], next[target]] = [next[target], next[index]];
+ onValueChange(next);
+ };
+
+ const remove = (id: string) => {
+ const next = value.filter((profileID) => profileID !== id);
+ onValueChange(next);
+ if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
+ };
+
+ const handleValueChange = (next: string[]) => {
+ onValueChange(next);
+ if (activeProfileId && !next.includes(activeProfileId)) {
+ onActiveProfileChange?.(next[0] ?? "");
+ }
+ };
+
+ return (
+
+
+
+
+ {value.map((id) => (
+ {profilesByID.get(id)?.name ?? `#${id}`}
+ ))}
+
+ 0 ? "搜索并添加 LLM 配置" : "暂无可用 LLM 配置"}
+ disabled={disabled ? true : profilesUnavailable}
+ />
+
+
+ 没有匹配的 LLM 配置
+
+ {(id) => {
+ const profile = profilesByID.get(id);
+ return (
+
+
+
+
+ {profile?.name ?? `配置 #${id}`}
+ {profile?.is_default ? "(激活)" : ""}
+
+
+ {profile && {profile.model}}
+
+
+ );
+ }}
+
+
+
+
+ {value.length === 0 ? (
+
未指定配置时,任务跟随 Agent 或全局激活配置。
+ ) : (
+
+ {value.map((id, index) => {
+ const profile = profilesByID.get(id);
+ return (
+
+
{index + 1}
+
+
{profile?.name ?? `配置 #${id}`}
+
+
{profile?.model ?? "配置已不可用"}
+
+
+
+
+
move(index, -1)}
+ disabled={disabled ? true : index === 0}
+ >
+
+
+
move(index, 1)}
+ disabled={disabled ? true : index === value.length - 1}
+ >
+
+
+
remove(id)}
+ disabled={disabled}
+ >
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {onActiveProfileChange && value.length > 0 && (
+
+ 当前配置
+
+
+
+
+
+
+ {value.map((id) => {
+ const profile = profilesByID.get(id);
+ return (
+
+ {profile?.name ?? `配置 #${id}`}
+
+ );
+ })}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/web/src/components/transcript.tsx b/web/src/components/transcript.tsx
index 6fa21b2..f2925a8 100644
--- a/web/src/components/transcript.tsx
+++ b/web/src/components/transcript.tsx
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Markdown } from "@/components/markdown";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/types";
@@ -208,7 +209,7 @@ function InterceptCard({
}, [pendingId]);
async function decide(decision: "allowed" | "denied") {
- if (!pendingId || deciding) return;
+ if (step.inherited || !pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
@@ -246,7 +247,9 @@ function InterceptCard({
- {decided ? (
+ {step.inherited ? (
+ 历史记录 · 只读
+ ) : decided ? (
-
+
+
)
diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx
index 352afc3..64eac26 100644
--- a/web/src/components/ui/combobox.tsx
+++ b/web/src/components/ui/combobox.tsx
@@ -93,14 +93,17 @@ function ComboboxContent({
align = "start",
alignOffset = 0,
anchor,
+ portalContainer,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
- >) {
+ > & {
+ portalContainer?: ComboboxPrimitive.Portal.Props["container"]
+ }) {
return (
-
+
(path: string, init?: RequestInit): Promise {
}
throw new Error("未授权");
}
- if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`);
+ if (!r.ok) {
+ const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`;
+ let message = fallback;
+ try {
+ const payload = (await r.json()) as { error?: unknown };
+ if (typeof payload.error === "string" && payload.error.trim()) {
+ message = payload.error.trim();
+ }
+ } catch {
+ // Keep the status-based fallback for empty or non-JSON error responses.
+ }
+ throw new Error(message);
+ }
if (r.status === 204) return undefined as T;
return r.json();
}
@@ -113,26 +125,45 @@ export const api = {
// ---- tasks ----
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
- createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) =>
+ createTask: (input: {
+ description: string;
+ goal: string;
+ llmProfileIds?: number[];
+ sourceTaskIds?: string[];
+ timeoutSeconds?: number;
+ seedFirstIntent?: boolean;
+ planHeartbeatSeconds?: number;
+ }) =>
post("/tasks", {
- description,
- goal,
- llm_profile_id: llmProfileId ?? null,
- timeout_seconds: timeoutSeconds ?? 0,
- seed_first_intent: seedFirstIntent ?? false,
- plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
+ description: input.description,
+ goal: input.goal,
+ llm_profile_ids: input.llmProfileIds ?? [],
+ source_task_ids: input.sourceTaskIds ?? [],
+ timeout_seconds: input.timeoutSeconds ?? 0,
+ seed_first_intent: input.seedFirstIntent ?? false,
+ plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
}),
- // 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、
- // traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/ 目录)。
- deleteTask: (
- id: string,
- opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean },
- ) =>
- del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>(
- `/tasks/${id}`,
- opts,
- ),
+ updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
+ put<{
+ id: string;
+ llm_profile_ids: number[];
+ active_llm_profile_id?: number;
+ llm_failover_state: string;
+ reopened_intents: number;
+ switch_event?: Activity;
+ }>(`/tasks/${id}/llm`, {
+ llm_profile_ids: llmProfileIds,
+ active_llm_profile_id: activeLLMProfileId ?? null,
+ }),
+ deleteTask: (id: string, options: DeleteTaskOptions) =>
+ del(`/tasks/${id}`, options),
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
+ controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
+ post<{
+ id: number;
+ state: "paused" | "open" | "cancelled";
+ deleted?: { intents: number; facts: number; findings: number; activities: number };
+ }>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
@@ -257,7 +288,10 @@ export const api = {
return get(`/exploration/findings?${p.toString()}`);
},
findingStats: () => get("/exploration/findings/stats"),
- getFinding: (id: string) => get(`/exploration/findings/${id}`),
+ getFinding: (id: string, contextTaskId?: string) =>
+ get(
+ `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
+ ),
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
findingLineage: (id: string) =>
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts
index d738177..044457f 100644
--- a/web/src/lib/mock/data.ts
+++ b/web/src/lib/mock/data.ts
@@ -29,6 +29,10 @@ export const tasks: Task[] = [
paused: false, active: true, in_flight: 3, stalled: false,
goals_total: 5, goals_met: 2, engine_mode: "exploring",
llm_profile_id: 1,
+ llm_profile_ids: [1, 2],
+ active_llm_profile_id: 1,
+ llm_failover_state: "ready",
+ source_task_ids: [],
tokens: { input_tokens: 1284500, output_tokens: 96320, cache_read_tokens: 890400, cache_write_tokens: 132000 },
},
{
@@ -43,6 +47,10 @@ export const tasks: Task[] = [
paused: false, active: false, in_flight: 2, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "exploring",
llm_profile_id: 1,
+ llm_profile_ids: [1, 2],
+ active_llm_profile_id: 1,
+ llm_failover_state: "ready",
+ source_task_ids: ["t-acme-web"],
tokens: { input_tokens: 642300, output_tokens: 51200, cache_read_tokens: 401000, cache_write_tokens: 60000 },
},
{
@@ -57,6 +65,10 @@ export const tasks: Task[] = [
paused: true, active: false, in_flight: 0, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "paused",
llm_profile_id: 2,
+ llm_profile_ids: [2, 1],
+ active_llm_profile_id: 2,
+ llm_failover_state: "ready",
+ source_task_ids: ["t-acme-web", "t-acme-api"],
tokens: { input_tokens: 233000, output_tokens: 18700, cache_read_tokens: 120000, cache_write_tokens: 22000 },
},
{
@@ -73,6 +85,10 @@ export const tasks: Task[] = [
paused: false, active: false, in_flight: 0, stalled: false,
goals_total: 3, goals_met: 3, engine_mode: "idle",
llm_profile_id: 1,
+ llm_profile_ids: [1],
+ active_llm_profile_id: 1,
+ llm_failover_state: "ready",
+ source_task_ids: [],
tokens: { input_tokens: 512000, output_tokens: 40100, cache_read_tokens: 300000, cache_write_tokens: 41000 },
},
];
@@ -90,20 +106,26 @@ const grandTotal: TokenTotal = tasks.reduce(
);
// ── Stats ──────────────────────────────────────────────────────────────────
-export function stats(taskId?: string): Stats & { active_task?: unknown } {
+export function stats(
+ taskId?: string,
+ state: { tasks?: readonly Task[]; findings?: readonly Finding[]; activeTask?: string } = {},
+): Stats & { active_task?: unknown } {
+ const taskList = state.tasks ?? tasks;
+ const findingList = state.findings ?? findings;
const base: Stats = {
assets: assets.length,
engine_mode: "exploring",
llm_configured: true,
roe_enabled: true,
- findings_confirmed: findings.filter((f) => f.severity !== "low").length,
+ findings_confirmed: findingList.filter((f) => f.severity !== "low").length,
};
- const t = taskId ? getTask(taskId) : getTask(ACTIVE_TASK);
+ const selectedTaskID = taskId ?? state.activeTask ?? ACTIVE_TASK;
+ const t = taskList.find((item) => item.id === selectedTaskID);
if (!t) return base;
return {
...base,
engine_mode: t.engine_mode ?? "idle",
- findings_confirmed: findings.filter((f) => f.task_id === t.id).length,
+ findings_confirmed: findingList.filter((f) => f.task_id === t.id).length,
active_task: {
id: t.id, in_flight: t.in_flight, goals_total: t.goals_total,
goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused,
@@ -892,8 +914,8 @@ const llmTaskCounts: Record = {};
for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1;
export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count }));
-export function llmRecordDetail(id: number) {
- const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
+export function llmRecordDetail(id: number, records = llmRecords) {
+ const item = records.find((r) => r.id === id) ?? records[0];
return {
...item,
request_body: JSON.stringify(
diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts
index 1d59db2..1b57783 100644
--- a/web/src/lib/mock/handler.ts
+++ b/web/src/lib/mock/handler.ts
@@ -2,10 +2,18 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
+import type { Task } from "../types";
import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
+// Requests mutate a runtime copy, never the exported fixtures. This keeps module
+// initialization deterministic for tests/HMR while preserving state across mock calls.
+const mockTasks = structuredClone(D.tasks);
+const mockFindings = structuredClone(D.findings);
+const mockLLMRecords = structuredClone(D.llmRecords);
+let mockActiveTask = D.ACTIVE_TASK;
+
function parseBody(body?: BodyInit | null): Record {
if (typeof body !== "string") return {};
try {
@@ -34,13 +42,107 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/auth/change-password") return { ok: true };
// ── tasks ──
- if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
- if (path === "/tasks" && m === "POST")
- return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
- if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
+ if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
+ if (path === "/tasks" && m === "POST") {
+ let suffix = 1;
+ while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
+ const id = `t-new-${suffix}`;
+ const now = new Date();
+ const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
+ const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
+ const created: Task = {
+ id,
+ description: String(b.description ?? "新任务"),
+ goal: String(b.goal ?? ""),
+ status: "created",
+ created_at: now.toISOString(),
+ created_unix: Math.floor(now.getTime() / 1000),
+ paused: false,
+ active: true,
+ in_flight: 0,
+ stalled: false,
+ goals_total: 0,
+ goals_met: 0,
+ engine_mode: "idle",
+ tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
+ llm_profile_id: profileIDs[0],
+ llm_profile_ids: profileIDs,
+ active_llm_profile_id: profileIDs[0],
+ llm_failover_state: profileIDs.length ? "ready" : "default",
+ source_task_ids: sourceTaskIDs,
+ };
+ for (const item of mockTasks) item.active = false;
+ mockTasks.unshift(created);
+ mockActiveTask = id;
+ return created;
+ }
+ if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
+ const id = seg[1];
+ const index = mockTasks.findIndex((item) => item.id === id);
+ if (index >= 0) mockTasks.splice(index, 1);
+
+ let findingsDeleted = 0;
+ if (b.delete_findings) {
+ for (let i = mockFindings.length - 1; i >= 0; i--) {
+ if (mockFindings[i].task_id !== id) continue;
+ mockFindings.splice(i, 1);
+ findingsDeleted++;
+ }
+ }
+
+ let llmRecordsDeleted = 0;
+ if (b.delete_llm_records) {
+ for (let i = mockLLMRecords.length - 1; i >= 0; i--) {
+ if (mockLLMRecords[i].task_id !== id) continue;
+ mockLLMRecords.splice(i, 1);
+ llmRecordsDeleted++;
+ }
+ }
+
+ if (mockActiveTask === id) {
+ const nextActive = mockTasks[0]?.id ?? "";
+ mockActiveTask = nextActive;
+ for (const item of mockTasks) item.active = item.id === nextActive;
+ }
+ return {
+ deleted: id,
+ assets_deleted: b.delete_assets ? 1 : 0,
+ assets_detached: 0,
+ traffic_deleted: b.delete_traffic ? 1 : 0,
+ files_deleted: Boolean(b.delete_files),
+ findings_deleted: findingsDeleted,
+ llm_records_deleted: llmRecordsDeleted,
+ };
+ }
+ if (seg[0] === "tasks" && seg[2] === "llm" && m === "PUT") {
+ const ids = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
+ const activeID = typeof b.active_llm_profile_id === "number" ? b.active_llm_profile_id : ids[0];
+ const target = mockTasks.find((item) => item.id === seg[1]);
+ if (target) {
+ target.llm_profile_ids = ids;
+ target.llm_profile_id = ids[0];
+ target.active_llm_profile_id = activeID;
+ target.llm_failover_state = ids.length ? "ready" : "default";
+ target.llm_failover_reason = undefined;
+ }
+ return {
+ id: seg[1],
+ llm_profile_ids: ids,
+ active_llm_profile_id: activeID,
+ llm_failover_state: ids.length ? "ready" : "default",
+ reopened_intents: 0,
+ };
+ }
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
- if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
+ if (path === "/active") {
+ const id = String(b.id ?? mockActiveTask);
+ if (mockTasks.some((item) => item.id === id)) {
+ mockActiveTask = id;
+ for (const item of mockTasks) item.active = item.id === id;
+ }
+ return { active: mockActiveTask };
+ }
// ── 覆盖度 / 覆盖图 / 资产关联(任务维度)──
if (seg[0] === "tasks" && seg[2] === "coverage" && seg.length === 3) return D.coverage;
@@ -52,7 +154,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
// ── stats ──
- if (path === "/stats") return D.stats(task);
+ if (path === "/stats") {
+ return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
+ }
// ── assets ──
if (path === "/assets/counts") return D.assetCounts;
@@ -74,10 +178,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
- const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
+ const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
const taskMap = new Map();
- for (const f of D.findings) {
+ for (const f of mockFindings) {
if (!f.task_id) continue;
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
cur.count++;
@@ -85,12 +189,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
return {
- total: D.findings.length,
- pending: D.findings.filter((f) => f.status === "pending").length,
- critical: D.findings.filter((f) => f.severity === "critical").length,
- high: D.findings.filter((f) => f.severity === "high").length,
- medium: D.findings.filter((f) => f.severity === "medium").length,
- low: D.findings.filter((f) => f.severity === "low").length,
+ total: mockFindings.length,
+ pending: mockFindings.filter((f) => f.status === "pending").length,
+ critical: mockFindings.filter((f) => f.severity === "critical").length,
+ high: mockFindings.filter((f) => f.severity === "high").length,
+ medium: mockFindings.filter((f) => f.severity === "medium").length,
+ low: mockFindings.filter((f) => f.severity === "low").length,
vulnclasses,
tasks,
};
@@ -102,7 +206,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
seg.length === 3 &&
seg[2] !== "stats"
) {
- const f = D.findings.find((x) => x.id === seg[2]);
+ const f = mockFindings.find((x) => x.id === seg[2]);
if (!f) return {};
if (m === "PATCH") {
if (typeof b.status === "string") f.status = b.status as typeof f.status;
@@ -111,21 +215,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (typeof b.name === "string") f.name = b.name;
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
}
- return { ...f, finding_id: f.id };
+ const contextTaskId = q.get("context_task");
+ const contextTask = contextTaskId ? mockTasks.find((item) => item.id === contextTaskId) : undefined;
+ const inherited = !!(
+ contextTask &&
+ f.task_id &&
+ f.task_id !== contextTask.id &&
+ contextTask.source_task_ids?.includes(f.task_id)
+ );
+ return {
+ ...f,
+ finding_id: f.id,
+ ...(inherited ? { inherited: true, source_task_id: f.task_id } : {}),
+ };
}
if (path === "/exploration/findings") {
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
// report 仅详情接口返回,列表剥掉(与后端一致)。
- const withFid = (f: (typeof D.findings)[number]) => ({
+ const withFid = (f: (typeof mockFindings)[number]) => ({
...f,
report: undefined,
finding_id: f.id,
});
- if (task) return D.findings.filter((f) => f.task_id === task).map(withFid);
+ if (task) {
+ const owner = mockTasks.find((item) => item.id === task);
+ const sources = new Set(owner?.source_task_ids ?? []);
+ return mockFindings
+ .filter((f) => f.task_id === task || (!!f.task_id && sources.has(f.task_id)))
+ .map((f) => ({
+ ...withFid(f),
+ ...(f.task_id !== task ? { inherited: true, source_task_id: f.task_id } : {}),
+ }));
+ }
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
- if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid);
+ if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
- let list = D.findings.slice();
+ let list = mockFindings.slice();
const fSev = q.get("severity");
const fStatus = q.get("status");
const fVuln = q.get("vulnclass");
@@ -151,9 +276,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/intents") {
if (q.has("page")) {
const before = Number(q.get("before") ?? 0);
- const limit = Number(q.get("limit") ?? 300);
+ const limit = Math.max(1, Number(q.get("limit") ?? 300));
let list = D.intents;
- if (before > 0) list = list.filter((n) => Number(n.id.replace(/\D/g, "") || n.id) < before);
+ if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before);
return { items: list.slice(0, limit), has_more: list.length > limit };
}
return D.intents;
@@ -161,8 +286,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
if (path === "/exploration/graph") return D.explorationGraph;
if (path === "/exploration/activity" && seg.length === 2) {
- const items = D.activityForTask();
- return { items, cursor: items.length ? items[items.length - 1].seq : 0 };
+ const since = Number(q.get("since") ?? 0);
+ const limit = Math.max(1, Number(q.get("limit") ?? 300));
+ const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit);
+ return { items, cursor: items.length ? items[items.length - 1].seq : since };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
const a = D.activity.find((x) => x.seq === Number(seg[2]));
@@ -189,10 +316,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
// ── LLM ──
- if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
+ if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
if (path === "/llm/records" && m === "DELETE") return { deleted: 0 };
- if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
- if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
+ if (path === "/llm/records/tasks") {
+ const counts = new Map();
+ for (const record of mockLLMRecords) {
+ if (record.task_id) counts.set(record.task_id, (counts.get(record.task_id) ?? 0) + 1);
+ }
+ return { tasks: [...counts].map(([task_id, count]) => ({ task_id, count })) };
+ }
+ if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") {
+ return D.llmRecordDetail(Number(seg[2]), mockLLMRecords);
+ }
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts
index ab3245e..eb180ca 100644
--- a/web/src/lib/status.ts
+++ b/web/src/lib/status.ts
@@ -47,6 +47,7 @@ interface StatusMeta {
const intent: Record = {
open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" },
+ paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
blocked: { label: "被拦截", tone: "red" },
exhausted: { label: "已穷尽", tone: "violet" },
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 33d04e8..ca761f7 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -15,6 +15,7 @@ export interface Task {
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
paused?: boolean;
+ queued?: boolean;
active?: boolean;
in_flight?: number;
last_activity?: string;
@@ -24,6 +25,30 @@ export interface Task {
engine_mode?: EngineMode;
tokens?: TokenTotal; // whole-task token consumption
llm_profile_id?: number; // LLM profile used; absent = default profile
+ llm_profile_ids?: number[]; // ordered task-level failover chain
+ active_llm_profile_id?: number; // profile used by the next LLM call
+ llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
+ llm_failover_reason?: string;
+ source_task_ids?: string[]; // directly related tasks inherited as read-only context
+}
+
+export interface DeleteTaskOptions {
+ delete_assets: boolean;
+ delete_traffic: boolean;
+ delete_files: boolean;
+ delete_findings: boolean;
+ delete_llm_records: boolean;
+}
+
+export interface DeleteTaskResult {
+ deleted: string;
+ assets_deleted: number;
+ assets_detached: number;
+ traffic_deleted: number;
+ files_deleted: boolean;
+ findings_deleted: number;
+ llm_records_deleted: number;
+ cleanup_warning?: string;
}
// ---- Asset graph (global, shared across tasks) ----
@@ -171,6 +196,8 @@ export interface CoverageAssetRef {
kind: string;
state: string;
summary: string;
+ source_task_id?: string;
+ inherited?: boolean;
}
export interface CoverageAssetRefs {
intents: CoverageAssetRef[];
@@ -234,6 +261,8 @@ export interface TaskNode {
state: string; // GoalState | IntentState | FindingState | HintState
origin: string;
ts: string;
+ source_task_id?: string;
+ inherited?: boolean;
}
// ---- Findings ----
@@ -271,6 +300,8 @@ export interface Finding {
param_id?: string;
task_id?: string;
task_description?: string;
+ source_task_id?: string;
+ inherited?: boolean;
assets?: FindingAsset[];
ts: string;
}
@@ -325,6 +356,8 @@ export type ActivityKind =
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
| "round" // planner round boundary marker (engine-emitted)
| "usage" // live cumulative token usage (per model turn); not rendered
+ | "llm_switch" // automatic/manual task-level LLM switch
+ | "llm_failover" // task-level provider switch / chain exhaustion audit event
| "intercept_request"; // user-approval request from the intercept layer
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
@@ -346,6 +379,11 @@ export interface Activity {
is_error?: boolean;
summary: string;
detail?: string;
+ metadata?: {
+ llm_transition?: LLMTransition;
+ };
+ source_task_id?: string;
+ inherited?: boolean;
// token usage (present only on kind='result')
input_tokens?: number;
output_tokens?: number;
@@ -353,6 +391,20 @@ export interface Activity {
cache_write_tokens?: number;
}
+export interface LLMAuditProfile {
+ id: number;
+ name: string;
+ format: string;
+ model: string;
+}
+
+export interface LLMTransition {
+ mode: "automatic" | "manual" | "exhausted";
+ reason: string;
+ previous?: LLMAuditProfile;
+ next?: LLMAuditProfile;
+}
+
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
export interface AgentTrigger {
id: number;
@@ -394,8 +446,8 @@ export interface LogLine {
text: string;
}
-export type SessionRole = "mainagent" | "planner" | "worker";
-export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
+export type SessionRole = "mainagent" | "planner" | "worker" | "system";
+export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
// Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket {
@@ -431,6 +483,8 @@ export interface Session {
live: boolean;
last_activity: string;
intent_id?: string;
+ source_task_id?: string;
+ inherited?: boolean;
}
// ---- Security ----
@@ -669,6 +723,7 @@ export interface Stats {
llm_configured: boolean;
roe_enabled: boolean;
findings_confirmed: number;
+ active_task?: Partial;
}
// ---- Intercept Rules ----